musehub_releases.py
python
| 1 | """MuseHub release persistence adapter — single point of DB access for releases. |
| 2 | |
| 3 | This module is the ONLY place that touches the ``musehub_releases`` table. |
| 4 | Route handlers delegate here; no business logic lives in routes. |
| 5 | |
| 6 | Releases tie a human-readable tag (e.g. "v1.0") to a commit snapshot and |
| 7 | carry Markdown release notes plus a structured map of download package URLs. |
| 8 | Tags are unique per repo — creating a duplicate tag raises ``ValueError``. |
| 9 | |
| 10 | Boundary rules: |
| 11 | - Must NOT import state stores, SSE queues, or LLM clients. |
| 12 | - Must NOT import musehub.core.* modules. |
| 13 | - May import ORM models from musehub.db.musehub_models. |
| 14 | - May import Pydantic response models from musehub.models.musehub. |
| 15 | - May import the packager to resolve download URLs. |
| 16 | """ |
| 17 | |
| 18 | import logging |
| 19 | |
| 20 | from sqlalchemy import select |
| 21 | from sqlalchemy.ext.asyncio import AsyncSession |
| 22 | |
| 23 | from musehub.db import musehub_models as db |
| 24 | from musehub.models.musehub import ( |
| 25 | ReleaseAssetDownloadCount, |
| 26 | ReleaseAssetListResponse, |
| 27 | ReleaseAssetResponse, |
| 28 | ReleaseDownloadStatsResponse, |
| 29 | ReleaseDownloadUrls, |
| 30 | ReleaseListResponse, |
| 31 | ReleaseResponse, |
| 32 | ) |
| 33 | from musehub.services.musehub_release_packager import build_empty_download_urls |
| 34 | |
| 35 | logger = logging.getLogger(__name__) |
| 36 | |
| 37 | |
| 38 | def _urls_from_json(raw: dict[str, str]) -> ReleaseDownloadUrls: |
| 39 | """Coerce the JSON blob stored in ``download_urls`` to a typed model.""" |
| 40 | return ReleaseDownloadUrls( |
| 41 | midi_bundle=raw.get("midi_bundle"), |
| 42 | stems=raw.get("stems"), |
| 43 | mp3=raw.get("mp3"), |
| 44 | musicxml=raw.get("musicxml"), |
| 45 | metadata=raw.get("metadata"), |
| 46 | ) |
| 47 | |
| 48 | |
| 49 | def _to_release_response(row: db.MusehubRelease) -> ReleaseResponse: |
| 50 | raw: dict[str, str] = row.download_urls if isinstance(row.download_urls, dict) else {} |
| 51 | return ReleaseResponse( |
| 52 | release_id=row.release_id, |
| 53 | tag=row.tag, |
| 54 | title=row.title, |
| 55 | body=row.body, |
| 56 | commit_id=row.commit_id, |
| 57 | download_urls=_urls_from_json(raw), |
| 58 | author=row.author, |
| 59 | is_prerelease=row.is_prerelease, |
| 60 | is_draft=row.is_draft, |
| 61 | gpg_signature=row.gpg_signature, |
| 62 | created_at=row.created_at, |
| 63 | ) |
| 64 | |
| 65 | |
| 66 | async def _tag_exists(session: AsyncSession, repo_id: str, tag: str) -> bool: |
| 67 | """Return True if a release with this tag already exists for the repo.""" |
| 68 | stmt = select(db.MusehubRelease.release_id).where( |
| 69 | db.MusehubRelease.repo_id == repo_id, |
| 70 | db.MusehubRelease.tag == tag, |
| 71 | ) |
| 72 | result = (await session.execute(stmt)).scalar_one_or_none() |
| 73 | return result is not None |
| 74 | |
| 75 | |
| 76 | async def create_release( |
| 77 | session: AsyncSession, |
| 78 | *, |
| 79 | repo_id: str, |
| 80 | tag: str, |
| 81 | title: str, |
| 82 | body: str, |
| 83 | commit_id: str | None, |
| 84 | download_urls: ReleaseDownloadUrls | None = None, |
| 85 | author: str = "", |
| 86 | is_prerelease: bool = False, |
| 87 | is_draft: bool = False, |
| 88 | gpg_signature: str | None = None, |
| 89 | ) -> ReleaseResponse: |
| 90 | """Persist a new release and return its wire representation. |
| 91 | |
| 92 | ``tag`` must be unique per repo. Raises ``ValueError`` if a release with |
| 93 | the same tag already exists. The caller is responsible for committing the |
| 94 | session after this call. |
| 95 | |
| 96 | Args: |
| 97 | session: Active async DB session. |
| 98 | repo_id: UUID of the target repo. |
| 99 | tag: Version tag (e.g. "v1.0") — unique per repo. |
| 100 | title: Human-readable release title. |
| 101 | body: Markdown release notes. |
| 102 | commit_id: Optional commit to pin this release to. |
| 103 | download_urls: Pre-built download URL map; defaults to empty URLs. |
| 104 | author: Display name or identifier of the user publishing this release. |
| 105 | is_prerelease: Mark as pre-release (shows badge in UI). |
| 106 | is_draft: Save as draft — not yet publicly visible. |
| 107 | gpg_signature: ASCII-armoured GPG signature for the tag object. |
| 108 | |
| 109 | Returns: |
| 110 | ``ReleaseResponse`` with all fields populated. |
| 111 | |
| 112 | Raises: |
| 113 | ValueError: If a release with ``tag`` already exists for ``repo_id``. |
| 114 | """ |
| 115 | if await _tag_exists(session, repo_id, tag): |
| 116 | raise ValueError(f"Release tag '{tag}' already exists for repo {repo_id}") |
| 117 | |
| 118 | urls = download_urls or build_empty_download_urls() |
| 119 | urls_dict: dict[str, str] = { |
| 120 | k: v |
| 121 | for k, v in { |
| 122 | "midi_bundle": urls.midi_bundle, |
| 123 | "stems": urls.stems, |
| 124 | "mp3": urls.mp3, |
| 125 | "musicxml": urls.musicxml, |
| 126 | "metadata": urls.metadata, |
| 127 | }.items() |
| 128 | if v is not None |
| 129 | } |
| 130 | |
| 131 | release = db.MusehubRelease( |
| 132 | repo_id=repo_id, |
| 133 | tag=tag, |
| 134 | title=title, |
| 135 | body=body, |
| 136 | commit_id=commit_id, |
| 137 | download_urls=urls_dict, |
| 138 | author=author, |
| 139 | is_prerelease=is_prerelease, |
| 140 | is_draft=is_draft, |
| 141 | gpg_signature=gpg_signature, |
| 142 | ) |
| 143 | session.add(release) |
| 144 | await session.flush() |
| 145 | await session.refresh(release) |
| 146 | logger.info("✅ Created release %s for repo %s: %s", tag, repo_id, title) |
| 147 | return _to_release_response(release) |
| 148 | |
| 149 | |
| 150 | async def list_releases( |
| 151 | session: AsyncSession, |
| 152 | repo_id: str, |
| 153 | ) -> list[ReleaseResponse]: |
| 154 | """Return all releases for a repo, ordered newest first. |
| 155 | |
| 156 | Args: |
| 157 | session: Active async DB session. |
| 158 | repo_id: UUID of the target repo. |
| 159 | |
| 160 | Returns: |
| 161 | List of ``ReleaseResponse`` objects ordered by ``created_at`` descending. |
| 162 | """ |
| 163 | stmt = ( |
| 164 | select(db.MusehubRelease) |
| 165 | .where(db.MusehubRelease.repo_id == repo_id) |
| 166 | .order_by(db.MusehubRelease.created_at.desc()) |
| 167 | ) |
| 168 | rows = (await session.execute(stmt)).scalars().all() |
| 169 | return [_to_release_response(r) for r in rows] |
| 170 | |
| 171 | |
| 172 | async def get_release_by_tag( |
| 173 | session: AsyncSession, |
| 174 | repo_id: str, |
| 175 | tag: str, |
| 176 | ) -> ReleaseResponse | None: |
| 177 | """Return a release by its tag for the given repo, or ``None`` if not found. |
| 178 | |
| 179 | Args: |
| 180 | session: Active async DB session. |
| 181 | repo_id: UUID of the target repo. |
| 182 | tag: Version tag to look up (e.g. "v1.0"). |
| 183 | |
| 184 | Returns: |
| 185 | ``ReleaseResponse`` if found, otherwise ``None``. |
| 186 | """ |
| 187 | stmt = select(db.MusehubRelease).where( |
| 188 | db.MusehubRelease.repo_id == repo_id, |
| 189 | db.MusehubRelease.tag == tag, |
| 190 | ) |
| 191 | row = (await session.execute(stmt)).scalar_one_or_none() |
| 192 | if row is None: |
| 193 | return None |
| 194 | return _to_release_response(row) |
| 195 | |
| 196 | |
| 197 | async def get_latest_release( |
| 198 | session: AsyncSession, |
| 199 | repo_id: str, |
| 200 | ) -> ReleaseResponse | None: |
| 201 | """Return the most recently created release for a repo, or ``None``. |
| 202 | |
| 203 | Used to populate the "Latest release" badge on the repo home page. |
| 204 | |
| 205 | Args: |
| 206 | session: Active async DB session. |
| 207 | repo_id: UUID of the target repo. |
| 208 | |
| 209 | Returns: |
| 210 | The newest ``ReleaseResponse`` or ``None`` if no releases exist. |
| 211 | """ |
| 212 | stmt = ( |
| 213 | select(db.MusehubRelease) |
| 214 | .where(db.MusehubRelease.repo_id == repo_id) |
| 215 | .order_by(db.MusehubRelease.created_at.desc()) |
| 216 | .limit(1) |
| 217 | ) |
| 218 | row = (await session.execute(stmt)).scalar_one_or_none() |
| 219 | if row is None: |
| 220 | return None |
| 221 | return _to_release_response(row) |
| 222 | |
| 223 | |
| 224 | async def get_release_list_response( |
| 225 | session: AsyncSession, |
| 226 | repo_id: str, |
| 227 | ) -> ReleaseListResponse: |
| 228 | """Convenience wrapper that returns a ``ReleaseListResponse`` directly. |
| 229 | |
| 230 | Args: |
| 231 | session: Active async DB session. |
| 232 | repo_id: UUID of the target repo. |
| 233 | |
| 234 | Returns: |
| 235 | ``ReleaseListResponse`` containing all releases newest first. |
| 236 | """ |
| 237 | releases = await list_releases(session, repo_id) |
| 238 | return ReleaseListResponse(releases=releases) |
| 239 | |
| 240 | |
| 241 | # ── Release asset helpers ───────────────────────────────────────────────────── |
| 242 | |
| 243 | |
| 244 | def _to_asset_response(row: db.MusehubReleaseAsset) -> ReleaseAssetResponse: |
| 245 | """Convert a ``MusehubReleaseAsset`` ORM row to its wire representation.""" |
| 246 | return ReleaseAssetResponse( |
| 247 | asset_id=row.asset_id, |
| 248 | release_id=row.release_id, |
| 249 | name=row.name, |
| 250 | label=row.label, |
| 251 | content_type=row.content_type, |
| 252 | size=row.size, |
| 253 | download_url=row.download_url, |
| 254 | download_count=row.download_count, |
| 255 | created_at=row.created_at, |
| 256 | ) |
| 257 | |
| 258 | |
| 259 | async def attach_asset( |
| 260 | session: AsyncSession, |
| 261 | *, |
| 262 | release_id: str, |
| 263 | repo_id: str, |
| 264 | name: str, |
| 265 | label: str = "", |
| 266 | content_type: str = "", |
| 267 | size: int = 0, |
| 268 | download_url: str, |
| 269 | ) -> ReleaseAssetResponse: |
| 270 | """Attach a new downloadable asset to an existing release. |
| 271 | |
| 272 | The caller is responsible for committing the session after this call. |
| 273 | |
| 274 | Args: |
| 275 | session: Active async DB session. |
| 276 | release_id: UUID of the release to attach the asset to. |
| 277 | repo_id: UUID of the owning repo (denormalised for efficient queries). |
| 278 | name: Filename shown in the UI. |
| 279 | label: Optional human-readable label (e.g. "MIDI Bundle"). |
| 280 | content_type: MIME type of the artifact. |
| 281 | size: File size in bytes; 0 when unknown. |
| 282 | download_url: Direct download URL for the artifact. |
| 283 | |
| 284 | Returns: |
| 285 | ``ReleaseAssetResponse`` for the newly created asset. |
| 286 | """ |
| 287 | asset = db.MusehubReleaseAsset( |
| 288 | release_id=release_id, |
| 289 | repo_id=repo_id, |
| 290 | name=name, |
| 291 | label=label, |
| 292 | content_type=content_type, |
| 293 | size=size, |
| 294 | download_url=download_url, |
| 295 | ) |
| 296 | session.add(asset) |
| 297 | await session.flush() |
| 298 | await session.refresh(asset) |
| 299 | logger.info("✅ Attached asset %r to release %s", name, release_id) |
| 300 | return _to_asset_response(asset) |
| 301 | |
| 302 | |
| 303 | async def get_asset( |
| 304 | session: AsyncSession, |
| 305 | asset_id: str, |
| 306 | ) -> db.MusehubReleaseAsset | None: |
| 307 | """Return the ``MusehubReleaseAsset`` row for ``asset_id``, or ``None``. |
| 308 | |
| 309 | Used by route handlers to validate that the asset belongs to the |
| 310 | expected release before performing mutations. |
| 311 | """ |
| 312 | stmt = select(db.MusehubReleaseAsset).where( |
| 313 | db.MusehubReleaseAsset.asset_id == asset_id |
| 314 | ) |
| 315 | return (await session.execute(stmt)).scalar_one_or_none() |
| 316 | |
| 317 | |
| 318 | async def remove_asset( |
| 319 | session: AsyncSession, |
| 320 | asset_id: str, |
| 321 | ) -> bool: |
| 322 | """Delete a release asset by its ID. |
| 323 | |
| 324 | The caller is responsible for committing the session after this call. |
| 325 | |
| 326 | Args: |
| 327 | session: Active async DB session. |
| 328 | asset_id: UUID of the asset to remove. |
| 329 | |
| 330 | Returns: |
| 331 | ``True`` if the asset was found and deleted; ``False`` if not found. |
| 332 | """ |
| 333 | row = await get_asset(session, asset_id) |
| 334 | if row is None: |
| 335 | return False |
| 336 | await session.delete(row) |
| 337 | logger.info("✅ Removed asset %s", asset_id) |
| 338 | return True |
| 339 | |
| 340 | |
| 341 | async def get_download_stats( |
| 342 | session: AsyncSession, |
| 343 | release_id: str, |
| 344 | tag: str, |
| 345 | ) -> ReleaseDownloadStatsResponse: |
| 346 | """Return per-asset download counts for a release. |
| 347 | |
| 348 | Args: |
| 349 | session: Active async DB session. |
| 350 | release_id: UUID of the release. |
| 351 | tag: Version tag — echoed back in the response for convenience. |
| 352 | |
| 353 | Returns: |
| 354 | ``ReleaseDownloadStatsResponse`` with per-asset counts and total. |
| 355 | """ |
| 356 | stmt = ( |
| 357 | select(db.MusehubReleaseAsset) |
| 358 | .where(db.MusehubReleaseAsset.release_id == release_id) |
| 359 | .order_by(db.MusehubReleaseAsset.created_at.asc()) |
| 360 | ) |
| 361 | rows = (await session.execute(stmt)).scalars().all() |
| 362 | asset_counts = [ |
| 363 | ReleaseAssetDownloadCount( |
| 364 | asset_id=row.asset_id, |
| 365 | name=row.name, |
| 366 | label=row.label, |
| 367 | download_count=row.download_count, |
| 368 | ) |
| 369 | for row in rows |
| 370 | ] |
| 371 | total = sum(a.download_count for a in asset_counts) |
| 372 | return ReleaseDownloadStatsResponse( |
| 373 | release_id=release_id, |
| 374 | tag=tag, |
| 375 | assets=asset_counts, |
| 376 | total_downloads=total, |
| 377 | ) |
| 378 | |
| 379 | |
| 380 | async def list_release_assets( |
| 381 | session: AsyncSession, |
| 382 | release_id: str, |
| 383 | tag: str, |
| 384 | ) -> ReleaseAssetListResponse: |
| 385 | """Return all assets attached to a release, ordered by creation time. |
| 386 | |
| 387 | Called by the release detail page to populate the Assets panel. |
| 388 | Each asset exposes its file size, download count, and direct download URL |
| 389 | so the UI can render the panel without additional API calls. |
| 390 | |
| 391 | Args: |
| 392 | session: Active async DB session. |
| 393 | release_id: UUID of the owning release. |
| 394 | tag: Version tag — echoed back in the response for convenience. |
| 395 | |
| 396 | Returns: |
| 397 | ``ReleaseAssetListResponse`` with assets ordered oldest-first. |
| 398 | """ |
| 399 | stmt = ( |
| 400 | select(db.MusehubReleaseAsset) |
| 401 | .where(db.MusehubReleaseAsset.release_id == release_id) |
| 402 | .order_by(db.MusehubReleaseAsset.created_at.asc()) |
| 403 | ) |
| 404 | rows = (await session.execute(stmt)).scalars().all() |
| 405 | return ReleaseAssetListResponse( |
| 406 | release_id=release_id, |
| 407 | tag=tag, |
| 408 | assets=[_to_asset_response(r) for r in rows], |
| 409 | ) |
| 410 | |
| 411 | |
| 412 | async def increment_asset_download_count( |
| 413 | session: AsyncSession, |
| 414 | asset_id: str, |
| 415 | ) -> bool: |
| 416 | """Atomically increment the download counter for a release asset. |
| 417 | |
| 418 | Called by the UI download-tracking endpoint each time a user clicks a |
| 419 | Download button on the release detail page. Uses an UPDATE statement so |
| 420 | the counter increment is atomic and does not require a SELECT+UPDATE pair. |
| 421 | |
| 422 | Args: |
| 423 | session: Active async DB session. |
| 424 | asset_id: UUID of the asset to increment. |
| 425 | |
| 426 | Returns: |
| 427 | ``True`` if the asset was found and updated; ``False`` otherwise. |
| 428 | """ |
| 429 | from sqlalchemy import update as sa_update |
| 430 | from sqlalchemy.engine import CursorResult |
| 431 | |
| 432 | raw = await session.execute( |
| 433 | sa_update(db.MusehubReleaseAsset) |
| 434 | .where(db.MusehubReleaseAsset.asset_id == asset_id) |
| 435 | .values(download_count=db.MusehubReleaseAsset.download_count + 1) |
| 436 | ) |
| 437 | cursor: CursorResult[tuple[()]] = raw # type: ignore[assignment] # SQLAlchemy UPDATE always returns CursorResult |
| 438 | return cursor.rowcount > 0 |