batch albumart fix

This commit is contained in:
Dallas Groot 2026-04-12 18:07:12 -07:00
parent 551e59a148
commit aa97be4caa
5 changed files with 196 additions and 64 deletions

View file

@ -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)

View file

@ -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)
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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)
}