CODEX - More changes to the binary files, listing and storing
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user