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:
parent
0de8841b14
commit
4eb028a7ce
2 changed files with 146 additions and 1 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue