safeguards to id3 tagging and mismatch info
This commit is contained in:
parent
ffcddc86e2
commit
db9d79f023
5 changed files with 719 additions and 4 deletions
|
|
@ -24,6 +24,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- /home/pi/navidrome/music:/music:rw
|
- /home/pi/navidrome/music:/music:rw
|
||||||
- ./companion_data:/app/data
|
- ./companion_data:/app/data
|
||||||
|
- /home/pi/docker/navidrome/navidrome_data:/navidrome_data:ro
|
||||||
environment:
|
environment:
|
||||||
- MUSIC_DIR=/music
|
- MUSIC_DIR=/music
|
||||||
- DB_PATH=/app/data/smart_dj.db
|
- DB_PATH=/app/data/smart_dj.db
|
||||||
|
|
@ -31,6 +32,7 @@ services:
|
||||||
- COVER_ART_DIR=/app/data/cover_art
|
- COVER_ART_DIR=/app/data/cover_art
|
||||||
- ARTIST_PHOTO_DIR=/app/data/artist_photos
|
- ARTIST_PHOTO_DIR=/app/data/artist_photos
|
||||||
- NAVIDROME_URL=http://navidrome:4533/navidrome
|
- NAVIDROME_URL=http://navidrome:4533/navidrome
|
||||||
|
- NAVIDROME_DB_PATH=/navidrome_data/navidrome.db
|
||||||
- SUBSONIC_USER=Dallasgroot
|
- SUBSONIC_USER=Dallasgroot
|
||||||
- SUBSONIC_TOKEN=
|
- SUBSONIC_TOKEN=
|
||||||
- SUBSONIC_SALT=
|
- SUBSONIC_SALT=
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,11 @@ class BatchMetadataUpdate(BaseModel):
|
||||||
genre: Optional[str] = None
|
genre: Optional[str] = None
|
||||||
year: Optional[int] = None
|
year: Optional[int] = None
|
||||||
|
|
||||||
|
class FixConflictRequest(BaseModel):
|
||||||
|
action: str
|
||||||
|
fix_data: dict = {}
|
||||||
|
|
||||||
|
|
||||||
class TrackUploadMeta(BaseModel):
|
class TrackUploadMeta(BaseModel):
|
||||||
filename: str
|
filename: str
|
||||||
title: str
|
title: str
|
||||||
|
|
@ -1348,13 +1353,13 @@ async def batch_edit_metadata(update: BatchMetadataUpdate):
|
||||||
results["failed"].append({"path": rp, "error": str(e)})
|
results["failed"].append({"path": rp, "error": str(e)})
|
||||||
await trigger_scan()
|
await trigger_scan()
|
||||||
# Wait for Navidrome to finish scanning before the app re-fetches paths.
|
# 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 asyncio.sleep(4)
|
||||||
await push.broadcast("batch_metadata_updated",
|
await push.broadcast("batch_metadata_updated",
|
||||||
{"count": len(results["succeeded"]),
|
{"count": len(results["succeeded"]),
|
||||||
"album": update.album or "",
|
"album": update.album or "",
|
||||||
"paths_changed": "true"})
|
"paths_changed": "true"})
|
||||||
|
# Run conflict check in background after every batch edit
|
||||||
|
asyncio.create_task(_run_conflict_check_and_broadcast())
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1776,6 +1781,343 @@ async def get_artist_photo(artist_name: str):
|
||||||
return FileResponse(row[0], media_type=mt)
|
return FileResponse(row[0], media_type=mt)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_conflict_check_and_broadcast():
|
||||||
|
"""Run conflict check in the background and broadcast results."""
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(6) # Wait for Navidrome scan to complete
|
||||||
|
navidrome_db = os.getenv("NAVIDROME_DB_PATH", "/navidrome_data/navidrome.db")
|
||||||
|
issues = check_library_conflicts(navidrome_db)
|
||||||
|
error_count = sum(1 for i in issues if i["severity"] == "error")
|
||||||
|
warning_count = sum(1 for i in issues if i["severity"] == "warning")
|
||||||
|
if issues:
|
||||||
|
await push.broadcast("conflicts_updated", {
|
||||||
|
"total": str(len(issues)),
|
||||||
|
"errors": str(error_count),
|
||||||
|
"warnings": str(warning_count)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Background conflict check failed: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LIBRARY CONFLICTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def check_library_conflicts(navidrome_db_path: str = os.getenv("NAVIDROME_DB_PATH", "/navidrome_data/navidrome.db")) -> list:
|
||||||
|
"""
|
||||||
|
Run all conflict checks and return a list of issues.
|
||||||
|
Each issue is a dict with: type, severity, title, detail, affected_paths, fix_action
|
||||||
|
"""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# ── 1. Duplicate albums (same name, multiple album_artist values) ─────────
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(navidrome_db_path) as nav:
|
||||||
|
rows = nav.execute("""
|
||||||
|
SELECT name, COUNT(DISTINCT album_artist) as aa_count,
|
||||||
|
GROUP_CONCAT(id, '|||') as ids,
|
||||||
|
GROUP_CONCAT(album_artist, '|||') as artists,
|
||||||
|
GROUP_CONCAT(song_count, '|||') as counts
|
||||||
|
FROM album
|
||||||
|
GROUP BY name
|
||||||
|
HAVING aa_count > 1
|
||||||
|
ORDER BY name
|
||||||
|
""").fetchall()
|
||||||
|
for name, aa_count, ids, artists, counts in rows:
|
||||||
|
id_list = ids.split('|||')
|
||||||
|
artist_list = artists.split('|||')
|
||||||
|
count_list = counts.split('|||')
|
||||||
|
issues.append({
|
||||||
|
"type": "duplicate_album",
|
||||||
|
"severity": "error",
|
||||||
|
"title": f"Duplicate album: {name}",
|
||||||
|
"detail": f"{aa_count} versions found with different album artists: {', '.join(artist_list)}",
|
||||||
|
"affected_paths": [],
|
||||||
|
"fix_action": "fix_duplicate_album",
|
||||||
|
"fix_data": {
|
||||||
|
"album_name": name,
|
||||||
|
"album_ids": id_list,
|
||||||
|
"album_artists": artist_list,
|
||||||
|
"song_counts": count_list
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f" conflict check 1 failed: {e}", flush=True)
|
||||||
|
|
||||||
|
# ── 2. Missing files (Navidrome knows about them but they're gone) ────────
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(navidrome_db_path) as nav:
|
||||||
|
rows = nav.execute(
|
||||||
|
"SELECT path FROM media_file WHERE missing = 1 LIMIT 50"
|
||||||
|
).fetchall()
|
||||||
|
if rows:
|
||||||
|
issues.append({
|
||||||
|
"type": "missing_files",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": f"{len(rows)} missing file(s)",
|
||||||
|
"detail": "Files registered in Navidrome but not found on disk.",
|
||||||
|
"affected_paths": [r[0] for r in rows],
|
||||||
|
"fix_action": "fix_missing_files",
|
||||||
|
"fix_data": {}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f" conflict check 2 failed: {e}", flush=True)
|
||||||
|
|
||||||
|
# ── 3. Picard legacy tags (FLAC files with conflicting albumartist tags) ──
|
||||||
|
try:
|
||||||
|
legacy_files = []
|
||||||
|
with sqlite3.connect(DB_PATH) as c:
|
||||||
|
rows = c.execute("SELECT full_path FROM songs").fetchall()
|
||||||
|
for (full_path,) in rows:
|
||||||
|
if not full_path.lower().endswith('.flac'):
|
||||||
|
continue
|
||||||
|
if not os.path.isfile(full_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
from mutagen.flac import FLAC
|
||||||
|
f = FLAC(full_path)
|
||||||
|
keys = [k.upper() for k in f.keys()]
|
||||||
|
has_legacy = any(k in keys for k in ['ALBUM ARTIST', 'ALBUM_ARTIST'])
|
||||||
|
has_canonical = 'ALBUMARTIST' in keys
|
||||||
|
if has_legacy and has_canonical:
|
||||||
|
legacy_files.append(os.path.relpath(full_path, MUSIC_DIR))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if legacy_files:
|
||||||
|
issues.append({
|
||||||
|
"type": "picard_legacy_tags",
|
||||||
|
"severity": "error",
|
||||||
|
"title": f"{len(legacy_files)} file(s) with conflicting album artist tags",
|
||||||
|
"detail": "These FLAC files have both Picard legacy tags (ALBUM ARTIST/ALBUM_ARTIST) and the canonical ALBUMARTIST tag. Navidrome reads the legacy tag and gets the wrong album artist.",
|
||||||
|
"affected_paths": legacy_files[:50],
|
||||||
|
"fix_action": "fix_picard_tags",
|
||||||
|
"fix_data": {}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f" conflict check 3 failed: {e}", flush=True)
|
||||||
|
|
||||||
|
# ── 4. Orphaned tracks (album_id points to non-existent album) ────────────
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(navidrome_db_path) as nav:
|
||||||
|
rows = nav.execute("""
|
||||||
|
SELECT mf.path FROM media_file mf
|
||||||
|
LEFT JOIN album a ON mf.album_id = a.id
|
||||||
|
WHERE mf.album_id IS NOT NULL AND a.id IS NULL
|
||||||
|
LIMIT 50
|
||||||
|
""").fetchall()
|
||||||
|
if rows:
|
||||||
|
issues.append({
|
||||||
|
"type": "orphaned_tracks",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": f"{len(rows)} orphaned track(s)",
|
||||||
|
"detail": "Tracks whose album_id points to a non-existent album entry.",
|
||||||
|
"affected_paths": [r[0] for r in rows],
|
||||||
|
"fix_action": "fix_orphaned_tracks",
|
||||||
|
"fix_data": {}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f" conflict check 4 failed: {e}", flush=True)
|
||||||
|
|
||||||
|
# ── 5. Duplicate tracks (same title+artist+duration appearing >1 time) ───
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(DB_PATH) as c:
|
||||||
|
rows = c.execute("""
|
||||||
|
SELECT title, artist, COUNT(*) as cnt,
|
||||||
|
GROUP_CONCAT(relative_path, '|||') as paths
|
||||||
|
FROM songs
|
||||||
|
WHERE title != '' AND artist != ''
|
||||||
|
GROUP BY title, artist, CAST(duration AS INT)
|
||||||
|
HAVING cnt > 1
|
||||||
|
LIMIT 30
|
||||||
|
""").fetchall()
|
||||||
|
for title, artist, cnt, paths in rows:
|
||||||
|
path_list = paths.split('|||') if paths else []
|
||||||
|
issues.append({
|
||||||
|
"type": "duplicate_track",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": f"Duplicate: {title} -- {artist}",
|
||||||
|
"detail": f"Found {cnt} copies of this track.",
|
||||||
|
"affected_paths": path_list,
|
||||||
|
"fix_action": None,
|
||||||
|
"fix_data": {}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f" conflict check 5 failed: {e}", flush=True)
|
||||||
|
|
||||||
|
# ── 6. Stale Companion paths (full_path no longer exists on disk) ─────────
|
||||||
|
try:
|
||||||
|
stale = []
|
||||||
|
with sqlite3.connect(DB_PATH) as c:
|
||||||
|
rows = c.execute("SELECT relative_path, full_path FROM songs").fetchall()
|
||||||
|
for rel, full in rows:
|
||||||
|
if not os.path.isfile(full):
|
||||||
|
stale.append(rel)
|
||||||
|
if stale:
|
||||||
|
issues.append({
|
||||||
|
"type": "stale_companion_paths",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": f"{len(stale)} stale path(s) in Companion DB",
|
||||||
|
"detail": "The Companion's song database has entries whose files no longer exist at the recorded path. Run a library scan to fix.",
|
||||||
|
"affected_paths": stale[:50],
|
||||||
|
"fix_action": "fix_stale_paths",
|
||||||
|
"fix_data": {}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f" conflict check 6 failed: {e}", flush=True)
|
||||||
|
|
||||||
|
print(f" Conflict check complete: {len(issues)} issues found", flush=True)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/library/conflicts")
|
||||||
|
async def library_conflicts():
|
||||||
|
"""Run all conflict checks and return structured results."""
|
||||||
|
navidrome_db = os.getenv("NAVIDROME_DB_PATH", "/navidrome_data/navidrome.db")
|
||||||
|
issues = check_library_conflicts(navidrome_db)
|
||||||
|
error_count = sum(1 for i in issues if i["severity"] == "error")
|
||||||
|
warning_count = sum(1 for i in issues if i["severity"] == "warning")
|
||||||
|
return {
|
||||||
|
"total": len(issues),
|
||||||
|
"errors": error_count,
|
||||||
|
"warnings": warning_count,
|
||||||
|
"issues": issues
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/library/fix-conflict")
|
||||||
|
async def fix_conflict(request: FixConflictRequest):
|
||||||
|
"""
|
||||||
|
Fix a specific conflict by action type.
|
||||||
|
Request body: {"action": "fix_duplicate_album", "fix_data": {...}}
|
||||||
|
"""
|
||||||
|
navidrome_db = os.getenv("NAVIDROME_DB_PATH", "/navidrome_data/navidrome.db")
|
||||||
|
action = request.action
|
||||||
|
fix_data = request.fix_data
|
||||||
|
|
||||||
|
if action == "fix_duplicate_album":
|
||||||
|
# Strategy: rewrite ALBUMARTIST tags on all affected files so Navidrome's
|
||||||
|
# next scan groups them under one canonical album entry.
|
||||||
|
# We cannot write to Navidrome's DB directly while it is running.
|
||||||
|
album_name = fix_data.get("album_name", "")
|
||||||
|
album_artists = fix_data.get("album_artists", [])
|
||||||
|
song_counts = fix_data.get("song_counts", [])
|
||||||
|
|
||||||
|
if not album_artists:
|
||||||
|
raise HTTPException(400, "No album artists provided")
|
||||||
|
|
||||||
|
# Canonical = album artist with the most songs
|
||||||
|
try:
|
||||||
|
counts = [int(c) for c in song_counts]
|
||||||
|
canonical_idx = counts.index(max(counts))
|
||||||
|
except Exception:
|
||||||
|
canonical_idx = 0
|
||||||
|
canonical_artist = album_artists[canonical_idx]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(DB_PATH) as c:
|
||||||
|
rows = c.execute(
|
||||||
|
"SELECT full_path FROM songs WHERE album = ?", (album_name,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
fixed = 0
|
||||||
|
for (full_path,) in rows:
|
||||||
|
if not os.path.isfile(full_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ext = Path(full_path).suffix.lower()
|
||||||
|
if ext == '.flac':
|
||||||
|
from mutagen.flac import FLAC
|
||||||
|
f = FLAC(full_path)
|
||||||
|
for variant in ['ALBUM ARTIST', 'ALBUM_ARTIST', 'ALBUMARTIST', 'albumartist']:
|
||||||
|
if variant in f:
|
||||||
|
del f[variant]
|
||||||
|
f['ALBUMARTIST'] = canonical_artist
|
||||||
|
f['ALBUM'] = album_name
|
||||||
|
f.save()
|
||||||
|
else:
|
||||||
|
audio = MutagenFile(full_path, easy=True)
|
||||||
|
if audio:
|
||||||
|
audio['albumartist'] = canonical_artist
|
||||||
|
audio['album'] = album_name
|
||||||
|
audio.save()
|
||||||
|
update_song_in_db(full_path)
|
||||||
|
fixed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" fix_duplicate_album: {os.path.basename(full_path)}: {e}", flush=True)
|
||||||
|
|
||||||
|
await trigger_scan()
|
||||||
|
await asyncio.sleep(4)
|
||||||
|
await push.broadcast("conflicts_updated", {"action": "fix_duplicate_album", "album": album_name})
|
||||||
|
return {"status": "fixed", "album": album_name, "fixed": fixed, "canonical_artist": canonical_artist}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Fix failed: {e}")
|
||||||
|
|
||||||
|
elif action == "fix_missing_files":
|
||||||
|
# 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:
|
||||||
|
await trigger_scan()
|
||||||
|
return {"status": "triggered_scan", "detail": "Navidrome will remove missing entries on next scan"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Fix failed: {e}")
|
||||||
|
|
||||||
|
elif action == "fix_picard_tags":
|
||||||
|
fixed = 0
|
||||||
|
failed = []
|
||||||
|
with sqlite3.connect(DB_PATH) as c:
|
||||||
|
rows = c.execute("SELECT full_path FROM songs WHERE full_path LIKE '%.flac'").fetchall()
|
||||||
|
for (full_path,) in rows:
|
||||||
|
if not os.path.isfile(full_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
from mutagen.flac import FLAC
|
||||||
|
f = FLAC(full_path)
|
||||||
|
keys = list(f.keys())
|
||||||
|
upper_keys = [k.upper() for k in keys]
|
||||||
|
has_legacy = any(k in upper_keys for k in ['ALBUM ARTIST', 'ALBUM_ARTIST'])
|
||||||
|
if not has_legacy:
|
||||||
|
continue
|
||||||
|
# Get canonical value
|
||||||
|
canonical = None
|
||||||
|
for k in keys:
|
||||||
|
if k.upper() == 'ALBUMARTIST':
|
||||||
|
canonical = f[k][0] if f[k] else None
|
||||||
|
break
|
||||||
|
if not canonical:
|
||||||
|
for k in keys:
|
||||||
|
if k.upper() in ['ALBUM ARTIST', 'ALBUM_ARTIST']:
|
||||||
|
canonical = f[k][0] if f[k] else None
|
||||||
|
break
|
||||||
|
if not canonical:
|
||||||
|
continue
|
||||||
|
# Remove all variants and write canonical
|
||||||
|
for k in list(f.keys()):
|
||||||
|
if k.upper() in ['ALBUM ARTIST', 'ALBUM_ARTIST', 'ALBUMARTIST', 'albumartist']:
|
||||||
|
del f[k]
|
||||||
|
f['ALBUMARTIST'] = canonical
|
||||||
|
f.save()
|
||||||
|
update_song_in_db(full_path)
|
||||||
|
fixed += 1
|
||||||
|
except Exception as e:
|
||||||
|
failed.append(os.path.relpath(full_path, MUSIC_DIR))
|
||||||
|
await trigger_scan()
|
||||||
|
await push.broadcast("conflicts_updated", {"action": "fix_picard_tags"})
|
||||||
|
return {"status": "fixed", "fixed": fixed, "failed": len(failed)}
|
||||||
|
|
||||||
|
elif action == "fix_stale_paths":
|
||||||
|
count = scan_library(full_rescan=False)
|
||||||
|
build_file_index()
|
||||||
|
return {"status": "fixed", "rescanned": count}
|
||||||
|
|
||||||
|
elif action == "fix_orphaned_tracks":
|
||||||
|
await trigger_scan()
|
||||||
|
return {"status": "triggered_scan"}
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise HTTPException(400, f"Unknown action: {action}")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# WebSocket Push
|
# WebSocket Push
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -528,12 +528,14 @@ class CompanionPushClient: ObservableObject {
|
||||||
case "batch_metadata_updated":
|
case "batch_metadata_updated":
|
||||||
NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data)
|
NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data)
|
||||||
case "cover_art_updated":
|
case "cover_art_updated":
|
||||||
// Clear image cache so updated cover art loads immediately
|
|
||||||
ImageCache.shared.clearAll()
|
ImageCache.shared.clearAll()
|
||||||
NotificationCenter.default.post(name: .companionCoverArtUpdated, object: nil, userInfo: msg.data)
|
NotificationCenter.default.post(name: .companionCoverArtUpdated, object: nil, userInfo: msg.data)
|
||||||
case "artist_photo_updated":
|
case "artist_photo_updated":
|
||||||
ImageCache.shared.clearAll()
|
ImageCache.shared.clearAll()
|
||||||
NotificationCenter.default.post(name: .companionArtistPhotoUpdated, object: nil, userInfo: msg.data)
|
NotificationCenter.default.post(name: .companionArtistPhotoUpdated, object: nil, userInfo: msg.data)
|
||||||
|
case "conflicts_updated":
|
||||||
|
// Refresh conflict badge count in the background
|
||||||
|
Task { await ConflictManager.shared.refresh() }
|
||||||
case "profile":
|
case "profile":
|
||||||
if let path = msg.data?["path"] as? String,
|
if let path = msg.data?["path"] as? String,
|
||||||
let jsonData = try? JSONSerialization.data(withJSONObject: msg.data ?? [:]),
|
let jsonData = try? JSONSerialization.data(withJSONObject: msg.data ?? [:]),
|
||||||
|
|
@ -693,6 +695,37 @@ extension CompanionAPIService {
|
||||||
return CompanionSettings.shared.baseURL?
|
return CompanionSettings.shared.baseURL?
|
||||||
.appendingPathComponent("library/artist-photo/\(encoded)")
|
.appendingPathComponent("library/artist-photo/\(encoded)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch all library conflicts and issues.
|
||||||
|
func fetchConflicts() async throws -> ConflictsResponse {
|
||||||
|
let base = try baseURL()
|
||||||
|
let url = base.appendingPathComponent("library/conflicts")
|
||||||
|
let (data, response) = try await session.data(from: url)
|
||||||
|
try validateResponse(response)
|
||||||
|
return try JSONDecoder().decode(ConflictsResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fix a specific conflict by action type.
|
||||||
|
func fixConflict(action: String, fixData: [String: AnyCodable]?) async throws {
|
||||||
|
let base = try baseURL()
|
||||||
|
let url = base.appendingPathComponent("library/fix-conflict")
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
// Build body — AnyCodable.value can be String, Int, or [String]
|
||||||
|
var bodyFix: [String: Any] = [:]
|
||||||
|
if let fd = fixData {
|
||||||
|
for (k, v) in fd {
|
||||||
|
bodyFix[k] = v.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let body: [String: Any] = ["action": action, "fix_data": bodyFix]
|
||||||
|
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
|
||||||
|
let (_, response) = try await session.data(for: req)
|
||||||
|
try validateResponse(response)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Errors
|
// MARK: - Errors
|
||||||
|
|
|
||||||
312
iOS/Views/Companion/LibraryConflictsView.swift
Normal file
312
iOS/Views/Companion/LibraryConflictsView.swift
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Conflict Models
|
||||||
|
|
||||||
|
struct LibraryConflict: Codable, Identifiable {
|
||||||
|
let type: String
|
||||||
|
let severity: String
|
||||||
|
let title: String
|
||||||
|
let detail: String
|
||||||
|
let affected_paths: [String]
|
||||||
|
let fix_action: String?
|
||||||
|
let fix_data: [String: AnyCodable]?
|
||||||
|
|
||||||
|
var id: String { "\(type)|\(title)" }
|
||||||
|
|
||||||
|
var severityColor: Color {
|
||||||
|
severity == "error" ? Color(red: 1, green: 0.176, blue: 0.333) : .yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
var severityIcon: String {
|
||||||
|
severity == "error" ? "xmark.circle.fill" : "exclamationmark.triangle.fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
var typeIcon: String {
|
||||||
|
switch type {
|
||||||
|
case "duplicate_album": return "rectangle.on.rectangle.fill"
|
||||||
|
case "missing_files": return "doc.badge.questionmark"
|
||||||
|
case "picard_legacy_tags": return "tag.slash.fill"
|
||||||
|
case "orphaned_tracks": return "link.badge.plus"
|
||||||
|
case "duplicate_track": return "music.note.list"
|
||||||
|
case "stale_companion_paths": return "externaldrive.badge.xmark"
|
||||||
|
default: return "exclamationmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConflictsResponse: Codable {
|
||||||
|
let total: Int
|
||||||
|
let errors: Int
|
||||||
|
let warnings: Int
|
||||||
|
let issues: [LibraryConflict]
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnyCodable wrapper for heterogeneous fix_data dict
|
||||||
|
struct AnyCodable: Codable {
|
||||||
|
let value: Any
|
||||||
|
|
||||||
|
init(_ value: Any) { self.value = value }
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let v = try? container.decode([String].self) { value = v; return }
|
||||||
|
if let v = try? container.decode([String: String].self) { value = v; return }
|
||||||
|
if let v = try? container.decode(String.self) { value = v; return }
|
||||||
|
if let v = try? container.decode(Int.self) { value = v; return }
|
||||||
|
value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch value {
|
||||||
|
case let v as [String]: try container.encode(v)
|
||||||
|
case let v as [String: String]: try container.encode(v)
|
||||||
|
case let v as String: try container.encode(v)
|
||||||
|
case let v as Int: try container.encode(v)
|
||||||
|
default: try container.encode("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Manager
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class ConflictManager: ObservableObject {
|
||||||
|
static let shared = ConflictManager()
|
||||||
|
|
||||||
|
@Published var conflicts: ConflictsResponse?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var lastError: String?
|
||||||
|
@Published var isFixing: String? = nil // currently fixing conflict id
|
||||||
|
|
||||||
|
private let api = CompanionAPIService()
|
||||||
|
|
||||||
|
var badgeCount: Int { conflicts?.errors ?? 0 }
|
||||||
|
var totalCount: Int { conflicts?.total ?? 0 }
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
guard CompanionSettings.shared.isEnabled else { return }
|
||||||
|
isLoading = true
|
||||||
|
lastError = nil
|
||||||
|
do {
|
||||||
|
conflicts = try await api.fetchConflicts()
|
||||||
|
} catch {
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func fix(_ conflict: LibraryConflict) async {
|
||||||
|
guard let action = conflict.fix_action else { return }
|
||||||
|
isFixing = conflict.id
|
||||||
|
do {
|
||||||
|
try await api.fixConflict(action: action, fixData: conflict.fix_data)
|
||||||
|
// Refresh after fix
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
await refresh()
|
||||||
|
} catch {
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
isFixing = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Issues & Conflicts View
|
||||||
|
|
||||||
|
struct LibraryConflictsView: View {
|
||||||
|
@StateObject private var manager = ConflictManager.shared
|
||||||
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if manager.isLoading {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
ProgressView().tint(accentPink)
|
||||||
|
Text("Scanning library for issues…")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.leading, 8)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
} else if let error = manager.lastError {
|
||||||
|
Section {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
} else if let conflicts = manager.conflicts {
|
||||||
|
if conflicts.total == 0 {
|
||||||
|
Section {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
.font(.system(size: 28))
|
||||||
|
.foregroundColor(.green)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("No issues found")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
Text("Your library looks clean.")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Section {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
summaryBadge(count: conflicts.errors, label: "Errors", color: accentPink)
|
||||||
|
summaryBadge(count: conflicts.warnings, label: "Warnings", color: .yellow)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} header: {
|
||||||
|
Text("Summary")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by severity — errors first
|
||||||
|
let sorted = conflicts.issues.sorted {
|
||||||
|
($0.severity == "error" ? 0 : 1) < ($1.severity == "error" ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(sorted) { conflict in
|
||||||
|
conflictRow(conflict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Section {
|
||||||
|
Text("Tap Scan to check your library.")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Issues & Conflicts")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button(action: {
|
||||||
|
Task { await manager.refresh() }
|
||||||
|
}) {
|
||||||
|
if manager.isLoading {
|
||||||
|
ProgressView().tint(accentPink).scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.foregroundColor(accentPink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(manager.isLoading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if manager.conflicts == nil {
|
||||||
|
await manager.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Row
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func conflictRow(_ conflict: LibraryConflict) -> some View {
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
// Header
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: conflict.typeIcon)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(conflict.severityColor)
|
||||||
|
Text(conflict.title)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: conflict.severityIcon)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(conflict.severityColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(conflict.detail)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
// Affected paths (collapsed, show first 3)
|
||||||
|
if !conflict.affected_paths.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(conflict.affected_paths.prefix(3), id: \.self) { path in
|
||||||
|
Text(path)
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.45))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
if conflict.affected_paths.count > 3 {
|
||||||
|
Text("+ \(conflict.affected_paths.count - 3) more")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.gray.opacity(0.6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(Color.white.opacity(0.05))
|
||||||
|
.cornerRadius(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix button
|
||||||
|
if let action = conflict.fix_action {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
Task { await manager.fix(conflict) }
|
||||||
|
}) {
|
||||||
|
if manager.isFixing == conflict.id {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ProgressView().tint(.white).scaleEffect(0.75)
|
||||||
|
Text("Fixing…").font(.system(size: 13))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(Color.gray.opacity(0.3))
|
||||||
|
.cornerRadius(8)
|
||||||
|
} else {
|
||||||
|
Text(fixButtonLabel(action))
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(conflict.severityColor.opacity(0.25))
|
||||||
|
.foregroundColor(conflict.severityColor)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(manager.isFixing != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func summaryBadge(count: Int, label: String, color: Color) -> some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(count)")
|
||||||
|
.font(.system(size: 24, weight: .bold))
|
||||||
|
.foregroundColor(count > 0 ? color : .gray)
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fixButtonLabel(_ action: String) -> String {
|
||||||
|
switch action {
|
||||||
|
case "fix_duplicate_album": return "Merge Duplicate"
|
||||||
|
case "fix_missing_files": return "Remove from DB"
|
||||||
|
case "fix_picard_tags": return "Fix Tags"
|
||||||
|
case "fix_stale_paths": return "Rescan Library"
|
||||||
|
case "fix_orphaned_tracks": return "Trigger Rescan"
|
||||||
|
default: return "Fix"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -502,7 +502,8 @@ struct SettingsView: View {
|
||||||
@EnvironmentObject var serverManager: ServerManager
|
@EnvironmentObject var serverManager: ServerManager
|
||||||
@ObservedObject var quality = StreamingQuality.shared
|
@ObservedObject var quality = StreamingQuality.shared
|
||||||
@ObservedObject var debugLogger = DebugLogger.shared
|
@ObservedObject var debugLogger = DebugLogger.shared
|
||||||
|
@ObservedObject private var conflictManager = ConflictManager.shared
|
||||||
|
|
||||||
@State private var showVisualizerSettings = false
|
@State private var showVisualizerSettings = false
|
||||||
@State private var cacheSizeText = "..."
|
@State private var cacheSizeText = "..."
|
||||||
|
|
||||||
|
|
@ -546,6 +547,31 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if CompanionSettings.shared.isEnabled {
|
||||||
|
NavigationLink {
|
||||||
|
LibraryConflictsView()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Issues & Conflicts")
|
||||||
|
Spacer()
|
||||||
|
let count = ConflictManager.shared.badgeCount
|
||||||
|
if count > 0 {
|
||||||
|
Text("\(count)")
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color(red: 1, green: 0.176, blue: 0.333))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
} else if ConflictManager.shared.totalCount > 0 {
|
||||||
|
Text("\(ConflictManager.shared.totalCount)")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WiFi Streaming
|
// WiFi Streaming
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue