From aa97be4caa9ddc5709a115f49ebf85ce5ae6a3e8 Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sun, 12 Apr 2026 18:07:12 -0700 Subject: [PATCH] batch albumart fix --- companion-api/main.py | 190 ++++++++++++------ .../Companion/BatchAlbumEditorSheet.swift | 9 +- iOS/Views/Companion/CompanionAPIService.swift | 42 ++++ .../Companion/MultiAlbumEditorSheet.swift | 10 +- iOS/Views/Companion/TrackEditorView.swift | 9 +- 5 files changed, 196 insertions(+), 64 deletions(-) diff --git a/companion-api/main.py b/companion-api/main.py index ce69cea..1a47a75 100644 --- a/companion-api/main.py +++ b/companion-api/main.py @@ -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) diff --git a/iOS/Views/Companion/BatchAlbumEditorSheet.swift b/iOS/Views/Companion/BatchAlbumEditorSheet.swift index 78d81d4..12f19e0 100644 --- a/iOS/Views/Companion/BatchAlbumEditorSheet.swift +++ b/iOS/Views/Companion/BatchAlbumEditorSheet.swift @@ -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) } diff --git a/iOS/Views/Companion/CompanionAPIService.swift b/iOS/Views/Companion/CompanionAPIService.swift index ab1e6f6..21af4ca 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -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) diff --git a/iOS/Views/Companion/MultiAlbumEditorSheet.swift b/iOS/Views/Companion/MultiAlbumEditorSheet.swift index ec48da2..90d2cd9 100644 --- a/iOS/Views/Companion/MultiAlbumEditorSheet.swift +++ b/iOS/Views/Companion/MultiAlbumEditorSheet.swift @@ -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) } diff --git a/iOS/Views/Companion/TrackEditorView.swift b/iOS/Views/Companion/TrackEditorView.swift index daf1edd..7258463 100644 --- a/iOS/Views/Companion/TrackEditorView.swift +++ b/iOS/Views/Companion/TrackEditorView.swift @@ -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) }