Merge pull request #540 from Nezreka/fix/plex-non-english-music-section-issue-535

Plex: trigger_library_scan + is_library_scanning use auto-detected se…
This commit is contained in:
BoulderBadgeDad 2026-05-10 10:34:07 -07:00 committed by GitHub
commit 03533454bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 240 additions and 9 deletions

View file

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

View file

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

View file

@ -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' },