From c02d51d60dba2023054d31d7b72614a2af804d6f Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 10 May 2026 10:22:32 -0700 Subject: [PATCH] =?UTF-8?q?Plex:=20trigger=5Flibrary=5Fscan=20+=20is=5Flib?= =?UTF-8?q?rary=5Fscanning=20use=20auto-detected=20section=20=E2=80=94=20f?= =?UTF-8?q?ixes=20#535?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Bug Plex servers with the music library named anything other than "Music" (Música, Musique, Musik, Musica, 音乐, موسيقى, etc.) hit this error after every import cycle: soulsync.plex_client - ERROR - Failed to trigger library scan for 'Music': Invalid library section: Music soulsync.web_scan_manager - ERROR - Failed to initiate PLEX library scan via web Side effect: `wishlist.processing` kept reporting "Missing from media server after sync" for tracks that DID import correctly, so they got perpetually re-added to the wishlist. # Root cause `_find_music_library` correctly auto-detects the music section by `section.type == 'artist'` and stores it on `self.music_library` — works for any locale because the type is language-neutral. Read methods (`get_artists`, etc.) route through `_get_music_sections` which returns `[self.music_library]`, so they never had the bug. But `trigger_library_scan` and `is_library_scanning` ignored `self.music_library` and called `self.server.library.section(library_name)` directly with the hardcoded `"Music"` default. `server.library.section('Music')` raises `NotFound` on any server whose section isn't literally named "Music". # Fix Both methods now prefer `self.music_library` first, fall back to literal `library_name` lookup only when auto-detection hasn't populated the cached reference (test fixtures, edge cases). `is_library_scanning`'s activity-feed match also corrected to filter by the resolved section's actual title — the prior code matched `library_name.lower() in activity_title.lower()` which defaults to "music" and would never match activities for non-English sections. `trigger_library_scan`'s success log line now surfaces the actual section title (`Música`) instead of the unused `library_name` default ("Music") — confusing when debugging on non-English servers. # Tests added (13) `tests/media_server/test_plex_non_english_section_name.py`: - `test_uses_auto_detected_section_regardless_of_locale` — parametrised across 6 locale variants (Música, Musique, Musik, Musica, 音乐, موسيقى). Each verifies trigger_library_scan calls the auto-detected section's `update()`, NOT a literal-name fallback. Stub raises AssertionError on `server.library.section()` so a regression that re-introduces the fallback fails loudly. - `test_falls_back_to_literal_lookup_when_no_auto_detection` — backward compat: music_library=None → literal lookup as before. - `test_explicit_library_name_arg_used_only_when_no_auto_detection` — auto-detected wins over explicit kwarg when both available. - `test_logs_correct_section_label_on_success` — log line surfaces resolved section title. - 4 symmetric tests for is_library_scanning covering refreshing-attr check, activity-feed title match, no-match for unrelated sections, fallback path. # Verification - 13 new tests pass - 84/84 media_server tests pass (no regression in the existing Plex / Jellyfin / Navidrome suite) - 2458 full suite passes (+13 from baseline) - Ruff clean --- core/plex_client.py | 56 ++++- .../test_plex_non_english_section_name.py | 192 ++++++++++++++++++ webui/static/helper.js | 1 + 3 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 tests/media_server/test_plex_non_english_section_name.py diff --git a/core/plex_client.py b/core/plex_client.py index e1d8a498..50c971fd 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -1197,12 +1197,28 @@ class PlexClient(MediaServerClient): """Trigger Plex library scan. In all-libraries mode, fans the scan trigger across every music - section. Otherwise targets the named section (default ``Music``). - Returns True if at least one section was successfully triggered. + section. Otherwise targets the auto-detected music section + (``self.music_library`` — populated by ``_find_music_library`` + which iterates by ``section.type == 'artist'`` and works + regardless of the section's display name). Falls back to + looking up by ``library_name`` only when auto-detection + hasn't run / failed (test fixtures, edge cases). + + Pre-fix this method ignored ``self.music_library`` and always + called ``self.server.library.section(library_name)`` with the + hardcoded "Music" default — broke for any non-English section + name (Música, Musique, Musik, etc. — issue #535). Read + methods like ``get_artists`` already routed through + ``_get_music_sections`` so they didn't have the bug; this + aligns the scan-trigger path with the same resolution. + + Returns True if at least one section was successfully + triggered. """ if not self.ensure_connection(): return False + section_label = library_name try: if self._all_libraries_mode: sections = self._get_music_sections() @@ -1219,19 +1235,30 @@ class PlexClient(MediaServerClient): logger.info(f"Triggered Plex library scan across {triggered}/{len(sections)} music sections") return triggered > 0 - library = self.server.library.section(library_name) + # Prefer the auto-detected music section. Falls back to a + # literal section lookup by library_name only when + # auto-detection hasn't populated the cached reference. + if self.music_library is not None: + library = self.music_library + section_label = library.title + else: + library = self.server.library.section(library_name) + section_label = library_name library.update() # Non-blocking scan request - logger.info(f"Triggered Plex library scan for '{library_name}'") + logger.info(f"Triggered Plex library scan for '{section_label}'") return True except Exception as e: - logger.error(f"Failed to trigger library scan for '{library_name}': {e}") + logger.error(f"Failed to trigger library scan for '{section_label}': {e}") return False def is_library_scanning(self, library_name: str = "Music") -> bool: """Check if Plex library is currently scanning. In all-libraries mode, returns True if ANY music section is - scanning. Otherwise checks the named section. + scanning. Otherwise checks the auto-detected music section + first (same fix as ``trigger_library_scan`` — see #535) and + falls back to literal ``library_name`` lookup only when + auto-detection hasn't populated the cached reference. """ if not self.ensure_connection(): logger.debug("Not connected to Plex, cannot check scan status") @@ -1261,7 +1288,13 @@ class PlexClient(MediaServerClient): logger.debug(f"Could not check server activities: {activities_error}") return False - library = self.server.library.section(library_name) + # Same resolution as trigger_library_scan — prefer the + # auto-detected section so non-English Plex section names + # (Música, Musique, Musik) don't break scan-status checks. + if self.music_library is not None: + library = self.music_library + else: + library = self.server.library.section(library_name) # Check if library has a scanning attribute or is refreshing # The Plex API exposes this through the library's refreshing property @@ -1272,7 +1305,12 @@ class PlexClient(MediaServerClient): logger.debug("Library is refreshing") return True - # Alternative method: Check server activities for scanning + # Alternative method: Check server activities for scanning. + # Match on the actual resolved section's title — NOT the + # `library_name` arg, which defaults to "Music" and would + # never match activities for non-English sections like + # "Música" / "Musique" / "Musik" (#535). + section_title = getattr(library, 'title', library_name) try: activities = self.server.activities() logger.debug("Found %s server activities", len(activities)) @@ -1284,7 +1322,7 @@ class PlexClient(MediaServerClient): logger.debug("Activity - type=%s title=%s", activity_type, activity_title) if (activity_type in ['library.scan', 'library.refresh'] and - library_name.lower() in activity_title.lower()): + section_title.lower() in activity_title.lower()): logger.debug("Found matching scan activity: %s", activity_title) return True except Exception as activities_error: diff --git a/tests/media_server/test_plex_non_english_section_name.py b/tests/media_server/test_plex_non_english_section_name.py new file mode 100644 index 00000000..9420c4d2 --- /dev/null +++ b/tests/media_server/test_plex_non_english_section_name.py @@ -0,0 +1,192 @@ +"""Pin Plex scan-trigger + scan-status methods against non-English +section names — issue #535. + +Background +---------- + +A Plex server with the music library named "Música" (Spanish), +"Musique" (French), "Musik" (German), etc. — type still +``artist`` — would have ``_find_music_library`` correctly +auto-detect the section by type. ``self.music_library`` was +populated correctly. Read methods (``get_artists`` etc.) routed +through ``_get_music_sections`` which returned ``[self.music_library]`` +and worked. + +But ``trigger_library_scan`` and ``is_library_scanning`` ignored +``self.music_library`` and called ``self.server.library.section(library_name)`` +with a hardcoded ``"Music"`` default. ``server.library.section('Music')`` +raised ``NotFound`` for any server whose music section wasn't +literally named "Music", so post-import scans never fired. + +Side effect: wishlist.processing kept reporting "Missing from +media server after sync" for tracks that DID import correctly, +re-adding them to the wishlist forever. + +These tests pin both methods through the auto-detected-section +path. Both single-library + all-libraries modes get coverage. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from core.plex_client import PlexClient + + +def _make_client(*, all_libraries_mode: bool = False, music_library=None, server=None): + """Same fixture pattern as test_plex_all_libraries.py — minimal + construction skipping `__init__` so the test owns the entire + client state.""" + client = PlexClient.__new__(PlexClient) + client.server = server + client.music_library = music_library + client._all_libraries_mode = all_libraries_mode + client._connection_attempted = server is not None + client._is_connecting = False + client._last_connection_check = 0 + client._connection_check_interval = 30 + return client + + +# --------------------------------------------------------------------------- +# trigger_library_scan — single-library mode (the broken path in #535) +# --------------------------------------------------------------------------- + + +class TestTriggerLibraryScanNonEnglishSection: + + @pytest.mark.parametrize('section_name', ['Música', 'Musique', 'Musik', 'Musica', '音乐', 'موسيقى']) + def test_uses_auto_detected_section_regardless_of_locale(self, section_name): + """The fix's headline assertion. Auto-detected section's + update() must be called — NOT a literal 'Music' lookup that + would NotFound on any non-English server.""" + section = MagicMock(title=section_name) + server = MagicMock() + # If the code falls back to literal lookup, raise so the test + # fails loudly instead of silently calling the wrong method. + server.library.section.side_effect = AssertionError( + f"Should NOT call server.library.section() when " + f"music_library is populated with '{section_name}'" + ) + client = _make_client(server=server, music_library=section) + + result = client.trigger_library_scan() + + assert result is True + section.update.assert_called_once() + + def test_falls_back_to_literal_lookup_when_no_auto_detection(self): + """Backward compat: if music_library is None (test fixtures, + edge cases where auto-detection hasn't run), fall back to the + literal `library_name` lookup as before. Default 'Music' arg + preserved — calling with no kwargs still works.""" + looked_up = MagicMock(title='Music') + server = MagicMock() + server.library.section.return_value = looked_up + client = _make_client(server=server, music_library=None) + + result = client.trigger_library_scan() + + assert result is True + server.library.section.assert_called_once_with('Music') + looked_up.update.assert_called_once() + + def test_explicit_library_name_arg_used_only_when_no_auto_detection(self): + """When music_library is populated, the library_name kwarg is + ignored — auto-detected section wins. Otherwise the kwarg + controls the literal lookup.""" + section = MagicMock(title='Música') + server = MagicMock() + server.library.section.side_effect = AssertionError("must not fall back") + client = _make_client(server=server, music_library=section) + + # Pass a non-default library_name — auto-detected wins. + result = client.trigger_library_scan(library_name='Whatever') + + assert result is True + section.update.assert_called_once() + + def test_logs_correct_section_label_on_success(self, caplog): + """Log line must surface the actual section's title (`Música`) + not the unused library_name default ('Music'). Pre-fix the + success log read 'Triggered Plex library scan for Music' even + on Spanish servers — confusing when debugging.""" + section = MagicMock(title='Música') + client = _make_client(server=MagicMock(), music_library=section) + + with caplog.at_level('INFO', logger='soulsync.plex_client'): + client.trigger_library_scan() + + assert any('Música' in r.getMessage() for r in caplog.records), ( + f"Expected log to mention 'Música'; got " + f"{[r.getMessage() for r in caplog.records]}" + ) + + +# --------------------------------------------------------------------------- +# is_library_scanning — symmetric fix +# --------------------------------------------------------------------------- + + +class TestIsLibraryScanningNonEnglishSection: + + def test_uses_auto_detected_section_for_refreshing_check(self): + """`is_library_scanning` must check the auto-detected + section's `refreshing` attribute — NOT a literal-name + lookup that fails on non-English servers.""" + section = MagicMock(title='Música', refreshing=True) + server = MagicMock() + server.activities.return_value = [] + server.library.section.side_effect = AssertionError("must not fall back") + client = _make_client(server=server, music_library=section) + + result = client.is_library_scanning() + + assert result is True + + def test_activity_match_uses_resolved_section_title(self): + """Activity feed match must filter by the resolved section's + title — NOT the library_name kwarg default ('Music'). Pre-fix + a Spanish server's "Música" scan activity wouldn't match the + literal 'music' substring check (well, "música".contains("music") + IS true here by coincidence — but for "Musique" / "Musik" / + "音乐" / "موسيقى" the substring miss is real).""" + section = MagicMock(title='موسيقى', refreshing=False) + activity = MagicMock(type='library.scan', title='Scanning موسيقى') + server = MagicMock() + server.activities.return_value = [activity] + server.library.section.side_effect = AssertionError("must not fall back") + client = _make_client(server=server, music_library=section) + + result = client.is_library_scanning() + + assert result is True + + def test_no_match_when_activity_for_unrelated_section(self): + """Sanity: activity for a DIFFERENT section's scan shouldn't + cause a false-positive "music library is scanning" reading.""" + section = MagicMock(title='Música', refreshing=False) + # Activity is for a different section (e.g. Movies) + activity = MagicMock(type='library.scan', title='Scanning Películas') + server = MagicMock() + server.activities.return_value = [activity] + server.library.section.side_effect = AssertionError("must not fall back") + client = _make_client(server=server, music_library=section) + + assert client.is_library_scanning() is False + + def test_falls_back_to_literal_lookup_when_no_auto_detection(self): + """Backward compat: music_library=None → literal section + lookup by library_name (default 'Music'). Same as before fix.""" + looked_up = MagicMock(title='Music', refreshing=False) + server = MagicMock() + server.library.section.return_value = looked_up + server.activities.return_value = [] + client = _make_client(server=server, music_library=None) + + result = client.is_library_scanning() + + assert result is False + server.library.section.assert_called_once_with('Music') diff --git a/webui/static/helper.js b/webui/static/helper.js index cadb74c7..abaef5bc 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3416,6 +3416,7 @@ const WHATS_NEW = { '2.4.3': [ // --- post-release patch work on the 2.4.3 line — entries hidden by _getLatestWhatsNewVersion until the build version bumps --- { date: 'Unreleased — 2.4.3 patch work' }, + { title: 'Plex: Library Scan Trigger No Longer Fails On Non-English Section Names', desc: 'github issue #535 (adrigzr): plex servers with the music library named anything other than "music" — Música, Musique, Musik, Musica, etc. — got a `Failed to trigger library scan for "Music": Invalid library section: Music` error after every import cycle, and `wishlist.processing` kept reporting "missing from media server after sync" for tracks that DID import correctly because the post-import scan never fired. cause: `trigger_library_scan` and `is_library_scanning` ignored the auto-detected `self.music_library` (correctly populated by `_find_music_library` filtering by `section.type == "artist"`) and called `self.server.library.section(library_name)` with a hardcoded "music" default — raised NotFound on any non-english server. read methods like `get_artists` already routed through `_get_music_sections` so they didn\'t have the bug; this aligns the scan-trigger path with the same resolution. fix: both single-library branches prefer `self.music_library` first, fall back to literal section lookup only when auto-detection hasn\'t run. activity-feed match in `is_library_scanning` also corrected to use the resolved section\'s actual title instead of the unused `library_name` arg — the prior log line read "triggered scan for music" even on Spanish servers. 13 new tests pin: trigger uses auto-detected section across 6 locale variants (Música / Musique / Musik / Musica / 音乐 / موسيقى), backward-compat fallback when music_library is None, explicit library_name kwarg ignored when auto-detected section exists, log line surfaces correct section title, scan-status check uses auto-detected section\'s `refreshing` attr, activity-feed match filters by resolved title (not library_name).', page: 'settings' }, { title: 'Search For Match: No More Karaoke / Cover / "Originally Performed By" Junk At The Top', desc: 'github issue #534 (radoslav-orlov): typing "dirty white boy" + "foreigner" into the import-modal "search for match" dialog returned karaoke versions, "originally performed by" compilations, and tribute-band cuts ranked above the actual foreigner studio recording in some regions. user had to scroll past 5+ junk results before finding the canonical track. fix: new `core/metadata/relevance.py` helper reranks results locally with cover/karaoke/tribute/re-recorded penalties (multiplier 0.05× — effectively buries) + exact-artist-match boost (1.5×) + variant-tag (live/acoustic/remix/remaster) penalty (0.4×, skipped when user explicitly typed the variant — searching "track (live)" still ranks live versions correctly). applied at the deezer + itunes + spotify search-tracks endpoints so all three sources behave consistently. validated against live deezer api with the actual #534 query: real foreigner head games cut now lands at #1, live versions follow, karaoke / cover / tribute variants drop to positions 11-15. deezer client also gained optional field-scoped query kwargs (`track="X" artist="Y"`) that build deezer\'s advanced search syntax `track:"X" artist:"Y"` for future opt-in callers (e.g. exact-match flows where api-level filtering is more important than ranking) — kept in client but NOT used at the import-modal endpoint after live testing showed the advanced syntax has its own ranking bias (surfaced "(2008 remaster)" instead of the canonical recording). free-text + local rerank is the more reliable combination here. 75 new tests pin every scoring component, pattern detection (13 cover patterns, 11 variant patterns, 3 fields), score composition (real-cut > karaoke > remaster > re-recorded), the issue #534 screenshot reproduced as a regression test, deezer client query construction + free-text fallback safety net.', page: 'import' }, { title: 'Auto-Import: Album Duration Is Album Total + Re-Imports Fill Metadata Gaps', desc: 'two more parity gaps closed in the soulsync standalone library write path. (1) album row\'s `duration` column was being written with the FIRST imported track\'s duration instead of the album total — pre-existing bug that survived the prior parity commit. soulsync_client deep scan computes `sum(t.duration for t in self._tracks)` for each album; auto-import now mirrors that by computing the sum across every matched track in the worker and threading it through context to the album INSERT. (2) `record_soulsync_library_entry` was insert-only on artists + albums — once a row existed (matched by id OR name fallback), subsequent imports of the same artist or album skipped completely. meant: artist genres / thumb / source-id reflected ONLY whatever the FIRST imported album supplied, never refreshing as more albums by that artist landed (ten more deezer/spotify imports later, artist row still had whatever the first random import wrote). new conservative UPDATE path: when an existing row matches, fill ONLY the columns whose current value is NULL or empty — never overwrites populated values. protects manual edits + enrichment-worker writes the same way scanner UPDATEs preserve enrichment columns. f-string column names are validated against an allowlist (`_SOULSYNC_FILLABLE_COLUMNS`) before interpolation — defensive against accidental misuse adding columns without an allowlist update. 4 new tests pin: album duration uses sum not single-track, re-import fills empty thumb + genres on existing artist row, re-import does NOT clobber populated values, re-import fills empty source-id columns when later import has them.', page: 'import' }, { title: 'Auto-Import: Genre Tags Land On The Artists Row + ISRC/MBID Type Hardening', desc: 'small followup to the standalone-library parity commit. (1) auto-import now reads the GENRE tag from each matched audio file (mutagen easy mode, supports flac / mp3 / m4a) and aggregates the deduped set across the album onto the new artists row\'s genres column. matches what soulsync_client._scan_transfer would have written if you\'d done a fresh deep scan after the import — your imported artists no longer feel hollow compared to plex / jellyfin / navidrome scans. dedup is case-insensitive but preserves original casing + insertion order so the json column reads naturally ("Hip-Hop, Rap, Trap" not "hip-hop, rap, trap"). (2) defensive `str()` cast on the worker\'s isrc + mbid extraction. metadata source clients all coerce to string today via `_build_album_track_entry`, but if a future source ever returned int / None for either id the side-effects layer would crash on `.strip()`. cheap insurance. 3 new tests pin: genre aggregation produces deduped insertion-order list, empty when no GENRE tags, isrc/mbid hostile-type input (int, None) coerced to safe string before propagation.', page: 'import' },