updated companion api
This commit is contained in:
parent
9b1d6a74e0
commit
5b319ad643
2 changed files with 257 additions and 53 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue