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 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)" }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user