updated companion api

This commit is contained in:
Dallas Groot 2026-04-10 15:23:43 -07:00
parent 9b1d6a74e0
commit 5b319ad643
2 changed files with 257 additions and 53 deletions

View file

@ -12,8 +12,8 @@ services:
- ND_BASEURL=/navidrome
# ... other env vars ...
volumes:
- /home/pi/navidrome:/music:ro
- ./navidrome_data:/data
- /home/pi/navidrome/music:/music:ro
- /home/pi/docker/navidrome/navidrome_data:/data
music-companion:
build: ./companion_api
@ -23,8 +23,6 @@ services:
- "8000:8000"
volumes:
- /home/pi/navidrome/music:/music:rw
# All persistent data lives under ./companion_data/ on the host.
# cover_art and artist_photos are subdirectories created automatically.
- ./companion_data:/app/data
environment:
- MUSIC_DIR=/music
@ -33,9 +31,9 @@ services:
- COVER_ART_DIR=/app/data/cover_art
- ARTIST_PHOTO_DIR=/app/data/artist_photos
- NAVIDROME_URL=http://navidrome:4533/navidrome
- SUBSONIC_USER=dallas
- SUBSONIC_TOKEN=your_token
- SUBSONIC_SALT=your_salt
- SUBSONIC_USER=Dallasgroot
- SUBSONIC_TOKEN=
- SUBSONIC_SALT=
deploy:
resources:
limits:

View file

@ -28,7 +28,7 @@ Endpoints (Phase 1 - library database):
POST /library/artist-photo
GET /library/artist-photo/{artist_name}
"""
import os, re, json, asyncio, hashlib, sqlite3, subprocess, shutil, time, warnings
import os, re, json, asyncio, hashlib, sqlite3, subprocess, shutil, time, warnings, unicodedata
from pathlib import Path
from typing import Optional, List
from contextlib import asynccontextmanager
@ -245,7 +245,7 @@ def scan_library(full_rescan: bool = False) -> int:
continue
song_id = hashlib.md5(full_path.encode()).hexdigest()
relative = os.path.relpath(full_path, MUSIC_DIR)
relative = unicodedata.normalize("NFC", os.path.relpath(full_path, MUSIC_DIR))
if not full_rescan:
row = c.execute(
@ -507,50 +507,248 @@ async def trigger_scan():
async def sync_navidrome_ids_task():
"""Fetch all songs from Navidrome and write navidrome_id into our songs table."""
if not all([SUBSONIC_USER, SUBSONIC_TOKEN, SUBSONIC_SALT]):
print("Subsonic credentials not set - cannot sync IDs")
return
print("Syncing Navidrome IDs...", flush=True)
base_params = {"u": SUBSONIC_USER, "t": SUBSONIC_TOKEN, "s": SUBSONIC_SALT,
"v": "1.16.1", "c": "CompanionAPI", "f": "json",
"albumCount": 0, "artistCount": 0, "songCount": 500, "query": ""}
all_songs = []
offset = 0
async with httpx.AsyncClient(timeout=30) as client:
while True:
try:
r = await client.get(
f"{NAVIDROME_URL}/rest/search3.view",
params={**base_params, "songOffset": offset}
)
songs = (r.json().get("subsonic-response", {})
.get("searchResult3", {}).get("song", []))
if not songs:
try:
if not all([SUBSONIC_USER, SUBSONIC_TOKEN, SUBSONIC_SALT]):
print("Subsonic credentials not set - cannot sync IDs")
return
print(f"Syncing Navidrome IDs... URL={NAVIDROME_URL}", flush=True)
base_params = {"u": SUBSONIC_USER, "t": SUBSONIC_TOKEN, "s": SUBSONIC_SALT,
"v": "1.16.1", "c": "CompanionAPI", "f": "json",
"albumCount": 0, "artistCount": 0, "songCount": 500, "query": ""}
all_songs = []
offset = 0
async with httpx.AsyncClient(timeout=60) as client:
while True:
try:
r = await client.get(
f"{NAVIDROME_URL}/rest/search3.view",
params={**base_params, "songOffset": offset}
)
print(f" Navidrome response: HTTP {r.status_code}", flush=True)
body = r.json()
# Check for auth errors
resp = body.get("subsonic-response", {})
if resp.get("status") == "failed":
err = resp.get("error", {})
print(f" Navidrome auth error: {err}", flush=True)
return
songs = resp.get("searchResult3", {}).get("song", [])
print(f" Page offset={offset}: {len(songs)} songs", flush=True)
if not songs:
break
all_songs.extend(songs)
offset += len(songs)
if len(songs) < 500:
break
except Exception as e:
print(f" Navidrome fetch error: {e}", flush=True)
break
all_songs.extend(songs)
offset += len(songs)
if len(songs) < 500:
break
except Exception as e:
print(f"Navidrome fetch error: {e}", flush=True)
break
matched = 0
with sqlite3.connect(DB_PATH) as c:
for ns in all_songs:
nd_path = ns.get("path", "")
nd_id = ns.get("id", "")
if nd_path and nd_id:
c.execute("UPDATE songs SET navidrome_id = ? WHERE relative_path = ?",
(nd_id, nd_path))
if c.rowcount:
matched = 0
with sqlite3.connect(DB_PATH) as c:
cur = c.cursor()
total_db = cur.execute("SELECT COUNT(*) FROM songs").fetchone()[0]
print(f" DB songs total: {total_db}", flush=True)
# Match by (title, artist) from ID3 tags — both Navidrome and Companion
# read the same tags so this is always consistent regardless of
# folder structure or filename format differences.
db_rows = cur.execute(
"SELECT id, LOWER(TRIM(title)), LOWER(TRIM(artist)) FROM songs"
).fetchall()
db_lookup = {}
for song_id, title, artist in db_rows:
key = (unicodedata.normalize("NFC", title),
unicodedata.normalize("NFC", artist))
# If duplicate title+artist, keep first (edge case)
if key not in db_lookup:
db_lookup[key] = song_id
print(f" DB lookup built: {len(db_lookup)} entries", flush=True)
for ns in all_songs:
nd_id = ns.get("id", "")
nd_title = unicodedata.normalize("NFC",
(ns.get("title") or "").lower().strip())
nd_artist = unicodedata.normalize("NFC",
(ns.get("artist") or "").lower().strip())
if not nd_id or not nd_title:
continue
key = (nd_title, nd_artist)
if key in db_lookup:
cur.execute("UPDATE songs SET navidrome_id = ? WHERE id = ?",
(nd_id, db_lookup[key]))
matched += 1
print(f"Navidrome ID sync: {matched}/{len(all_songs)} matched", flush=True)
print(f"Navidrome ID sync: {matched}/{len(all_songs)} matched", flush=True)
except Exception as e:
import traceback
print(f"sync_navidrome_ids_task FAILED: {e}", flush=True)
traceback.print_exc()
# ── Metadata helpers ─────────────────────────────────────────────────────────
def apply_tags(path: str, u: MetadataUpdate):
def sanitize(name: str) -> str:
"""Strip characters that are unsafe in folder/file names."""
# Remove: / : * ? " < > | \ and control characters
cleaned = re.sub(r'[/\\:*?"<>|]', '', name)
# Collapse multiple spaces, strip leading/trailing whitespace and dots
cleaned = re.sub(r'\s+', ' ', cleaned).strip().strip('.')
return cleaned or 'Unknown'
def build_target_path(full_path: str) -> Optional[str]:
"""
Compute the canonical target path for a file based on its current ID3 tags.
Structure:
MUSIC_DIR/Album Artist/Album/TT Song Title.ext (single disc)
MUSIC_DIR/Album Artist/Album/DD-TT Song Title.ext (multi disc)
- Album Artist tag is used for the top-level folder.
Falls back to Artist if Album Artist is blank.
- Track number zero-padded to 2 digits.
- Disc prefix only when disc > 1.
- Unsafe characters stripped from all components.
"""
try:
audio = MutagenFile(full_path, easy=True)
if audio is None:
return None
def get(key):
return audio[key][0] if key in audio and audio[key] else ''
title = sanitize(get('title') or Path(full_path).stem)
album_artist = sanitize(get('albumartist') or get('artist') or 'Unknown Artist')
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_num = 0
raw_track = get('tracknumber')
if raw_track:
m = re.match(r'(\d+)', raw_track)
if m:
track_num = int(m.group(1))
# Disc number
disc_num = 0
raw_disc = get('discnumber')
if raw_disc:
m = re.match(r'(\d+)', raw_disc)
if m:
disc_num = int(m.group(1))
# Build track prefix
if disc_num > 1:
prefix = f"{disc_num:02d}-{track_num:02d}"
elif track_num > 0:
prefix = f"{track_num:02d}"
else:
prefix = ''
filename = f"{prefix} {title}{ext}".strip() if prefix else f"{title}{ext}"
target = os.path.join(MUSIC_DIR, album_artist, album, filename)
return target
except Exception as e:
print(f" build_target_path error for {os.path.basename(full_path)}: {e}", flush=True)
return None
def restructure_file(full_path: str) -> Optional[str]:
"""
Move a file to its canonical location based on current ID3 tags.
Updates the songs DB row with the new path.
Removes empty directories left behind.
Returns the new full path, or None if no move was needed / failed.
"""
target = build_target_path(full_path)
if not target:
return None
# Already in the right place
if os.path.normpath(full_path) == os.path.normpath(target):
return None
# Avoid overwriting a different existing file
if os.path.exists(target) and os.path.abspath(target) != os.path.abspath(full_path):
print(f" restructure: target exists, skipping: {os.path.basename(target)}", flush=True)
return None
try:
os.makedirs(os.path.dirname(target), exist_ok=True)
shutil.move(full_path, target)
print(f" restructure: {os.path.relpath(full_path, MUSIC_DIR)}"
f" -> {os.path.relpath(target, MUSIC_DIR)}", flush=True)
# Remove empty directories left behind (walk up, stop at MUSIC_DIR)
old_dir = os.path.dirname(full_path)
while old_dir != MUSIC_DIR and os.path.isdir(old_dir):
if not os.listdir(old_dir):
os.rmdir(old_dir)
old_dir = os.path.dirname(old_dir)
else:
break
# Update DB with new path
new_relative = os.path.relpath(target, MUSIC_DIR)
song_id = hashlib.md5(full_path.encode()).hexdigest()
new_id = hashlib.md5(target.encode()).hexdigest()
with sqlite3.connect(DB_PATH) as c:
# Update the existing row to reflect new path and new id
c.execute("""UPDATE songs SET
id=?, full_path=?, relative_path=?,
sort_title=?, sort_artist=?, sort_album=?, sort_album_artist=?,
file_mtime=?, date_modified=?
WHERE id=?""", (
new_id, target, new_relative,
sort_key(Path(target).stem),
sort_key(os.path.dirname(new_relative).split(os.sep)[0] if os.sep in new_relative else ''),
sort_key(os.path.dirname(new_relative).split(os.sep)[1] if new_relative.count(os.sep) > 0 else ''),
sort_key(os.path.dirname(new_relative).split(os.sep)[0] if os.sep in new_relative else ''),
os.stat(target).st_mtime,
datetime.utcnow().isoformat(),
song_id
))
if c.rowcount == 0:
# Row used old id — try by full_path
c.execute("""UPDATE songs SET
id=?, full_path=?, relative_path=?, file_mtime=?, date_modified=?
WHERE full_path=?""", (
new_id, target, new_relative,
os.stat(target).st_mtime,
datetime.utcnow().isoformat(),
full_path
))
return target
except Exception as e:
print(f" restructure FAILED for {os.path.basename(full_path)}: {e}", flush=True)
return None
def restructure_all() -> dict:
"""Restructure every file in the library to match its tags. Used by bulk-fix."""
print("Restructuring library...", flush=True)
moved = 0
skipped = 0
failed = 0
with sqlite3.connect(DB_PATH) as c:
rows = c.execute("SELECT full_path FROM songs").fetchall()
for (full_path,) in rows:
if not os.path.isfile(full_path):
skipped += 1
continue
result = restructure_file(full_path)
if result:
moved += 1
else:
skipped += 1
print(f"Restructure complete: {moved} moved, {skipped} skipped, {failed} failed", flush=True)
return {"moved": moved, "skipped": skipped, "failed": failed}
audio = MutagenFile(path, easy=True)
if audio is None:
raise ValueError(f"Unsupported format: {path}")
@ -759,14 +957,17 @@ async def edit_metadata(update: MetadataUpdate):
raise HTTPException(404, f"File not found. raw='{update.relative_path}' MUSIC_DIR='{MUSIC_DIR}'")
try:
apply_tags(fp, update)
update_song_in_db(fp)
# Restructure to canonical path based on new tags
new_fp = restructure_file(fp) or fp
update_song_in_db(new_fp)
new_relative = os.path.relpath(new_fp, MUSIC_DIR)
await trigger_scan()
await push.broadcast("metadata_updated", {
"path": update.relative_path,
"path": new_relative,
"title": update.title or "", "artist": update.artist or "",
"album": update.album or ""
})
return {"status": "success", "file": update.relative_path, "resolved": fp}
return {"status": "success", "file": new_relative, "resolved": new_fp}
except Exception as e:
raise HTTPException(500, str(e))
@ -788,8 +989,9 @@ async def batch_edit_metadata(update: BatchMetadataUpdate):
continue
try:
apply_tags_dict(fp, tags)
update_song_in_db(fp)
results["succeeded"].append(rp)
new_fp = restructure_file(fp) or fp
update_song_in_db(new_fp)
results["succeeded"].append(os.path.relpath(new_fp, MUSIC_DIR))
except Exception as e:
results["failed"].append({"path": rp, "error": str(e)})
await trigger_scan()
@ -1001,8 +1203,12 @@ async def precompute(background_tasks: BackgroundTasks, relative_path: str = "")
@app.post("/bulk-fix")
async def bulk_fix(background_tasks: BackgroundTasks):
background_tasks.add_task(trigger_scan)
return {"message": "Scan triggered"}
async def run():
result = restructure_all()
await trigger_scan()
await push.broadcast("library_restructured", result)
background_tasks.add_task(run)
return {"message": "Library restructure started"}
# =============================================================================