batch updated with cover art support

This commit is contained in:
Dallas Groot 2026-04-11 08:07:55 -07:00
parent 3ea57fa99b
commit d32d63a749
4 changed files with 634 additions and 183 deletions

View file

@ -57,8 +57,8 @@ SUBSONIC_TOKEN = os.getenv("SUBSONIC_TOKEN")
SUBSONIC_SALT = os.getenv("SUBSONIC_SALT")
AUDIO_EXTS = ('.mp3', '.flac', '.m4a', '.ogg', '.opus', '.wav', '.aiff', '.aif')
COVER_NAMES = ('folder.jpg', 'cover.jpg', 'artwork.jpg', 'front.jpg',
'folder.png', 'cover.png', 'artwork.png', 'front.png')
COVER_NAMES = ('cover.jpg', 'folder.jpg', 'artwork.jpg', 'front.jpg',
'cover.png', 'folder.png', 'artwork.png', 'front.png')
# ── Database ────────────────────────────────────────────────────────────────
@ -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):
@ -471,14 +471,14 @@ 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
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
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):
@ -501,7 +501,7 @@ def resolve_path(relative: str) -> Optional[str]:
if os.path.isfile(sub):
return sub
# 3. Companion songs table -- look up by relative_path.
# 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:
@ -579,7 +579,7 @@ def resolve_path(relative: str) -> Optional[str]:
print(f" resolve: exact filename -> {found}", flush=True)
return found
# 5. Fuzzy title match (lowest confidence -- last resort)
# 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()
@ -631,10 +631,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]):
@ -842,7 +842,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))
@ -923,7 +923,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:
@ -991,7 +991,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()
@ -1013,7 +1013,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=?,
@ -1071,7 +1071,7 @@ def restructure_all() -> dict:
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.
# reads ALBUMARTIST (uppercase) first so old values stick if not removed.
ext = Path(path).suffix.lower()
if ext == '.flac' and u.album_artist:
try:
@ -1101,7 +1101,7 @@ def apply_tags(path: str, u):
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
# 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'):
@ -1310,7 +1310,7 @@ 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)
# Do NOT restructure on single-track edits -- restructuring renames the
# 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.
@ -1399,7 +1399,7 @@ async def upload_tracks(
):
"""
Upload multiple audio files with per-track metadata.
Optional cover_art is saved as folder.jpg in the album directory.
Optional cover_art is saved as cover.jpg in the album directory.
"""
try:
meta_list = json.loads(metadata_json)
@ -1452,7 +1452,7 @@ async def upload_tracks(
results["failed"].append({"filename": file.filename, "error": str(e)})
if cover_art and album_dir:
cover_dest = os.path.join(album_dir, "folder.jpg")
cover_dest = os.path.join(album_dir, "cover.jpg")
try:
with open(cover_dest, "wb") as buf:
shutil.copyfileobj(cover_art.file, buf)
@ -1725,28 +1725,62 @@ 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 cover.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:
raise HTTPException(404, "Song not found")
song_dir = os.path.dirname(row[0])
cover_dest = os.path.join(song_dir, "folder.jpg")
cover_dest = os.path.join(song_dir, "cover.jpg")
try:
with open(cover_dest, "wb") as buf:
shutil.copyfileobj(file.file, buf)
with sqlite3.connect(DB_PATH) as c:
c.execute("UPDATE songs SET cover_art_path = ? WHERE full_path LIKE ?",
(cover_dest, os.path.join(song_dir, "%")))
cached = os.path.join(COVER_ART_DIR, f"{song_id}.jpg")
if os.path.isfile(cached):
os.remove(cached)
# Clear any cached extracted cover art for all songs in this directory
for (sid,) in sqlite3.connect(DB_PATH).execute(
"SELECT id FROM songs WHERE full_path LIKE ?",
(os.path.join(song_dir, "%"),)
).fetchall():
cached = os.path.join(COVER_ART_DIR, f"{sid}.jpg")
if os.path.isfile(cached):
os.remove(cached)
await push.broadcast("cover_art_updated", {"song_id": song_id})
return {"status": "saved", "path": cover_dest}
except Exception as e:
raise HTTPException(500, str(e))
@app.delete("/library/cover-art/{song_id}")
async def delete_cover_art(song_id: str):
"""Remove cover.jpg from the album directory and clear cover_art_path in DB."""
with sqlite3.connect(DB_PATH) as c:
row = c.execute("SELECT full_path FROM songs WHERE id = ?", (song_id,)).fetchone()
if not row:
raise HTTPException(404, "Song not found")
song_dir = os.path.dirname(row[0])
cover_path = os.path.join(song_dir, "cover.jpg")
try:
if os.path.isfile(cover_path):
os.remove(cover_path)
with sqlite3.connect(DB_PATH) as c:
c.execute("UPDATE songs SET cover_art_path = NULL WHERE full_path LIKE ?",
(os.path.join(song_dir, "%"),))
# Clear cached extracted covers for all songs in directory
for (sid,) in sqlite3.connect(DB_PATH).execute(
"SELECT id FROM songs WHERE full_path LIKE ?",
(os.path.join(song_dir, "%"),)
).fetchall():
cached = os.path.join(COVER_ART_DIR, f"{sid}.jpg")
if os.path.isfile(cached):
os.remove(cached)
await push.broadcast("cover_art_updated", {"song_id": song_id})
return {"status": "deleted"}
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/library/artist-photo")
async def upload_artist_photo(
artist_name: str = Form(...),
@ -1935,7 +1969,7 @@ def check_library_conflicts(navidrome_db_path: str = os.getenv("NAVIDROME_DB_PAT
issues.append({
"type": "duplicate_track",
"severity": "warning",
"title": f"Duplicate: {title} -- {artist}",
"title": f"Duplicate: {title} {artist}",
"detail": f"Found {cnt} copies of this track.",
"affected_paths": path_list,
"fix_action": None,
@ -2053,7 +2087,7 @@ async def fix_conflict(request: FixConflictRequest):
raise HTTPException(500, f"Fix failed: {e}")
elif action == "fix_missing_files":
# Trigger a full Navidrome rescan -- Navidrome will detect and remove
# Trigger a full Navidrome rescan Navidrome will detect and remove
# missing files automatically during a full scan. We cannot write to
# Navidrome's DB directly while it is running (mounted read-only).
try:

View file

@ -1,37 +1,63 @@
import SwiftUI
/// Batch-edit metadata for all songs in an album.
/// Fixes "Various Artists" albums that split into per-artist albums in Navidrome.
struct BatchAlbumEditorSheet: View {
let album: AlbumWithSongs
@Environment(\.dismiss) private var dismiss
@State private var albumName: String
@State private var albumArtist: String
@State private var artist: String
@State private var genre: String
@State private var year: String
@State private var applyArtistToAll = false
// Song removal start with all songs included
@State private var includedSongs: [Song]
// MusicBrainz
@State private var isSearchingMB = false
@State private var mbResults: [MBRecording] = []
@State private var selectedMB: MBRecording?
@State private var showMBPicker = false
// Cover art
@State private var pendingCoverImage: UIImage? = nil
@State private var removeCoverArt = false
@State private var showImagePicker = false
@State private var isSaving = false
@State private var progress: Double = 0
@State private var resultMessage: String?
@State private var failedTracks: [String] = []
@ObservedObject private var settings = CompanionSettings.shared
private let api = CompanionAPIService()
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
// The cover art ID to use first included song with a companion coverArt
private var coverArtSongId: String? {
includedSongs.first(where: {
$0.coverArt?.hasPrefix("companion:") == true
})?.coverArt
}
private var coverArtCompanionId: String? {
guard let id = coverArtSongId, id.hasPrefix("companion:") else { return nil }
return String(id.dropFirst("companion:".count))
}
init(album: AlbumWithSongs) {
self.album = album
_albumName = State(initialValue: album.name)
_albumName = State(initialValue: album.name)
_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!)" : "")
_artist = State(initialValue: album.albumArtist ?? album.artist ?? "")
_genre = State(initialValue: album.genre ?? "")
_year = State(initialValue: album.year != nil ? "\(album.year!)" : "")
_includedSongs = State(initialValue: album.song ?? [])
}
var body: some View {
NavigationStack {
List {
@ -52,52 +78,94 @@ struct BatchAlbumEditorSheet: View {
.padding(.vertical, 12)
}
} else {
// Scope
// Cover art + scope header
Section {
HStack {
Text("Tracks")
.foregroundColor(.gray)
HStack(alignment: .top, spacing: 14) {
CoverArtEditorWidget(
existingCoverArtId: coverArtSongId,
pendingImage: $pendingCoverImage,
removeArt: $removeCoverArt,
showPicker: $showImagePicker,
size: 72
)
VStack(alignment: .leading, spacing: 4) {
Text(albumName)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
.lineLimit(2)
Text("\(includedSongs.count) of \(album.song?.count ?? 0) tracks selected")
.font(.system(size: 12))
.foregroundColor(.gray)
if pendingCoverImage != nil {
Text("Cover art will be replaced")
.font(.system(size: 11))
.foregroundColor(.red.opacity(0.8))
} else if removeCoverArt {
Text("Cover art will be removed")
.font(.system(size: 11))
.foregroundColor(.red.opacity(0.8))
}
}
Spacer()
Text("\(album.song?.count ?? 0) songs")
.foregroundColor(.white)
}
.font(.system(size: 14))
} header: {
Text("Batch Edit")
} footer: {
Text("Changes will be applied to ALL \(album.song?.count ?? 0) tracks in this album on the server. Navidrome will rescan automatically.")
.padding(.vertical, 4)
} header: { Text("Batch Edit") } footer: {
Text("Changes apply to selected tracks only. Navidrome rescans automatically.")
}
// Fields
// MusicBrainz suggestion banner
if let mb = selectedMB {
Section {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "checkmark.seal.fill")
.foregroundColor(.green)
.font(.system(size: 13))
Text("MusicBrainz: \(mb.album)")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
Spacer()
Button("Change") { showMBPicker = true }
.font(.system(size: 12))
.foregroundColor(accentPink)
}
if !mb.label.isEmpty {
Text("\(mb.country) · \(mb.label)")
.font(.system(size: 11))
.foregroundColor(.gray)
}
}
.padding(.vertical, 2)
} header: { Text("MusicBrainz Match") } footer: {
Text("Tap ↓ next to any field to apply the suggestion.")
}
}
// Album tag fields
Section {
editField("Album", text: $albumName)
editField("Album Artist", text: $albumArtist)
editField("Genre", text: $genre)
editField("Year", text: $year, keyboard: .numberPad)
} header: {
Text("Album Tags")
}
mbEditField("Album", text: $albumName, mbValue: selectedMB?.album)
mbEditField("Album Artist", text: $albumArtist, mbValue: selectedMB?.albumArtist)
mbEditField("Genre", text: $genre, mbValue: selectedMB?.genre.isEmpty == false ? selectedMB?.genre : nil)
mbEditField("Year", text: $year, mbValue: selectedMB?.year, keyboard: .numberPad)
} header: { Text("Album Tags") }
Section {
Toggle("Set artist on all tracks", isOn: $applyArtistToAll)
.tint(accentPink)
if applyArtistToAll {
editField("Artist", text: $artist)
mbEditField("Artist", text: $artist, mbValue: selectedMB?.artist)
}
} header: {
Text("Artist Override")
} footer: {
} header: { Text("Artist Override") } footer: {
if applyArtistToAll {
Text("This will set the same artist on every track — useful for fixing compilation albums that split into separate artist albums.")
Text("Sets the same artist on every selected track.")
} else {
Text("Each track keeps its original artist. Only album-level tags are changed.")
Text("Each track keeps its original artist.")
}
}
// Track preview
// Track list swipe to remove
Section {
ForEach(album.song ?? [], id: \.id) { song in
ForEach(includedSongs, id: \.id) { song in
HStack(spacing: 8) {
Text("\(song.track ?? 0)")
.font(.system(size: 12, design: .monospaced))
@ -114,34 +182,48 @@ struct BatchAlbumEditorSheet: View {
.lineLimit(1)
}
Spacer()
if song.path != nil {
Image(systemName: "checkmark.circle")
.font(.system(size: 11))
.foregroundColor(.green.opacity(0.5))
} else {
Image(systemName: "xmark.circle")
.font(.system(size: 11))
.foregroundColor(.red.opacity(0.5))
Image(systemName: song.path != nil ? "checkmark.circle" : "xmark.circle")
.font(.system(size: 11))
.foregroundColor(song.path != nil ? .green.opacity(0.5) : .red.opacity(0.5))
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
withAnimation {
includedSongs.removeAll { $0.id == song.id }
}
} label: {
Label("Remove", systemImage: "minus.circle")
}
}
}
} header: {
Text("Tracks to Update")
HStack {
Text("Tracks to Update (\(includedSongs.count))")
Spacer()
if includedSongs.count < (album.song?.count ?? 0) {
Button("Reset") {
withAnimation { includedSongs = album.song ?? [] }
}
.font(.system(size: 12))
.foregroundColor(accentPink)
}
}
} footer: {
Text("Swipe left on a track to remove it from this batch edit.")
}
// Progress / Results
if isSaving {
Section {
VStack(spacing: 8) {
ProgressView(value: progress)
.tint(accentPink)
Text("Updating \(Int(progress * Double(album.song?.count ?? 0)))/\(album.song?.count ?? 0)...")
ProgressView(value: progress).tint(accentPink)
Text("Updating \(Int(progress * Double(includedSongs.count)))/\(includedSongs.count)...")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
}
if let msg = resultMessage {
Section {
VStack(spacing: 4) {
@ -162,85 +244,134 @@ struct BatchAlbumEditorSheet: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
.foregroundColor(.gray)
Button("Cancel") { dismiss() }.foregroundColor(.gray)
}
ToolbarItem(placement: .navigationBarTrailing) {
if settings.isEnabled {
Button("Apply All", action: applyBatch)
.fontWeight(.semibold)
.foregroundColor(accentPink)
.disabled(isSaving || albumName.isEmpty)
HStack(spacing: 14) {
// MusicBrainz search
Button(action: searchMusicBrainz) {
if isSearchingMB {
ProgressView().tint(.white).scaleEffect(0.8)
} else {
Image(systemName: "magnifyingglass")
.foregroundColor(selectedMB != nil ? .green : .white)
}
}
.disabled(isSearchingMB)
Button("Apply All", action: applyBatch)
.fontWeight(.semibold)
.foregroundColor(accentPink)
.disabled(isSaving || (albumName.isEmpty && pendingCoverImage == nil && !removeCoverArt))
}
}
}
}
.sheet(isPresented: $showMBPicker) {
MusicBrainzPickerSheet(results: mbResults) { rec in
selectedMB = rec
showMBPicker = false
}
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView { image in
pendingCoverImage = image
removeCoverArt = false
}
}
}
}
// MARK: - Apply Batch
private func applyBatch() {
guard let songs = album.song, !songs.isEmpty else { return }
// Collect paths skip songs without file paths
let paths = songs.compactMap { $0.path }
guard !paths.isEmpty else {
resultMessage = "No tracks have file paths"
return
// MARK: - MusicBrainz (album release search)
private func searchMusicBrainz() {
isSearchingMB = true
Task {
let results = await MusicBrainzService.searchAlbum(
album: albumName, artist: albumArtist
)
await MainActor.run {
isSearchingMB = false
if !results.isEmpty {
mbResults = results
showMBPicker = true
}
}
}
}
// MARK: - Apply Batch
private func applyBatch() {
guard !includedSongs.isEmpty || pendingCoverImage != nil || removeCoverArt else { return }
isSaving = true
progress = 0
failedTracks = []
resultMessage = nil
Task {
do {
// Build a single batch request with ALL tag fields
let request = BatchMetadataEditRequest(
relativePaths: paths,
title: nil, // Keep original titles
artist: applyArtistToAll ? artist : nil,
album: albumName.isEmpty ? nil : albumName,
albumArtist: albumArtist.isEmpty ? nil : albumArtist,
genre: genre.isEmpty ? nil : genre,
year: Int(year)
)
DebugLogger.shared.log(
"Batch edit: \(paths.count) tracks, album='\(albumName)', albumArtist='\(albumArtist)', artist=\(applyArtistToAll ? "'\(artist)'" : "nil")",
category: "Companion"
)
await MainActor.run { progress = 0.3 }
let result = try await api.batchEditMetadata(request)
// 1. Cover art
if let image = pendingCoverImage, let cid = coverArtCompanionId {
try await api.uploadCoverArt(songId: cid, image: image)
} else if removeCoverArt, let cid = coverArtCompanionId {
try await api.deleteCoverArt(songId: cid)
}
// Notify the watch for each song in the batch
if let songs = album.song {
for song in songs {
await MainActor.run { progress = 0.2 }
// 2. Tag edit
let paths = includedSongs.compactMap { $0.path }
if !paths.isEmpty {
let request = BatchMetadataEditRequest(
relativePaths: paths,
title: nil,
artist: applyArtistToAll ? artist : nil,
album: albumName.isEmpty ? nil : albumName,
albumArtist: albumArtist.isEmpty ? nil : albumArtist,
genre: genre.isEmpty ? nil : genre,
year: Int(year)
)
DebugLogger.shared.log(
"Batch edit: \(paths.count) tracks, album='\(albumName)', albumArtist='\(albumArtist)'",
category: "Companion"
)
await MainActor.run { progress = 0.4 }
let result = try await api.batchEditMetadata(request)
// Notify watch
for song in includedSongs {
WatchConnectivityManager.shared.notifyTagsUpdated(
songId: song.id,
title: nil, // batch edit never changes titles
title: nil,
artist: applyArtistToAll ? (artist.isEmpty ? nil : artist) : nil,
album: albumName.isEmpty ? nil : albumName,
albumArtist: albumArtist.isEmpty ? nil : albumArtist
)
}
}
await MainActor.run {
progress = 1.0
isSaving = false
let succeeded = result.succeeded?.count ?? 0
let failed = result.failed ?? []
if failed.isEmpty {
resultMessage = "✓ All \(succeeded) tracks updated"
} else {
resultMessage = "\(succeeded)/\(paths.count) succeeded, \(failed.count) failed"
failedTracks = failed.map { "\($0.path): \($0.error)" }
await MainActor.run {
progress = 1.0
isSaving = false
let succeeded = result.succeeded?.count ?? 0
let failed = result.failed ?? []
if failed.isEmpty {
resultMessage = "✓ All \(succeeded) tracks updated"
} else {
resultMessage = "\(succeeded)/\(paths.count) succeeded, \(failed.count) failed"
failedTracks = failed.map { "\($0.path): \($0.error)" }
}
}
} else {
await MainActor.run {
progress = 1.0
isSaving = false
resultMessage = "✓ Cover art updated"
}
}
} catch {
@ -251,19 +382,45 @@ struct BatchAlbumEditorSheet: View {
}
}
}
// MARK: - Helpers
private func editField(_ label: String, text: Binding<String>, keyboard: UIKeyboardType = .default) -> some View {
HStack {
Text(label)
.foregroundColor(.gray)
.frame(width: 100, alignment: .leading)
TextField(label, text: text)
.keyboardType(keyboard)
.multilineTextAlignment(.trailing)
.keyboardDoneButton()
// MARK: - Field with optional MB suggestion pill
private func mbEditField(
_ label: String,
text: Binding<String>,
mbValue: String?,
keyboard: UIKeyboardType = .default
) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(label)
.foregroundColor(.gray)
.frame(width: 100, alignment: .leading)
TextField(label, text: text)
.keyboardType(keyboard)
.multilineTextAlignment(.trailing)
.keyboardDoneButton()
}
.font(.system(size: 14))
if let mb = mbValue, !mb.isEmpty, mb != text.wrappedValue {
Button {
text.wrappedValue = mb
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.down.circle.fill").font(.system(size: 10))
Text(mb).font(.system(size: 11)).lineLimit(1)
}
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.35))
.cornerRadius(8)
}
.buttonStyle(.plain)
.padding(.leading, 4)
}
}
.font(.system(size: 14))
.padding(.vertical, mbValue != nil && mbValue != text.wrappedValue ? 2 : 0)
}
}
}

View file

@ -1,4 +1,5 @@
import Foundation
import UIKit
// MARK: - Companion API Settings
@ -792,6 +793,41 @@ extension CompanionAPIService {
.appendingPathComponent("library/cover-art/\(companionId)")
}
/// Upload a JPEG image as cover.jpg for the album containing this song.
func uploadCoverArt(songId: String, image: UIImage) async throws {
let base = try baseURL()
let url = base.appendingPathComponent("library/cover-art/\(songId)")
let boundary = "Boundary-\(UUID().uuidString)"
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
guard let jpeg = image.jpegData(compressionQuality: 0.92) else {
throw CompanionError.invalidResponse
}
let crlf = "\r\n"
var body = Data()
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"cover.jpg\"\(crlf)".data(using: .utf8)!)
body.append("Content-Type: image/jpeg\(crlf)\(crlf)".data(using: .utf8)!)
body.append(jpeg)
body.append("\(crlf)--\(boundary)--\(crlf)".data(using: .utf8)!)
req.httpBody = body
let (_, response) = try await session.data(for: req)
try validateResponse(response)
}
/// Delete cover.jpg from the album directory for the song.
func deleteCoverArt(songId: String) async throws {
let base = try baseURL()
let url = base.appendingPathComponent("library/cover-art/\(songId)")
var req = URLRequest(url: url)
req.httpMethod = "DELETE"
let (_, response) = try await session.data(for: req)
try validateResponse(response)
}
/// Build an artist photo URL for an artist name.
nonisolated func artistPhotoURL(artistName: String) -> URL? {
guard let encoded = artistName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)

View file

@ -88,6 +88,59 @@ struct MusicBrainzService {
}
return results
}
/// Search MusicBrainz by album/release name used by batch editor.
static func searchAlbum(album: String, artist: String) async -> [MBRecording] {
let query = "release:\"\(album)\" AND artist:\"\(artist)\""
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "\(baseURL)/release?query=\(encoded)&limit=10&fmt=json") else {
return []
}
var req = URLRequest(url: url)
req.setValue(userAgent, forHTTPHeaderField: "User-Agent")
req.timeoutInterval = 10
guard let (data, _) = try? await URLSession.shared.data(for: req),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let releases = json["releases"] as? [[String: Any]] else {
return []
}
var results: [MBRecording] = []
for rel in releases {
let id = rel["id"] as? String ?? UUID().uuidString
let title = rel["title"] as? String ?? ""
let date = rel["date"] as? String ?? ""
let year = String(date.prefix(4))
let country = rel["country"] as? String ?? ""
let credits = rel["artist-credit"] as? [[String: Any]] ?? []
let artistName = credits.compactMap {
($0["artist"] as? [String: Any])?["name"] as? String
}.joined(separator: ", ")
let labelInfo = (rel["label-info"] as? [[String: Any]])?.first
let labelName = (labelInfo?["label"] as? [String: Any])?["name"] as? String ?? ""
// Get track count from media
let media = rel["media"] as? [[String: Any]] ?? []
let trackCount = media.compactMap { $0["track-count"] as? Int }.reduce(0, +)
results.append(MBRecording(
id: id, title: title,
artist: artistName,
album: title,
albumArtist: artistName,
year: year,
trackNumber: trackCount > 0 ? "\(trackCount) tracks" : "",
discNumber: media.count > 1 ? "\(media.count) discs" : "",
genre: "",
country: country,
label: labelName
))
}
return results
}
}
// MARK: - TrackEditorView
@ -123,6 +176,11 @@ struct TrackEditorView: View {
@State private var showMBPicker = false
@State private var mbError: String?
// Cover art
@State private var pendingCoverImage: UIImage? = nil
@State private var removeCoverArt = false
@State private var showImagePicker = false
@State private var isSaving = false
@State private var errorMessage: String?
@State private var showSuccess = false
@ -134,9 +192,12 @@ struct TrackEditorView: View {
private var hasSelection: Bool {
editTitle || editArtist || editAlbum || editAlbumArtist ||
editGenre || editYear || editTrackNumber || editDiscNumber
editGenre || editYear || editTrackNumber || editDiscNumber ||
pendingCoverImage != nil || removeCoverArt
}
private var coverArtChanged: Bool { pendingCoverImage != nil || removeCoverArt }
/// Live preview of where the file will end up after restructure.
private var pathPreview: String {
let aa = albumArtist
@ -197,14 +258,22 @@ struct TrackEditorView: View {
.padding(.vertical, 12)
}
} else {
// File info (read-only)
// File info + cover art
Section {
infoRow("File", song.path ?? song.id)
if let suffix = song.suffix {
infoRow("Format", suffix.uppercased())
}
if let bitRate = song.bitRate {
infoRow("Bit Rate", "\(bitRate) kbps")
HStack(alignment: .top, spacing: 12) {
// File details
VStack(alignment: .leading, spacing: 6) {
infoRow("File", song.path ?? song.id)
if let suffix = song.suffix {
infoRow("Format", suffix.uppercased())
}
if let bitRate = song.bitRate {
infoRow("Bit Rate", "\(bitRate) kbps")
}
}
Spacer()
// Cover art thumbnail top right
coverArtWidget(songId: song.coverArt)
}
} header: {
Text("File Info")
@ -332,6 +401,12 @@ struct TrackEditorView: View {
}
)
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView { image in
pendingCoverImage = image
removeCoverArt = false
}
}
.overlay {
if showSuccess {
VStack(spacing: 12) {
@ -411,26 +486,38 @@ struct TrackEditorView: View {
Task {
do {
let request = MetadataEditRequest(
relativePath: path,
title: editTitle ? (title.isEmpty ? nil : title) : nil,
artist: editArtist ? (artist.isEmpty ? nil : artist) : nil,
album: editAlbum ? (album.isEmpty ? nil : album) : nil,
albumArtist: editAlbumArtist ? (albumArtist.isEmpty ? nil : albumArtist) : nil,
genre: editGenre ? (genre.isEmpty ? nil : genre) : nil,
year: editYear ? Int(year) : nil,
trackNumber: editTrackNumber ? Int(trackNumber) : nil
)
// Handle cover art first
if let image = pendingCoverImage, let companionId = companionSongId() {
try await api.uploadCoverArt(songId: companionId, image: image)
} else if removeCoverArt, let companionId = companionSongId() {
try await api.deleteCoverArt(songId: companionId)
}
try await api.editMetadata(request)
// Only send metadata edit if any tag fields are checked
let hasTagChanges = editTitle || editArtist || editAlbum || editAlbumArtist ||
editGenre || editYear || editTrackNumber || editDiscNumber
if hasTagChanges {
let request = MetadataEditRequest(
relativePath: path,
title: editTitle ? (title.isEmpty ? nil : title) : nil,
artist: editArtist ? (artist.isEmpty ? nil : artist) : nil,
album: editAlbum ? (album.isEmpty ? nil : album) : nil,
albumArtist: editAlbumArtist ? (albumArtist.isEmpty ? nil : albumArtist) : nil,
genre: editGenre ? (genre.isEmpty ? nil : genre) : nil,
year: editYear ? Int(year) : nil,
trackNumber: editTrackNumber ? Int(trackNumber) : nil
)
WatchConnectivityManager.shared.notifyTagsUpdated(
songId: song.id,
title: request.title,
artist: request.artist,
album: request.album,
albumArtist: request.albumArtist
)
try await api.editMetadata(request)
WatchConnectivityManager.shared.notifyTagsUpdated(
songId: song.id,
title: request.title,
artist: request.artist,
album: request.album,
albumArtist: request.albumArtist
)
}
await MainActor.run {
isSaving = false
@ -450,6 +537,12 @@ struct TrackEditorView: View {
}
}
/// Extract the Companion song ID from coverArt field ("companion:{id}")
private func companionSongId() -> String? {
guard let coverArt = song.coverArt, coverArt.hasPrefix("companion:") else { return nil }
return String(coverArt.dropFirst("companion:".count))
}
// MARK: - Info Row (read-only)
private func infoRow(_ label: String, _ value: String) -> some View {
@ -461,6 +554,18 @@ struct TrackEditorView: View {
.font(.system(size: 14))
}
// MARK: - Cover Art Widget
private func coverArtWidget(songId: String?) -> some View {
CoverArtEditorWidget(
existingCoverArtId: songId,
pendingImage: $pendingCoverImage,
removeArt: $removeCoverArt,
showPicker: $showImagePicker,
size: 72
)
}
// MARK: - Checkmark Field with optional MusicBrainz suggestion
private func mbCheckField(
@ -581,3 +686,122 @@ struct MusicBrainzPickerSheet: View {
}
}
// MARK: - Cover Art Widget (shared by TrackEditorView and BatchAlbumEditorSheet)
struct CoverArtEditorWidget: View {
let existingCoverArtId: String?
@Binding var pendingImage: UIImage?
@Binding var removeArt: Bool
@Binding var showPicker: Bool
let size: CGFloat
private var isChanged: Bool { pendingImage != nil || removeArt }
var body: some View {
ZStack(alignment: .bottomTrailing) {
Group {
if let img = pendingImage {
Image(uiImage: img)
.resizable().scaledToFill()
} else if removeArt {
ZStack {
Color.gray.opacity(0.2)
Image(systemName: "photo.slash")
.font(.system(size: size * 0.35))
.foregroundColor(.gray)
}
} else if let id = existingCoverArtId,
id.hasPrefix("companion:"),
let url = CompanionSettings.shared.baseURL?
.appendingPathComponent("library/cover-art/\(id.dropFirst("companion:".count))") {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img): img.resizable().scaledToFill()
default:
ZStack {
Color.gray.opacity(0.15)
Image(systemName: "music.note")
.font(.system(size: size * 0.35))
.foregroundColor(.gray)
}
}
}
} else {
ZStack {
Color.gray.opacity(0.15)
Image(systemName: "photo.badge.plus")
.font(.system(size: size * 0.35))
.foregroundColor(.gray.opacity(0.7))
}
}
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(isChanged ? Color.red.opacity(0.9) : Color.clear, lineWidth: 2)
.shadow(color: isChanged ? .red.opacity(0.6) : .clear, radius: 4)
)
if pendingImage != nil || (existingCoverArtId != nil && !removeArt) {
Menu {
Button { showPicker = true } label: {
Label("Replace", systemImage: "photo")
}
Button(role: .destructive) {
pendingImage = nil
removeArt = true
} label: {
Label("Remove", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle.fill")
.font(.system(size: 18))
.foregroundColor(.white)
.shadow(radius: 2)
}
.offset(x: 4, y: 4)
}
}
.frame(width: size, height: size)
.onTapGesture {
if pendingImage == nil && existingCoverArtId == nil {
showPicker = true
}
}
}
}
// MARK: - Image Picker (UIKit wrapper)
struct ImagePickerView: UIViewControllerRepresentable {
let onPick: (UIImage) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onPick: onPick) }
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
picker.allowsEditing = true
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onPick: (UIImage) -> Void
init(onPick: @escaping (UIImage) -> Void) { self.onPick = onPick }
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
let img = (info[.editedImage] ?? info[.originalImage]) as? UIImage
picker.dismiss(animated: true)
if let img { onPick(img) }
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
}