mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
Fix localized images selection (#677)
This commit is contained in:
commit
29f38e42ae
@ -10,8 +10,10 @@ LIBRARY_ROOT=./video
|
|||||||
# It will automatically be cleaned up on kyoo's startup/shutdown/runtime.
|
# It will automatically be cleaned up on kyoo's startup/shutdown/runtime.
|
||||||
CACHE_ROOT=/tmp/kyoo_cache
|
CACHE_ROOT=/tmp/kyoo_cache
|
||||||
LIBRARY_LANGUAGES=en
|
LIBRARY_LANGUAGES=en
|
||||||
# A pattern (regex) to ignore video files.
|
# If this is true, kyoo will prefer to download the media in the original language of the item.
|
||||||
LIBRARY_IGNORE_PATTERN=".*/[dD]ownloads?/.*"
|
MEDIA_PREFER_ORIGINAL_LANGUAGE=false
|
||||||
|
# A pattern (regex) to ignore files.
|
||||||
|
LIBRARY_IGNORE_PATTERN=".*/[dD]ownloads?/.*|.*[Tt][Rr][Aa][Ii][Ll][Ee][Rr].*"
|
||||||
|
|
||||||
# If this is true, new accounts wont have any permissions before you approve them in your admin dashboard.
|
# If this is true, new accounts wont have any permissions before you approve them in your admin dashboard.
|
||||||
REQUIRE_ACCOUNT_VERIFICATION=true
|
REQUIRE_ACCOUNT_VERIFICATION=true
|
||||||
|
@ -52,7 +52,7 @@ class Matcher:
|
|||||||
if "mimetype" not in raw or not raw["mimetype"].startswith("video"):
|
if "mimetype" not in raw or not raw["mimetype"].startswith("video"):
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Identied %s: %s", path, raw)
|
logger.info("Identified %s: %s", path, raw)
|
||||||
|
|
||||||
title = raw.get("title")
|
title = raw.get("title")
|
||||||
if not isinstance(title, str):
|
if not isinstance(title, str):
|
||||||
|
@ -4,6 +4,7 @@ from datetime import datetime, timedelta
|
|||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Awaitable, Callable, Dict, List, Optional, Any, TypeVar
|
from typing import Awaitable, Callable, Dict, List, Optional, Any, TypeVar
|
||||||
from itertools import accumulate, zip_longest
|
from itertools import accumulate, zip_longest
|
||||||
|
from langcodes import Language
|
||||||
|
|
||||||
from providers.utils import ProviderError
|
from providers.utils import ProviderError
|
||||||
from matcher.cache import cache
|
from matcher.cache import cache
|
||||||
@ -31,7 +32,7 @@ class TheMovieDatabase(Provider):
|
|||||||
api_key: str,
|
api_key: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._languages = languages
|
self._languages = [Language.get(l) for l in languages]
|
||||||
self._client = client
|
self._client = client
|
||||||
self.base = "https://api.themoviedb.org/3"
|
self.base = "https://api.themoviedb.org/3"
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
@ -78,7 +79,7 @@ class TheMovieDatabase(Provider):
|
|||||||
[self.genre_map[x["id"]] for x in genres if x["id"] in self.genre_map]
|
[self.genre_map[x["id"]] for x in genres if x["id"] in self.genre_map]
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_languages(self, *args):
|
def get_languages(self, *args) -> list[Language]:
|
||||||
return self._languages + list(args)
|
return self._languages + list(args)
|
||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
@ -99,16 +100,17 @@ class TheMovieDatabase(Provider):
|
|||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
def merge_translations(self, host, translations, *, languages: list[str]):
|
def merge_translations(self, host, translations, *, languages: list[Language]):
|
||||||
host.translations = {
|
host.translations = {
|
||||||
k: v.translations[k] for k, v in zip(languages, translations)
|
k.to_tag(): v.translations[k.to_tag()]
|
||||||
|
for k, v in zip(languages, translations)
|
||||||
}
|
}
|
||||||
return host
|
return host
|
||||||
|
|
||||||
async def process_translations(
|
async def process_translations(
|
||||||
self,
|
self,
|
||||||
for_language: Callable[[str], Awaitable[T]],
|
for_language: Callable[[str], Awaitable[T]],
|
||||||
languages: list[str],
|
languages: list[Language],
|
||||||
post_merge: Callable[[T, list[T]], T] | None = None,
|
post_merge: Callable[[T, list[T]], T] | None = None,
|
||||||
) -> T:
|
) -> T:
|
||||||
tasks = map(lambda lng: for_language(lng), languages)
|
tasks = map(lambda lng: for_language(lng), languages)
|
||||||
@ -138,6 +140,62 @@ class TheMovieDatabase(Provider):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_best_image(
|
||||||
|
self, item: dict[str, Any], lng: Language, key: str
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Retrieves the best available images for a item based on localization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item (dict): A dictionary containing item information, including images and language details.
|
||||||
|
lng (Language): The preferred language for the images.
|
||||||
|
key (str): The key to access the images in the item dictionary. (e.g. "posters", "backdrops", "logos")
|
||||||
|
Returns:
|
||||||
|
list: A list of images, prioritized by localization, original language, and any available image.
|
||||||
|
"""
|
||||||
|
# Order images by size and vote average
|
||||||
|
item["images"][key] = sorted(
|
||||||
|
item["images"][key],
|
||||||
|
key=lambda x: (x.get("vote_average", 0), x.get("width", 0)),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 1: Try to get localized images
|
||||||
|
localized_images = [
|
||||||
|
image
|
||||||
|
for image in item["images"][key]
|
||||||
|
if image.get("iso_639_1") == lng.language
|
||||||
|
]
|
||||||
|
|
||||||
|
# Step 2: If no localized images, try images in the original language
|
||||||
|
if not localized_images:
|
||||||
|
localized_images = [
|
||||||
|
image
|
||||||
|
for image in item["images"][key]
|
||||||
|
if image.get("iso_639_1") == item.get("original_language")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Step 3: If still no images, use any available images
|
||||||
|
if not localized_images:
|
||||||
|
localized_images = item["images"][key]
|
||||||
|
|
||||||
|
# Step 4: If there are no images at all, fallback to _path attribute.
|
||||||
|
if not localized_images:
|
||||||
|
localized_images = self._get_image_fallback(item, key)
|
||||||
|
|
||||||
|
return self.get_image(localized_images)
|
||||||
|
|
||||||
|
def _get_image_fallback(self, item: dict[str, Any], key: str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Fallback to _path attribute if there are no images available in the images list.
|
||||||
|
"""
|
||||||
|
if key == "posters":
|
||||||
|
return [{"file_path": item.get("poster_path")}]
|
||||||
|
elif key == "backdrops":
|
||||||
|
return [{"file_path": item.get("backdrop_path")}]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
async def search_movie(self, name: str, year: Optional[int]) -> Movie:
|
async def search_movie(self, name: str, year: Optional[int]) -> Movie:
|
||||||
search_results = (
|
search_results = (
|
||||||
await self.get("search/movie", params={"query": name, "year": year})
|
await self.get("search/movie", params={"query": name, "year": year})
|
||||||
@ -145,17 +203,23 @@ class TheMovieDatabase(Provider):
|
|||||||
if len(search_results) == 0:
|
if len(search_results) == 0:
|
||||||
raise ProviderError(f"No result for a movie named: {name}")
|
raise ProviderError(f"No result for a movie named: {name}")
|
||||||
search = self.get_best_result(search_results, name, year)
|
search = self.get_best_result(search_results, name, year)
|
||||||
return await self.identify_movie(search["id"])
|
original_language = Language.get(search["original_language"])
|
||||||
|
return await self.identify_movie(
|
||||||
|
search["id"], original_language=original_language
|
||||||
|
)
|
||||||
|
|
||||||
async def identify_movie(self, movie_id: str) -> Movie:
|
async def identify_movie(
|
||||||
|
self, movie_id: str, original_language: Optional[Language] = None
|
||||||
|
) -> Movie:
|
||||||
languages = self.get_languages()
|
languages = self.get_languages()
|
||||||
|
|
||||||
async def for_language(lng: str) -> Movie:
|
async def for_language(lng: Language) -> Movie:
|
||||||
movie = await self.get(
|
movie = await self.get(
|
||||||
f"movie/{movie_id}",
|
f"movie/{movie_id}",
|
||||||
params={
|
params={
|
||||||
"language": lng,
|
"language": lng.to_tag(),
|
||||||
"append_to_response": "alternative_titles,videos,credits,keywords,images",
|
"append_to_response": "alternative_titles,videos,credits,keywords,images",
|
||||||
|
"include_image_language": f"{lng.language},null,{original_language.language if original_language else ''}",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
logger.debug("TMDb responded: %s", movie)
|
logger.debug("TMDb responded: %s", movie)
|
||||||
@ -210,30 +274,16 @@ class TheMovieDatabase(Provider):
|
|||||||
tagline=movie["tagline"] if movie["tagline"] else None,
|
tagline=movie["tagline"] if movie["tagline"] else None,
|
||||||
tags=list(map(lambda x: x["name"], movie["keywords"]["keywords"])),
|
tags=list(map(lambda x: x["name"], movie["keywords"]["keywords"])),
|
||||||
overview=movie["overview"],
|
overview=movie["overview"],
|
||||||
posters=self.get_image(
|
posters=self.get_best_image(movie, lng, "posters"),
|
||||||
movie["images"]["posters"]
|
logos=self.get_best_image(movie, lng, "logos"),
|
||||||
+ (
|
thumbnails=self.get_best_image(movie, lng, "backdrops"),
|
||||||
[{"file_path": movie["poster_path"]}]
|
|
||||||
if lng == movie["original_language"]
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
),
|
|
||||||
logos=self.get_image(movie["images"]["logos"]),
|
|
||||||
thumbnails=self.get_image(
|
|
||||||
movie["images"]["backdrops"]
|
|
||||||
+ (
|
|
||||||
[{"file_path": movie["backdrop_path"]}]
|
|
||||||
if lng == movie["original_language"]
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
),
|
|
||||||
trailers=[
|
trailers=[
|
||||||
f"https://www.youtube.com/watch?v={x['key']}"
|
f"https://www.youtube.com/watch?v={x['key']}"
|
||||||
for x in movie["videos"]["results"]
|
for x in movie["videos"]["results"]
|
||||||
if x["type"] == "Trailer" and x["site"] == "YouTube"
|
if x["type"] == "Trailer" and x["site"] == "YouTube"
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
ret.translations = {lng: translation}
|
ret.translations = {lng.to_tag(): translation}
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
ret = await self.process_translations(for_language, languages)
|
ret = await self.process_translations(for_language, languages)
|
||||||
@ -241,9 +291,10 @@ class TheMovieDatabase(Provider):
|
|||||||
ret.original_language is not None
|
ret.original_language is not None
|
||||||
and ret.original_language not in ret.translations
|
and ret.original_language not in ret.translations
|
||||||
):
|
):
|
||||||
ret.translations[ret.original_language] = (
|
orig_language = Language.get(ret.original_language)
|
||||||
await for_language(ret.original_language)
|
ret.translations[orig_language.to_tag()] = (
|
||||||
).translations[ret.original_language]
|
await for_language(orig_language)
|
||||||
|
).translations[orig_language.to_tag()]
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@cache(ttl=timedelta(days=1))
|
@cache(ttl=timedelta(days=1))
|
||||||
@ -253,12 +304,13 @@ class TheMovieDatabase(Provider):
|
|||||||
) -> Show:
|
) -> Show:
|
||||||
languages = self.get_languages()
|
languages = self.get_languages()
|
||||||
|
|
||||||
async def for_language(lng: str) -> Show:
|
async def for_language(lng: Language) -> Show:
|
||||||
show = await self.get(
|
show = await self.get(
|
||||||
f"tv/{show_id}",
|
f"tv/{show_id}",
|
||||||
params={
|
params={
|
||||||
"language": lng,
|
"language": lng.to_tag(),
|
||||||
"append_to_response": "alternative_titles,videos,credits,keywords,images,external_ids",
|
"append_to_response": "alternative_titles,videos,credits,keywords,images,external_ids",
|
||||||
|
"include_image_language": f"{lng.language},null,en",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
logger.debug("TMDb responded: %s", show)
|
logger.debug("TMDb responded: %s", show)
|
||||||
@ -311,30 +363,16 @@ class TheMovieDatabase(Provider):
|
|||||||
tagline=show["tagline"] if show["tagline"] else None,
|
tagline=show["tagline"] if show["tagline"] else None,
|
||||||
tags=list(map(lambda x: x["name"], show["keywords"]["results"])),
|
tags=list(map(lambda x: x["name"], show["keywords"]["results"])),
|
||||||
overview=show["overview"],
|
overview=show["overview"],
|
||||||
posters=self.get_image(
|
posters=self.get_best_image(show, lng, "posters"),
|
||||||
show["images"]["posters"]
|
logos=self.get_best_image(show, lng, "logos"),
|
||||||
+ (
|
thumbnails=self.get_best_image(show, lng, "backdrops"),
|
||||||
[{"file_path": show["poster_path"]}]
|
|
||||||
if lng == show["original_language"]
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
),
|
|
||||||
logos=self.get_image(show["images"]["logos"]),
|
|
||||||
thumbnails=self.get_image(
|
|
||||||
show["images"]["backdrops"]
|
|
||||||
+ (
|
|
||||||
[{"file_path": show["backdrop_path"]}]
|
|
||||||
if lng == show["original_language"]
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
),
|
|
||||||
trailers=[
|
trailers=[
|
||||||
f"https://www.youtube.com/watch?v={x['key']}"
|
f"https://www.youtube.com/watch?v={x['key']}"
|
||||||
for x in show["videos"]["results"]
|
for x in show["videos"]["results"]
|
||||||
if x["type"] == "Trailer" and x["site"] == "YouTube"
|
if x["type"] == "Trailer" and x["site"] == "YouTube"
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
ret.translations = {lng: translation}
|
ret.translations = {lng.to_tag(): translation}
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def merge_seasons_translations(item: Show, items: list[Show]) -> Show:
|
def merge_seasons_translations(item: Show, items: list[Show]) -> Show:
|
||||||
@ -362,13 +400,14 @@ class TheMovieDatabase(Provider):
|
|||||||
ret.original_language is not None
|
ret.original_language is not None
|
||||||
and ret.original_language not in ret.translations
|
and ret.original_language not in ret.translations
|
||||||
):
|
):
|
||||||
ret.translations[ret.original_language] = (
|
orig_language = Language.get(ret.original_language)
|
||||||
await for_language(ret.original_language)
|
ret.translations[orig_language.to_tag()] = (
|
||||||
).translations[ret.original_language]
|
await for_language(orig_language)
|
||||||
|
).translations[orig_language.to_tag()]
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def to_season(
|
def to_season(
|
||||||
self, season: dict[str, Any], *, language: str, show_id: str
|
self, season: dict[str, Any], *, language: Language, show_id: str
|
||||||
) -> Season:
|
) -> Season:
|
||||||
return Season(
|
return Season(
|
||||||
season_number=season["season_number"],
|
season_number=season["season_number"],
|
||||||
@ -384,7 +423,7 @@ class TheMovieDatabase(Provider):
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
translations={
|
translations={
|
||||||
language: SeasonTranslation(
|
language.to_tag(): SeasonTranslation(
|
||||||
name=season["name"],
|
name=season["name"],
|
||||||
overview=season["overview"],
|
overview=season["overview"],
|
||||||
posters=[
|
posters=[
|
||||||
@ -456,19 +495,19 @@ class TheMovieDatabase(Provider):
|
|||||||
async def identify_episode(
|
async def identify_episode(
|
||||||
self, show_id: str, season: Optional[int], episode_nbr: int, absolute: int
|
self, show_id: str, season: Optional[int], episode_nbr: int, absolute: int
|
||||||
) -> Episode:
|
) -> Episode:
|
||||||
async def for_language(lng: str) -> Episode:
|
async def for_language(lng: Language) -> Episode:
|
||||||
try:
|
try:
|
||||||
episode = await self.get(
|
episode = await self.get(
|
||||||
f"tv/{show_id}/season/{season}/episode/{episode_nbr}",
|
f"tv/{show_id}/season/{season}/episode/{episode_nbr}",
|
||||||
params={
|
params={
|
||||||
"language": lng,
|
"language": lng.to_tag(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except:
|
except:
|
||||||
episode = await self.get(
|
episode = await self.get(
|
||||||
f"tv/{show_id}/season/{season}/episode/{absolute}",
|
f"tv/{show_id}/season/{season}/episode/{absolute}",
|
||||||
params={
|
params={
|
||||||
"language": lng,
|
"language": lng.to_tag(),
|
||||||
},
|
},
|
||||||
not_found_fail=f"Could not find episode {episode_nbr} of season {season} of serie {show_id} (absolute: {absolute})",
|
not_found_fail=f"Could not find episode {episode_nbr} of season {season} of serie {show_id} (absolute: {absolute})",
|
||||||
)
|
)
|
||||||
@ -509,7 +548,7 @@ class TheMovieDatabase(Provider):
|
|||||||
name=episode["name"],
|
name=episode["name"],
|
||||||
overview=episode["overview"],
|
overview=episode["overview"],
|
||||||
)
|
)
|
||||||
ret.translations = {lng: translation}
|
ret.translations = {lng.to_tag(): translation}
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
return await self.process_translations(for_language, self.get_languages())
|
return await self.process_translations(for_language, self.get_languages())
|
||||||
@ -698,11 +737,13 @@ class TheMovieDatabase(Provider):
|
|||||||
async def identify_collection(self, provider_id: str) -> Collection:
|
async def identify_collection(self, provider_id: str) -> Collection:
|
||||||
languages = self.get_languages()
|
languages = self.get_languages()
|
||||||
|
|
||||||
async def for_language(lng: str) -> Collection:
|
async def for_language(lng: Language) -> Collection:
|
||||||
collection = await self.get(
|
collection = await self.get(
|
||||||
f"collection/{provider_id}",
|
f"collection/{provider_id}",
|
||||||
params={
|
params={
|
||||||
"language": lng,
|
"language": lng.to_tag(),
|
||||||
|
"append_to_response": "images",
|
||||||
|
"include_image_language": f"{lng.language},null,en",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
logger.debug("TMDb responded: %s", collection)
|
logger.debug("TMDb responded: %s", collection)
|
||||||
@ -718,15 +759,11 @@ class TheMovieDatabase(Provider):
|
|||||||
translation = CollectionTranslation(
|
translation = CollectionTranslation(
|
||||||
name=collection["name"],
|
name=collection["name"],
|
||||||
overview=collection["overview"],
|
overview=collection["overview"],
|
||||||
posters=[
|
posters=self.get_best_image(collection, lng, "posters"),
|
||||||
f"https://image.tmdb.org/t/p/original{collection['poster_path']}"
|
|
||||||
],
|
|
||||||
logos=[],
|
logos=[],
|
||||||
thumbnails=[
|
thumbnails=self.get_best_image(collection, lng, "backdrops"),
|
||||||
f"https://image.tmdb.org/t/p/original{collection['backdrop_path']}"
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
ret.translations = {lng: translation}
|
ret.translations = {lng.to_tag(): translation}
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
return await self.process_translations(for_language, languages)
|
return await self.process_translations(for_language, languages)
|
||||||
|
@ -28,6 +28,9 @@ def normalize_lang(lang: str) -> str:
|
|||||||
|
|
||||||
# For now, the API of kyoo only support one language so we remove the others.
|
# For now, the API of kyoo only support one language so we remove the others.
|
||||||
default_languages = os.environ.get("LIBRARY_LANGUAGES", "").split(",")
|
default_languages = os.environ.get("LIBRARY_LANGUAGES", "").split(",")
|
||||||
|
media_prefer_original_language = (
|
||||||
|
os.environ.get("MEDIA_PREFER_ORIGINAL_LANGUAGE", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def sort_translations(
|
def sort_translations(
|
||||||
@ -64,7 +67,9 @@ def select_image(
|
|||||||
chain(
|
chain(
|
||||||
*(
|
*(
|
||||||
getattr(trans, kind)
|
getattr(trans, kind)
|
||||||
for trans in sort_translations(value, prefer_orginal=True)
|
for trans in sort_translations(
|
||||||
|
value, prefer_orginal=media_prefer_original_language
|
||||||
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user