NavidromeApp/companion-api/CLAUDE.md
Dallas Groot 99bf17ec1a Fix 3 crashes from crash logs: SmartDJCache race, AVPlayer observer, KVO threading
🔴 SmartDJCache concurrent Dictionary crash (April 27+28 crash logs):
- EXC_BAD_ACCESS + doesNotRecognizeSelector in bulkImport()
- memoryCache dictionary mutated from main thread (loadBulkCache)
  and background Task.detached (bulkImport) simultaneously
- Fix: NSLock serializes all memoryCache reads and writes

🔴 stopAVPlayer removeTimeObserver crash (April 30 crash log):
- SIGABRT in -[AVPlayer removeTimeObserver:] from Previous button
- timeObserver registered on old player, self.player swapped to
  crossfade's active player by finalizeCrossfade
- Fix: remove observer from OLD player at both swap sites before
  assignment + reorder stopAVPlayer (observer before replaceCurrentItem)

🟡 Audit fixes (no crash logs, preventative):
- KVO in radioSeekBack wrapped in DispatchQueue.main.async
- Stale vis Task guarded by songId in all MainActor.run blocks
- CHANGELOG.md with full findings documentation
2026-04-30 17:27:54 -07:00

2.5 KiB

Companion API

Python FastAPI server running on Raspberry Pi 5 inside Docker. Provides metadata editing, Smart DJ analysis, visualizer precomputation, lyrics, and library management.

Deployment

# On the Pi, from ~/docker/navidrome/
docker compose build music-companion && docker compose up -d music-companion
  • Port: 8000
  • Music dir inside container: /music (mapped from /home/pi/navidrome/music)
  • Navidrome DB: /navidrome_data/navidrome.db (read-only)
  • Companion data: /app/data/ (smart_dj.db, vis_cache, cover_art, tag backups)

Architecture

  • main.py is the entire server (~3800 lines, single file)
  • Dockerfile + docker-compose.yml for containerization
  • diagnose.py — standalone diagnostic script
  • pre_analyze.py — bulk pre-analysis for Smart DJ profiles

Critical Rules

Tag Safety

  • ALL tag writes go through apply_tags() (single track) or apply_tags_dict() (batch)
  • Both functions call backup_tags() FIRST and abort with RuntimeError if backup fails
  • Files are NEVER modified without a backup safety net
  • enforce_tag_whitelist() has separate paths:
    • FLAC/OGG/Opus: checks against NAVIDROME_TAGS (Vorbis Comment names)
    • MP3: checks against ID3_FRAME_WHITELIST (ID3v2 frame IDs like TPE1, TALB)
    • AIFF: uses mutagen.aiff.AIFF with raw ID3 frames (easy=True returns None for AIFF)
  • NEVER use audio.delete() in restore — it destroys APIC (album art) and USLT (lyrics)

Backup System

  • Dual-key backups: _backup_key(full_path) + _backup_key_rel(relative_path)
  • _find_backup() tries 4 strategies (current path, current rel, original path, original rel)
  • Survives file moves from /bulk-fix restructure
  • save_batch_manifest() stores: batch_id, paths, tags_changed, affected_albums/artists, edit_type, is_reverted
  • Single-track edits (PATCH /edit-metadata) also create manifests with edit_type: "single"
  • Double undo prevented: is_reverted flag checked, HTTP 409 on repeat

Path Resolution

  • resolve_path() has 5 fallback strategies for finding files
  • Handles URL encoding, NFC unicode normalization, library folder prefixes
  • Falls back to Companion songs table and fuzzy title matching

Common Pitfalls

  • MutagenFile(path, easy=True) returns None for AIFF — always check
  • datetime.utcnow().isoformat() produces timestamps WITHOUT timezone suffix — Swift must parse with fallback formats
  • ffmpeg subprocess can hang on corrupt files — 120s timeout with process kill
  • Pi has limited CPU (4 cores) — deploy.resources.limits.cpus: '2.0' in docker-compose