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