batch updated with cover art support
This commit is contained in:
parent
3ea57fa99b
commit
d32d63a749
4 changed files with 634 additions and 183 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue