diff --git a/core/repair_jobs/orphan_file_detector.py b/core/repair_jobs/orphan_file_detector.py index 80c5a237..5c6e69cd 100644 --- a/core/repair_jobs/orphan_file_detector.py +++ b/core/repair_jobs/orphan_file_detector.py @@ -67,22 +67,28 @@ class OrphanFileDetectorJob(RepairJob): suffix = '/'.join(parts[-depth:]).lower() known_suffixes.add(suffix) - # Build title+artist sets for tag-based fallback matching + # Build title+artist sets for fallback matching. Include both + # track artist and album artist so Picard-style albumartist paths + # don't look orphaned when tracks have featured/guest artists. cursor.execute(""" - SELECT t.title, ar.name FROM tracks t - LEFT JOIN artists ar ON ar.id = t.artist_id + SELECT t.title, track_ar.name, album_ar.name FROM tracks t + LEFT JOIN artists track_ar ON track_ar.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + LEFT JOIN artists album_ar ON album_ar.id = al.artist_id WHERE t.title IS NOT NULL AND t.title != '' """) for row in cursor.fetchall(): title = (row[0] or '').lower().strip() - artist = (row[1] or '').lower().strip() if title: - known_titles.add((title, artist)) - # Also store normalized version (stripped of feat., parentheticals, etc.) - clean_t = _strip_extras(title) - clean_a = _strip_extras(artist) - if clean_t: - known_titles_clean.add((clean_t, clean_a)) + for artist_value in (row[1], row[2]): + artist = (artist_value or '').lower().strip() + known_titles.add((title, artist)) + # Also store normalized version (stripped of feat., + # parentheticals, etc.) + clean_t = _strip_extras(title) + clean_a = _strip_extras(artist) + if clean_t: + known_titles_clean.add((clean_t, clean_a)) except Exception as e: logger.error("Error reading known file paths from DB: %s", e, exc_info=True) result.errors += 1 @@ -144,14 +150,21 @@ class OrphanFileDetectorJob(RepairJob): if audio: file_title = ((audio.get('title') or [None])[0] or '').lower().strip() file_artist = ((audio.get('artist') or [None])[0] or '').lower().strip() + file_albumartist = ( + (audio.get('albumartist') or audio.get('album_artist') or [None])[0] + or '' + ).lower().strip() if file_title: + file_artists = [a for a in (file_artist, file_albumartist) if a] + if not file_artists: + file_artists = [''] # Exact match first (fast path) - if (file_title, file_artist) in known_titles: + if any((file_title, artist) in known_titles for artist in file_artists): is_known = True else: # Normalized match: strip (feat. X), [FLAC 16bit], etc. clean_title = _strip_extras(file_title) - clean_artist = _strip_extras(file_artist) + clean_artist = _strip_extras(file_artists[0]) # Also try first artist only (handles "Gorillaz, Dennis Hopper" → "Gorillaz") first_artist = clean_artist.split(',')[0].strip() if clean_artist else '' if clean_title and ( @@ -159,6 +172,16 @@ class OrphanFileDetectorJob(RepairJob): (first_artist and (clean_title, first_artist) in known_titles_clean) ): is_known = True + if clean_title and not is_known: + for artist in file_artists[1:]: + clean_artist = _strip_extras(artist) + first_artist = clean_artist.split(',')[0].strip() if clean_artist else '' + if ( + (clean_title, clean_artist) in known_titles_clean or + (first_artist and (clean_title, first_artist) in known_titles_clean) + ): + is_known = True + break except Exception as e: logger.debug("tag-based orphan check: %s", e) diff --git a/tests/test_orphan_file_detector.py b/tests/test_orphan_file_detector.py new file mode 100644 index 00000000..02ba7150 --- /dev/null +++ b/tests/test_orphan_file_detector.py @@ -0,0 +1,81 @@ +"""Regression tests for the orphan file detector.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from core.repair_jobs.base import JobContext +from core.repair_jobs.orphan_file_detector import OrphanFileDetectorJob + + +class _DB: + def __init__(self, path: Path) -> None: + self.path = path + + def _get_connection(self): + return sqlite3.connect(self.path) + + +def _seed_library(db_path: Path) -> None: + conn = sqlite3.connect(db_path) + try: + conn.executescript( + """ + CREATE TABLE artists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + CREATE TABLE albums ( + id INTEGER PRIMARY KEY, + artist_id INTEGER NOT NULL, + title TEXT NOT NULL + ); + CREATE TABLE tracks ( + id INTEGER PRIMARY KEY, + album_id INTEGER NOT NULL, + artist_id INTEGER NOT NULL, + title TEXT NOT NULL, + file_path TEXT + ); + INSERT INTO artists (id, name) VALUES + (1, 'Clouddead89'), + (2, 'Featured Artist'); + INSERT INTO albums (id, artist_id, title) VALUES + (10, 1, 'Perfect Match Error'); + INSERT INTO tracks (id, album_id, artist_id, title, file_path) VALUES + (100, 10, 2, 'Perfect Match', '/old/prefix/elsewhere.mp3'); + """ + ) + conn.commit() + finally: + conn.close() + + +def test_orphan_detector_accepts_picard_albumartist_folder_match(tmp_path: Path) -> None: + """Picard paths use albumartist/album (year)/track - title. + + Even when the DB track artist is a featured artist, the album artist + folder should be enough to recognize the file as tracked. + """ + db_path = tmp_path / "library.sqlite" + _seed_library(db_path) + + transfer = tmp_path / "Clouddead89" / "Perfect Match Error (2026)" + transfer.mkdir(parents=True) + audio_path = transfer / "01 - Perfect Match.mp3" + audio_path.write_bytes(b"not a real mp3; filename fallback handles this") + + findings = [] + context = JobContext( + db=_DB(db_path), + transfer_folder=str(tmp_path), + config_manager=None, + create_finding=lambda **kwargs: findings.append(kwargs) or True, + ) + + result = OrphanFileDetectorJob().scan(context) + + assert result.scanned == 1 + assert result.findings_created == 0 + assert findings == []