edit-metadata endpoint

iOS — 6 fixes across 5 files:
Models.swift — Song, Album, AlbumWithSongs all now have albumArtist:
String?. CompanionSong.toSong() passes albumArtist.
CompanionAlbum.toAlbum() passes albumArtist to the new field.
TrackEditorView.swift — Album Artist field now initialises from
song.albumArtist ?? song.artist instead of just song.artist.
fetchCompanionDetails no longer requires companion: prefix — it
fetches for all songs using the album name, decodes properly using
CompanionLibraryResponse, and matches by relative_path.
BatchAlbumEditorSheet.swift — initialises from album.albumArtist ??
album.artist.
MultiAlbumEditorSheet.swift — pre-fills from first.albumArtist ??
first.artist.
AlbumDetailView.swift — buildAlbumWithSongs now passes albumArtist
from the companion songs.
Companion — 2 fixes:
apply_tags — now does full FLAC cleanup (removes all Picard legacy
variants) before writing, same as apply_tags_dict already did.
edit-metadata endpoint — no longer calls restructure_file. File
renaming only happens via /bulk-fix. This was the root cause of the
500 errors on compilation tracks with disc numbers.
This commit is contained in:
Dallas Groot 2026-04-11 00:50:37 -07:00
parent 55820fdb38
commit f761f65e87
8 changed files with 244 additions and 63 deletions

View file

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

View file

@ -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<T: Decodable>(_ type: T.Type, key: String) -> T? {
let url = cacheURL(for: key)

View file

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

View file

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

View file

@ -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!)" : "")
}

View file

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

View file

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

View file

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