Files
shelfmark/tests/core/test_languages.py
Andrew Doering 816a735cde Add a {Language} naming template variable, and consolidate language resolution (#1142)
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>
2026-07-28 15:19:59 -04:00

257 lines
9.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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