Add fix handler for Library Reorganize findings and expand year sources

- Add _fix_path_mismatch handler so fixing Library Reorganize dry-run
  findings actually moves files (was returning 400 with no handler)
- Add path_mismatch to fixable_types and _execute_fix handler map
- Add recent_releases and wishlist_tracks as year sources in
  _load_album_years to cover more playlist-synced tracks missing years
- Add sys and json imports needed by new code
This commit is contained in:
Broque Thomas 2026-03-19 09:24:09 -07:00
parent 0de8841b14
commit 4eb028a7ce
2 changed files with 146 additions and 1 deletions

View file

@ -11,6 +11,7 @@ Safety design:
- Moves are always within the transfer folder; cannot escape to parent dirs.
"""
import json
import os
import re
import shutil
@ -698,6 +699,52 @@ class LibraryReorganizeJob(RepairJob):
except Exception:
pass # discovery_pool may not exist on all installs
# Source 3: recent_releases (watchlist artist releases)
try:
cursor.execute("""
SELECT wa.artist_name, rr.album_name, rr.release_date
FROM recent_releases rr
JOIN watchlist_artists wa ON wa.id = rr.watchlist_artist_id
WHERE rr.release_date IS NOT NULL AND rr.release_date != ''
""")
for row in cursor.fetchall():
artist_name, album_name, release_date = row
if artist_name and album_name and release_date:
key = (artist_name.lower(), album_name.lower())
if key not in years:
year_str = str(release_date)[:4]
if len(year_str) == 4 and year_str.isdigit():
years[key] = year_str
except Exception:
pass # recent_releases may not exist on all installs
# Source 4: wishlist tracks (spotify_data JSON contains release date)
try:
cursor.execute("""
SELECT spotify_data FROM wishlist_tracks
WHERE spotify_data IS NOT NULL AND spotify_data != ''
""")
for row in cursor.fetchall():
try:
data = json.loads(row[0])
artist_name = ''
if data.get('artists'):
a = data['artists'][0]
artist_name = a.get('name', '') if isinstance(a, dict) else str(a)
album_data = data.get('album', {})
album_name = album_data.get('name', '') if isinstance(album_data, dict) else ''
release_date = album_data.get('release_date', '') if isinstance(album_data, dict) else ''
if artist_name and album_name and release_date:
key = (artist_name.lower(), album_name.lower())
if key not in years:
year_str = str(release_date)[:4]
if len(year_str) == 4 and year_str.isdigit():
years[key] = year_str
except (ValueError, KeyError, TypeError):
continue
except Exception:
pass # wishlist_tracks may not exist or have different schema
except Exception as e:
logger.debug("Failed to load album years from DB: %s", e)
finally:

View file

@ -11,6 +11,7 @@ import json
import os
import re
import shutil
import sys
import threading
import time
from difflib import SequenceMatcher
@ -796,6 +797,7 @@ class RepairWorker:
'single_album_redundant': self._fix_single_album_redundant,
'mbid_mismatch': self._fix_mbid_mismatch,
'incomplete_album': self._fix_incomplete_album,
'path_mismatch': self._fix_path_mismatch,
}
handler = handlers.get(finding_type)
if not handler:
@ -1678,6 +1680,102 @@ class RepairWorker:
except Exception as e:
logger.warning("Retagging failed for '%s' (file still placed): %s", file_path, e)
def _fix_path_mismatch(self, entity_type, entity_id, file_path, details):
"""Move a file from its current location to the expected template path."""
rel_from = details.get('from', '')
rel_to = details.get('to', '')
if not rel_from or not rel_to:
return {'success': False, 'error': 'Missing from/to paths in finding details'}
transfer = self.transfer_folder
src = os.path.normpath(os.path.join(transfer, rel_from))
dst = os.path.normpath(os.path.join(transfer, rel_to))
# Safety: both paths must be inside transfer folder
transfer_norm = os.path.normpath(transfer)
if not src.startswith(transfer_norm + os.sep) or not dst.startswith(transfer_norm + os.sep):
return {'success': False, 'error': 'Path escapes transfer folder'}
if not os.path.isfile(src):
# Source may have been moved already — check if destination already exists
if os.path.isfile(dst):
return {'success': True, 'action': 'already_moved', 'message': 'File already at expected location'}
return {'success': False, 'error': f'Source file not found: {rel_from}'}
if os.path.exists(dst) and not os.path.samefile(src, dst):
return {'success': False, 'error': 'Destination already exists (different file)'}
try:
os.makedirs(os.path.dirname(dst), exist_ok=True)
# Case rename on case-insensitive FS
if sys.platform in ('win32', 'darwin') and os.path.exists(dst):
tmp = dst + '.tmp_rename'
shutil.move(src, tmp)
shutil.move(tmp, dst)
else:
shutil.move(src, dst)
# Move sidecar files (.lrc, cover art, etc.)
src_dir = os.path.dirname(src)
dst_dir = os.path.dirname(dst)
src_stem = os.path.splitext(os.path.basename(src))[0]
dst_stem = os.path.splitext(os.path.basename(dst))[0]
sidecar_exts = {'.lrc', '.jpg', '.jpeg', '.png', '.nfo', '.txt', '.cue'}
for ext in sidecar_exts:
sidecar_src = os.path.join(src_dir, src_stem + ext)
if os.path.isfile(sidecar_src):
sidecar_dst = os.path.join(dst_dir, dst_stem + ext)
if not os.path.exists(sidecar_dst):
try:
shutil.move(sidecar_src, sidecar_dst)
except Exception:
pass
# Update DB file path
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
# Try exact match
cursor.execute("UPDATE tracks SET file_path = ? WHERE file_path = ?", (dst, src))
if cursor.rowcount == 0:
cursor.execute("UPDATE tracks SET file_path = ? WHERE file_path = ?",
(dst, os.path.normpath(src)))
if cursor.rowcount == 0:
# Suffix match for cross-environment paths (Docker vs host)
try:
rel_suffix = os.path.relpath(src, transfer).replace('\\', '/')
escaped = rel_suffix.replace('^', '^^').replace('%', '^%').replace('_', '^_')
cursor.execute(
"UPDATE tracks SET file_path = ? WHERE file_path LIKE ? ESCAPE '^'",
(dst, '%/' + escaped))
except Exception:
pass
conn.commit()
except Exception as e:
logger.debug("DB path update failed for %s: %s", src, e)
finally:
if conn:
conn.close()
# Clean up empty source directories
parent = os.path.dirname(src)
for _ in range(5):
if (parent and os.path.isdir(parent)
and os.path.normpath(parent) != transfer_norm
and not os.listdir(parent)):
os.rmdir(parent)
parent = os.path.dirname(parent)
else:
break
return {'success': True, 'action': 'moved_file',
'message': f'Moved to {rel_to}'}
except Exception as e:
logger.error("Failed to move %s -> %s: %s", src, dst, e)
return {'success': False, 'error': str(e)}
def dismiss_finding(self, finding_id: int) -> bool:
"""Dismiss a finding."""
conn = None
@ -1711,7 +1809,7 @@ class RepairWorker:
fixable_types = ('dead_file', 'orphan_file', 'track_number_mismatch',
'missing_cover_art', 'metadata_gap', 'duplicate_tracks',
'single_album_redundant', 'mbid_mismatch',
'incomplete_album')
'incomplete_album', 'path_mismatch')
placeholders = ','.join(['?'] * len(fixable_types))
where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"]
params = list(fixable_types)