Merge remote-tracking branch 'refs/remotes/origin/main'
This commit is contained in:
commit
070777626f
5 changed files with 196 additions and 64 deletions
|
|
@ -25,6 +25,7 @@ Endpoints (Phase 1 - library database):
|
|||
GET /library/song/{song_id}
|
||||
GET /library/cover-art/{song_id}
|
||||
POST /library/cover-art/{song_id}
|
||||
POST /library/cover-art-by-path
|
||||
POST /library/artist-photo
|
||||
GET /library/artist-photo/{artist_name}
|
||||
"""
|
||||
|
|
@ -65,18 +66,18 @@ COVER_NAMES = ('cover.jpg', 'folder.jpg', 'artwork.jpg', 'front.jpg',
|
|||
# ── Database connection management ──────────────────────────────────────────
|
||||
#
|
||||
# CRITICAL: Python's `with sqlite3.connect(...) as c:` is a TRANSACTION
|
||||
# manager only -- it commits/rolls back but NEVER calls .close().
|
||||
# manager only — it commits/rolls back but NEVER calls .close().
|
||||
# Every bare connect() in the original code leaked a file handle until GC.
|
||||
#
|
||||
# get_db() fixes four issues at once:
|
||||
# 1. WAL mode -- readers never block writers; writers never block readers
|
||||
# 2. synchronous=NORMAL -- safe with WAL, ~3x faster than default FULL
|
||||
# 3. busy_timeout -- waits up to N seconds instead of raising immediately
|
||||
# 4. Explicit close -- in finally: block; no leaked handles under any path
|
||||
# 1. WAL mode — readers never block writers; writers never block readers
|
||||
# 2. synchronous=NORMAL — safe with WAL, ~3x faster than default FULL
|
||||
# 3. busy_timeout — waits up to N seconds instead of raising immediately
|
||||
# 4. Explicit close — in finally: block; no leaked handles under any path
|
||||
#
|
||||
# check_same_thread=False: BackgroundTasks run on a threadpool worker, not the
|
||||
# asyncio thread. Each get_db() call creates its own connection so there is no
|
||||
# actual cross-thread sharing -- the flag disables an overly conservative check.
|
||||
# actual cross-thread sharing — the flag disables an overly conservative check.
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
|
@ -146,7 +147,7 @@ def init_db():
|
|||
os.makedirs(COVER_ART_DIR, exist_ok=True)
|
||||
os.makedirs(ARTIST_PHOTO_DIR, exist_ok=True)
|
||||
with get_db() as c:
|
||||
# Existing tables -- untouched
|
||||
# Existing tables — untouched
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS dj_profiles (
|
||||
file_path TEXT PRIMARY KEY, bpm REAL,
|
||||
silence_start REAL, silence_end REAL, loudness_lufs REAL,
|
||||
|
|
@ -155,7 +156,7 @@ def init_db():
|
|||
basename TEXT, full_path TEXT, title_words TEXT,
|
||||
PRIMARY KEY (basename, full_path))""")
|
||||
|
||||
# Phase 1 -- authoritative song metadata
|
||||
# Phase 1 — authoritative song metadata
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS songs (
|
||||
id TEXT PRIMARY KEY,
|
||||
full_path TEXT UNIQUE NOT NULL,
|
||||
|
|
@ -261,7 +262,7 @@ def read_tags(full_path: str) -> dict:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# AIFF files don't support easy=True -- fall back to raw ID3 tags
|
||||
# AIFF files don't support easy=True — fall back to raw ID3 tags
|
||||
audio_raw = None
|
||||
ext = Path(full_path).suffix.lower()
|
||||
if ext in ('.aiff', '.aif') and (audio_easy is None or not audio_easy):
|
||||
|
|
@ -473,7 +474,7 @@ async def lifespan(app: FastAPI):
|
|||
print(f" Cannot list MUSIC_DIR: {e}", flush=True)
|
||||
# Run blocking startup work in a thread so the event loop stays responsive.
|
||||
# Uvicorn accepts connections during lifespan startup but cannot dispatch them
|
||||
# until yield -- keeping the loop unblocked allows health checks to queue properly.
|
||||
# until yield — keeping the loop unblocked allows health checks to queue properly.
|
||||
await asyncio.to_thread(build_file_index)
|
||||
await asyncio.to_thread(scan_library)
|
||||
yield
|
||||
|
|
@ -564,7 +565,7 @@ def _create_task(coro):
|
|||
Keeps a strong reference until the task finishes so the GC cannot
|
||||
cancel it prematurely. Logs unhandled exceptions instead of silencing them.
|
||||
"""
|
||||
task = _create_task(coro)
|
||||
task = asyncio.create_task(coro) #hotfix
|
||||
_background_tasks.add(task)
|
||||
def _on_done(t):
|
||||
_background_tasks.discard(t)
|
||||
|
|
@ -584,14 +585,14 @@ def resolve_path(relative: str) -> Optional[str]:
|
|||
Resolve a Navidrome-relative path to an absolute filesystem path.
|
||||
|
||||
Resolution order:
|
||||
1. Direct join with MUSIC_DIR -- works when paths match exactly
|
||||
2. Strip leading path components -- handles sub-library prefixes
|
||||
3. Companion songs table lookup by relative_path -- handles Picard
|
||||
1. Direct join with MUSIC_DIR — works when paths match exactly
|
||||
2. Strip leading path components — handles sub-library prefixes
|
||||
3. Companion songs table lookup by relative_path — handles Picard
|
||||
renames where Navidrome path no longer matches disk structure.
|
||||
This is the key fix: uses album+artist context so two files with
|
||||
the same title (e.g. 'In All the Wrong Places') resolve correctly.
|
||||
4. Exact filename match on disk -- last resort before fuzzy
|
||||
5. Fuzzy title match -- lowest confidence, only when nothing else works
|
||||
4. Exact filename match on disk — last resort before fuzzy
|
||||
5. Fuzzy title match — lowest confidence, only when nothing else works
|
||||
"""
|
||||
decoded = relative
|
||||
for _ in range(5):
|
||||
|
|
@ -614,7 +615,7 @@ def resolve_path(relative: str) -> Optional[str]:
|
|||
if os.path.isfile(sub):
|
||||
return sub
|
||||
|
||||
# 3. Companion songs table -- look up by relative_path.
|
||||
# 3. Companion songs table — look up by relative_path.
|
||||
# Also tries matching on title+album+artist to disambiguate files
|
||||
# with identical names in different albums (e.g. compilation tracks).
|
||||
try:
|
||||
|
|
@ -692,7 +693,7 @@ def resolve_path(relative: str) -> Optional[str]:
|
|||
print(f" resolve: exact filename -> {found}", flush=True)
|
||||
return found
|
||||
|
||||
# 5. Fuzzy title match (lowest confidence -- last resort)
|
||||
# 5. Fuzzy title match (lowest confidence — last resort)
|
||||
target_stem = Path(target).stem.lower() if target else ""
|
||||
target_ext = Path(target).suffix.lower() if target else ""
|
||||
title_part = re.sub(r'^\d+[\s\.\-]+', '', target_stem).strip()
|
||||
|
|
@ -725,8 +726,8 @@ def resolve_path(relative: str) -> Optional[str]:
|
|||
|
||||
# ── Navidrome HTTP client ────────────────────────────────────────────────────
|
||||
# Single shared AsyncClient reuses the TCP connection to Navidrome across all
|
||||
# trigger_scan() calls (AUDIT-020). Previously a new client -- and therefore a
|
||||
# new connection -- was created on every call, adding DNS + TCP + TLS overhead
|
||||
# trigger_scan() calls (AUDIT-020). Previously a new client — and therefore a
|
||||
# new connection — was created on every call, adding DNS + TCP + TLS overhead
|
||||
# on every metadata edit and upload, which is especially costly over Tailscale.
|
||||
#
|
||||
# The client is initialised in lifespan() and closed on shutdown.
|
||||
|
|
@ -762,10 +763,10 @@ async def sync_navidrome_ids_task():
|
|||
Fetch all songs from Navidrome and match them into our songs table.
|
||||
|
||||
Matching strategy (tried in order per song):
|
||||
1. title + artist -- primary, both read from same ID3 tags
|
||||
2. title + album -- fallback when artist field differs
|
||||
3. title only -- fallback for unique titles
|
||||
4. duration bucket -- last resort (±2s tolerance, unique per bucket)
|
||||
1. title + artist — primary, both read from same ID3 tags
|
||||
2. title + album — fallback when artist field differs
|
||||
3. title only — fallback for unique titles
|
||||
4. duration bucket — last resort (±2s tolerance, unique per bucket)
|
||||
"""
|
||||
try:
|
||||
if not all([SUBSONIC_USER, SUBSONIC_TOKEN, SUBSONIC_SALT]):
|
||||
|
|
@ -935,7 +936,7 @@ async def sync_navidrome_ids_task():
|
|||
matched_s5 = matched_s6 = matched_s7 = unmatched = 0
|
||||
unmatched_samples = []
|
||||
|
||||
# Build the update list entirely in Python (pure dict lookups -- fast, no I/O),
|
||||
# Build the update list entirely in Python (pure dict lookups — fast, no I/O),
|
||||
# then write to SQLite in a single executemany call.
|
||||
# This avoids holding the DB connection open for the entire iteration AND
|
||||
# never blocks the event loop with thousands of individual execute() calls
|
||||
|
|
@ -1002,7 +1003,7 @@ async def sync_navidrome_ids_task():
|
|||
f"duration={ns.get('duration')}"
|
||||
)
|
||||
|
||||
# Single batched write -- one connection open, one executemany, one commit
|
||||
# Single batched write — one connection open, one executemany, one commit
|
||||
with get_db() as c:
|
||||
c.executemany(
|
||||
"UPDATE songs SET navidrome_id = ?, navidrome_album_id = ? WHERE id = ?",
|
||||
|
|
@ -1095,9 +1096,9 @@ def enforce_tag_whitelist(
|
|||
# Never remove non-empty COMPOSER/LYRICS even if not in allowed
|
||||
# (preserve existing values the user may have set manually)
|
||||
elif ku == 'COMPOSER' and not f[k][0].strip():
|
||||
to_remove.append(k) # blank composer -- remove
|
||||
to_remove.append(k) # blank composer — remove
|
||||
elif ku == 'LYRICS' and not f[k][0].strip():
|
||||
to_remove.append(k) # blank lyrics -- remove
|
||||
to_remove.append(k) # blank lyrics — remove
|
||||
result["removed"] = to_remove
|
||||
result["kept"] = [k for k in f.keys() if k not in to_remove]
|
||||
if not dry_run and to_remove:
|
||||
|
|
@ -1129,7 +1130,7 @@ def enforce_tag_whitelist(
|
|||
|
||||
|
||||
def clean_picard_tags(full_path: str, dry_run: bool = False) -> dict:
|
||||
"""Legacy blacklist cleaner -- now delegates to whitelist enforcer."""
|
||||
"""Legacy blacklist cleaner — now delegates to whitelist enforcer."""
|
||||
return enforce_tag_whitelist(full_path, dry_run=dry_run)
|
||||
|
||||
|
||||
|
|
@ -1171,7 +1172,7 @@ def build_target_path(full_path: str) -> Optional[str]:
|
|||
album = sanitize(get('album') or 'Unknown Album')
|
||||
ext = Path(full_path).suffix.lower()
|
||||
|
||||
# Track number -- strip /total if present (e.g. "3/12" -> 3)
|
||||
# Track number — strip /total if present (e.g. "3/12" -> 3)
|
||||
track_num = 0
|
||||
raw_track = get('tracknumber')
|
||||
if raw_track:
|
||||
|
|
@ -1199,7 +1200,7 @@ def build_target_path(full_path: str) -> Optional[str]:
|
|||
has_disc_siblings = any(re.match(r'\d{2}-\d{2}', f) for f in siblings)
|
||||
if not has_disc_siblings:
|
||||
print(f" disc sanity: no disc siblings for {os.path.basename(full_path)}"
|
||||
f" -- overriding disc {disc_num} -> 1", flush=True)
|
||||
f" — overriding disc {disc_num} -> 1", flush=True)
|
||||
disc_num = 1
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -1256,7 +1257,7 @@ def restructure_file(full_path: str) -> Optional[str]:
|
|||
else:
|
||||
break
|
||||
|
||||
# Update DB with new path -- re-read tags for accurate sort keys
|
||||
# Update DB with new path — re-read tags for accurate sort keys
|
||||
new_relative = os.path.relpath(target, MUSIC_DIR)
|
||||
song_id = hashlib.md5(full_path.encode()).hexdigest()
|
||||
new_id = hashlib.md5(target.encode()).hexdigest()
|
||||
|
|
@ -1278,7 +1279,7 @@ def restructure_file(full_path: str) -> Optional[str]:
|
|||
song_id
|
||||
))
|
||||
if cur.rowcount == 0:
|
||||
# Row used old full_path as key -- try matching by path
|
||||
# Row used old full_path as key — try matching by path
|
||||
cur.execute("""UPDATE songs SET
|
||||
id=?, full_path=?, relative_path=?,
|
||||
sort_title=?, sort_artist=?, sort_album=?, sort_album_artist=?,
|
||||
|
|
@ -1313,7 +1314,7 @@ def restructure_all() -> dict:
|
|||
if not os.path.isfile(full_path):
|
||||
skipped += 1
|
||||
continue
|
||||
# Enforce whitelist before restructuring -- clean tags first so
|
||||
# Enforce whitelist before restructuring — clean tags first so
|
||||
# build_target_path reads clean data and generates the correct path
|
||||
enforce_tag_whitelist(full_path, preserve_composer=True, preserve_lyrics=True)
|
||||
result = restructure_file(full_path)
|
||||
|
|
@ -1326,7 +1327,7 @@ def restructure_all() -> dict:
|
|||
|
||||
|
||||
def apply_tags(path: str, u, preserve_composer: bool = True, preserve_lyrics: bool = True):
|
||||
"""Write tags then enforce whitelist -- only Navidrome tags survive."""
|
||||
"""Write tags then enforce whitelist — only Navidrome tags survive."""
|
||||
audio = MutagenFile(path, easy=True)
|
||||
if audio is None:
|
||||
raise ValueError(f"Unsupported format: {path}")
|
||||
|
|
@ -1338,12 +1339,12 @@ def apply_tags(path: str, u, preserve_composer: bool = True, preserve_lyrics: bo
|
|||
if u.year: audio['date'] = str(u.year)
|
||||
if u.track_number: audio['tracknumber'] = str(u.track_number)
|
||||
audio.save()
|
||||
# Enforce whitelist after writing -- nukes everything not in NAVIDROME_TAGS
|
||||
# Enforce whitelist after writing — nukes everything not in NAVIDROME_TAGS
|
||||
enforce_tag_whitelist(path, preserve_composer=preserve_composer, preserve_lyrics=preserve_lyrics)
|
||||
|
||||
|
||||
def apply_tags_dict(path: str, tags: dict, preserve_composer: bool = True, preserve_lyrics: bool = True):
|
||||
"""Write tags dict then enforce whitelist -- only Navidrome tags survive."""
|
||||
"""Write tags dict then enforce whitelist — only Navidrome tags survive."""
|
||||
audio = MutagenFile(path, easy=True)
|
||||
if audio is None:
|
||||
raise ValueError(f"Unsupported format: {path}")
|
||||
|
|
@ -1446,7 +1447,7 @@ def gen_vis_frames(path: str, fps: float = 30.0, fft_size: int = 1024, pts: int
|
|||
fp.append(avg) # no eqBoost
|
||||
frames.append(fp)
|
||||
del y
|
||||
# gc.collect() removed (AUDIT-014) -- del y is sufficient for numpy arrays
|
||||
# gc.collect() removed (AUDIT-014) — del y is sufficient for numpy arrays
|
||||
vals = sorted(v for f in frames for v in f if v > 0.001)
|
||||
if vals:
|
||||
p95 = vals[min(int(len(vals) * 0.95), len(vals) - 1)]
|
||||
|
|
@ -1555,7 +1556,7 @@ async def edit_metadata(update: MetadataUpdate):
|
|||
if not fp:
|
||||
raise HTTPException(404, f"File not found. raw='{update.relative_path}' MUSIC_DIR='{MUSIC_DIR}'")
|
||||
try:
|
||||
# Offload blocking Mutagen I/O to a thread -- audio.save() on FLAC can
|
||||
# Offload blocking Mutagen I/O to a thread — audio.save() on FLAC can
|
||||
# take 100-500ms and must not block the event loop (AUDIT-009)
|
||||
await asyncio.to_thread(apply_tags, fp, update)
|
||||
await asyncio.to_thread(update_song_in_db, fp)
|
||||
|
|
@ -1638,7 +1639,7 @@ async def upload_track(
|
|||
return {"status": "uploaded", "path": f"uploads/{file.filename}", "profile": profile}
|
||||
except Exception as e:
|
||||
# Clean up both the file AND the DB row that update_song_in_db already
|
||||
# wrote -- leaving an orphaned row pointing to a deleted file (AUDIT-017)
|
||||
# wrote — leaving an orphaned row pointing to a deleted file (AUDIT-017)
|
||||
if os.path.exists(fp):
|
||||
os.remove(fp)
|
||||
song_id = hashlib.md5(fp.encode()).hexdigest()
|
||||
|
|
@ -1802,7 +1803,7 @@ async def vis_frames(relative_path: str):
|
|||
if not fp:
|
||||
raise HTTPException(404, "Not found")
|
||||
# gen_vis_frames() loads the full audio file via librosa (~20MB+ for a 5min song)
|
||||
# and runs FFT on every frame -- must not block the event loop (AUDIT-008)
|
||||
# and runs FFT on every frame — must not block the event loop (AUDIT-008)
|
||||
frames = await asyncio.to_thread(get_vis, fp)
|
||||
if not frames:
|
||||
raise HTTPException(500, "Generation failed")
|
||||
|
|
@ -1828,7 +1829,7 @@ async def precompute(background_tasks: BackgroundTasks, relative_path: str = "")
|
|||
fp = resolve_path(relative_path)
|
||||
if not fp:
|
||||
raise HTTPException(404, f"Cannot resolve path: {relative_path!r}")
|
||||
# Pass fp directly -- avoids the lambda capturing relative_path and
|
||||
# Pass fp directly — avoids the lambda capturing relative_path and
|
||||
# silently calling get_vis("") if resolve fails later (AUDIT-016)
|
||||
background_tasks.add_task(get_vis, fp)
|
||||
return {"message": f"Computing: {relative_path}"}
|
||||
|
|
@ -1843,7 +1844,7 @@ async def bulk_fix(background_tasks: BackgroundTasks, dry_run: bool = False):
|
|||
?dry_run=true returns a list of {from, to} moves without executing them.
|
||||
"""
|
||||
if dry_run:
|
||||
# Preview only -- return what would move
|
||||
# Preview only — return what would move
|
||||
moves = []
|
||||
with get_db() as c:
|
||||
rows = c.execute("SELECT full_path FROM songs").fetchall()
|
||||
|
|
@ -2021,21 +2022,32 @@ async def library_cover_art(song_id: str):
|
|||
|
||||
@app.post("/library/cover-art/{song_id}")
|
||||
async def upload_cover_art(song_id: str, file: UploadFile = File(...)):
|
||||
"""Upload cover art -- saves as cover.jpg and updates all songs in that directory."""
|
||||
"""Upload cover art — saves as cover.jpg and updates all songs in that directory."""
|
||||
print(f" [cover-art] POST /library/cover-art/{song_id}", flush=True)
|
||||
print(f" [cover-art] Upload filename: {file.filename}, content_type: {file.content_type}", flush=True)
|
||||
with get_db() as c:
|
||||
row = c.execute("SELECT full_path FROM songs WHERE id = ?", (song_id,)).fetchone()
|
||||
if not row:
|
||||
# Fallback: try navidrome_id (iOS may pass Navidrome ID when companion ID unavailable)
|
||||
row = c.execute("SELECT full_path FROM songs WHERE navidrome_id = ?", (song_id,)).fetchone()
|
||||
if row:
|
||||
print(f" [cover-art] Found by navidrome_id fallback: {row[0]}", flush=True)
|
||||
if not row:
|
||||
print(f" [cover-art] Song not found for id={song_id}", flush=True)
|
||||
raise HTTPException(404, "Song not found")
|
||||
song_dir = os.path.dirname(row[0])
|
||||
cover_dest = os.path.join(song_dir, "cover.jpg")
|
||||
print(f" [cover-art] Saving to: {cover_dest}", flush=True)
|
||||
try:
|
||||
file_data = await file.read()
|
||||
print(f" [cover-art] Received {len(file_data)} bytes", flush=True)
|
||||
with open(cover_dest, "wb") as buf:
|
||||
shutil.copyfileobj(file.file, buf)
|
||||
buf.write(file_data)
|
||||
saved_size = os.path.getsize(cover_dest)
|
||||
print(f" [cover-art] Written to disk: {saved_size} bytes", flush=True)
|
||||
with get_db() as c:
|
||||
c.execute("UPDATE songs SET cover_art_path = ? WHERE full_path LIKE ?",
|
||||
(cover_dest, os.path.join(song_dir, "%")))
|
||||
# Clear any cached extracted cover art for all songs in this directory.
|
||||
# Merged into the same connection: no leaked handle, one fewer open/close.
|
||||
sids = [r[0] for r in c.execute(
|
||||
"SELECT id FROM songs WHERE full_path LIKE ?",
|
||||
(os.path.join(song_dir, "%"),)
|
||||
|
|
@ -2044,9 +2056,73 @@ async def upload_cover_art(song_id: str, file: UploadFile = File(...)):
|
|||
cached = os.path.join(COVER_ART_DIR, f"{sid}.jpg")
|
||||
if os.path.isfile(cached):
|
||||
os.remove(cached)
|
||||
print(f" [cover-art] Success — updated {len(sids)} songs, cleared cached art", flush=True)
|
||||
await push.broadcast("cover_art_updated", {"song_id": song_id})
|
||||
return {"status": "saved", "path": cover_dest}
|
||||
except Exception as e:
|
||||
print(f" [cover-art] FAILED: {e}", flush=True)
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/library/cover-art-by-path")
|
||||
async def upload_cover_art_by_path(
|
||||
relative_path: str = Form(...),
|
||||
file: UploadFile = File(...)
|
||||
):
|
||||
"""
|
||||
Upload cover art using a relative file path instead of a companion song ID.
|
||||
Used when the iOS client has Navidrome-sourced songs without companion: prefix.
|
||||
Resolves the path to find the album directory, then saves cover.jpg there.
|
||||
"""
|
||||
print(f" [cover-art-by-path] POST relative_path='{relative_path}'", flush=True)
|
||||
print(f" [cover-art-by-path] Upload filename: {file.filename}, content_type: {file.content_type}", flush=True)
|
||||
|
||||
fp = resolve_path(relative_path)
|
||||
if not fp:
|
||||
print(f" [cover-art-by-path] Could not resolve path: '{relative_path}'", flush=True)
|
||||
raise HTTPException(404, f"File not found. relative_path='{relative_path}'")
|
||||
|
||||
song_dir = os.path.dirname(fp)
|
||||
cover_dest = os.path.join(song_dir, "cover.jpg")
|
||||
print(f" [cover-art-by-path] Resolved to: {fp}", flush=True)
|
||||
print(f" [cover-art-by-path] Saving cover to: {cover_dest}", flush=True)
|
||||
|
||||
try:
|
||||
file_data = await file.read()
|
||||
print(f" [cover-art-by-path] Received {len(file_data)} bytes", flush=True)
|
||||
with open(cover_dest, "wb") as buf:
|
||||
buf.write(file_data)
|
||||
saved_size = os.path.getsize(cover_dest)
|
||||
print(f" [cover-art-by-path] Written to disk: {saved_size} bytes", flush=True)
|
||||
|
||||
# Update all songs in this directory
|
||||
with get_db() as c:
|
||||
c.execute("UPDATE songs SET cover_art_path = ? WHERE full_path LIKE ?",
|
||||
(cover_dest, os.path.join(song_dir, "%")))
|
||||
updated = c.execute(
|
||||
"SELECT COUNT(*) FROM songs WHERE full_path LIKE ?",
|
||||
(os.path.join(song_dir, "%"),)
|
||||
).fetchone()[0]
|
||||
sids = [r[0] for r in c.execute(
|
||||
"SELECT id FROM songs WHERE full_path LIKE ?",
|
||||
(os.path.join(song_dir, "%"),)
|
||||
).fetchall()]
|
||||
|
||||
# Clear any cached extracted cover art
|
||||
cleared = 0
|
||||
for sid in sids:
|
||||
cached = os.path.join(COVER_ART_DIR, f"{sid}.jpg")
|
||||
if os.path.isfile(cached):
|
||||
os.remove(cached)
|
||||
cleared += 1
|
||||
|
||||
print(f" [cover-art-by-path] Success — {updated} songs updated, {cleared} cached cleared", flush=True)
|
||||
await push.broadcast("cover_art_updated", {"path": relative_path})
|
||||
return {"status": "saved", "path": cover_dest, "songs_updated": updated}
|
||||
except Exception as e:
|
||||
print(f" [cover-art-by-path] FAILED: {e}", flush=True)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
|
|
@ -2118,7 +2194,7 @@ async def auto_fix_duplicate_albums():
|
|||
Fix #9: After every Navidrome scan, detect duplicate album entries
|
||||
(same name, different album_artist) and rewrite tags on minority files
|
||||
so Navidrome groups them correctly on the next scan.
|
||||
Uses tag rewrites only -- never writes to Navidrome's DB directly.
|
||||
Uses tag rewrites only — never writes to Navidrome's DB directly.
|
||||
"""
|
||||
try:
|
||||
with get_navidrome_db() as nav:
|
||||
|
|
@ -2346,7 +2422,7 @@ def check_library_conflicts(navidrome_db_path: str = os.getenv("NAVIDROME_DB_PAT
|
|||
issues.append({
|
||||
"type": "duplicate_track",
|
||||
"severity": "warning",
|
||||
"title": f"Duplicate: {title} -- {artist}",
|
||||
"title": f"Duplicate: {title} — {artist}",
|
||||
"detail": f"Found {cnt} copies of this track.",
|
||||
"affected_paths": path_list,
|
||||
"fix_action": None,
|
||||
|
|
@ -2377,7 +2453,7 @@ def check_library_conflicts(navidrome_db_path: str = os.getenv("NAVIDROME_DB_PAT
|
|||
print(f" conflict check 6 failed: {e}", flush=True)
|
||||
|
||||
# ── 7. Album identity reassignment (Fix #13) ────────────────────────────
|
||||
# Detect tracks whose Navidrome album_id changed since last sync --
|
||||
# Detect tracks whose Navidrome album_id changed since last sync —
|
||||
# indicates Navidrome reassigned them to a different album entry.
|
||||
try:
|
||||
with get_db() as c:
|
||||
|
|
@ -2427,7 +2503,7 @@ def check_library_conflicts(navidrome_db_path: str = os.getenv("NAVIDROME_DB_PAT
|
|||
@app.get("/library/conflicts")
|
||||
async def library_conflicts():
|
||||
"""Run all conflict checks and return structured results."""
|
||||
issues = check_library_conflicts(navidrome_db)
|
||||
issues = await asyncio.to_thread(check_library_conflicts)
|
||||
error_count = sum(1 for i in issues if i["severity"] == "error")
|
||||
warning_count = sum(1 for i in issues if i["severity"] == "warning")
|
||||
return {
|
||||
|
|
@ -2506,7 +2582,7 @@ async def fix_conflict(request: FixConflictRequest):
|
|||
raise HTTPException(500, f"Fix failed: {e}")
|
||||
|
||||
elif action == "fix_missing_files":
|
||||
# Trigger a full Navidrome rescan -- Navidrome will detect and remove
|
||||
# Trigger a full Navidrome rescan — Navidrome will detect and remove
|
||||
# missing files automatically during a full scan. We cannot write to
|
||||
# Navidrome's DB directly while it is running (mounted read-only).
|
||||
try:
|
||||
|
|
@ -2557,7 +2633,7 @@ async def fix_conflict(request: FixConflictRequest):
|
|||
_failed.append(os.path.relpath(full_path, MUSIC_DIR))
|
||||
return _fixed, _failed
|
||||
|
||||
# All FLAC opens + saves are blocking I/O -- run in thread (AUDIT-010)
|
||||
# All FLAC opens + saves are blocking I/O — run in thread (AUDIT-010)
|
||||
fixed, failed = await asyncio.to_thread(_fix_picard_files)
|
||||
await trigger_scan()
|
||||
await push.broadcast("conflicts_updated", {"action": "fix_picard_tags"})
|
||||
|
|
@ -2587,7 +2663,7 @@ async def library_clean_tags(background_tasks: BackgroundTasks, dry_run: bool =
|
|||
?dry_run=true returns what would be removed without writing.
|
||||
"""
|
||||
if dry_run:
|
||||
# enforce_tag_whitelist opens every file -- run in thread (AUDIT-011)
|
||||
# enforce_tag_whitelist opens every file — run in thread (AUDIT-011)
|
||||
def _preview():
|
||||
_results = []
|
||||
with get_db() as c:
|
||||
|
|
@ -2641,7 +2717,7 @@ async def ws_push(ws: WebSocket):
|
|||
while True:
|
||||
raw = await ws.receive_text()
|
||||
|
||||
# JSON decode failures are recoverable -- send error, keep connection.
|
||||
# JSON decode failures are recoverable — send error, keep connection.
|
||||
# Previously any exception here permanently dropped the client (AUDIT-019).
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
|
|
@ -2674,7 +2750,7 @@ async def ws_push(ws: WebSocket):
|
|||
await push.send_to(ws, "profile",
|
||||
{"path": rp, "error": "not_analyzed"})
|
||||
except Exception as e:
|
||||
# DB error (e.g. locked during scan) -- report but keep connection
|
||||
# DB error (e.g. locked during scan) — report but keep connection
|
||||
await push.send_to(ws, "error", {"message": str(e)})
|
||||
|
||||
elif act == "get_vis":
|
||||
|
|
@ -2694,6 +2770,6 @@ async def ws_push(ws: WebSocket):
|
|||
except WebSocketDisconnect:
|
||||
push.disconnect(ws)
|
||||
except Exception as e:
|
||||
# Unrecoverable transport error -- log and clean up
|
||||
# Unrecoverable transport error — log and clean up
|
||||
print(f"WS transport error: {e}", flush=True)
|
||||
push.disconnect(ws)
|
||||
|
|
|
|||
|
|
@ -314,8 +314,13 @@ struct BatchAlbumEditorSheet: View {
|
|||
Task {
|
||||
do {
|
||||
// 1. Cover art
|
||||
if let image = pendingCoverImage, let cid = coverArtCompanionId {
|
||||
try await api.uploadCoverArt(songId: cid, image: image)
|
||||
if let image = pendingCoverImage {
|
||||
if let cid = coverArtCompanionId {
|
||||
try await api.uploadCoverArt(songId: cid, image: image)
|
||||
} else if let path = includedSongs.first(where: { $0.path != nil })?.path {
|
||||
// Fallback: no companion ID — use relative path endpoint
|
||||
try await api.uploadCoverArtByPath(relativePath: path, image: image)
|
||||
}
|
||||
} else if removeCoverArt, let cid = coverArtCompanionId {
|
||||
try await api.deleteCoverArt(songId: cid)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -840,6 +840,48 @@ extension CompanionAPIService {
|
|||
try validateResponse(response)
|
||||
}
|
||||
|
||||
/// Upload cover art using a relative file path (fallback when no companion DB ID).
|
||||
/// Uses POST /library/cover-art-by-path with multipart form containing
|
||||
/// the relative_path as a text field and the image as a file field.
|
||||
func uploadCoverArtByPath(relativePath: String, image: UIImage) async throws {
|
||||
let base = try baseURL()
|
||||
let url = base.appendingPathComponent("library/cover-art-by-path")
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
guard let jpeg = image.jpegData(compressionQuality: 0.92) else {
|
||||
throw CompanionError.invalidResponse
|
||||
}
|
||||
|
||||
let crlf = "\r\n"
|
||||
var body = Data()
|
||||
|
||||
// Field: relative_path
|
||||
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"relative_path\"\(crlf)\(crlf)".data(using: .utf8)!)
|
||||
body.append(relativePath.data(using: .utf8)!)
|
||||
body.append(crlf.data(using: .utf8)!)
|
||||
|
||||
// Field: file (image)
|
||||
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"cover.jpg\"\(crlf)".data(using: .utf8)!)
|
||||
body.append("Content-Type: image/jpeg\(crlf)\(crlf)".data(using: .utf8)!)
|
||||
body.append(jpeg)
|
||||
body.append("\(crlf)--\(boundary)--\(crlf)".data(using: .utf8)!)
|
||||
|
||||
req.httpBody = body
|
||||
|
||||
DebugLogger.shared.log(
|
||||
"uploadCoverArtByPath: \(relativePath) (\(jpeg.count) bytes JPEG)",
|
||||
category: "Companion"
|
||||
)
|
||||
|
||||
let (_, response) = try await session.data(for: req)
|
||||
try validateResponse(response)
|
||||
}
|
||||
|
||||
/// Build an artist photo URL for an artist name.
|
||||
nonisolated func artistPhotoURL(artistName: String) -> URL? {
|
||||
guard let encoded = artistName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
|
||||
|
|
|
|||
|
|
@ -376,9 +376,13 @@ struct MultiAlbumEditorSheet: View {
|
|||
|
||||
Task {
|
||||
do {
|
||||
// Cover art — use first included song's companion ID
|
||||
if let image = pendingCoverImage, let cid = coverArtCompanionId {
|
||||
try await api.uploadCoverArt(songId: cid, image: image)
|
||||
// Cover art — use companion ID, fall back to relative path
|
||||
if let image = pendingCoverImage {
|
||||
if let cid = coverArtCompanionId {
|
||||
try await api.uploadCoverArt(songId: cid, image: image)
|
||||
} else if let path = includedSongs.first(where: { $0.path != nil })?.path {
|
||||
try await api.uploadCoverArtByPath(relativePath: path, image: image)
|
||||
}
|
||||
} else if removeCoverArt, let cid = coverArtCompanionId {
|
||||
try await api.deleteCoverArt(songId: cid)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -487,8 +487,13 @@ struct TrackEditorView: View {
|
|||
Task {
|
||||
do {
|
||||
// Handle cover art first
|
||||
if let image = pendingCoverImage, let companionId = companionSongId() {
|
||||
try await api.uploadCoverArt(songId: companionId, image: image)
|
||||
if let image = pendingCoverImage {
|
||||
if let companionId = companionSongId() {
|
||||
try await api.uploadCoverArt(songId: companionId, image: image)
|
||||
} else if let path = song.path {
|
||||
// Fallback: no companion ID — use relative path endpoint
|
||||
try await api.uploadCoverArtByPath(relativePath: path, image: image)
|
||||
}
|
||||
} else if removeCoverArt, let companionId = companionSongId() {
|
||||
try await api.deleteCoverArt(songId: companionId)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue