soulsync/tests/media_server/test_plex_non_english_section_name.py
Broque Thomas c02d51d60d Plex: trigger_library_scan + is_library_scanning use auto-detected section — fixes #535
# 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
2026-05-10 10:22:32 -07:00

192 lines
8.2 KiB
Python

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