diff --git a/app/domain/projection/douban.py b/app/domain/projection/douban.py index fb5a103e8..de97c9827 100644 --- a/app/domain/projection/douban.py +++ b/app/domain/projection/douban.py @@ -6,10 +6,25 @@ import re from collections.abc import Mapping from typing import Any +from app.domain.classification.vocabulary import LANGUAGE_NAMES from app.domain.metainfo import MetaInfo from app.domain.projection.mapping import ProjectionBuilder from app.schemas.types import MediaSource, MediaType +_DOUBAN_LANGUAGE_CODES: dict[str, str] = { + **{name.casefold(): code for code, name in LANGUAGE_NAMES.items()}, + "汉语": "zh", + "汉语普通话": "zh", + "普通话": "zh", + "国语": "zh", + "简体中文": "zh", + "繁体中文": "zh", + "粤语": "cn", + "广东话": "cn", + "藏文": "bo", + "壮文": "za", +} + def _media_type(info: Mapping[str, Any]) -> MediaType | None: """从豆瓣多种详情形态解析标准媒体类型。""" @@ -67,6 +82,28 @@ def _aliases(info: Mapping[str, Any]) -> list[str]: return [re.sub(r"\([港台豆友译名]+\)", "", str(alias)) for alias in info.get("aka") or []] +def _original_language(info: Mapping[str, Any]) -> str | None: + """将豆瓣返回的首个语言名称转换为分类规则使用的代码。""" + languages = info.get("languages") + if isinstance(languages, str): + language_values: list[Any] = [languages] + elif isinstance(languages, (list, tuple)): + language_values = list(languages) + else: + return None + for language in language_values: + if not isinstance(language, str): + continue + value = language.strip() + if not value: + continue + normalized = value.casefold() + if re.fullmatch(r"[a-z]{2}(?:[-_]\w+)?", normalized): + return normalized.split("-", 1)[0].split("_", 1)[0] + return _DOUBAN_LANGUAGE_CODES.get(normalized, value) + return None + + def project( current: Mapping[str, Any], info: Mapping[str, Any], @@ -87,6 +124,7 @@ def project( builder.set_missing("title", info.get("title")) builder.set_missing("en_title", info.get("original_title")) builder.set_missing("original_title", info.get("original_title")) + builder.set_missing("original_language", _original_language(info)) if not builder.get("year"): year = str(info.get("year"))[:4] if info.get("year") else None diff --git a/tests/test_media_source_projection.py b/tests/test_media_source_projection.py index fdbc12a58..aa24fa550 100644 --- a/tests/test_media_source_projection.py +++ b/tests/test_media_source_projection.py @@ -117,6 +117,7 @@ def test_douban_projection_is_complete_and_does_not_mutate_source() -> None: "genres": ["剧情"], "durations": ["45分钟"], "countries": ["中国大陆"], + "languages": ["汉语普通话"], } original = deepcopy(source) @@ -139,6 +140,25 @@ def test_douban_projection_is_complete_and_does_not_mutate_source() -> None: assert media.genres == [{"id": "剧情", "name": "剧情"}] assert media.runtime == 45 assert media.production_countries == [{"id": "中国大陆", "name": "中国大陆"}] + assert media.original_language == "zh" + + +@pytest.mark.parametrize( + ("language", "expected"), + [("汉语普通话", "zh"), ("粤语", "cn"), ("英语", "en"), ("ja", "ja")], +) +def test_douban_projection_maps_language_names_to_codes(language: str, expected: str) -> None: + """豆瓣常见语言名称应转换为稳定的分类语言代码。""" + media = MediaInfo( + douban_info={ + "id": "1292052", + "title": "示例电影", + "type": "movie", + "languages": [language], + } + ) + + assert media.original_language == expected def test_bangumi_projection_is_complete_and_does_not_mutate_source() -> None: