/
/
/
1"""Tests for the translations controller: catalog loading and key resolution."""
2
3from __future__ import annotations
4
5import logging
6from contextlib import contextmanager
7from functools import partial
8from typing import TYPE_CHECKING
9from unittest.mock import MagicMock
10
11import pytest
12from music_assistant_models.api import ErrorResultMessage
13from music_assistant_models.background_task import BackgroundTask
14from music_assistant_models.config_entries import (
15 ConfigActionResult,
16 ConfigEntry,
17 ProviderConfig,
18)
19from music_assistant_models.enums import (
20 ConfigEntryType,
21 FlowStepType,
22 MediaType,
23 ProviderType,
24)
25from music_assistant_models.errors import LoginFailed, ProviderUnavailableError
26from music_assistant_models.media_items.media_item import BrowseFolder, RecommendationFolder
27from music_assistant_models.provider import ProviderManifest
28from music_assistant_models.setup_flow import SetupFlowStep
29from music_assistant_models.translations import TRANSLATION_RESOLVER
30
31from music_assistant.controllers import translations as translations_module
32from music_assistant.controllers.config.helpers import _with_translation_owner
33from music_assistant.controllers.music import MusicController
34from music_assistant.controllers.tasks.controller import _namespaced_translation_key
35from music_assistant.controllers.translations import (
36 SOURCE_LANGUAGE,
37 TranslationController,
38 _candidate_keys,
39 _format,
40 _locale_candidates,
41)
42from scripts import build_translations as build_translations_module
43from scripts.build_translations import (
44 _find_duplicate_keys,
45 _flatten_into,
46 _resolve_references,
47 build_translations_source,
48)
49
50if TYPE_CHECKING:
51 from collections.abc import Iterator
52 from pathlib import Path
53
54
55def _make_controller() -> TranslationController:
56 """Build a TranslationController without the full CoreController init (no mass needed)."""
57 ctrl = TranslationController.__new__(TranslationController)
58 ctrl.logger = logging.getLogger("test.translations")
59 ctrl._source = {}
60 ctrl._locales = {}
61 ctrl._locale_files = {}
62 ctrl._locale_locks = {}
63 ctrl._available_locales = {SOURCE_LANGUAGE}
64 return ctrl
65
66
67def test_flatten_into() -> None:
68 """Nested authoring is flattened into dotted, prefixed keys with string leaves."""
69 out: dict[str, str] = {}
70 _flatten_into(
71 {
72 "config_entries": {
73 "cookie": {"label": "Login Cookie", "description": "From a session."}
74 },
75 "media": {"mixes": "Your Mixes"},
76 "ignored_non_string": {"nested_number": 5},
77 },
78 "provider.ytmusic.",
79 out,
80 )
81 assert out == {
82 "provider.ytmusic.config_entries.cookie.label": "Login Cookie",
83 "provider.ytmusic.config_entries.cookie.description": "From a session.",
84 "provider.ytmusic.media.mixes": "Your Mixes",
85 }
86
87
88def test_namespaced_translation_key() -> None:
89 """A bare task key is namespaced under background_task; any dotted key is left as-is."""
90 assert _namespaced_translation_key("database_cleanup") == "background_task.database_cleanup"
91 assert _namespaced_translation_key(None) is None
92 # any key that already carries a namespace (a ".") is returned unchanged
93 assert _namespaced_translation_key("background_task.x") == "background_task.x"
94 assert _namespaced_translation_key("settings.sync") == "settings.sync"
95 assert (
96 _namespaced_translation_key("core.metadata.background_task.x")
97 == "core.metadata.background_task.x"
98 )
99
100
101def test_resolve_references_validated_and_omitted() -> None:
102 """A reference reuses an existing string: its target is validated and the key is omitted."""
103 resolved = _resolve_references(
104 {
105 "common.media.recommendations.recommended_tracks.name": "Recommended tracks",
106 "provider.deezer.media.recommendations.recommended_tracks.name": (
107 "[%key:common::media::recommendations::recommended_tracks::name%]"
108 ),
109 }
110 )
111 # the shared (target) string stays; the referencing key is dropped (resolved via the fallback)
112 assert resolved == {
113 "common.media.recommendations.recommended_tracks.name": "Recommended tracks",
114 }
115
116
117def test_resolve_references_missing_target_raises() -> None:
118 """A reference whose target does not exist fails the build with a clear error."""
119 with pytest.raises(ValueError, match="Unresolved translation reference"):
120 _resolve_references(
121 {"provider.deezer.media.x.name": "[%key:common::media::does::not::exist%]"}
122 )
123
124
125def test_find_duplicate_keys() -> None:
126 """Duplicated object keys are reported with their full key path, at any nesting depth."""
127 assert _find_duplicate_keys(b'{"a": "1", "b": {"c": "2"}}') == []
128 # two blocks with the same name at the top level (parsers keep only the last one)
129 assert _find_duplicate_keys(b'{"errors": {"a": "1"}, "other": {}, "errors": {"b": "2"}}') == [
130 "errors"
131 ]
132 assert _find_duplicate_keys(b'{"errors": {"pin": "a", "pin": "b"}}') == ["errors.pin"]
133 assert _find_duplicate_keys(b'{"a": [{"k": "1", "k": "2"}, {"k": "3"}]}') == ["a[0].k"]
134 assert _find_duplicate_keys(b'{"a": "1", "a": "2", "b": {"x": "1", "x": "2"}}') == [
135 "a",
136 "b.x",
137 ]
138
139
140def test_build_translations_duplicate_key_fails_loudly(
141 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
142) -> None:
143 """A duplicated key in an authoring file fails the build, naming the file and key path."""
144 strings_file = tmp_path / "strings.json"
145 strings_file.write_bytes(b'{"errors": {"pin": "a"}, "errors": {"pin": "b"}}')
146 monkeypatch.setattr(
147 build_translations_module,
148 "_collect_source_files",
149 lambda: [("provider.foo.", str(strings_file))],
150 )
151 with pytest.raises(ValueError, match=r"Duplicate strings\.json key\(s\):[\s\S]*: errors"):
152 build_translations_source()
153
154
155def test_build_translations_malformed_file_names_the_file(
156 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
157) -> None:
158 """An authoring file that fails to parse is reported with its file path."""
159 strings_file = tmp_path / "strings.json"
160 strings_file.write_bytes(b'{"errors": ')
161 monkeypatch.setattr(
162 build_translations_module,
163 "_collect_source_files",
164 lambda: [("provider.foo.", str(strings_file))],
165 )
166 with pytest.raises(ValueError, match=r"strings\.json: "):
167 build_translations_source()
168
169
170def test_candidate_keys_common_rewrite() -> None:
171 """An owner-namespaced key falls back to the shared common namespace."""
172 assert _candidate_keys("provider.ytmusic.config_entries.username.label") == [
173 "provider.ytmusic.config_entries.username.label",
174 "common.config_entries.username.label",
175 ]
176 assert _candidate_keys("core.metadata.config_entries.language.label") == [
177 "core.metadata.config_entries.language.label",
178 "common.config_entries.language.label",
179 ]
180
181
182def test_candidate_keys_name_fallback() -> None:
183 """A trailing .name falls back to the bare key, in both owner and common namespaces."""
184 assert _candidate_keys("provider.ytmusic.media.mixes.name") == [
185 "provider.ytmusic.media.mixes.name",
186 "provider.ytmusic.media.mixes",
187 "common.media.mixes.name",
188 "common.media.mixes",
189 ]
190
191
192def test_candidate_keys_multi_instance_domain_fallback() -> None:
193 """A multi-instance owner (<domain>--<id>) also tries the bare-domain prefix."""
194 assert _candidate_keys("media.folder.blah.name", "provider.spotify--ab12cd") == [
195 "provider.spotify--ab12cd.media.folder.blah.name",
196 "provider.spotify--ab12cd.media.folder.blah",
197 "provider.spotify.media.folder.blah.name",
198 "provider.spotify.media.folder.blah",
199 "common.media.folder.blah.name",
200 "common.media.folder.blah",
201 "media.folder.blah.name",
202 "media.folder.blah",
203 ]
204
205
206def test_locale_candidates() -> None:
207 """Locale collapses to its base language and normalizes separators."""
208 assert _locale_candidates("nl") == ["nl"]
209 assert _locale_candidates("de_DE") == ["de_DE", "de"]
210 assert _locale_candidates("pt-BR") == ["pt_BR", "pt"]
211
212
213def test_format_positional_args() -> None:
214 """Positional placeholders are substituted; bad/missing args leave the template intact."""
215 assert _format("Liked Songs {0}", ["Bob"]) == "Liked Songs Bob"
216 assert _format("no params here", None) == "no params here"
217 # missing positional arg -> IndexError -> template returned unchanged (never crashes)
218 assert _format("missing {0} {1}", ["only"]) == "missing {0} {1}"
219
220
221def test_get_translation_source_fallback() -> None:
222 """With no locale, resolution returns the English source (incl. common rewrite) or None."""
223 ctrl = _make_controller()
224 ctrl._source = {"common.config_entries.username.label": "Username"}
225 # exact common key
226 assert ctrl.get_translation("common.config_entries.username.label") == "Username"
227 # provider-namespaced key resolves via the common fallback
228 assert ctrl.get_translation("provider.ytmusic.config_entries.username.label") == "Username"
229 # unknown key -> None (so the caller keeps any existing value rather than a raw key)
230 assert ctrl.get_translation("provider.ytmusic.config_entries.nope.label") is None
231
232
233def test_get_translation_locale_precedence_and_params() -> None:
234 """A loaded locale wins over the source; base-language and source act as fallbacks."""
235 ctrl = _make_controller()
236 ctrl._source = {"provider.ytmusic.media.liked": "Liked Songs {0}"}
237 ctrl._locales = {"nl": {"provider.ytmusic.media.liked": "Nummers die je leuk vindt {0}"}}
238 assert (
239 ctrl.get_translation("provider.ytmusic.media.liked", "nl", params=["Bob"])
240 == "Nummers die je leuk vindt Bob"
241 )
242 # locale not loaded -> falls back to English source
243 assert (
244 ctrl.get_translation("provider.ytmusic.media.liked", "de", params=["Bob"])
245 == "Liked Songs Bob"
246 )
247
248
249def test_get_translation_name_bare_fallback() -> None:
250 """A media name key resolves from a bare common entry."""
251 ctrl = _make_controller()
252 ctrl._source = {"common.media.mixes": "Your Mixes"}
253 assert ctrl.get_translation("provider.ytmusic.media.mixes.name") == "Your Mixes"
254
255
256@pytest.mark.asyncio
257async def test_catalog_loading_and_lazy_locale(
258 tmp_path: object, monkeypatch: pytest.MonkeyPatch
259) -> None:
260 """English source (translations/en.json) loads eagerly; a downloaded locale loads lazily."""
261 translations = tmp_path / "translations" # type: ignore[operator]
262 translations.mkdir()
263 (translations / "en.json").write_text(
264 '{"common.settings.username.label": "Username", "provider.demo.media.mixes": "Your Mixes"}'
265 )
266 (translations / "nl.json").write_text(
267 '{"common.settings.username.label": "Gebruikersnaam", '
268 '"provider.demo.media.mixes": "Jouw mixes"}'
269 )
270
271 monkeypatch.setattr(translations_module, "TRANSLATIONS_PATH", str(translations))
272 monkeypatch.setattr(translations_module, "SOURCE_FILE", str(translations / "en.json"))
273
274 ctrl = _make_controller()
275 await ctrl.setup(None) # type: ignore[arg-type]
276
277 # English source loaded eagerly from translations/en.json
278 assert ctrl.get_translation("common.settings.username.label") == "Username"
279 assert ctrl.get_translation("provider.demo.media.mixes") == "Your Mixes"
280
281 # nl discovered but not parsed yet
282 assert "nl" in ctrl.available_locales
283 assert "nl" not in ctrl._locales
284 # before warm-up, an nl lookup falls back to the English source
285 assert ctrl.get_translation("provider.demo.media.mixes", "nl") == "Your Mixes"
286
287 # warm up the locale, then lookups return the translated strings
288 await ctrl.ensure_locale_loaded("nl")
289 assert "nl" in ctrl._locales
290 assert ctrl.get_translation("provider.demo.media.mixes", "nl") == "Jouw mixes"
291 assert ctrl.get_translation("common.settings.username.label", "nl") == "Gebruikersnaam"
292
293
294def _nl_controller() -> TranslationController:
295 """Build a controller pre-populated with English source + an nl translation bundle."""
296 ctrl = _make_controller()
297 ctrl._source = {
298 "common.config_entries.username.label": "Username",
299 "common.config_categories.generic": "Generic",
300 "provider.spotify.config_entries.api_key.label": "API key",
301 "provider.demo.manifest.name": "Demo Music Provider",
302 "provider.demo.manifest.description": "A demo provider.",
303 # recommendation folders key under media.recommendations.*
304 "common.media.recommendations.recently_played.name": "Recently played",
305 "common.media.recommendations.recently_played.subtitle": "Pick up where you left off",
306 # a genre name (searchable, so used by the reverse-lookup test)
307 "common.media.genre.classical.name": "Classical",
308 # a genre description (resolved into a Genre's nested metadata.description)
309 "common.media.genre.classical.description": "Classical music is art music.",
310 # error messages: a shared default (common.errors.*) + a provider-specific override
311 "common.errors.provider_unavailable": "The provider is currently unavailable.",
312 "common.errors.setup_required": "Music Assistant is not set up yet.",
313 "common.errors.insufficient_permissions": "You do not have permission to perform this action.",
314 "provider.spotify.errors.token_expired": "Your Spotify session expired.",
315 }
316 ctrl._locales = {
317 "nl": {
318 "common.config_entries.username.label": "Gebruikersnaam",
319 "common.config_categories.generic": "Algemeen",
320 "provider.spotify.config_entries.api_key.label": "API-sleutel",
321 "provider.demo.manifest.name": "Demo-muziekprovider",
322 "provider.demo.manifest.description": "Een demoprovider.",
323 "common.media.recommendations.recently_played.name": "Onlangs afgespeeld",
324 "common.media.recommendations.recently_played.subtitle": "Ga verder waar je gebleven was",
325 "common.media.genre.classical.name": "Klassiek",
326 "common.media.genre.classical.description": "Klassieke muziek is kunstmuziek.",
327 "common.errors.provider_unavailable": "De provider is niet beschikbaar.",
328 "common.errors.setup_required": "Music Assistant is nog niet ingesteld.",
329 "common.errors.insufficient_permissions": "Je hebt geen toestemming voor deze actie.",
330 "provider.spotify.errors.token_expired": "Je Spotify-sessie is verlopen.",
331 }
332 }
333 ctrl._available_locales = {"en", "nl"}
334 return ctrl
335
336
337@contextmanager
338def _active_resolver(ctrl: TranslationController, locale: str | None) -> Iterator[None]:
339 """Bind the controller as the active TRANSLATION_RESOLVER for the given locale."""
340 token = TRANSLATION_RESOLVER.set(partial(ctrl.get_translation, locale=locale))
341 try:
342 yield
343 finally:
344 TRANSLATION_RESOLVER.reset(token)
345
346
347def test_config_entry_localized_serialization() -> None:
348 """ConfigEntry serialization injects localized label/category_label when a resolver is set."""
349 ctrl = _nl_controller()
350 entry = ConfigEntry(key="username", type=ConfigEntryType.STRING, label="Username")
351 # no resolver -> in-code English; category_label (server-filled) stays null
352 plain = entry.to_dict()
353 assert plain["label"] == "Username"
354 assert plain["category_label"] is None
355 # nl resolver -> localized label + injected category_label
356 with _active_resolver(ctrl, "nl"):
357 localized = entry.to_dict()
358 assert localized["label"] == "Gebruikersnaam"
359 assert localized["category_label"] == "Algemeen"
360 # a locale without translations -> English source
361 with _active_resolver(ctrl, "fr"):
362 fallback = entry.to_dict()
363 assert fallback["label"] == "Username"
364 # the translation machinery is never serialized to the client
365 for machinery_key in (
366 "translation_key",
367 "translation_params",
368 "category_translation_key",
369 "category_translation_params",
370 ):
371 assert machinery_key not in plain
372 assert machinery_key not in localized
373
374
375def test_config_entry_provider_specific_owner() -> None:
376 """A config entry resolves provider-specific strings under its stamped owner namespace."""
377 ctrl = _nl_controller()
378 entry = ConfigEntry(key="api_key", type=ConfigEntryType.STRING, label="API key")
379 entry.translation_owner = "provider.spotify"
380 with _active_resolver(ctrl, "nl"):
381 assert entry.to_dict()["label"] == "API-sleutel"
382
383
384def test_provider_config_parse_stamps_owner() -> None:
385 """ProviderConfig.parse stamps the provider owner so embedded entries resolve correctly."""
386 ctrl = _nl_controller()
387 entry = ConfigEntry(key="api_key", type=ConfigEntryType.STRING, label="API key")
388 config = ProviderConfig.parse(
389 [entry],
390 {"type": ProviderType.MUSIC, "domain": "spotify", "instance_id": "spotify--1"},
391 )
392 assert config.values["api_key"].translation_owner == "provider.spotify"
393 with _active_resolver(ctrl, "nl"):
394 serialized = config.to_dict()
395 assert serialized["values"]["api_key"]["label"] == "API-sleutel"
396
397
398def test_bare_list_config_entries_stamp_owner() -> None:
399 """
400 The get_*_config_entries handlers return owner-stamped copies, so a bare list localizes.
401
402 The api_commands (config/providers|players|core/get_entries) return a list of ConfigEntry
403 rather than a Config object; _with_translation_owner is what gives each entry its owner so it
404 resolves the same way embedded entries do.
405 """
406 ctrl = _nl_controller()
407 original = ConfigEntry(key="api_key", type=ConfigEntryType.STRING, label="API key")
408 stamped = _with_translation_owner([original], "provider.spotify")
409 # the originals (often module-level CONF_ENTRY_* singletons) must not be mutated
410 assert original.translation_owner is None
411 assert stamped[0].translation_owner == "provider.spotify"
412 with _active_resolver(ctrl, "nl"):
413 assert stamped[0].to_dict()["label"] == "API-sleutel"
414
415
416def test_provider_manifest_localized_serialization() -> None:
417 """ProviderManifest name/description are localized from provider.<domain>.manifest.*."""
418 ctrl = _nl_controller()
419 manifest = ProviderManifest(
420 type=ProviderType.MUSIC,
421 domain="demo",
422 name="Demo Music Provider",
423 description="A demo provider.",
424 codeowners=[],
425 )
426 assert manifest.to_dict()["name"] == "Demo Music Provider"
427 with _active_resolver(ctrl, "nl"):
428 localized = manifest.to_dict()
429 assert localized["name"] == "Demo-muziekprovider"
430 assert localized["description"] == "Een demoprovider."
431
432
433def test_recommendation_folder_localized_serialization() -> None:
434 """A media item with a translation_key gets its name and subtitle localized."""
435 ctrl = _nl_controller()
436 rec = RecommendationFolder(
437 item_id="recently_played",
438 provider="library",
439 name="Recently played",
440 translation_key="recently_played",
441 subtitle="Pick up where you left off",
442 )
443 assert rec.to_dict()["name"] == "Recently played"
444 with _active_resolver(ctrl, "nl"):
445 localized = rec.to_dict()
446 assert localized["name"] == "Onlangs afgespeeld"
447 assert localized["subtitle"] == "Ga verder waar je gebleven was"
448
449
450def test_provider_sync_task_localized_serialization() -> None:
451 """
452 Provider-sync BackgroundTasks resolve their name from the built catalog with the provider name.
453
454 Guards that every key returned by MusicController._get_sync_task_translation_key resolves to a
455 core.music.background_task.* entry authored in strings.json (the tasks controller namespaces the
456 bare key under the background_task group), that the provider name fills the {0} placeholder, and
457 that the translation machinery is stripped from the wire under a resolver.
458 """
459 ctrl = _make_controller()
460 ctrl._source = build_translations_source()
461 expected = {
462 MediaType.ARTIST: "Sync Artists for Spotify",
463 MediaType.ALBUM: "Sync Albums for Spotify",
464 MediaType.TRACK: "Sync Tracks for Spotify",
465 MediaType.PLAYLIST: "Sync Playlists for Spotify",
466 MediaType.RADIO: "Sync Radios for Spotify",
467 MediaType.AUDIOBOOK: "Sync Audiobooks for Spotify",
468 MediaType.PODCAST: "Sync Podcasts for Spotify",
469 }
470 # the method does not use self, so call it on the class without instantiating the controller
471 get_key = MusicController._get_sync_task_translation_key
472 with _active_resolver(ctrl, None):
473 for media_type, name in expected.items():
474 key = get_key(None, media_type) # type: ignore[arg-type]
475 task = BackgroundTask(
476 name=f"Sync Spotify {media_type.value}s", # in-code English fallback
477 translation_key=_namespaced_translation_key(key),
478 translation_args=["Spotify"],
479 translation_owner="core.music",
480 )
481 serialized = task.to_dict()
482 assert serialized["name"] == name
483 assert "translation_key" not in serialized
484 assert "translation_args" not in serialized
485
486
487def test_core_owned_strings_moved_out_of_common() -> None:
488 """
489 Owner-specific strings live under their owner namespace, not in the shared common space.
490
491 Background-task names and the stream server's network/normalization config entries each belong
492 to a single core module (or provider), so they are authored there rather than in common; only
493 genuinely shared strings (e.g. the bind address, used by several modules) stay in common.
494 """
495 source = build_translations_source()
496 # background-task names moved to their owning module/provider
497 assert "core.music.background_task.sync_provider_artists" in source
498 assert "core.music.background_task.database_cleanup" in source
499 assert "core.cache.background_task.cache_database_cleanup" in source
500 assert (
501 "provider.lastfm_recommendations.background_task.refresh_lastfm_recommendations" in source
502 )
503 # stream-server config entries moved to core.streams
504 assert "core.streams.config_entries.publish_ip.label" in source
505 assert "core.streams.config_entries.background_scan_concurrency.label" in source
506 assert "core.streams.config_entries.volume_normalization_radio.label" in source
507 # none of the relocated keys remain in common
508 assert not any(key.startswith("common.background_task.") for key in source)
509 assert "common.config_entries.publish_ip.label" not in source
510 assert "common.config_entries.volume_normalization_radio.label" not in source
511 # genuinely shared network config (built by several modules) stays in common
512 assert "common.config_entries.bind_ip.label" in source
513 assert "common.config_entries.bind_port.label" in source
514
515
516def test_setup_flow_finish_library_sync_is_shared() -> None:
517 """
518 The FINISH step explaining the initial library import resolves for any music provider.
519
520 The copy is authored once in common, so a provider that ships no strings of its own still
521 gets it via the owner -> common fallback.
522 """
523 ctrl = _make_controller()
524 ctrl._source = build_translations_source()
525 step = SetupFlowStep(
526 flow_id="test",
527 step_id="finish_library_sync",
528 type=FlowStepType.FINISH,
529 translation_owner="provider.qobuz",
530 )
531 with _active_resolver(ctrl, None):
532 serialized = step.to_dict()
533 assert (
534 serialized["description"]
535 == ctrl._source["common.setup_flow.finish_library_sync.description"]
536 )
537
538
539def test_config_action_result_localized_serialization() -> None:
540 """ConfigActionResult resolves its message from the owner's config_actions group."""
541 ctrl = _make_controller()
542 ctrl._source = build_translations_source()
543 result = ConfigActionResult(
544 translation_key="clear_cache.result", translation_owner="core.cache"
545 )
546 # no resolver -> no message yet, machinery kept for internal round-trips
547 plain = result.to_dict()
548 assert plain["message"] is None
549 assert plain["translation_key"] == "clear_cache.result"
550 # resolver bound -> message filled from the owner's strings, machinery stripped
551 with _active_resolver(ctrl, None):
552 localized = result.to_dict()
553 assert localized["message"] == "The cache has been cleared"
554 for machinery_key in ("translation_key", "translation_args", "translation_owner"):
555 assert machinery_key not in localized
556
557
558def test_config_action_result_keys_are_authored() -> None:
559 """Every migrated action result key resolves under its owning core module."""
560 ctrl = _make_controller()
561 ctrl._source = build_translations_source()
562 cases = [
563 ("clear_cache.result", "core.cache", "The cache has been cleared"),
564 ("reset_db.result", "core.music", "The database has been reset."),
565 ]
566 for key, owner, expected in cases:
567 assert ctrl.get_translation(f"config_actions.{key}", owner=owner) == expected
568
569
570def test_error_result_message_localized_serialization() -> None:
571 """ErrorResultMessage localizes `details` from a MusicAssistantError's default key."""
572 ctrl = _nl_controller()
573 err = ProviderUnavailableError("WebDAV PROPFIND failed with status 503")
574 msg = ErrorResultMessage(
575 "msg-1",
576 err.error_code,
577 str(err),
578 translation_key=err.translation_key,
579 translation_args=err.translation_args,
580 translation_owner=err.translation_owner,
581 )
582 # no resolver -> raw English details + machinery kept (internal round-trips stay localizable)
583 plain = msg.to_dict()
584 assert plain["details"] == "WebDAV PROPFIND failed with status 503"
585 assert plain["translation_key"] == "provider_unavailable"
586 # nl resolver -> localized details; error_code/message_id kept, machinery stripped
587 with _active_resolver(ctrl, "nl"):
588 localized = msg.to_dict()
589 assert localized["details"] == "De provider is niet beschikbaar."
590 assert localized["error_code"] == err.error_code
591 assert localized["message_id"] == "msg-1"
592 assert "translation_key" not in localized
593 assert "translation_args" not in localized
594 # any bound locale (incl. an untranslated one) resolves to the English source string,
595 # not the original specific message (the specific text stays in the server log)
596 with _active_resolver(ctrl, "fr"):
597 fallback = msg.to_dict()
598 assert fallback["details"] == "The provider is currently unavailable."
599
600
601def test_error_result_message_provider_specific_override() -> None:
602 """A provider can override translation_key to localize a provider-specific message."""
603 ctrl = _nl_controller()
604 err = LoginFailed(
605 "token exchange failed",
606 translation_key="token_expired",
607 translation_owner="provider.spotify",
608 )
609 msg = ErrorResultMessage(
610 "m",
611 err.error_code,
612 str(err),
613 translation_key=err.translation_key,
614 translation_args=err.translation_args,
615 translation_owner=err.translation_owner,
616 )
617 with _active_resolver(ctrl, "nl"):
618 assert msg.to_dict()["details"] == "Je Spotify-sessie is verlopen."
619
620
621def test_error_result_message_provider_stream_limit() -> None:
622 """The provider stream limit error resolves with provider name and limit interpolated."""
623 ctrl = _make_controller()
624 ctrl._source = build_translations_source()
625 msg = ErrorResultMessage(
626 "m",
627 7,
628 "Spotify has reached its limit of 5 concurrent source streams.",
629 translation_key="provider_stream_limit",
630 translation_args=["Spotify", 5],
631 )
632 with _active_resolver(ctrl, None):
633 details = msg.to_dict()["details"]
634 assert details == (
635 "Spotify has reached its limit of 5 simultaneous streams. "
636 "Stop playback on another player and try again."
637 )
638
639
640def test_error_result_message_unresolved_key_keeps_details() -> None:
641 """An unresolvable or absent translation_key leaves the raw `details` string intact."""
642 ctrl = _nl_controller()
643 # key not in the catalog -> details kept as-is
644 typed = ErrorResultMessage("m", 2, "Track 123 not found", translation_key="media_not_found")
645 with _active_resolver(ctrl, "nl"):
646 assert typed.to_dict()["details"] == "Track 123 not found"
647 # no key at all (e.g. an unexpected non-MA error) -> details kept
648 untyped = ErrorResultMessage("m", 999, "boom")
649 with _active_resolver(ctrl, "nl"):
650 out = untyped.to_dict()
651 assert out["details"] == "boom"
652 assert out["error_code"] == 999
653
654
655def test_error_result_message_protocol_error_localization() -> None:
656 """The hard-coded auth/command error responses localize via their translation_key too."""
657 ctrl = _nl_controller()
658 # the new connection-time setup_required key resolves under nl
659 setup = ErrorResultMessage(
660 "connection", 503, "Setup required", translation_key="setup_required"
661 )
662 # a reused generic key for the admin/role error
663 admin = ErrorResultMessage(
664 "m", 22, "Admin access required", translation_key="insufficient_permissions"
665 )
666 with _active_resolver(ctrl, "nl"):
667 assert setup.to_dict()["details"] == "Music Assistant is nog niet ingesteld."
668 assert admin.to_dict()["details"] == "Je hebt geen toestemming voor deze actie."
669
670
671def test_provider_specific_error_keys_resolve_with_params() -> None:
672 """Provider-specific error keys resolve from the built source and fill their {0} param."""
673 ctrl = _make_controller()
674 ctrl._source = build_translations_source()
675 cases = [
676 ("provider.audiobookshelf.errors.login_failed", "https://abs.local"),
677 ("provider.chromecast.errors.app_launch_timeout", "Living Room TV"),
678 ("provider.opensubsonic.errors.connect_failed", "subsonic.local"),
679 ]
680 for key, arg in cases:
681 resolved = ctrl.get_translation(key, params=[arg])
682 assert resolved is not None, f"{key} did not resolve"
683 assert arg in resolved, f"{key} did not fill its param: {resolved!r}"
684
685
686def test_media_item_without_translation_key_is_untouched() -> None:
687 """A media item with no translation_key keeps its in-code name even under a resolver."""
688 ctrl = _nl_controller()
689 folder = BrowseFolder(item_id="x", provider="library", name="My Folder")
690 with _active_resolver(ctrl, "nl"):
691 assert folder.to_dict()["name"] == "My Folder"
692
693
694@pytest.mark.asyncio
695async def test_build_translations_matches_runtime_source() -> None:
696 """
697 The standalone Lokalise source generator yields the same strings as the runtime loader.
698
699 Guards against the key scheme drifting between the (standalone) build script and the
700 controller's live source scan.
701 """
702 ctrl = _make_controller()
703 await ctrl.setup(None) # type: ignore[arg-type] # scans the real repo authoring files
704 assert ctrl._source == build_translations_source()
705
706
707def test_media_names_are_keyed_by_media_type() -> None:
708 """Media names live under media.<media_type>.*; folders/recommendations are namespaced apart."""
709 source = build_translations_source()
710 assert source["common.media.genre.jazz.name"] == "Jazz"
711 # built-in playlists are provider-specific, so they live under the builtin provider
712 assert source["provider.builtin.media.playlist.random_album.name"]
713 assert source["common.media.folder.albums.name"] == "Albums" # browse-folder titles
714 assert source["common.media.recommendations.recommended_tracks.name"] == "Recommended tracks"
715 # the old flat keys are gone (would silently break localization if left behind)
716 for stale in (
717 "common.media.jazz.name", # genre was flat
718 "common.media.albums.name", # browse noun was flat
719 "common.media.recently_played.name", # loose recommendation key was flat
720 "common.media.builtin_playlist.random_album.name", # built-in playlist sub-dict
721 "common.media.playlist.random_album.name", # built-in playlists moved to provider.builtin
722 ):
723 assert stale not in source
724
725
726def test_genre_descriptions_are_authored() -> None:
727 """
728 Genre descriptions are authored centrally under common.media.genre.<slug>.description.
729
730 Migrated from the frontend's genre_descriptions.* keys; the model resolver fills them into
731 a Genre's nested metadata.description, the same way names fill the top-level name field.
732 """
733 source = build_translations_source()
734 assert source["common.media.genre.jazz.description"].startswith("Jazz")
735 # every authored genre name carries a paired description (parity with the migrated set)
736 name_slugs = {
737 key[len("common.media.genre.") : -len(".name")]
738 for key in source
739 if key.startswith("common.media.genre.") and key.endswith(".name")
740 }
741 description_slugs = {
742 key[len("common.media.genre.") : -len(".description")]
743 for key in source
744 if key.startswith("common.media.genre.") and key.endswith(".description")
745 }
746 assert name_slugs == description_slugs
747
748
749def test_genre_description_resolves_via_controller() -> None:
750 """
751 The resolver maps a genre's media.genre.<slug>.description key to the common entry.
752
753 Mirrors how the model's _resolve_translation looks up a genre's nested metadata.description:
754 a relative key plus the item's provider as owner, resolved via the common.* rewrite.
755 """
756 ctrl = _nl_controller()
757 assert (
758 ctrl.get_translation("media.genre.classical.description", "nl", owner="library")
759 == "Klassieke muziek is kunstmuziek."
760 )
761 # a locale without a translation falls back to the English source
762 assert (
763 ctrl.get_translation("media.genre.classical.description", "fr", owner="library")
764 == "Classical music is art music."
765 )
766
767
768async def test_reverse_lookup_media_names() -> None:
769 """A localized media name maps back to its canonical English name for the search fallback."""
770 ctrl = _nl_controller()
771 ctrl.mass = MagicMock()
772 # reverse lookups always use the metadata controller's configured language
773 ctrl.mass.metadata.locale = "nl"
774 # a localized (nl) genre name resolves back to its canonical English name
775 assert await ctrl.reverse_lookup_media_names("klassiek") == {"Classical"}
776 # recommendation/folder names are NOT searchable, so a localized one yields nothing
777 assert await ctrl.reverse_lookup_media_names("onlangs afgespeeld") == set()
778 # a non-matching query yields nothing
779 assert await ctrl.reverse_lookup_media_names("zzznomatch") == set()
780 # English (source) locale: nothing to reverse-translate (literal search already covers it)
781 ctrl.mass.metadata.locale = "en"
782 assert await ctrl.reverse_lookup_media_names("klassiek") == set()
783