#862: Library Reorganize falls back to tag-mode when an album has no source ID (media-server libs now actually reorganize)

This commit is contained in:
BoulderBadgeDad 2026-06-12 20:05:50 -07:00
parent f5787764d4
commit cb2d920a9e
2 changed files with 106 additions and 7 deletions

View file

@ -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:

View file

@ -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