Fix library reorganize not updating DB paths and leaving sidecars behind
- DB path update uses suffix matching when exact match fails, so server paths (/mnt/musicBackup/...) are found and updated after local moves - Escape LIKE wildcards in artist/album names to prevent false matches - Move album-level sidecars (cover.jpg, folder.jpg, etc.) alongside audio - Post-pass sweep moves all remaining sidecars from emptied directories - Multi-disc albums place album art in album root, not disc subfolders
This commit is contained in:
parent
0a5a874c08
commit
bd56c96634
1 changed files with 103 additions and 7 deletions
|
|
@ -25,6 +25,13 @@ logger = get_logger("repair_job.library_reorganize")
|
||||||
|
|
||||||
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'}
|
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'}
|
||||||
SIDECAR_EXTENSIONS = {'.lrc', '.jpg', '.jpeg', '.png', '.nfo', '.txt', '.cue'}
|
SIDECAR_EXTENSIONS = {'.lrc', '.jpg', '.jpeg', '.png', '.nfo', '.txt', '.cue'}
|
||||||
|
ALBUM_SIDECAR_NAMES = {
|
||||||
|
'cover.jpg', 'cover.jpeg', 'cover.png',
|
||||||
|
'folder.jpg', 'folder.jpeg', 'folder.png',
|
||||||
|
'album.jpg', 'album.jpeg', 'album.png',
|
||||||
|
'front.jpg', 'front.jpeg', 'front.png',
|
||||||
|
'thumb.jpg', 'thumb.png',
|
||||||
|
}
|
||||||
|
|
||||||
# Windows and macOS use case-insensitive filesystems by default
|
# Windows and macOS use case-insensitive filesystems by default
|
||||||
_CASE_INSENSITIVE = sys.platform in ('win32', 'darwin')
|
_CASE_INSENSITIVE = sys.platform in ('win32', 'darwin')
|
||||||
|
|
@ -325,6 +332,8 @@ class LibraryReorganizeJob(RepairJob):
|
||||||
|
|
||||||
# Track claimed destinations to detect in-batch collisions
|
# Track claimed destinations to detect in-batch collisions
|
||||||
claimed_destinations = set()
|
claimed_destinations = set()
|
||||||
|
# Track src_dir -> dest_dir for post-pass sidecar cleanup
|
||||||
|
moved_dirs = {} # src_dir -> dest_dir (last used destination)
|
||||||
|
|
||||||
for i, fpath in enumerate(audio_files):
|
for i, fpath in enumerate(audio_files):
|
||||||
if context.check_stop():
|
if context.check_stop():
|
||||||
|
|
@ -490,11 +499,18 @@ class LibraryReorganizeJob(RepairJob):
|
||||||
shutil.move(actual_norm, expected_norm)
|
shutil.move(actual_norm, expected_norm)
|
||||||
|
|
||||||
result.auto_fixed += 1
|
result.auto_fixed += 1
|
||||||
|
# Record src->dest for post-pass sidecar cleanup
|
||||||
|
# For multi-disc, use album root (parent of Disc N/) as destination
|
||||||
|
sidecar_dest = dest_dir
|
||||||
|
if is_album and total_discs > 1 and '$disc' not in album_template:
|
||||||
|
sidecar_dest = os.path.dirname(dest_dir)
|
||||||
|
moved_dirs[os.path.dirname(actual_norm)] = sidecar_dest
|
||||||
|
|
||||||
# Move sidecar files (LRC, cover art, etc.)
|
# Move sidecar files (LRC, cover art, etc.)
|
||||||
if move_sidecars:
|
if move_sidecars:
|
||||||
stem = os.path.splitext(os.path.basename(actual_norm))[0]
|
stem = os.path.splitext(os.path.basename(actual_norm))[0]
|
||||||
src_dir = os.path.dirname(actual_norm)
|
src_dir = os.path.dirname(actual_norm)
|
||||||
|
# Track-level sidecars (same stem as audio file)
|
||||||
for sidecar_ext in SIDECAR_EXTENSIONS:
|
for sidecar_ext in SIDECAR_EXTENSIONS:
|
||||||
sidecar_src = os.path.join(src_dir, stem + sidecar_ext)
|
sidecar_src = os.path.join(src_dir, stem + sidecar_ext)
|
||||||
if os.path.isfile(sidecar_src):
|
if os.path.isfile(sidecar_src):
|
||||||
|
|
@ -504,9 +520,24 @@ class LibraryReorganizeJob(RepairJob):
|
||||||
shutil.move(sidecar_src, sidecar_dst)
|
shutil.move(sidecar_src, sidecar_dst)
|
||||||
except Exception as se:
|
except Exception as se:
|
||||||
logger.debug("Failed to move sidecar %s: %s", sidecar_src, se)
|
logger.debug("Failed to move sidecar %s: %s", sidecar_src, se)
|
||||||
|
# Album-level sidecars (cover.jpg, folder.jpg, etc.)
|
||||||
|
# For multi-disc, place in the album root (parent of Disc N/)
|
||||||
|
album_dest = dest_dir
|
||||||
|
if is_album and total_discs > 1 and '$disc' not in album_template:
|
||||||
|
album_dest = os.path.dirname(dest_dir)
|
||||||
|
os.makedirs(album_dest, exist_ok=True)
|
||||||
|
for album_sidecar in ALBUM_SIDECAR_NAMES:
|
||||||
|
sidecar_src = os.path.join(src_dir, album_sidecar)
|
||||||
|
if os.path.isfile(sidecar_src):
|
||||||
|
sidecar_dst = os.path.join(album_dest, album_sidecar)
|
||||||
|
if not os.path.exists(sidecar_dst):
|
||||||
|
try:
|
||||||
|
shutil.move(sidecar_src, sidecar_dst)
|
||||||
|
except Exception as se:
|
||||||
|
logger.debug("Failed to move album sidecar %s: %s", sidecar_src, se)
|
||||||
|
|
||||||
# Update DB file_path if there's a matching track
|
# Update DB file_path if there's a matching track
|
||||||
self._update_db_path(context.db, actual_norm, expected_norm)
|
self._update_db_path(context.db, actual_norm, expected_norm, transfer)
|
||||||
|
|
||||||
# Clean up empty source directories
|
# Clean up empty source directories
|
||||||
_remove_empty_dirs(os.path.dirname(actual_norm), transfer)
|
_remove_empty_dirs(os.path.dirname(actual_norm), transfer)
|
||||||
|
|
@ -534,6 +565,35 @@ class LibraryReorganizeJob(RepairJob):
|
||||||
if context.update_progress:
|
if context.update_progress:
|
||||||
context.update_progress(total, total)
|
context.update_progress(total, total)
|
||||||
|
|
||||||
|
# Post-pass: move leftover sidecar files from directories that lost all audio
|
||||||
|
if not dry_run and move_sidecars and moved_dirs:
|
||||||
|
for src_dir, dest_dir in moved_dirs.items():
|
||||||
|
if not os.path.isdir(src_dir):
|
||||||
|
continue
|
||||||
|
# Check if any audio files remain in this directory
|
||||||
|
has_audio = any(
|
||||||
|
os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS
|
||||||
|
for f in os.listdir(src_dir)
|
||||||
|
if os.path.isfile(os.path.join(src_dir, f))
|
||||||
|
)
|
||||||
|
if has_audio:
|
||||||
|
continue
|
||||||
|
# Move all remaining sidecar-type files to the destination
|
||||||
|
for f in os.listdir(src_dir):
|
||||||
|
fpath_full = os.path.join(src_dir, f)
|
||||||
|
if not os.path.isfile(fpath_full):
|
||||||
|
continue
|
||||||
|
ext = os.path.splitext(f)[1].lower()
|
||||||
|
if ext in SIDECAR_EXTENSIONS:
|
||||||
|
dst = os.path.join(dest_dir, f)
|
||||||
|
if not os.path.exists(dst):
|
||||||
|
try:
|
||||||
|
shutil.move(fpath_full, dst)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Try cleaning up the now-potentially-empty directory
|
||||||
|
_remove_empty_dirs(src_dir, transfer)
|
||||||
|
|
||||||
mode_text = 'Dry run' if dry_run else 'Reorganize'
|
mode_text = 'Dry run' if dry_run else 'Reorganize'
|
||||||
summary = f"{mode_text} complete: {result.scanned} scanned, {result.auto_fixed} moved, {result.findings_created} findings, {result.skipped} skipped, {result.errors} errors"
|
summary = f"{mode_text} complete: {result.scanned} scanned, {result.auto_fixed} moved, {result.findings_created} findings, {result.skipped} skipped, {result.errors} errors"
|
||||||
logger.info(summary)
|
logger.info(summary)
|
||||||
|
|
@ -564,19 +624,55 @@ class LibraryReorganizeJob(RepairJob):
|
||||||
return context.config_manager.get(f'repair.jobs.{self.job_id}.settings.{key}', default)
|
return context.config_manager.get(f'repair.jobs.{self.job_id}.settings.{key}', default)
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def _update_db_path(self, db, old_path: str, new_path: str):
|
def _update_db_path(self, db, old_path: str, new_path: str, transfer_folder: str = ''):
|
||||||
"""Update file_path in the tracks table when a file is moved."""
|
"""Update file_path in the tracks table when a file is moved.
|
||||||
|
|
||||||
|
DB may store server-side paths (e.g. /mnt/musicBackup/Artist/Album/track.flac)
|
||||||
|
while local paths use the transfer folder (e.g. H:\\Music\\Artist\\Album\\track.flac).
|
||||||
|
Falls back to suffix matching when exact match fails.
|
||||||
|
"""
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = db._get_connection()
|
conn = db._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Try exact match first
|
# Try exact match first
|
||||||
cursor.execute("UPDATE tracks SET file_path = ? WHERE file_path = ?",
|
cursor.execute("UPDATE tracks SET file_path = ? WHERE file_path = ?",
|
||||||
(new_path, old_path))
|
(new_path, old_path))
|
||||||
if cursor.rowcount == 0:
|
if cursor.rowcount > 0:
|
||||||
# Try normalized path match
|
conn.commit()
|
||||||
cursor.execute("UPDATE tracks SET file_path = ? WHERE file_path = ?",
|
return
|
||||||
(new_path, os.path.normpath(old_path)))
|
|
||||||
|
# Try normalized path match
|
||||||
|
cursor.execute("UPDATE tracks SET file_path = ? WHERE file_path = ?",
|
||||||
|
(new_path, os.path.normpath(old_path)))
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Suffix match: compute path relative to transfer folder and match
|
||||||
|
# against DB paths that may use a different base prefix
|
||||||
|
if transfer_folder:
|
||||||
|
try:
|
||||||
|
rel_suffix = os.path.relpath(old_path, transfer_folder).replace('\\', '/')
|
||||||
|
# Escape LIKE wildcards (% _ ^) so artist/album names are literal
|
||||||
|
escaped = rel_suffix.replace('^', '^^').replace('%', '^%').replace('_', '^_')
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE tracks SET file_path = ? WHERE file_path LIKE ? ESCAPE '^'",
|
||||||
|
(new_path, '%/' + escaped)
|
||||||
|
)
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
conn.commit()
|
||||||
|
return
|
||||||
|
# Also try with backslash separators (Windows DB paths)
|
||||||
|
escaped_bs = escaped.replace('/', '\\')
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE tracks SET file_path = ? WHERE file_path LIKE ? ESCAPE '^'",
|
||||||
|
(new_path, '%\\' + escaped_bs)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("DB path update failed for %s: %s", old_path, e)
|
logger.debug("DB path update failed for %s: %s", old_path, e)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue