diff --git a/backend/melodies/router.py b/backend/melodies/router.py index 35a5944..b029c1b 100644 --- a/backend/melodies/router.py +++ b/backend/melodies/router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException +from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException, Response from typing import Optional from auth.models import TokenPayload from auth.dependencies import require_permission @@ -99,7 +99,14 @@ async def upload_file( if file_type == "binary": content_type = "application/octet-stream" - url = service.upload_file(melody_id, contents, file.filename, content_type) + url = service.upload_file_for_melody( + melody_id=melody_id, + melody_uid=melody.uid, + melody_pid=melody.pid, + file_bytes=contents, + filename=file.filename, + content_type=content_type, + ) # Update the melody document with the file URL if file_type == "preview": @@ -125,8 +132,8 @@ async def delete_file( if file_type not in ("binary", "preview"): raise HTTPException(status_code=400, detail="file_type must be 'binary' or 'preview'") - await service.get_melody(melody_id) - service.delete_file(melody_id, file_type) + melody = await service.get_melody(melody_id) + service.delete_file(melody_id, file_type, melody.uid) @router.get("/{melody_id}/files") @@ -135,5 +142,18 @@ async def get_files( _user: TokenPayload = Depends(require_permission("melodies", "view")), ): """Get storage file URLs for a melody.""" - await service.get_melody(melody_id) - return service.get_storage_files(melody_id) + melody = await service.get_melody(melody_id) + return service.get_storage_files(melody_id, melody.uid) + + +@router.get("/{melody_id}/download/binary") +async def download_binary_file( + melody_id: str, + _user: TokenPayload = Depends(require_permission("melodies", "view")), +): + """Download current melody binary with a PID-based filename.""" + melody = await service.get_melody(melody_id) + file_bytes, content_type = service.get_binary_file_bytes(melody_id, melody.uid) + filename = f"{(melody.pid or 'binary')}.bsm" + headers = {"Content-Disposition": f'attachment; filename="{filename}"'} + return Response(content=file_bytes, media_type=content_type, headers=headers) diff --git a/backend/melodies/service.py b/backend/melodies/service.py index 78d7a4a..5f51f6d 100644 --- a/backend/melodies/service.py +++ b/backend/melodies/service.py @@ -232,7 +232,7 @@ async def delete_melody(melody_id: str) -> None: doc_ref.delete() # Delete storage files - _delete_storage_files(melody_id) + _delete_storage_files(melody_id, row["data"].get("uid")) # Delete from SQLite await melody_db.delete_melody(melody_id) @@ -256,14 +256,116 @@ def upload_file(melody_id: str, file_bytes: bytes, filename: str, content_type: return blob.public_url -def delete_file(melody_id: str, file_type: str) -> None: +def _is_binary_blob_name(blob_name: str) -> bool: + lower = (blob_name or "").lower() + base = lower.rsplit("/", 1)[-1] + if "preview" in base: + return False + return ("binary" in base) or base.endswith(".bin") or base.endswith(".bsm") + + +def _safe_storage_segment(raw: str | None, fallback: str) -> str: + value = (raw or "").strip() + if not value: + value = fallback + chars = [] + for ch in value: + if ch.isalnum() or ch in ("-", "_", "."): + chars.append(ch) + else: + chars.append("_") + cleaned = "".join(chars).strip("._") + return cleaned or fallback + + +def _storage_prefixes(melody_id: str, melody_uid: str | None) -> list[str]: + uid_seg = _safe_storage_segment(melody_uid, melody_id) + id_seg = _safe_storage_segment(melody_id, melody_id) + prefixes = [f"melodies/{uid_seg}/"] + if uid_seg != id_seg: + # Legacy path support + prefixes.append(f"melodies/{id_seg}/") + return prefixes + + +def _list_blobs_for_prefixes(bucket, prefixes: list[str]): + all_blobs = [] + seen = set() + for prefix in prefixes: + for blob in bucket.list_blobs(prefix=prefix): + if blob.name in seen: + continue + seen.add(blob.name) + all_blobs.append(blob) + return all_blobs + + +def upload_file_for_melody(melody_id: str, melody_uid: str | None, melody_pid: str | None, file_bytes: bytes, filename: str, content_type: str) -> str: + """Upload a file to Firebase Storage under melodies/{melody_uid or melody_id}/. + Binary files are stored as {pid}.bsm and replace previous melody binaries. + """ + bucket = get_bucket() + if not bucket: + raise RuntimeError("Firebase Storage not initialized") + + prefixes = _storage_prefixes(melody_id, melody_uid) + primary_prefix = prefixes[0] + + if content_type in ("application/octet-stream", "application/macbinary"): + # Keep one active binary per melody, clean older binaries in both legacy/current prefixes. + for blob in _list_blobs_for_prefixes(bucket, prefixes): + if _is_binary_blob_name(blob.name): + blob.delete() + + stem = filename.rsplit(".", 1)[0] if "." in filename else filename + pid_seg = _safe_storage_segment(stem or melody_pid, "binary") + storage_path = f"{primary_prefix}{pid_seg}.bsm" + binary_content_type = "application/octet-stream" + blob = bucket.blob(storage_path) + blob.upload_from_string(file_bytes, content_type=binary_content_type) + blob.make_public() + return blob.public_url + + ext = filename.rsplit(".", 1)[-1] if "." in filename else "mp3" + storage_path = f"{primary_prefix}preview.{ext}" + blob = bucket.blob(storage_path) + blob.upload_from_string(file_bytes, content_type=content_type) + blob.make_public() + return blob.public_url + + +def get_binary_file_bytes(melody_id: str, melody_uid: str | None = None) -> tuple[bytes, str]: + """Fetch current binary bytes for a melody from Firebase Storage.""" + bucket = get_bucket() + if not bucket: + raise RuntimeError("Firebase Storage not initialized") + + prefixes = _storage_prefixes(melody_id, melody_uid) + blobs = [b for b in _list_blobs_for_prefixes(bucket, prefixes) if _is_binary_blob_name(b.name)] + if not blobs: + raise NotFoundError("Binary file") + + # Prefer explicit binary.* naming, then newest. + blobs.sort( + key=lambda b: ( + 0 if "binary" in b.name.rsplit("/", 1)[-1].lower() else 1, + -(int(b.time_created.timestamp()) if getattr(b, "time_created", None) else 0), + ) + ) + chosen = blobs[0] + data = chosen.download_as_bytes() + content_type = chosen.content_type or "application/octet-stream" + return data, content_type + + +def delete_file(melody_id: str, file_type: str, melody_uid: str | None = None) -> None: """Delete a specific file from storage. file_type is 'binary' or 'preview'.""" bucket = get_bucket() if not bucket: return - prefix = f"melodies/{melody_id}/" - blobs = list(bucket.list_blobs(prefix=prefix)) + prefixes = _storage_prefixes(melody_id, melody_uid) + blobs = _list_blobs_for_prefixes(bucket, prefixes) for blob in blobs: if file_type == "binary" and "binary" in blob.name: @@ -272,31 +374,31 @@ def delete_file(melody_id: str, file_type: str) -> None: blob.delete() -def _delete_storage_files(melody_id: str) -> None: +def _delete_storage_files(melody_id: str, melody_uid: str | None = None) -> None: """Delete all storage files for a melody.""" bucket = get_bucket() if not bucket: return - prefix = f"melodies/{melody_id}/" - blobs = list(bucket.list_blobs(prefix=prefix)) + prefixes = _storage_prefixes(melody_id, melody_uid) + blobs = _list_blobs_for_prefixes(bucket, prefixes) for blob in blobs: blob.delete() -def get_storage_files(melody_id: str) -> dict: +def get_storage_files(melody_id: str, melody_uid: str | None = None) -> dict: """List storage files for a melody, returning URLs.""" bucket = get_bucket() if not bucket: return {"binary_url": None, "preview_url": None} - prefix = f"melodies/{melody_id}/" - blobs = list(bucket.list_blobs(prefix=prefix)) + prefixes = _storage_prefixes(melody_id, melody_uid) + blobs = _list_blobs_for_prefixes(bucket, prefixes) result = {"binary_url": None, "preview_url": None} for blob in blobs: blob.make_public() - if "binary" in blob.name: + if _is_binary_blob_name(blob.name): result["binary_url"] = blob.public_url elif "preview" in blob.name: result["preview_url"] = blob.public_url diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx index 1037be1..20a696a 100644 --- a/frontend/src/melodies/MelodyDetail.jsx +++ b/frontend/src/melodies/MelodyDetail.jsx @@ -557,11 +557,12 @@ export default function MelodyDetail() { const handleDownload = async (e) => { e.preventDefault(); + const preferredUrl = melody?.id ? `/api/melodies/${melody.id}/download/binary` : binaryUrl; try { const token = localStorage.getItem("access_token"); let res = null; try { - res = await fetch(binaryUrl, { + res = await fetch(preferredUrl, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); } catch { diff --git a/frontend/src/melodies/MelodyForm.jsx b/frontend/src/melodies/MelodyForm.jsx index 6e29184..7437206 100644 --- a/frontend/src/melodies/MelodyForm.jsx +++ b/frontend/src/melodies/MelodyForm.jsx @@ -227,14 +227,15 @@ export default function MelodyForm() { return { effectiveUrl, effectiveName }; }; - const downloadExistingFile = async (fileUrl, fallbackName, e) => { + const downloadExistingFile = async (fileUrl, fallbackName, e, downloadEndpoint = null) => { e?.preventDefault?.(); if (!fileUrl) return; try { const token = localStorage.getItem("access_token"); + const sourceUrl = downloadEndpoint || fileUrl; let res = null; try { - res = await fetch(fileUrl, { + res = await fetch(sourceUrl, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); } catch { @@ -793,7 +794,14 @@ export default function MelodyForm() {