CODEX - More changes to the binary files, listing and storing

This commit is contained in:
2026-02-23 13:58:40 +02:00
parent d390bdac0d
commit 04b2a0bcb8
5 changed files with 154 additions and 22 deletions

View File

@@ -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 typing import Optional
from auth.models import TokenPayload from auth.models import TokenPayload
from auth.dependencies import require_permission from auth.dependencies import require_permission
@@ -99,7 +99,14 @@ async def upload_file(
if file_type == "binary": if file_type == "binary":
content_type = "application/octet-stream" 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 # Update the melody document with the file URL
if file_type == "preview": if file_type == "preview":
@@ -125,8 +132,8 @@ async def delete_file(
if file_type not in ("binary", "preview"): if file_type not in ("binary", "preview"):
raise HTTPException(status_code=400, detail="file_type must be 'binary' or 'preview'") raise HTTPException(status_code=400, detail="file_type must be 'binary' or 'preview'")
await service.get_melody(melody_id) melody = await service.get_melody(melody_id)
service.delete_file(melody_id, file_type) service.delete_file(melody_id, file_type, melody.uid)
@router.get("/{melody_id}/files") @router.get("/{melody_id}/files")
@@ -135,5 +142,18 @@ async def get_files(
_user: TokenPayload = Depends(require_permission("melodies", "view")), _user: TokenPayload = Depends(require_permission("melodies", "view")),
): ):
"""Get storage file URLs for a melody.""" """Get storage file URLs for a melody."""
await service.get_melody(melody_id) melody = await service.get_melody(melody_id)
return service.get_storage_files(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)

View File

@@ -232,7 +232,7 @@ async def delete_melody(melody_id: str) -> None:
doc_ref.delete() doc_ref.delete()
# Delete storage files # Delete storage files
_delete_storage_files(melody_id) _delete_storage_files(melody_id, row["data"].get("uid"))
# Delete from SQLite # Delete from SQLite
await melody_db.delete_melody(melody_id) 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 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'.""" """Delete a specific file from storage. file_type is 'binary' or 'preview'."""
bucket = get_bucket() bucket = get_bucket()
if not bucket: if not bucket:
return return
prefix = f"melodies/{melody_id}/" prefixes = _storage_prefixes(melody_id, melody_uid)
blobs = list(bucket.list_blobs(prefix=prefix)) blobs = _list_blobs_for_prefixes(bucket, prefixes)
for blob in blobs: for blob in blobs:
if file_type == "binary" and "binary" in blob.name: 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() 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.""" """Delete all storage files for a melody."""
bucket = get_bucket() bucket = get_bucket()
if not bucket: if not bucket:
return return
prefix = f"melodies/{melody_id}/" prefixes = _storage_prefixes(melody_id, melody_uid)
blobs = list(bucket.list_blobs(prefix=prefix)) blobs = _list_blobs_for_prefixes(bucket, prefixes)
for blob in blobs: for blob in blobs:
blob.delete() 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.""" """List storage files for a melody, returning URLs."""
bucket = get_bucket() bucket = get_bucket()
if not bucket: if not bucket:
return {"binary_url": None, "preview_url": None} return {"binary_url": None, "preview_url": None}
prefix = f"melodies/{melody_id}/" prefixes = _storage_prefixes(melody_id, melody_uid)
blobs = list(bucket.list_blobs(prefix=prefix)) blobs = _list_blobs_for_prefixes(bucket, prefixes)
result = {"binary_url": None, "preview_url": None} result = {"binary_url": None, "preview_url": None}
for blob in blobs: for blob in blobs:
blob.make_public() blob.make_public()
if "binary" in blob.name: if _is_binary_blob_name(blob.name):
result["binary_url"] = blob.public_url result["binary_url"] = blob.public_url
elif "preview" in blob.name: elif "preview" in blob.name:
result["preview_url"] = blob.public_url result["preview_url"] = blob.public_url

View File

@@ -557,11 +557,12 @@ export default function MelodyDetail() {
const handleDownload = async (e) => { const handleDownload = async (e) => {
e.preventDefault(); e.preventDefault();
const preferredUrl = melody?.id ? `/api/melodies/${melody.id}/download/binary` : binaryUrl;
try { try {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
let res = null; let res = null;
try { try {
res = await fetch(binaryUrl, { res = await fetch(preferredUrl, {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
} catch { } catch {

View File

@@ -227,14 +227,15 @@ export default function MelodyForm() {
return { effectiveUrl, effectiveName }; return { effectiveUrl, effectiveName };
}; };
const downloadExistingFile = async (fileUrl, fallbackName, e) => { const downloadExistingFile = async (fileUrl, fallbackName, e, downloadEndpoint = null) => {
e?.preventDefault?.(); e?.preventDefault?.();
if (!fileUrl) return; if (!fileUrl) return;
try { try {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
const sourceUrl = downloadEndpoint || fileUrl;
let res = null; let res = null;
try { try {
res = await fetch(fileUrl, { res = await fetch(sourceUrl, {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
} catch { } catch {
@@ -793,7 +794,14 @@ export default function MelodyForm() {
<div className="inline-flex items-center gap-2.5"> <div className="inline-flex items-center gap-2.5">
<button <button
type="button" type="button"
onClick={(e) => downloadExistingFile(binaryUrl, binaryName, e)} onClick={(e) =>
downloadExistingFile(
binaryUrl,
binaryName,
e,
(id || savedMelodyId) ? `/api/melodies/${id || savedMelodyId}/download/binary` : null
)
}
className="px-2 py-0.5 text-xs rounded-full" className="px-2 py-0.5 text-xs rounded-full"
style={{ color: "var(--text-link)", backgroundColor: "rgba(88,156,250,0.14)" }} style={{ color: "var(--text-link)", backgroundColor: "rgba(88,156,250,0.14)" }}
> >

View File

@@ -438,13 +438,14 @@ export default function MelodyList() {
e.stopPropagation(); e.stopPropagation();
const resolved = resolveEffectiveBinary(row); const resolved = resolveEffectiveBinary(row);
const binaryUrl = resolved.url; const binaryUrl = resolved.url;
const preferredUrl = row?.id ? `/api/melodies/${row.id}/download/binary` : binaryUrl;
if (!binaryUrl) return; if (!binaryUrl) return;
try { try {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
let res = null; let res = null;
try { try {
res = await fetch(binaryUrl, { res = await fetch(preferredUrl, {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
} catch { } catch {