diff --git a/Shared/Models/Models.swift b/Shared/Models/Models.swift index 19f5028..7d03f2a 100644 --- a/Shared/Models/Models.swift +++ b/Shared/Models/Models.swift @@ -114,6 +114,7 @@ struct Album: Codable, Identifiable { let name: String let artist: String? let artistId: String? + let albumArtist: String? let coverArt: String? let songCount: Int? let duration: Int? @@ -129,6 +130,7 @@ struct AlbumWithSongs: Codable, Identifiable { let name: String let artist: String? let artistId: String? + let albumArtist: String? let coverArt: String? let songCount: Int? let duration: Int? @@ -152,6 +154,7 @@ struct Song: Codable, Identifiable { let title: String let album: String? let artist: String? + let albumArtist: String? let track: Int? let year: Int? let genre: String? @@ -351,6 +354,7 @@ struct CompanionSong: Codable, Identifiable { title: title, album: album, artist: artist, + albumArtist: album_artist, track: track_number, year: year, genre: genre.isEmpty ? nil : genre, @@ -390,18 +394,19 @@ struct CompanionAlbum: Codable, Identifiable { func toAlbum() -> Album { Album( - id: id, - name: album, - artist: album_artist, - artistId: nil, - coverArt: cover_art_url.map { extractCompanionId($0) }.map { "companion:\($0)" }, - songCount: track_count, - duration: nil, - playCount: nil, - created: nil, - starred: nil, - year: year, - genre: nil + id: id, + name: album, + artist: album_artist, + artistId: nil, + albumArtist: album_artist, + coverArt: cover_art_url.map { extractCompanionId($0) }.map { "companion:\($0)" }, + songCount: track_count, + duration: nil, + playCount: nil, + created: nil, + starred: nil, + year: year, + genre: nil ) } } diff --git a/Shared/Storage/LibraryCache.swift b/Shared/Storage/LibraryCache.swift index a805be9..3353966 100644 --- a/Shared/Storage/LibraryCache.swift +++ b/Shared/Storage/LibraryCache.swift @@ -78,6 +78,20 @@ class LibraryCache: ObservableObject { try? encoded.write(to: cacheURL(for: key), options: .atomic) } } + + func remove(key: String) { + try? FileManager.default.removeItem(at: cacheURL(for: key)) + } + + /// Remove all album detail caches (keyed as "album_ALBUMID") so stale + /// song paths from before a file restructure aren't served to the Companion. + func removeAlbumDetails() { + guard let files = try? FileManager.default.contentsOfDirectory( + at: cacheDir, includingPropertiesForKeys: nil) else { return } + for url in files where url.lastPathComponent.hasPrefix("album_") { + try? FileManager.default.removeItem(at: url) + } + } func load(_ type: T.Type, key: String) -> T? { let url = cacheURL(for: key) diff --git a/companion-api/main.py b/companion-api/main.py index c26a3f3..63c89b1 100644 --- a/companion-api/main.py +++ b/companion-api/main.py @@ -68,7 +68,7 @@ def init_db(): os.makedirs(COVER_ART_DIR, exist_ok=True) os.makedirs(ARTIST_PHOTO_DIR, exist_ok=True) with sqlite3.connect(DB_PATH) 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, @@ -77,7 +77,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, @@ -182,7 +182,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): @@ -462,6 +462,19 @@ push = PushManager() # ── Path resolution ────────────────────────────────────────────────────────── 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 + 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 + """ decoded = relative for _ in range(5): next_d = unquote(decoded) @@ -471,22 +484,97 @@ def resolve_path(relative: str) -> Optional[str]: cleaned = decoded.lstrip("/") print(f" resolve_path: '{relative}' -> '{cleaned}'", flush=True) + # 1. Direct path join direct = os.path.join(MUSIC_DIR, cleaned) if os.path.isfile(direct): return direct + # 2. Strip leading path components (handles library folder prefix) parts = Path(cleaned).parts for i in range(1, len(parts)): sub = os.path.join(MUSIC_DIR, *parts[i:]) if os.path.isfile(sub): return sub + # 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: + with sqlite3.connect(DB_PATH) as c: + # First try exact relative_path match + row = c.execute( + "SELECT full_path FROM songs WHERE relative_path = ?", (cleaned,) + ).fetchone() + if row and os.path.isfile(row[0]): + print(f" resolve: songs table exact -> {os.path.basename(row[0])}", flush=True) + return row[0] + + # Try normalised path (NFC unicode) + nfc = unicodedata.normalize("NFC", cleaned) + row = c.execute( + "SELECT full_path FROM songs WHERE relative_path = ?", (nfc,) + ).fetchone() + if row and os.path.isfile(row[0]): + print(f" resolve: songs table NFC -> {os.path.basename(row[0])}", flush=True) + return row[0] + + # Extract context from path: Artist/Album/filename + path_parts = Path(cleaned).parts # e.g. ('Artist', 'Album', 'file.flac') + if len(path_parts) >= 3: + path_artist = path_parts[0] + path_album = path_parts[1] + filename = path_parts[-1] + stem = Path(filename).stem.lower() + ext = Path(filename).suffix.lower() + # Strip leading track number from stem for matching + clean_stem = re.sub(r'^\d{1,2}[-\s\.]+\d{0,2}[-\s\.]*', '', stem).strip() + clean_stem = re.sub(r'^\d{1,2}[-\s\.]+', '', clean_stem).strip() + + # Match by title similarity + artist/album folder context + rows = c.execute( + """SELECT full_path, relative_path FROM songs + WHERE full_path LIKE ? AND sort_title LIKE ?""", + (f'%{ext}', f'%{clean_stem[:6]}%') + ).fetchall() + best_fp, best_score = None, 0.0 + for fp, rp in rows: + rp_parts = Path(rp).parts + if len(rp_parts) < 2: + continue + # Score: title match + artist folder match + album folder match + score = 0.0 + rp_stem = re.sub(r'^\d{1,2}[-\s\.]+', '', Path(rp).stem.lower()).strip() + if clean_stem and rp_stem: + words_q = set(re.split(r'[\s\-_\.]+', clean_stem)) + words_r = set(re.split(r'[\s\-_\.]+', rp_stem)) + if words_q: + score += len(words_q & words_r) / len(words_q) * 0.6 + # Bonus for matching artist folder + if path_artist.lower()[:4] in rp_parts[0].lower(): + score += 0.2 + # Bonus for matching album folder + if path_album.lower()[:4] in (rp_parts[1].lower() if len(rp_parts) > 1 else ''): + score += 0.2 + if score > best_score and os.path.isfile(fp): + best_score = score + best_fp = fp + if best_fp and best_score >= 0.7: + print(f" resolve: songs table context ({best_score:.0%}) -> {os.path.basename(best_fp)}", flush=True) + return best_fp + + except Exception as e: + print(f" resolve: songs table error: {e}", flush=True) + + # 4. Exact filename match on disk target = os.path.basename(cleaned) if target: for root, _, files in os.walk(MUSIC_DIR): if target in files: - return os.path.join(root, target) + found = os.path.join(root, target) + print(f" resolve: exact filename -> {found}", flush=True) + return found + # 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() @@ -538,10 +626,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]): @@ -749,7 +837,7 @@ async def sync_navidrome_ids_task(): hit = by_dur_only.get(dk) if hit: db_song_id, strategy = hit, 6 - # S7: clean title + duration bucket — catches untagged AIFF/files + # S7: clean title + duration bucket -- catches untagged AIFF/files # where artist is unknown but filename+duration uniquely identify song if not db_song_id and dk is not None: hit = by_clean_dur.get((ct, dk)) @@ -830,7 +918,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: @@ -898,7 +986,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() @@ -920,7 +1008,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=?, @@ -975,7 +1063,54 @@ def restructure_all() -> dict: audio.save() +def apply_tags(path: str, u): + # For FLAC files, clean up all legacy albumartist variants written by Picard + # before writing. easy=True only writes lowercase 'albumartist' but Navidrome + # reads ALBUMARTIST (uppercase) first -- so old values stick if not removed. + ext = Path(path).suffix.lower() + if ext == '.flac' and u.album_artist: + try: + from mutagen.flac import FLAC + raw = FLAC(path) + for variant in ['ALBUM ARTIST', 'ALBUM_ARTIST', 'ALBUMARTIST', 'albumartist']: + if variant in raw: + del raw[variant] + raw['ALBUMARTIST'] = u.album_artist + raw.save() + except Exception as e: + print(f" FLAC albumartist cleanup failed for {os.path.basename(path)}: {e}", flush=True) + + audio = MutagenFile(path, easy=True) + if audio is None: + raise ValueError(f"Unsupported format: {path}") + if u.title: audio['title'] = u.title + if u.artist: audio['artist'] = u.artist + if u.album: audio['album'] = u.album + if u.album_artist: audio['albumartist'] = u.album_artist + if u.genre: audio['genre'] = u.genre + if u.year: audio['date'] = str(u.year) + if u.track_number: audio['tracknumber'] = str(u.track_number) + audio.save() + + def apply_tags_dict(path: str, tags: dict): + # For FLAC files, use raw mutagen to nuke all legacy tag variants before + # writing. Tools like Picard write ALBUM ARTIST, ALBUM_ARTIST, albumartist + # etc. -- Navidrome reads the uppercase ones and gets the wrong value if we + # only write the lowercase easy-mode key without removing the others. + ext = Path(path).suffix.lower() + if ext == '.flac' and tags.get('album_artist'): + try: + from mutagen.flac import FLAC + raw = FLAC(path) + for variant in ['ALBUM ARTIST', 'ALBUM_ARTIST', 'ALBUMARTIST', 'albumartist']: + if variant in raw: + del raw[variant] + raw['ALBUMARTIST'] = tags['album_artist'] + raw.save() + except Exception as e: + print(f" FLAC albumartist cleanup failed for {os.path.basename(path)}: {e}", flush=True) + audio = MutagenFile(path, easy=True) if audio is None: raise ValueError(f"Unsupported format: {path}") @@ -1170,18 +1305,22 @@ 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) - # 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) + # Do NOT restructure on single-track edits -- restructuring renames the + # file based on all current tags including disc number, which causes + # wrong filenames for compilation tracks (e.g. disc 2 gets 02-13 prefix). + # Restructuring is only done via the explicit bulk-fix endpoint. + update_song_in_db(fp) + new_relative = os.path.relpath(fp, MUSIC_DIR) await trigger_scan() await push.broadcast("metadata_updated", { "path": new_relative, "title": update.title or "", "artist": update.artist or "", "album": update.album or "" }) - return {"status": "success", "file": new_relative, "resolved": new_fp} + return {"status": "success", "file": new_relative, "resolved": fp} except Exception as e: + import traceback + traceback.print_exc() raise HTTPException(500, str(e)) @@ -1208,8 +1347,14 @@ async def batch_edit_metadata(update: BatchMetadataUpdate): except Exception as e: results["failed"].append({"path": rp, "error": str(e)}) await trigger_scan() + # Wait for Navidrome to finish scanning before the app re-fetches paths. + # Without this, the app gets stale paths from Navidrome's DB before it + # has processed the restructured file locations. + await asyncio.sleep(4) await push.broadcast("batch_metadata_updated", - {"count": len(results["succeeded"]), "album": update.album or ""}) + {"count": len(results["succeeded"]), + "album": update.album or "", + "paths_changed": "true"}) return results @@ -1575,7 +1720,7 @@ 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 folder.jpg and updates all songs in that directory.""" + """Upload cover art -- saves as folder.jpg and updates all songs in that directory.""" with sqlite3.connect(DB_PATH) as c: row = c.execute("SELECT full_path FROM songs WHERE id = ?", (song_id,)).fetchone() if not row: diff --git a/iOS/Data/SyncEngine.swift b/iOS/Data/SyncEngine.swift index 64c783e..428f3c2 100644 --- a/iOS/Data/SyncEngine.swift +++ b/iOS/Data/SyncEngine.swift @@ -42,6 +42,10 @@ class SyncEngine: ObservableObject { .receive(on: DispatchQueue.main) .debounce(for: .seconds(2), scheduler: DispatchQueue.main) .sink { [weak self] _ in + // Paths may have changed due to file restructuring — invalidate song caches + // so next fetch returns the fresh Navidrome paths instead of stale ones + LibraryCache.shared.remove(key: "all_songs_sorted") + LibraryCache.shared.removeAlbumDetails() self?.lastSyncTimestamp = 0 self?.syncIfNeeded() } diff --git a/iOS/Views/Companion/BatchAlbumEditorSheet.swift b/iOS/Views/Companion/BatchAlbumEditorSheet.swift index 84f2ebe..3bfca33 100644 --- a/iOS/Views/Companion/BatchAlbumEditorSheet.swift +++ b/iOS/Views/Companion/BatchAlbumEditorSheet.swift @@ -26,8 +26,8 @@ struct BatchAlbumEditorSheet: View { init(album: AlbumWithSongs) { self.album = album _albumName = State(initialValue: album.name) - _albumArtist = State(initialValue: album.artist ?? "") - _artist = State(initialValue: album.artist ?? "") + _albumArtist = State(initialValue: album.albumArtist ?? album.artist ?? "") + _artist = State(initialValue: album.albumArtist ?? album.artist ?? "") _genre = State(initialValue: album.genre ?? "") _year = State(initialValue: album.year != nil ? "\(album.year!)" : "") } diff --git a/iOS/Views/Companion/MultiAlbumEditorSheet.swift b/iOS/Views/Companion/MultiAlbumEditorSheet.swift index 63a2575..913ec6d 100644 --- a/iOS/Views/Companion/MultiAlbumEditorSheet.swift +++ b/iOS/Views/Companion/MultiAlbumEditorSheet.swift @@ -250,8 +250,8 @@ struct MultiAlbumEditorSheet: View { // Pre-fill from first album if let first = loaded.first { if albumName.isEmpty { albumName = first.name } - if albumArtist.isEmpty { albumArtist = first.artist ?? "" } - if artist.isEmpty { artist = first.artist ?? "" } + if albumArtist.isEmpty { albumArtist = first.albumArtist ?? first.artist ?? "" } + if artist.isEmpty { artist = first.albumArtist ?? first.artist ?? "" } if genre.isEmpty { genre = first.genre ?? "" } if year.isEmpty, let y = first.year { year = "\(y)" } } diff --git a/iOS/Views/Companion/TrackEditorView.swift b/iOS/Views/Companion/TrackEditorView.swift index 6dd994e..b07887e 100644 --- a/iOS/Views/Companion/TrackEditorView.swift +++ b/iOS/Views/Companion/TrackEditorView.swift @@ -76,9 +76,10 @@ struct TrackEditorView: View { _trackNumber = State(initialValue: song.track.map { "\($0)" } ?? "") _discNumber = State(initialValue: song.discNumber.map { "\($0)" } ?? "") - // Pre-populate album artist from companion cover art ID if available - // Falls back to artist; will be overwritten by fetchCompanionDetails on appear - _albumArtist = State(initialValue: song.artist ?? "") + // Use albumArtist from the Song model (decoded from Subsonic's albumArtist field). + // Falls back to artist only if albumArtist is nil — this is the correct behaviour + // for non-compilation tracks where artist == albumArtist. + _albumArtist = State(initialValue: song.albumArtist ?? song.artist ?? "") } var body: some View { @@ -212,18 +213,28 @@ struct TrackEditorView: View { // MARK: - Fetch companion details to populate album artist + disc number private func fetchCompanionDetails() async { - guard let coverArt = song.coverArt, coverArt.hasPrefix("companion:") else { return } - let companionId = String(coverArt.dropFirst("companion:".count)) - guard let baseURL = CompanionSettings.shared.baseURL else { return } - let url = baseURL.appendingPathComponent("library/song/\(companionId)") - guard let (data, _) = try? await URLSession.shared.data(from: url), - let json = try? JSONDecoder().decode(CompanionSong.self, from: data) else { return } - await MainActor.run { - if albumArtist.isEmpty || albumArtist == artist { - albumArtist = json.album_artist - } - if discNumber.isEmpty, let dn = json.disc_number { - discNumber = "\(dn)" + // The Subsonic Song model now decodes albumArtist directly from the API. + // This fetch is a fallback to get the correct album_artist from the + // Companion's own DB (which may have been manually corrected). + guard let path = song.path, + let baseURL = CompanionSettings.shared.baseURL else { return } + + var components = URLComponents(url: baseURL.appendingPathComponent("library/songs"), resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "album", value: song.album ?? "")] + guard let url = components?.url, + let (data, _) = try? await URLSession.shared.data(from: url), + let response = try? JSONDecoder().decode(CompanionLibraryResponse.self, from: data), + let songs = response.songs else { return } + + // Match by relative_path + if let match = songs.first(where: { $0.relative_path == path || path.hasSuffix($0.relative_path) }) { + await MainActor.run { + if albumArtist.isEmpty || albumArtist == (song.artist ?? "") { + albumArtist = match.album_artist + } + if discNumber.isEmpty, let dn = match.disc_number { + discNumber = "\(dn)" + } } } } diff --git a/iOS/Views/Library/AlbumDetailView.swift b/iOS/Views/Library/AlbumDetailView.swift index 334ff3a..292909d 100644 --- a/iOS/Views/Library/AlbumDetailView.swift +++ b/iOS/Views/Library/AlbumDetailView.swift @@ -526,20 +526,22 @@ struct AlbumDetailView: View { let stdSongs = songs.map { $0.toSong() } let coverArt = songs.first.map { "companion:\($0.id)" } let totalDuration = stdSongs.compactMap { $0.duration }.reduce(0, +) + let albumArtist = songs.first?.album_artist ?? artist return AlbumWithSongs( - id: albumId, - name: name, - artist: artist, - artistId: nil, - coverArt: coverArt, - songCount: songs.count, - duration: totalDuration > 0 ? totalDuration : nil, - playCount: nil, - created: nil, - starred: nil, - year: songs.first?.year, - genre: songs.first?.genre, - song: stdSongs + id: albumId, + name: name, + artist: artist, + artistId: nil, + albumArtist: albumArtist, + coverArt: coverArt, + songCount: songs.count, + duration: totalDuration > 0 ? totalDuration : nil, + playCount: nil, + created: nil, + starred: nil, + year: songs.first?.year, + genre: songs.first?.genre, + song: stdSongs ) }