mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-07-31 06:46:39 -04:00
Fixes #1138 Fixes #1141 ## Problem Two language editions of one book resolve to the same canonical title, so they render to the same path and the second gets a `_1` collision suffix. Audiobookshelf treats a folder as exactly one library item, so the pair becomes a single book with both files as tracks and a summed runtime. Shelfmark already parses and displays the language. It just never reached the template engine. ## `{Language}` template variable A template like `{Author}/{Title}{ (Language)}/{Author} - {Title}` now yields: ``` /library/J K Rowling/Harry Potter (sv)/J K Rowling - Harry Potter.m4b /library/J K Rowling/Harry Potter/J K Rowling - Harry Potter.m4b ``` The untagged edition's path is byte-identical to today, so no existing layout shifts. Three details worth flagging: **The value is casefolded.** On a case-insensitive filesystem `(SV)` and `(sv)` would collapse back into one folder, reintroducing the exact collision being fixed. **Values meaning "we don't know" render nothing** rather than producing `Project Hail Mary (unknown)` folders. Anna's Archive reports that string literally (`direct_download.py`, `language = detected or "unknown"`). **The frontend wasn't sending the release language at all**, so the token would have stayed empty for exactly the audiobook sources in the report. Prowlarr and AudiobookBay do not put language in `extra` the way `direct_download` does, hence the payload plumbing. It reads `release.language`, never `book.language` — the latter is the provider's canonical edition and would mislabel a translation, with a regression test for that specifically. Not gated to audiobooks: Calibre-Web-Automated stages ingested files by basename and discards folder structure, so the rename (filename) template is the only lever those users have. Verified that form works: `J K Rowling - Harry Potter (sv).epub`. ## Language consolidation (#1141) Three release sources each carried their own alias map, all resolving to the same ISO 639-1 codes, alongside a bundled database that only one of them used. Adding a language meant editing three places. Aliases now live in `data/book-languages.json` beside the code and name they belong to, and `shelfmark/core/languages.py` resolves any of them — two-letter code, ISO 639-2 three-letter in either the bibliographic or terminological form, or English name. Prowlarr and AudiobookBay drop their tables. Direct Download keeps its own path-parsing heuristics, including the ambiguous short codes that collide with English words (`de`, `en`, `no`, `in`), and takes only the alias data. This also closes a coverage gap. MyAnonamouse offers 62 languages; Prowlarr mapped 37, and an unmapped code is *dropped* rather than passed through, so the other 25 carried no language at all — leaving `{Language}` empty and the collision unfixed for Latin, Farsi, Tamil, Urdu and the rest. Seven languages MAM offers had no database entry at all: Bosnian, Burmese, Estonian, Icelandic, Manx, Scottish Gaelic, Sanskrit. Also fixes the Traditional Chinese code, which used a U+2011 non-breaking hyphen. Nothing compares against the ASCII spelling today so it was latent, but it would silently defeat the first thing that did. ## Validation Verified end to end against a live Prowlarr and MyAnonamouse, not just unit tests. A real search returning both an English and a Swedish edition, through the actual `queue_release` → `DownloadTask` → naming path: ``` STEP 1 real MAM search -> 37 releases, languages: ['en', 'sv'] STEP 3 queue_release -> task.language='sv' STEP 4 build_metadata_dict -> metadata['Language']='sv' STEP 5 build_library_path -> /library/J K Rowling/Harry Potter (sv)/... two language editions resolve to DIFFERENT folders: True ``` The refactor is pinned by a snapshot of both per-source maps taken *before* they were deleted. All 131 aliases are asserted to still resolve to the same code, one parametrised test each, so a regression names the specific alias. Also verified: the filename-only template, the retry round-trip (`serialize_task_for_retry` → `_restore_task_from_retry_payload`, plus a legacy payload with no `language` key), and placeholder handling. Added a `KNOWN_TOKENS` ordering invariant test — `find_placeholder()` does a substring `.find()` in list order and nothing protected that contract, so a future token in the wrong position could silently shadow an existing one. And a lockstep guard on the frontend, since `KNOWN_TOKENS` is hand-duplicated in TypeScript. **One caveat worth stating.** Three MAM codes are confirmed by observation (`ENG`→`en`, `SWE`→`sv`, `MAL`→`ml`, the last from a real `[MAL / EPUB]` Tagore release). The remaining ~59 are derived from ISO 639-2 rather than observed, because MAM's catalogue is overwhelmingly English — enabling 27 extra languages still yielded only one non-English hit across 258 results. Mitigated rather than closed: both 639-2 variants are present for every language where they differ, and a wrong alias is an unused entry while a missing one loses the language. Happy to correct any code a maintainer knows differs. ## Test results 2056 Python tests pass (up from 1906). Frontend typecheck, lint, format and 126 unit tests pass. Pre-existing failures on my machine, unchanged by this branch and unrelated: `tests/bypass/` needs `seleniumbase`, and `tests/config/test_entrypoint_permissions.py` uses bash-4 syntax that macOS bash 3.2 rejects. --------- Co-authored-by: delize <4028612+delize@users.noreply.github.com> Co-authored-by: CaliBrain <calibrain@l4n.xyz>
257 lines
9.2 KiB
Python
257 lines
9.2 KiB
Python
"""Tests for the shared language resolution used by every release source."""
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from shelfmark.core.languages import (
|
||
LANGUAGE_DATA_PATH,
|
||
known_language_codes,
|
||
language_alias_map,
|
||
language_name,
|
||
normalize_language,
|
||
supported_book_languages,
|
||
)
|
||
|
||
BASELINE = json.loads(
|
||
(Path(__file__).parent / "fixtures" / "language_alias_baseline.json").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
)
|
||
|
||
|
||
class TestBaselineEquivalence:
|
||
"""Every alias the per-source maps used to handle must still resolve the same.
|
||
|
||
These maps lived in prowlarr/source.py and audiobookbay/source.py before they
|
||
were consolidated here. The fixture is a frozen snapshot taken before the
|
||
move, so a regression shows up as a concrete alias rather than a vague
|
||
behaviour change.
|
||
"""
|
||
|
||
@pytest.mark.parametrize(
|
||
("alias", "expected"), sorted(BASELINE["prowlarr_three_letter"].items())
|
||
)
|
||
def test_prowlarr_three_letter_aliases_unchanged(self, alias, expected):
|
||
assert normalize_language(alias) == expected
|
||
|
||
@pytest.mark.parametrize(("alias", "expected"), sorted(BASELINE["audiobookbay_names"].items()))
|
||
def test_audiobookbay_names_unchanged(self, alias, expected):
|
||
assert normalize_language(alias) == expected
|
||
|
||
@pytest.mark.parametrize(
|
||
("alias", "expected"), sorted(BASELINE["direct_download_derived"].items())
|
||
)
|
||
def test_direct_download_derived_aliases_unchanged(self, alias, expected):
|
||
# Direct Download built its aliases from the data file rather than a
|
||
# literal map, so consolidating silently dropped the spellings it
|
||
# derived -- notably the underscore form of a hyphenated code.
|
||
assert normalize_language(alias) == expected
|
||
|
||
|
||
class TestNormalizeLanguage:
|
||
def test_accepts_two_letter_codes(self):
|
||
assert normalize_language("en") == "en"
|
||
assert normalize_language("sv") == "sv"
|
||
|
||
def test_accepts_three_letter_codes_in_both_iso_639_2_forms(self):
|
||
# Bibliographic and terminological forms differ for these.
|
||
assert normalize_language("ger") == normalize_language("deu") == "de"
|
||
assert normalize_language("fre") == normalize_language("fra") == "fr"
|
||
assert normalize_language("per") == normalize_language("fas") == "fa"
|
||
assert normalize_language("ice") == normalize_language("isl") == "is"
|
||
assert normalize_language("may") == normalize_language("msa") == "ms"
|
||
|
||
def test_accepts_english_names(self):
|
||
assert normalize_language("Swedish") == "sv"
|
||
assert normalize_language("Scottish Gaelic") == "gd"
|
||
|
||
def test_is_case_and_whitespace_insensitive(self):
|
||
assert normalize_language(" ENG ") == "en"
|
||
assert normalize_language("sWeDiSh") == "sv"
|
||
|
||
def test_returns_none_for_placeholders(self):
|
||
for placeholder in ("unknown", "unk", "n/a", "na", "-", "--", "none", "null", "", " "):
|
||
assert normalize_language(placeholder) is None, placeholder
|
||
|
||
def test_returns_none_for_unknown_values(self):
|
||
assert normalize_language("xyz") is None
|
||
assert normalize_language("Klingon") is None
|
||
|
||
def test_returns_none_for_none(self):
|
||
assert normalize_language(None) is None
|
||
|
||
|
||
class TestLanguageData:
|
||
def test_every_alias_resolves_to_a_known_code(self):
|
||
codes = known_language_codes()
|
||
assert set(language_alias_map().values()) <= codes
|
||
|
||
def test_codes_are_ascii(self):
|
||
# "zh-Hant" once used a U+2011 non-breaking hyphen, which silently
|
||
# defeats any comparison against the normal spelling.
|
||
entries = json.loads(LANGUAGE_DATA_PATH.read_text(encoding="utf-8"))
|
||
assert [e["code"] for e in entries if not e["code"].isascii()] == []
|
||
|
||
def test_codes_are_unique(self):
|
||
entries = json.loads(LANGUAGE_DATA_PATH.read_text(encoding="utf-8"))
|
||
codes = [e["code"] for e in entries]
|
||
assert len(codes) == len(set(codes))
|
||
|
||
def test_language_name_round_trips(self):
|
||
assert language_name("sv") == "Swedish"
|
||
assert language_name("ml") == "Malayalam"
|
||
assert language_name("zzz") is None
|
||
assert language_name(None) is None
|
||
|
||
|
||
class TestMyAnonamouseCoverage:
|
||
"""MyAnonamouse offers 62 languages and Prowlarr passes its code through
|
||
untransformed, so every one has to resolve here or the language is lost."""
|
||
|
||
# Observed in live MyAnonamouse data via Prowlarr.
|
||
OBSERVED = {"ENG": "en", "SWE": "sv", "MAL": "ml"}
|
||
|
||
@pytest.mark.parametrize(("tag", "expected"), sorted(OBSERVED.items()))
|
||
def test_observed_tags_resolve(self, tag, expected):
|
||
assert normalize_language(tag) == expected
|
||
|
||
def test_every_offered_language_resolves(self):
|
||
# Names as MyAnonamouse's own searchLanguages selector lists them.
|
||
offered = [
|
||
"English",
|
||
"Afrikaans",
|
||
"Arabic",
|
||
"Bengali",
|
||
"Bosnian",
|
||
"Bulgarian",
|
||
"Burmese",
|
||
"Catalan",
|
||
"Chinese",
|
||
"Croatian",
|
||
"Czech",
|
||
"Danish",
|
||
"Dutch",
|
||
"Estonian",
|
||
"Farsi",
|
||
"Finnish",
|
||
"French",
|
||
"German",
|
||
"Greek",
|
||
"Gujarati",
|
||
"Hebrew",
|
||
"Hindi",
|
||
"Hungarian",
|
||
"Icelandic",
|
||
"Indonesian",
|
||
"Irish",
|
||
"Italian",
|
||
"Japanese",
|
||
"Javanese",
|
||
"Kannada",
|
||
"Korean",
|
||
"Lithuanian",
|
||
"Latin",
|
||
"Latvian",
|
||
"Malay",
|
||
"Malayalam",
|
||
"Manx",
|
||
"Marathi",
|
||
"Norwegian",
|
||
"Polish",
|
||
"Portuguese",
|
||
"Punjabi",
|
||
"Romanian",
|
||
"Russian",
|
||
"Scottish Gaelic",
|
||
"Sanskrit",
|
||
"Serbian",
|
||
"Slovenian",
|
||
"Spanish",
|
||
"Swedish",
|
||
"Tagalog",
|
||
"Tamil",
|
||
"Telugu",
|
||
"Thai",
|
||
"Turkish",
|
||
"Ukrainian",
|
||
"Urdu",
|
||
"Vietnamese",
|
||
]
|
||
unresolved = [name for name in offered if normalize_language(name) is None]
|
||
assert unresolved == []
|
||
|
||
|
||
class TestSupportedBookLanguages:
|
||
"""What the settings dropdown and /api/config expose to clients."""
|
||
|
||
def test_exposes_only_the_fields_clients_declare(self):
|
||
# The frontend Language type is {code, language}. Aliases are an
|
||
# implementation detail and would bloat every /api/config response.
|
||
entries = supported_book_languages()
|
||
assert entries
|
||
assert all(set(e) == {"code", "language"} for e in entries)
|
||
|
||
def test_covers_every_known_code(self):
|
||
assert {e["code"] for e in supported_book_languages()} == set(known_language_codes())
|
||
|
||
|
||
class TestLegacyTraditionalChineseCode:
|
||
"""Traditional Chinese was stored with a U+2011 non-breaking hyphen.
|
||
|
||
The canonical code is now the ASCII spelling, but anything persisted
|
||
earlier still carries U+2011, so both have to resolve to the same language
|
||
or those users lose their selection (reported on PR #1142).
|
||
"""
|
||
|
||
LEGACY = "zh\u2011Hant"
|
||
CANONICAL = "zh-Hant"
|
||
|
||
def test_the_legacy_spelling_still_resolves(self):
|
||
assert normalize_language(self.LEGACY) == self.CANONICAL
|
||
|
||
def test_both_spellings_are_the_same_language(self):
|
||
assert normalize_language(self.LEGACY) == normalize_language(self.CANONICAL)
|
||
|
||
def test_the_legacy_spelling_really_does_use_a_different_character(self):
|
||
# Guards the test itself: if this ever became a plain hyphen the two
|
||
# cases above would pass for the wrong reason.
|
||
assert self.LEGACY != self.CANONICAL
|
||
assert not self.LEGACY.isascii()
|
||
|
||
@pytest.mark.parametrize("dash", ["-", "\u2010", "\u2011", "\u2012", "\u2013", "\u2014"])
|
||
def test_any_dash_variant_resolves(self, dash):
|
||
assert normalize_language(f"zh{dash}Hant") == self.CANONICAL
|
||
|
||
|
||
class TestCodesDoNotShadowEachOther:
|
||
"""A code must never resolve to a different language than itself.
|
||
|
||
'zh' and 'zh-Hant' are distinct entries; registering the base of a
|
||
hyphenated code as an alias made 'zh' resolve correctly only because
|
||
Chinese happens to appear first in the data file.
|
||
"""
|
||
|
||
def test_chinese_does_not_resolve_to_traditional_chinese(self):
|
||
assert normalize_language("zh") == "zh"
|
||
assert normalize_language("zh-Hant") == "zh-Hant"
|
||
|
||
def test_every_code_resolves_to_itself(self):
|
||
for code in known_language_codes():
|
||
assert normalize_language(code) == code, f"{code} resolved elsewhere"
|
||
|
||
|
||
class TestSubtagSeparators:
|
||
"""The U+2011 in the old Traditional Chinese code renders close enough to
|
||
both a hyphen and an underscore that either is a plausible thing to type."""
|
||
|
||
@pytest.mark.parametrize("separator", ["-", "_", "‐", "‑", "–", "—"])
|
||
def test_any_separator_spelling_resolves(self, separator):
|
||
assert normalize_language(f"zh{separator}Hant") == "zh-Hant"
|
||
|
||
def test_separators_do_not_merge_unrelated_codes(self):
|
||
# Folding a separator must not make one language answer to another.
|
||
assert normalize_language("zh") == "zh"
|
||
assert normalize_language("en_GB") is None
|