gabriel / musehub public
musehub_releases.py python
438 lines 13.5 KB
6b53f1af feat: supercharge all pages, full SOC refactor, and Python 3.14 upgrade (#7) Gabriel Cardona <cgcardona@gmail.com> 5d ago
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