Rewrite Library Reorganize job to delegate to per-album planner

GitHub issue #500 (@bafoed). Library Reorganize repair job moved
album tracks to single-template paths because of a fragile
classification heuristic. Concrete symptom: a track at
``Surf Curse/Surf Curse - Nothing Yet (2017)/01 - Christine F.flac``
got proposed for a move to
``Surf Curse/Surf Curse - Christine F/Surf Curse - Christine F.flac``
(single template) instead of staying under the album folder.

Root cause: the job had its own tag-reading + transfer-folder-walk +
template-application implementation. The classification was
``is_album = (group_size > 1)`` where ``group_size`` was the count
of same-album tracks currently sitting in the transfer folder being
scanned. Two failure modes:

- only one track of an album was in the transfer folder (rest already
  moved to the library, or not yet downloaded), or
- album tags varied slightly across tracks (e.g. ``"Buds"`` vs
  ``"Buds (Bonus)"``)

Either case gave a 1-element group → routed through the SINGLE
template → wrong destination.

Rewrite — delegate to the per-album planner the artist-detail
"Reorganize" modal already uses:

- ``core.library_reorganize.preview_album_reorganize`` for path
  computation (DB-driven, knows the album has N tracks regardless of
  how many sit in transfer; album-vs-single is structurally correct)
- ``core.reorganize_queue.enqueue_many`` for apply mode; the queue
  worker dispatches via ``reorganize_album`` which handles file move
  + post-processing + DB update + sidecar through the same code path
  the per-album modal uses

Job's per-album loop:

- iterate albums for the active media server only (matches the artist-
  detail modal's scope; multi-server users won't have the job touch
  the inactive server's files at paths they can't see)
- preview each album, catch exceptions per-album so one bad row
  doesn't abort the scan
- branch on planner status:
  - ``no_album`` / ``no_tracks`` (race: album deleted mid-scan) →
    skip silently
  - ``no_source_id`` (album never enriched) → emit ONE album-level
    "needs enrichment first" finding (vs N per-track findings cluttering
    the UI)
  - ``planned`` → filter mismatched tracks (matched + new_path +
    not unchanged + file_exists), emit per-track findings (dry-run)
    or collect album for bulk enqueue (apply)
- bulk enqueue at end of loop using the queue's correct return-shape
  (``{'enqueued': N, 'already_queued': M, 'total': K}``)

What's gone (~500 LOC):
- ``_read_tag_metadata`` / ``_get_audio_quality`` / transfer-folder walk
- ``_load_album_years`` / ``_lookup_years_from_api`` (planner does this)
- ``_apply_path_template`` / ``_build_path_from_template``
- direct ``shutil.move`` + sidecar move logic (queue handles)
- the fragile ``is_album = group_size > 1`` heuristic — structurally gone
- ``move_sidecars`` setting (no longer applicable; queue's post-process
  re-downloads cover art at the destination)

What stays:
- dry-run vs apply toggle
- ``file_organization.enabled`` gate
- stop / pause respect
- progress reporting
- findings for the UI

Cleaner separation of concerns:
- this job: DB-known tracks at wrong paths (active server only)
- ``orphan_file_detector``: files on disk with no DB entry
- ``dead_file_cleaner``: DB entries pointing to nonexistent files

Tests: 12 tests in ``tests/test_library_reorganize.py`` pin the
delegation contract — every status branch, every track-filter case,
exception handling, apply-mode enqueue payload, active-server scope,
estimate-scope shape. Three obsolete ``_lookup_years_*`` tests removed
(year handling moved to planner).

Closes #500 (the misclassification half — orphan + dead-file are
downstream sync-gap symptoms, separate concern).
pull/511/head
Broque Thomas 3 weeks ago
parent 4f19b2ffb8
commit ca5c93162c

File diff suppressed because it is too large Load Diff

@ -1,8 +1,29 @@
"""Tests for the rewritten Library Reorganize repair job.
Issue #500: pre-rewrite the job had its own tag-reading + transfer-
folder-grouping + template-application implementation. The
``is_album = group_size > 1`` heuristic misclassified album tracks
as singles when only one track of an album sat in the transfer folder
or when album tags varied across tracks.
Post-rewrite the job delegates to the per-album planner
(``core.library_reorganize.preview_album_reorganize`` /
``reorganize_queue``) no second move/template implementation. These
tests pin the delegation contract so future drift fails here instead
of at runtime against a real library.
"""
from __future__ import annotations
import sys
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
# Stub optional Spotify dependency so metadata_service can import in tests.
# ── stubs (same shape used elsewhere in the suite) ──────────────────────
if 'spotipy' not in sys.modules:
spotipy = types.ModuleType('spotipy')
oauth2 = types.ModuleType('spotipy.oauth2')
@ -36,97 +57,459 @@ if 'config.settings' not in sys.modules:
sys.modules['config'] = config_mod
sys.modules['config.settings'] = settings_mod
from core.repair_jobs import library_reorganize as lr
from core.repair_jobs.library_reorganize import LibraryReorganizeJob
from core.repair_jobs.base import JobContext
class _FakeSearchClient:
def __init__(self, source_name, year=None):
self.source_name = source_name
self.year = year
self.calls = []
def search_albums(self, query, limit=3):
self.calls.append((query, limit))
if self.year is None:
return []
return [SimpleNamespace(release_date=f"{self.year}-01-01")]
# ── fixtures ──────────────────────────────────────────────────────────
def test_lookup_years_prefers_primary_source(monkeypatch):
deezer_client = _FakeSearchClient('deezer', '2022')
spotify_client = _FakeSearchClient('spotify', '1999')
class _FakeConfigManager:
"""Minimal config manager. Reads via ``get(key, default)``."""
monkeypatch.setattr(lr, 'get_primary_source', lambda: 'deezer')
monkeypatch.setattr(lr, 'get_source_priority', lambda primary: [primary, 'itunes', 'spotify'])
monkeypatch.setattr(
lr,
'get_client_for_source',
lambda source: {'deezer': deezer_client, 'spotify': spotify_client}.get(source),
)
def __init__(self, settings: dict | None = None, file_org_enabled: bool = True,
active_server: str = 'plex'):
self._settings = settings or {}
self._file_org_enabled = file_org_enabled
self._active_server = active_server
job = lr.LibraryReorganizeJob()
result = job._lookup_years_from_api(SimpleNamespace(report_progress=None, check_stop=lambda: False, sleep_or_stop=lambda *_: False), {('Artist', 'Album')})
def get(self, key, default=None):
if key == 'file_organization.enabled':
return self._file_org_enabled
return self._settings.get(key, default)
assert result == {('artist', 'album'): '2022'}
assert deezer_client.calls == [('Artist Album', 3)]
assert spotify_client.calls == []
def get_active_media_server(self):
return self._active_server
def test_lookup_years_falls_through_to_later_source(monkeypatch):
deezer_client = _FakeSearchClient('deezer', None)
spotify_client = _FakeSearchClient('spotify', '1999')
class _FakeDB:
"""Stand-in for MusicDatabase. Returns the canned album list from
``_load_albums``'s SELECT.
monkeypatch.setattr(lr, 'get_primary_source', lambda: 'deezer')
monkeypatch.setattr(lr, 'get_source_priority', lambda primary: [primary, 'itunes', 'spotify'])
monkeypatch.setattr(
lr,
'get_client_for_source',
lambda source: {'deezer': deezer_client, 'spotify': spotify_client}.get(source),
)
Supports per-server filtering: pass ``rows_by_server`` as a dict to
return different album sets depending on the SQL parameters. The
helper inspects the SQL string for ``server_source = ?`` and returns
the matching slice. Falls back to ``album_rows`` when ``rows_by_server``
isn't set (back-compat with single-server tests)."""
job = lr.LibraryReorganizeJob()
result = job._lookup_years_from_api(
SimpleNamespace(report_progress=None, check_stop=lambda: False, sleep_or_stop=lambda *_: False),
{('Artist', 'Album')},
)
def __init__(self, album_rows: list = None, rows_by_server: dict = None):
self._album_rows = album_rows or []
self._rows_by_server = rows_by_server or {}
def _get_connection(self):
cursor = MagicMock()
rows_by_server = self._rows_by_server
default_rows = self._album_rows
def _execute(sql, params=()):
if rows_by_server and 'server_source = ?' in sql and params:
cursor._captured_rows = rows_by_server.get(params[0], [])
else:
cursor._captured_rows = default_rows
cursor.fetchall.return_value = cursor._captured_rows
cursor.fetchone.return_value = (len(cursor._captured_rows),)
cursor.execute.side_effect = _execute
cursor.fetchall.return_value = default_rows
cursor.fetchone.return_value = (len(default_rows),)
conn = MagicMock()
conn.cursor.return_value = cursor
return conn
@pytest.fixture
def make_context():
"""Build a JobContext with optional finding-collector."""
def _make(*, db, cm=None, dry_run=True, transfer='/tmp/transfer'):
findings = []
def _create_finding(**kwargs):
findings.append(kwargs)
return True # 'inserted' return value
ctx = JobContext(
db=db,
transfer_folder=transfer,
config_manager=cm or _FakeConfigManager(
settings={
f'repair.jobs.library_reorganize.settings.dry_run': dry_run,
},
),
create_finding=_create_finding,
)
# Attach so tests can inspect.
ctx._captured_findings = findings # type: ignore[attr-defined]
return ctx
return _make
def _make_album_row(*, id_, title='Test Album', artist_id=10, artist_name='Test Artist'):
"""Match the row shape ``_load_albums`` returns."""
return {
'id': id_,
'title': title,
'artist_id': artist_id,
'artist_name': artist_name,
}
def _stub_preview(monkeypatch, response_by_album_id: dict):
"""Patch ``preview_album_reorganize`` import inside scan() so it
returns a canned response per album_id."""
from core import library_reorganize as core_lr
def _fake_preview(*, album_id, **kwargs):
return response_by_album_id.get(album_id) or {
'success': False, 'status': 'no_album', 'tracks': [],
}
monkeypatch.setattr(core_lr, 'preview_album_reorganize', _fake_preview)
# ── core delegation contract ─────────────────────────────────────────
def test_scan_skips_when_file_organization_disabled(make_context):
"""Pin: file_organization.enabled=False → scan returns immediately
with empty result, no DB iteration, no preview calls."""
db = _FakeDB([])
cm = _FakeConfigManager(file_org_enabled=False)
ctx = make_context(db=db, cm=cm)
job = LibraryReorganizeJob()
result = job.scan(ctx)
assert result.scanned == 0
assert result.findings_created == 0
assert ctx._captured_findings == [] # type: ignore[attr-defined]
def test_scan_returns_empty_when_no_albums(make_context):
"""Pin: empty DB → empty result, no errors."""
db = _FakeDB([])
ctx = make_context(db=db)
job = LibraryReorganizeJob()
result = job.scan(ctx)
assert result.scanned == 0
assert result.findings_created == 0
assert result.errors == 0
def test_scan_emits_path_mismatch_finding_for_each_changed_track(make_context, monkeypatch):
"""Pin: dry-run mode emits one finding per matched-but-not-unchanged
track returned by the planner. Album with two tracks, both
mismatched two findings."""
db = _FakeDB([_make_album_row(id_='A1')])
_stub_preview(monkeypatch, {
'A1': {
'success': True, 'status': 'planned',
'source': 'spotify',
'album': 'Album One',
'artist': 'Artist One',
'tracks': [
{
'track_id': 't1', 'title': 'Track One', 'track_number': 1,
'current_path': 'old/path/01 - Track One.flac',
'new_path': 'new/path/01 - Track One.flac',
'matched': True, 'unchanged': False, 'file_exists': True,
},
{
'track_id': 't2', 'title': 'Track Two', 'track_number': 2,
'current_path': 'old/path/02 - Track Two.flac',
'new_path': 'new/path/02 - Track Two.flac',
'matched': True, 'unchanged': False, 'file_exists': True,
},
],
},
})
ctx = make_context(db=db, dry_run=True)
job = LibraryReorganizeJob()
result = job.scan(ctx)
assert result.findings_created == 2
assert result.scanned == 2
findings = ctx._captured_findings # type: ignore[attr-defined]
titles = {f['title'] for f in findings}
assert any('Track One' in t for t in titles)
assert any('Track Two' in t for t in titles)
def test_scan_skips_unchanged_tracks(make_context, monkeypatch):
"""Pin: tracks with unchanged=True don't produce findings — they're
already at the right path."""
db = _FakeDB([_make_album_row(id_='A1')])
_stub_preview(monkeypatch, {
'A1': {
'success': True, 'status': 'planned',
'source': 'spotify',
'album': 'Album One', 'artist': 'Artist One',
'tracks': [
{
'track_id': 't1', 'title': 'Track One',
'current_path': 'right/path/01 - Track One.flac',
'new_path': 'right/path/01 - Track One.flac',
'matched': True, 'unchanged': True, 'file_exists': True,
},
],
},
})
ctx = make_context(db=db, dry_run=True)
job = LibraryReorganizeJob()
result = job.scan(ctx)
assert result.findings_created == 0
assert result.scanned == 1
def test_scan_skips_unmatched_tracks_within_planned_album(make_context, monkeypatch):
"""Pin: tracks the planner couldn't match (matched=False) are
skipped no path was computed for them, can't reorganize."""
db = _FakeDB([_make_album_row(id_='A1')])
_stub_preview(monkeypatch, {
'A1': {
'success': True, 'status': 'planned',
'source': 'spotify',
'album': 'Album One', 'artist': 'Artist One',
'tracks': [
{
'track_id': 't1', 'title': 'Bonus Track',
'current_path': 'old/path/12 - Bonus.flac',
'new_path': '',
'matched': False, 'unchanged': False, 'file_exists': True,
'reason': 'No matching track in spotify tracklist',
},
],
},
})
ctx = make_context(db=db, dry_run=True)
job = LibraryReorganizeJob()
result = job.scan(ctx)
assert result.findings_created == 0
assert result.scanned == 1
def test_scan_skips_tracks_with_missing_files(make_context, monkeypatch):
"""Pin: file_exists=False → not eligible for move (handled by the
Dead File Cleaner job instead). No path_mismatch finding emitted."""
db = _FakeDB([_make_album_row(id_='A1')])
_stub_preview(monkeypatch, {
'A1': {
'success': True, 'status': 'planned',
'source': 'spotify',
'album': 'Album One', 'artist': 'Artist One',
'tracks': [
{
'track_id': 't1', 'title': 'Missing Track',
'current_path': 'gone/01 - Missing.flac',
'new_path': 'new/01 - Missing.flac',
'matched': True, 'unchanged': False, 'file_exists': False,
},
],
},
})
ctx = make_context(db=db, dry_run=True)
job = LibraryReorganizeJob()
result = job.scan(ctx)
assert result.findings_created == 0
def test_scan_emits_album_needs_enrichment_when_planner_returns_no_source_id(make_context, monkeypatch):
"""Pin: planner returns status='no_source_id' → emit ONE
album-level finding ('needs enrichment') instead of N per-track
'no source' findings (which would clutter the UI)."""
db = _FakeDB([_make_album_row(id_='A1', title='Unenriched Album')])
_stub_preview(monkeypatch, {
'A1': {
'success': False, 'status': 'no_source_id',
'source': None,
'album': 'Unenriched Album', 'artist': 'Some Artist',
'tracks': [
{'track_id': 't1', 'title': 'Track 1', 'matched': False, 'reason': '...'},
{'track_id': 't2', 'title': 'Track 2', 'matched': False, 'reason': '...'},
],
},
})
ctx = make_context(db=db, dry_run=True)
job = LibraryReorganizeJob()
result = job.scan(ctx)
findings = ctx._captured_findings # type: ignore[attr-defined]
assert result.findings_created == 1
assert findings[0]['finding_type'] == 'album_needs_enrichment'
assert 'Unenriched Album' in findings[0]['title']
def test_scan_skips_albums_planner_reports_as_no_album(make_context, monkeypatch):
"""Pin: planner returns 'no_album' (race: album deleted between
SELECT and preview) silently skipped, no finding, no error."""
db = _FakeDB([_make_album_row(id_='A1')])
_stub_preview(monkeypatch, {
'A1': {'success': False, 'status': 'no_album', 'tracks': []},
})
ctx = make_context(db=db, dry_run=True)
job = LibraryReorganizeJob()
result = job.scan(ctx)
assert result.findings_created == 0
assert result.errors == 0
assert result.skipped == 1
def test_scan_handles_preview_exceptions_gracefully(make_context, monkeypatch):
"""Pin: preview raising for one album doesn't abort the whole
scan counts as one error, continues to next album."""
db = _FakeDB([
_make_album_row(id_='A1'),
_make_album_row(id_='A2', title='Good Album'),
])
from core import library_reorganize as core_lr
def _flaky(*, album_id, **kwargs):
if album_id == 'A1':
raise RuntimeError("preview boom")
return {
'success': True, 'status': 'planned',
'source': 'spotify',
'album': 'Good Album', 'artist': 'Artist',
'tracks': [
{
'track_id': 't1', 'title': 'Track',
'current_path': 'old/01 - Track.flac',
'new_path': 'new/01 - Track.flac',
'matched': True, 'unchanged': False, 'file_exists': True,
},
],
}
monkeypatch.setattr(core_lr, 'preview_album_reorganize', _flaky)
ctx = make_context(db=db, dry_run=True)
job = LibraryReorganizeJob()
result = job.scan(ctx)
assert result.errors == 1 # A1 failed
assert result.findings_created == 1 # A2 succeeded
def test_scan_apply_mode_enqueues_albums_via_reorganize_queue(make_context, monkeypatch):
"""Pin: dry_run=False → mismatched albums are bulk-enqueued via
``core.reorganize_queue.get_queue().enqueue_many(...)``. Repair
job does NOT do file moves itself delegates to the queue worker
which uses the same code path the per-album modal does."""
db = _FakeDB([
_make_album_row(id_='A1', title='First Album', artist_id=10, artist_name='A'),
_make_album_row(id_='A2', title='Second Album', artist_id=20, artist_name='B'),
])
_stub_preview(monkeypatch, {
'A1': {
'success': True, 'status': 'planned',
'source': 'spotify',
'album': 'First Album', 'artist': 'A',
'tracks': [
{
'track_id': 't1', 'title': 'X',
'current_path': 'old/01 - X.flac', 'new_path': 'new/01 - X.flac',
'matched': True, 'unchanged': False, 'file_exists': True,
},
],
},
'A2': {
'success': True, 'status': 'planned',
'source': 'deezer',
'album': 'Second Album', 'artist': 'B',
'tracks': [
{
'track_id': 't2', 'title': 'Y',
'current_path': 'old/01 - Y.flac', 'new_path': 'new/01 - Y.flac',
'matched': True, 'unchanged': False, 'file_exists': True,
},
],
},
})
enqueue_calls = []
class _StubQueue:
def enqueue_many(self, items):
enqueue_calls.append(items)
# Match the real queue's return shape:
# {'enqueued': N, 'already_queued': M, 'total': K}
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)
job = LibraryReorganizeJob()
result = job.scan(ctx)
assert len(enqueue_calls) == 1
queued = enqueue_calls[0]
assert {q['album_id'] for q in queued} == {'A1', 'A2'}
assert {q['source'] for q in queued} == {'spotify', 'deezer'}
assert result.auto_fixed == 2
# Apply mode does NOT emit findings — it enqueues for actual move.
assert result.findings_created == 0
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
skipped so we don't move files at paths the user can't see in
the artist-detail UI."""
db = _FakeDB(rows_by_server={
'plex': [_make_album_row(id_='plex_album_1', title='Plex-only Album')],
'jellyfin': [
_make_album_row(id_='jelly_album_1', title='Jelly Album 1'),
_make_album_row(id_='jelly_album_2', title='Jelly Album 2'),
],
})
cm = _FakeConfigManager(active_server='jellyfin')
ctx = make_context(db=db, cm=cm)
seen_album_ids = []
assert result == {('artist', 'album'): '1999'}
assert deezer_client.calls == [('Artist Album', 3)]
assert spotify_client.calls == [('Artist Album', 3)]
from core import library_reorganize as core_lr
def _track_calls(*, album_id, **kwargs):
seen_album_ids.append(album_id)
return {'success': False, 'status': 'no_album', 'tracks': []}
monkeypatch.setattr(core_lr, 'preview_album_reorganize', _track_calls)
def test_lookup_years_rechecks_client_availability_per_album(monkeypatch):
availability = {'spotify': True}
job = LibraryReorganizeJob()
job.scan(ctx)
class _SpotifyClient(_FakeSearchClient):
def search_albums(self, query, limit=3):
self.calls.append((query, limit))
availability['spotify'] = False
return []
# Only Jellyfin's two albums were processed; the Plex album was
# filtered out by the SQL active-server clause.
assert sorted(seen_album_ids) == ['jelly_album_1', 'jelly_album_2']
spotify_client = _SpotifyClient('spotify', None)
itunes_client = _FakeSearchClient('itunes', '2002')
helper_calls = []
def fake_get_client_for_source(source):
helper_calls.append(source)
if source == 'spotify' and not availability['spotify']:
return None
return {'spotify': spotify_client, 'itunes': itunes_client}.get(source)
def test_estimate_scope_returns_album_count(monkeypatch):
"""Pin: ``estimate_scope`` returns the DB album count (matches
what scan iterates over)."""
cursor = MagicMock()
cursor.fetchone.return_value = (42,)
conn = MagicMock()
conn.cursor.return_value = cursor
monkeypatch.setattr(lr, 'get_primary_source', lambda: 'spotify')
monkeypatch.setattr(lr, 'get_source_priority', lambda primary: [primary, 'itunes'])
monkeypatch.setattr(lr, 'get_client_for_source', fake_get_client_for_source)
db = MagicMock()
db._get_connection.return_value = conn
job = lr.LibraryReorganizeJob()
result = job._lookup_years_from_api(
SimpleNamespace(report_progress=None, check_stop=lambda: False, sleep_or_stop=lambda *_: False),
{('Artist A', 'Album A'), ('Artist B', 'Album B')},
cm = _FakeConfigManager()
ctx = JobContext(
db=db, transfer_folder='/tmp', config_manager=cm,
)
assert result == {('artist a', 'album a'): '2002', ('artist b', 'album b'): '2002'}
assert helper_calls.count('spotify') == 2
assert helper_calls.count('itunes') == 2
assert len(spotify_client.calls) == 1
assert spotify_client.calls[0] in [('Artist A Album A', 3), ('Artist B Album B', 3)]
assert set(itunes_client.calls) == {('Artist A Album A', 3), ('Artist B Album B', 3)}
job = LibraryReorganizeJob()
assert job.estimate_scope(ctx) == 42

@ -3432,6 +3432,7 @@ const WHATS_NEW = {
'2.4.2': [
// --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
{ date: 'Unreleased — 2.4.2 dev cycle' },
{ title: 'Fix: Library Reorganize Job Misclassified Album Tracks As Singles', desc: 'github issue #500 (bafoed): library reorganize repair job moved tracks like `Surf Curse/Surf Curse - Nothing Yet (2017)/01 - Christine F.flac` to single-template paths like `Surf Curse/Surf Curse - Christine F/Surf Curse - Christine F.flac`. root cause: the job used `is_album = (group_size > 1)` where `group_size` was the count of tracks for the same album currently sitting in the transfer folder being scanned — when only one track of an album was in transfer (rest already moved to library, or album tags varied across tracks like "Buds" vs "Buds (Bonus)"), each track became a 1-element group → all routed through single template. fix: rewrote the job to delegate to the per-album planner (`core.library_reorganize.preview_album_reorganize` / `reorganize_queue`) — the same planner the artist-detail "reorganize" modal uses. db-driven: the planner knows the album has multiple tracks regardless of how many sit in the transfer folder, so the album-vs-single classification is structurally correct. apply mode delegates to the existing reorganize queue → file move + post-processing + db update + sidecar handling all flow through one code path. only iterates albums for the ACTIVE media server (matches the artist-detail modal\'s scope) — multi-server users (plex + jellyfin etc) won\'t accidentally have the job touch the inactive server\'s files. albums missing a metadata source id get a single "needs enrichment first" finding instead of n per-track "no source" findings. dropped ~500 loc of tag-reading + transfer-walk + template logic that was duplicated against the per-album path. files in transfer with no db entry are now exclusively the orphan_file_detector\'s domain (clean separation). 12 tests pin the delegation contract.', page: 'library' },
{ title: 'Fix: Enrich Honors Manual Album Matches', desc: 'github issue #501 (tacobell444): if you manually matched an album to a specific source ID via the match-chip UI, then clicked "enrich" on that album, the worker would search by name and overwrite your manual match with whatever the search returned (or revert status to "not_found" if it found nothing). reorganize then read the now-wrong id and moved files to the wrong destination. fix: extracted a shared `core/enrichment/manual_match_honoring.py` helper. every per-source enrichment worker (spotify / itunes / deezer / tidal / qobuz) now reads its stored id column at the top of `_process_*_individual` — if present, it fetches via `client.get_album(stored_id)` directly and refreshes metadata without touching the id. fuzzy name search only runs as fallback for never-matched entities. discogs / audiodb / musicbrainz already had inline stored-id fast paths and are left alone. lastfm / genius are name-based and don\'t store ids. cin-shape lift: same fix in 5 workers gets exactly one helper, per-worker variability (column name, client method, response shape) plugs in via callbacks. 11 new helper tests pin: stored-id fast-path, no-id fallthrough, fetch-failure fallthrough, table/column whitelist, callback contract.', page: 'library' },
{ title: 'Fix: "no such table: hifi_instances" When Adding HiFi Instance', desc: 'github issue #503 (hadshaw21): adding a hifi instance via downloader settings popped up `no such table: hifi_instances` even though the connection test and "check all instances" both worked. root cause: `_initialize_database` runs every CREATE TABLE + every migration step inside one sqlite transaction. python\'s sqlite3 module doesn\'t autocommit DDL by default, so if any later migration step throws on a user\'s specific DB shape (e.g. an old volume from a prior soulsync version with quirky schema state), the WHOLE batch rolls back — including the hifi_instances CREATE that ran successfully. user\'s next boot retries init, hits the same migration failure, rolls back again. table never lands. fix: defensive lazy-create. every hifi_instances CRUD method now runs `CREATE TABLE IF NOT EXISTS hifi_instances (...)` immediately before its operation. idempotent — costs one PRAGMA-level no-op when the table is already present, fully recovers from a broken init. read methods (`get_hifi_instances`, `get_all_hifi_instances`) now return empty instead of raising when init failed. write methods (`add`, `remove`, `toggle`, `reorder`, `seed`) work end-to-end. doesn\'t paper over the underlying init issue (still worth tracking down which migration breaks for which users) but makes hifi instance management self-healing. 7 new tests pin the lazy-create behavior — every method works against a DB that\'s missing the table.', page: 'settings' },
{ title: 'Plex: "All Libraries (Combined)" Mode', desc: 'github issue #505 (popebruhlxix): users with multiple plex music libraries (e.g. one per plex home user) only saw one library inside soulsync because the connection settings forced you to pick a single library section. now there\'s a new "all libraries (combined)" option in settings → connections → plex → music library dropdown. picking it flips the plex client into a server-wide read mode where every read method (`get_all_artists` / `get_all_album_ids` / `search_tracks` / `get_library_stats` / etc) dispatches through `server.library.search(libtype=...)` instead of querying a single section. one api call, plex handles the aggregation. cross-section dedup applied at the listing layer — same-name artists across sections collapse to a canonical entry (the one with more tracks), so plex home families with overlapping music tastes don\'t see "drake" twice. removal-detection id enumeration stays raw on purpose — deduping there would falsely prune tracks linked to non-canonical ratingKeys. write methods (genre / poster / metadata updates) are unaffected and operate on plex objects via ratingKey directly — write-back targets one section\'s copy of an artist if it exists in multiple, document and revisit if it matters. trigger_library_scan + is_library_scanning fan out across every music section in the new mode. backward compatible — existing users with a real library name saved see no behavior change. the "all libraries" option only appears in the dropdown when more than one music library exists on the server. 29 new tests pin both modes (single-section preserved, all-libraries dispatches through server-wide search, dedup keeps canonical, id enumeration stays raw).', page: 'settings' },
@ -3779,6 +3780,20 @@ const WHATS_NEW = {
// Section shape: { title, description, features: [bullet strings],
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
title: "Library Reorganize No Longer Mistakes Album Tracks for Singles",
description: "github issue #500 (bafoed): library reorganize repair job was moving album tracks like `01 - Christine F.flac` to single-template paths because of a fragile classification heuristic.",
features: [
"• pre-rewrite the job had its own tag-reading + transfer-folder walk + template logic — used `is_album = (group_size > 1)` where group_size was the count of same-album tracks in the transfer folder being scanned",
"• when only one track of an album sat in transfer (rest already moved, or album tags varied slightly like \"Buds\" vs \"Buds (Bonus)\") → group size 1 → routed to single template → wrong destination",
"• fix: delegate to the per-album planner the artist-detail \"reorganize\" modal already uses — db-driven, knows the album has n tracks regardless of how many currently sit in transfer",
"• only iterates albums on the ACTIVE media server (matches what the artist-detail modal sees) — multi-server users (plex + jellyfin etc) won\'t accidentally have the job touch the inactive server\'s files",
"• apply mode dispatches to the existing reorganize queue → one code path for file move + post-processing + db update + sidecar",
"• albums missing a metadata source id get a single \"needs enrichment first\" finding instead of n per-track \"no source\" findings cluttering the ui",
"• dropped ~500 loc that was duplicated against the per-album logic — files in transfer with no db entry are now exclusively the orphan file detector\'s domain",
],
usage_note: "no settings to change — applies on next library reorganize repair job run",
},
{
title: "Enrich Now Honors Manual Album Matches",
description: "github issue #501 (tacobell444): manually matching an album then clicking enrich would overwrite your manual match with whatever the worker\'s name-search returned, or revert status to \"not found\". reorganize then read the wrong id and moved files to the wrong destination.",

Loading…
Cancel
Save