diff --git a/core/repair_jobs/library_reorganize.py b/core/repair_jobs/library_reorganize.py index 6d7e68d2..845e8319 100644 --- a/core/repair_jobs/library_reorganize.py +++ b/core/repair_jobs/library_reorganize.py @@ -161,6 +161,16 @@ class LibraryReorganizeJob(RepairJob): album_id = album_row['id'] album_title = album_row['title'] or 'Unknown Album' + # Default to the API planner (authoritative metadata via source IDs). + # But media-server libraries usually have NO source IDs, so that path + # dead-ends at 'no_source_id' and the job only ever reports "needs + # enrichment" without moving anything (#862). Reorganizing to match + # the template only needs the metadata already on the files — so when + # the API planner can't resolve a source, fall back to TAG mode, which + # reads each file's embedded title/artist/album/year (the year is what + # the user's "($year) $album" template needs). Only genuinely tag-poor + # albums then fall through to a finding. + reorg_metadata_source = 'api' try: preview = preview_album_reorganize( album_id=str(album_id), @@ -169,6 +179,18 @@ class LibraryReorganizeJob(RepairJob): resolve_file_path_fn=_resolve, build_final_path_fn=build_final_path_for_track, ) + if preview.get('status') == 'no_source_id': + tags_preview = preview_album_reorganize( + album_id=str(album_id), + db=context.db, + transfer_dir=transfer_dir, + resolve_file_path_fn=_resolve, + build_final_path_fn=build_final_path_for_track, + metadata_source='tags', + ) + if tags_preview.get('status') == 'planned': + preview = tags_preview + reorg_metadata_source = 'tags' except Exception as exc: logger.warning( "Reorganize preview failed for album %s ('%s'): %s", @@ -189,9 +211,10 @@ class LibraryReorganizeJob(RepairJob): continue if status == 'no_source_id': - # Can't compute destinations without a metadata source — - # skip cleanly with a single finding rather than 12 per-track - # "no source" findings that would clutter the UI. + # Reached only when BOTH the API planner (no source ID) AND the + # tag-mode fallback (files missing essential title/artist/album + # tags, or not on disk) failed — so no destination can be computed. + # One album-level finding rather than N per-track ones (UI clutter). result.skipped += len(tracks) or 1 if dry_run and context.create_finding and tracks: inserted = context.create_finding( @@ -201,12 +224,13 @@ class LibraryReorganizeJob(RepairJob): entity_type='album', entity_id=str(album_id), file_path=None, - title=f'Needs enrichment: {album_title}', + title=f'Cannot place: {album_title}', description=( f"Album '{album_title}' by {preview.get('artist', '?')} " - "has no metadata source ID — run enrichment first to " - "populate at least one of spotify_album_id / " - "itunes_album_id / deezer_id / discogs_id / soul_id." + "couldn't be reorganized: it has no metadata source ID " + "AND its files are missing essential tags (title / artist " + "/ album) or aren't on disk. Re-tag the files or run " + "'Fix Unknown Artists', then run this job again." ), details={'album_id': str(album_id), 'reason': 'no_source_id'}, ) @@ -280,6 +304,10 @@ class LibraryReorganizeJob(RepairJob): 'artist_id': str(album_row.get('artist_id') or ''), 'artist_name': preview.get('artist') or album_row.get('artist_name') or 'Unknown Artist', 'source': preview.get('source'), + # Carry the mode the preview actually used so the live move + # matches it — otherwise the queue runner defaults to 'api' + # and a tag-mode-only album would fail at apply time (#862). + 'metadata_source': reorg_metadata_source, }) if context.update_progress and (i + 1) % 25 == 0: diff --git a/tests/test_library_reorganize.py b/tests/test_library_reorganize.py index 6c34c285..18dab60f 100644 --- a/tests/test_library_reorganize.py +++ b/tests/test_library_reorganize.py @@ -463,6 +463,77 @@ def test_scan_apply_mode_enqueues_albums_via_reorganize_queue(make_context, monk assert result.findings_created == 0 +def _stub_preview_by_mode(monkeypatch, api_resp, tags_resp): + """Patch preview to return different responses for api vs tag mode, so the + #862 api→tags fallback can be exercised.""" + from core import library_reorganize as core_lr + + def _fake_preview(*, album_id, metadata_source='api', **kwargs): + return tags_resp if metadata_source == 'tags' else api_resp + monkeypatch.setattr(core_lr, 'preview_album_reorganize', _fake_preview) + + +def test_scan_falls_back_to_tag_mode_when_api_has_no_source_id(make_context, monkeypatch): + """#862: media-server albums have no source ID, so the API planner returns + no_source_id. The job must fall back to TAG mode and, when that plans, emit + real path_mismatch findings — NOT a dead-end 'needs enrichment' finding.""" + db = _FakeDB([_make_album_row(id_='A1', title='Tagged Album')]) + _stub_preview_by_mode( + monkeypatch, + api_resp={'success': False, 'status': 'no_source_id', 'source': None, + 'album': 'Tagged Album', 'artist': 'A', + 'tracks': [{'track_id': 't1', 'title': 'X', 'matched': False}]}, + tags_resp={'success': True, 'status': 'planned', 'source': 'tags', + 'album': 'Tagged Album', 'artist': 'A', + 'tracks': [{'track_id': 't1', 'title': 'X', + 'current_path': 'old/X.flac', 'new_path': 'A/(2008) Tagged Album/01 - X.flac', + 'matched': True, 'unchanged': False, 'file_exists': True}]}, + ) + ctx = make_context(db=db, dry_run=True) + result = LibraryReorganizeJob().scan(ctx) + + findings = ctx._captured_findings # type: ignore[attr-defined] + assert result.findings_created == 1 + assert findings[0]['finding_type'] == 'path_mismatch' + # Crucially NOT the enrichment dead-end the user reported. + assert all(f['finding_type'] != 'album_needs_enrichment' for f in findings) + + +def test_apply_mode_enqueues_tag_metadata_source_on_fallback(make_context, monkeypatch): + """#862: when the album reorganizes via the tag-mode fallback, the enqueued + item must carry metadata_source='tags' so the live move uses tags too (the + queue runner otherwise defaults to 'api' and would fail again).""" + db = _FakeDB([_make_album_row(id_='A1', title='Tagged Album', artist_id=10, artist_name='A')]) + _stub_preview_by_mode( + monkeypatch, + api_resp={'success': False, 'status': 'no_source_id', 'source': None, + 'album': 'Tagged Album', 'artist': 'A', + 'tracks': [{'track_id': 't1', 'title': 'X', 'matched': False}]}, + tags_resp={'success': True, 'status': 'planned', 'source': 'tags', + 'album': 'Tagged Album', 'artist': 'A', + 'tracks': [{'track_id': 't1', 'title': 'X', + 'current_path': 'old/X.flac', 'new_path': 'A/(2008) Tagged Album/01 - X.flac', + 'matched': True, 'unchanged': False, 'file_exists': True}]}, + ) + + enqueue_calls = [] + + class _StubQueue: + def enqueue_many(self, items): + enqueue_calls.append(items) + return {'enqueued': len(items), 'already_queued': 0, 'total': len(items)} + + import core.reorganize_queue as queue_mod + monkeypatch.setattr(queue_mod, 'get_queue', lambda: _StubQueue()) + + ctx = make_context(db=db, dry_run=False) + LibraryReorganizeJob().scan(ctx) + + assert len(enqueue_calls) == 1 + assert enqueue_calls[0][0]['metadata_source'] == 'tags' + assert enqueue_calls[0][0]['source'] == 'tags' + + def test_scan_only_iterates_albums_for_active_server(make_context, monkeypatch): """Pin: multi-server users (Plex + Jellyfin etc) — the job only iterates albums on the ACTIVE server. Inactive server's rows are