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. - Moves are always within the transfer folder; cannot escape to parent dirs.
""" """
import json
import os import os
import re import re
import shutil import shutil
@ -698,6 +699,52 @@ class LibraryReorganizeJob(RepairJob):
except Exception: except Exception:
pass # discovery_pool may not exist on all installs 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: except Exception as e:
logger.debug("Failed to load album years from DB: %s", e) logger.debug("Failed to load album years from DB: %s", e)
finally: finally:

View file

@ -11,6 +11,7 @@ import json
import os import os
import re import re
import shutil import shutil
import sys
import threading import threading
import time import time
from difflib import SequenceMatcher from difflib import SequenceMatcher
@ -796,6 +797,7 @@ class RepairWorker:
'single_album_redundant': self._fix_single_album_redundant, 'single_album_redundant': self._fix_single_album_redundant,
'mbid_mismatch': self._fix_mbid_mismatch, 'mbid_mismatch': self._fix_mbid_mismatch,
'incomplete_album': self._fix_incomplete_album, 'incomplete_album': self._fix_incomplete_album,
'path_mismatch': self._fix_path_mismatch,
} }
handler = handlers.get(finding_type) handler = handlers.get(finding_type)
if not handler: if not handler:
@ -1678,6 +1680,102 @@ class RepairWorker:
except Exception as e: except Exception as e:
logger.warning("Retagging failed for '%s' (file still placed): %s", file_path, 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: def dismiss_finding(self, finding_id: int) -> bool:
"""Dismiss a finding.""" """Dismiss a finding."""
conn = None conn = None
@ -1711,7 +1809,7 @@ class RepairWorker:
fixable_types = ('dead_file', 'orphan_file', 'track_number_mismatch', fixable_types = ('dead_file', 'orphan_file', 'track_number_mismatch',
'missing_cover_art', 'metadata_gap', 'duplicate_tracks', 'missing_cover_art', 'metadata_gap', 'duplicate_tracks',
'single_album_redundant', 'mbid_mismatch', 'single_album_redundant', 'mbid_mismatch',
'incomplete_album') 'incomplete_album', 'path_mismatch')
placeholders = ','.join(['?'] * len(fixable_types)) placeholders = ','.join(['?'] * len(fixable_types))
where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"] where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"]
params = list(fixable_types) params = list(fixable_types)