Merge pull request #270 from aasmoe/feat/multi-language-metadata

Add Feature: multi language metadata
This commit is contained in:
Maximilian Dorninger
2025-12-22 20:07:57 +01:00
committed by GitHub
19 changed files with 237 additions and 52 deletions

View File

@@ -17,6 +17,26 @@ If you want to use your own TMDB relay service, set this to the URL of your own
- **Default:** `https://metadata-relay.dorninger.co/tmdb`
- **Example:** `https://your-own-relay.example.com/tmdb`
### `primary_languages`
If a TV show/movie's original language is in this list, metadata will be displayed and fetched in that language. Torrent searches done in Standard Mode uses the same fetched metadata, so if you use any language-specific tracker, you may enter the language here to get the desired search results.
Otherwise, `default_language` will be used.
**Format: ISO 639-1 codes (2 letters). Full list: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes**
- **Default:** `[]`
- **Example:** `["no", "de", "es"]`
### `default_language`
<warning>
`default_language` sets the TMDB `language` paramater when searching and adding TV shows and movies. If TMDB does not find a matching translation, metadata in the <strong>original language</strong> will be fetched with no option for a fallback language. It is therefore highly advised to only use "broad" languages. For most use cases, the default setting is safest.
</warning>
**Format: ISO 639-1 codes (2 letters).**
- **Default:** `en`
## TVDB Settings (`[metadata.tvdb]`)
<warning>

View File

@@ -0,0 +1,42 @@
"""add original_language columns to show and movie tables
Revision ID: 16e78af9e5bf
Revises: eb0bd3cc1852
Create Date: 2025-12-13 18:47:02.146038
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '16e78af9e5bf'
down_revision: Union[str, None] = 'eb0bd3cc1852'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# Add original_language column to show table
op.add_column(
'show',
sa.Column('original_language', sa.String(10), nullable=True)
)
# Add original_language column to movie table
op.add_column(
'movie',
sa.Column('original_language', sa.String(10), nullable=True)
)
def downgrade() -> None:
"""Downgrade schema."""
# Remove original_language column from movie table
op.drop_column('movie', 'original_language')
# Remove original_language column from show table
op.drop_column('show', 'original_language')

View File

@@ -172,6 +172,8 @@ rule_names = ["prefer_h265", "avoid_cam", "reject_non_freeleech"]
[metadata]
[metadata.tmdb]
tmdb_relay_url = "https://metadata-relay.dorninger.co/tmdb"
primary_languages = [""]
default_language = "en"
[metadata.tvdb]
tvdb_relay_url = "https://metadata-relay.dorninger.co/tvdb"

View File

@@ -3,7 +3,8 @@ from pydantic_settings import BaseSettings
class TmdbConfig(BaseSettings):
tmdb_relay_url: str = "https://metadata-relay.dorninger.co/tmdb"
primary_languages: list[str] = [] # ISO 639-1 language codes
default_language: str = "en" # ISO 639-1 language codes
class TvdbConfig(BaseSettings):
tvdb_relay_url: str = "https://metadata-relay.dorninger.co/tvdb"

View File

@@ -12,4 +12,5 @@ class MetaDataProviderSearchResult(BaseModel):
metadata_provider: str
added: bool
vote_average: float | None = None
original_language: str | None = None
id: MovieId | ShowId | None = None # Internal ID if already added

View File

@@ -25,10 +25,29 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
def __init__(self):
config = AllEncompassingConfig().metadata.tmdb
self.url = config.tmdb_relay_url
self.primary_languages = config.primary_languages
self.default_language = config.default_language
def __get_show_metadata(self, id: int) -> dict:
def __get_language_param(self, original_language: str | None) -> str:
"""
Determine the language parameter to use for TMDB API calls.
Returns the original language if it's in primary_languages, otherwise returns default_language.
:param original_language: The original language code (ISO 639-1) of the media
:return: Language parameter (ISO 639-1 format, e.g., 'en', 'no')
"""
if original_language and original_language in self.primary_languages:
return original_language
return self.default_language
def __get_show_metadata(self, id: int, language: str | None = None) -> dict:
if language is None:
language = self.default_language
try:
response = requests.get(url=f"{self.url}/tv/shows/{id}")
response = requests.get(
url=f"{self.url}/tv/shows/{id}",
params={"language": language}
)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
@@ -40,10 +59,13 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
)
raise
def __get_season_metadata(self, show_id: int, season_number: int) -> dict:
def __get_season_metadata(self, show_id: int, season_number: int, language: str | None = None) -> dict:
if language is None:
language = self.default_language
try:
response = requests.get(
url=f"{self.url}/tv/shows/{show_id}/{season_number}"
url=f"{self.url}/tv/shows/{show_id}/{season_number}",
params={"language": language}
)
response.raise_for_status()
return response.json()
@@ -80,7 +102,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
def __get_trending_tv(self) -> dict:
try:
response = requests.get(url=f"{self.url}/tv/trending")
response = requests.get(url=f"{self.url}/tv/trending", params={"language": self.default_language})
response.raise_for_status()
return response.json()
except requests.RequestException as e:
@@ -92,9 +114,14 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
)
raise
def __get_movie_metadata(self, id: int) -> dict:
def __get_movie_metadata(self, id: int, language: str | None = None) -> dict:
if language is None:
language = self.default_language
try:
response = requests.get(url=f"{self.url}/movies/{id}")
response = requests.get(
url=f"{self.url}/movies/{id}",
params={"language": language}
)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
@@ -128,7 +155,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
def __get_trending_movies(self) -> dict:
try:
response = requests.get(url=f"{self.url}/movies/trending")
response = requests.get(url=f"{self.url}/movies/trending", params={"language": self.default_language})
response.raise_for_status()
return response.json()
except requests.RequestException as e:
@@ -141,7 +168,12 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
raise
def download_show_poster_image(self, show: Show) -> bool:
show_metadata = self.__get_show_metadata(show.external_id)
# Determine which language to use based on show's original_language
language = self.__get_language_param(show.original_language)
# Fetch metadata in the appropriate language to get localized poster
show_metadata = self.__get_show_metadata(show.external_id, language=language)
# downloading the poster
# all pictures from TMDB should already be jpeg, so no need to convert
if show_metadata["poster_path"] is not None:
@@ -160,20 +192,34 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
return False
return True
def get_show_metadata(self, id: int = None) -> Show:
def get_show_metadata(self, id: int = None, language: str | None = None) -> Show:
"""
:param id: the external id of the show
:type id: int
:return: returns a ShowMetadata object
:rtype: ShowMetadata
:param language: optional language code (ISO 639-1) to fetch metadata in
:type language: str | None
:return: returns a Show object
:rtype: Show
"""
# If language not provided, fetch once to determine original language
if language is None:
show_metadata = self.__get_show_metadata(id)
language = show_metadata.get("original_language")
# Determine which language to use for metadata
language = self.__get_language_param(language)
# Fetch show metadata in the appropriate language
show_metadata = self.__get_show_metadata(id, language=language)
season_list = []
# inserting all the metadata into the objects
for season in show_metadata["seasons"]:
season_metadata = self.__get_season_metadata(
show_id=show_metadata["id"], season_number=season["season_number"]
show_id=show_metadata["id"],
season_number=season["season_number"],
language=language
)
episode_list = []
@@ -208,6 +254,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
seasons=season_list,
metadata_provider=self.name,
ended=show_metadata["status"] in ENDED_STATUS,
original_language=show_metadata.get("original_language"),
)
return show
@@ -240,11 +287,24 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
)
else:
poster_url = None
# Determine which name to use based on primary_languages
original_language = result.get("original_language")
original_name = result.get("original_name")
display_name = result["name"]
overview = result["overview"]
# Use original name if language is in primary_languages and skip overview
if original_language and original_language in self.primary_languages:
display_name = original_name
overview = None
formatted_results.append(
MetaDataProviderSearchResult(
poster_path=poster_url,
overview=result["overview"],
name=result["name"],
overview=overview,
name=display_name,
external_id=result["id"],
year=media_manager.metadataProvider.utils.get_year_from_date(
result["first_air_date"]
@@ -252,21 +312,35 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
metadata_provider=self.name,
added=False,
vote_average=result["vote_average"],
original_language=original_language,
)
)
except Exception as e:
log.warning(f"Error processing search result: {e}")
return formatted_results
def get_movie_metadata(self, id: int = None) -> Movie:
def get_movie_metadata(self, id: int = None, language: str | None = None) -> Movie:
"""
Get movie metadata with language-aware fetching.
:param id: the external id of the show
:param id: the external id of the movie
:type id: int
:return: returns a ShowMetadata object
:rtype: ShowMetadata
:param language: optional language code (ISO 639-1) to fetch metadata in
:type language: str | None
:return: returns a Movie object
:rtype: Movie
"""
# If language not provided, fetch once to determine original language
if language is None:
movie_metadata = self.__get_movie_metadata(id=id)
language = movie_metadata.get("original_language")
# Determine which language to use for metadata
language = self.__get_language_param(language)
# Fetch movie metadata in the appropriate language
movie_metadata = self.__get_movie_metadata(id=id, language=language)
year = media_manager.metadataProvider.utils.get_year_from_date(
movie_metadata["release_date"]
)
@@ -277,6 +351,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
overview=movie_metadata["overview"],
year=year,
metadata_provider=self.name,
original_language=movie_metadata.get("original_language"),
)
return movie
@@ -309,11 +384,23 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
)
else:
poster_url = None
# Determine which name to use based on primary_languages
original_language = result.get("original_language")
original_title = result.get("original_title")
display_name = result["title"]
overview = result["overview"]
# Use original name if language is in primary_languages and skip overview
if original_language and original_language in self.primary_languages:
display_name = original_title
overview = None
formatted_results.append(
MetaDataProviderSearchResult(
poster_path=poster_url,
overview=result["overview"],
name=result["title"],
overview=overview,
name=display_name,
external_id=result["id"],
year=media_manager.metadataProvider.utils.get_year_from_date(
result["release_date"]
@@ -321,6 +408,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
metadata_provider=self.name,
added=False,
vote_average=result["vote_average"],
original_language=original_language,
)
)
except Exception as e:
@@ -328,7 +416,12 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
return formatted_results
def download_movie_poster_image(self, movie: Movie) -> bool:
movie_metadata = self.__get_movie_metadata(id=movie.external_id)
# Determine which language to use based on movie's original_language
language = self.__get_language_param(movie.original_language)
# Fetch metadata in the appropriate language to get localized poster
movie_metadata = self.__get_movie_metadata(id=movie.external_id, language=language)
# downloading the poster
# all pictures from TMDB should already be jpeg, so no need to convert
if movie_metadata["poster_path"] is not None:
@@ -338,11 +431,11 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
if media_manager.metadataProvider.utils.download_poster_image(
storage_path=self.storage_path, poster_url=poster_url, id=movie.id
):
log.info("Successfully downloaded poster image for show " + movie.name)
log.info("Successfully downloaded poster image for movie " + movie.name)
else:
log.warning(f"download for image of show {movie.name} failed")
log.warning(f"download for image of movie {movie.name} failed")
return False
else:
log.warning(f"image for show {movie.name} could not be downloaded")
log.warning(f"image for movie {movie.name} could not be downloaded")
return False
return True

View File

@@ -19,6 +19,7 @@ class Movie(Base):
overview: Mapped[str]
year: Mapped[int | None]
library: Mapped[str] = mapped_column(default="")
original_language: Mapped[str | None] = mapped_column(default=None)
movie_requests: Mapped[list["MovieRequest"]] = relationship(
"MovieRequest", back_populates="movie", cascade="all, delete-orphan"
)

View File

@@ -115,6 +115,7 @@ class MovieRepository:
db_movie.name = movie.name
db_movie.overview = movie.overview
db_movie.year = movie.year
db_movie.original_language = movie.original_language
else: # Insert new movie
log.debug(f"Creating new movie: {movie.name}")
db_movie = Movie(**movie.model_dump())

View File

@@ -56,11 +56,13 @@ def add_a_movie(
movie_service: movie_service_dep,
metadata_provider: metadata_provider_dep,
movie_id: int,
language: str | None = None,
):
try:
movie = movie_service.add_movie(
external_id=movie_id,
metadata_provider=metadata_provider,
language=language,
)
except ValueError:
movie = movie_service.get_movie_by_external_id(

View File

@@ -23,6 +23,7 @@ class Movie(BaseModel):
external_id: int
metadata_provider: str
library: str = "Default"
original_language: str | None = None
class MovieFile(BaseModel):

View File

@@ -63,15 +63,16 @@ class MovieService:
self.notification_service = notification_service
def add_movie(
self, external_id: int, metadata_provider: AbstractMetadataProvider
self, external_id: int, metadata_provider: AbstractMetadataProvider, language: str | None = None
) -> Movie | None:
"""
Add a new movie to the database.
:param external_id: The ID of the movie in the metadata provider's system.
:param metadata_provider: The name of the metadata provider.
:param language: Optional language code (ISO 639-1) to fetch metadata in.
"""
movie_with_metadata = metadata_provider.get_movie_metadata(id=external_id)
movie_with_metadata = metadata_provider.get_movie_metadata(id=external_id, language=language)
saved_movie = self.movie_repository.save_movie(movie=movie_with_metadata)
metadata_provider.download_movie_poster_image(movie=saved_movie)
return saved_movie
@@ -697,7 +698,8 @@ class MovieService:
"""
log.debug(f"Found movie: {db_movie.name} for metadata update.")
fresh_movie_data = metadata_provider.get_movie_metadata(id=db_movie.external_id)
# Use stored original_language preference for metadata fetching
fresh_movie_data = metadata_provider.get_movie_metadata(id=db_movie.external_id, language=db_movie.original_language)
if not fresh_movie_data:
log.warning(
f"Could not fetch fresh metadata for movie {db_movie.name} (External ID: {db_movie.external_id}) from {db_movie.metadata_provider}."

View File

@@ -21,6 +21,7 @@ class Show(Base):
ended: Mapped[bool] = mapped_column(default=False)
continuous_download: Mapped[bool] = mapped_column(default=False)
library: Mapped[str] = mapped_column(default="")
original_language: Mapped[str | None] = mapped_column(default=None)
seasons: Mapped[list["Season"]] = relationship(
back_populates="show", cascade="all, delete"

View File

@@ -135,6 +135,7 @@ class TvRepository:
db_show.name = show.name
db_show.overview = show.overview
db_show.year = show.year
db_show.original_language = show.original_language
else: # Insert new show
db_show = Show(
id=show.id,
@@ -144,6 +145,7 @@ class TvRepository:
overview=show.overview,
year=show.year,
ended=show.ended,
original_language=show.original_language,
seasons=[
Season(
id=season.id,

View File

@@ -58,12 +58,13 @@ router = APIRouter()
},
)
def add_a_show(
tv_service: tv_service_dep, metadata_provider: metadata_provider_dep, show_id: int
tv_service: tv_service_dep, metadata_provider: metadata_provider_dep, show_id: int, language: str | None = None
):
try:
show = tv_service.add_show(
external_id=show_id,
metadata_provider=metadata_provider,
language=language,
)
except MediaAlreadyExists:
show = tv_service.get_show_by_external_id(

View File

@@ -55,6 +55,7 @@ class Show(BaseModel):
continuous_download: bool = False
library: str = "Default"
original_language: str | None = None
seasons: list[Season]

View File

@@ -69,15 +69,16 @@ class TvService:
self.notification_service = notification_service
def add_show(
self, external_id: int, metadata_provider: AbstractMetadataProvider
self, external_id: int, metadata_provider: AbstractMetadataProvider, language: str | None = None
) -> Show | None:
"""
Add a new show to the database.
:param external_id: The ID of the show in the metadata provider\\\'s system.
:param external_id: The ID of the show in the metadata provider\'s system.
:param metadata_provider: The name of the metadata provider.
:param language: Optional language code (ISO 639-1) to fetch metadata in.
"""
show_with_metadata = metadata_provider.get_show_metadata(id=external_id)
show_with_metadata = metadata_provider.get_show_metadata(id=external_id, language=language)
saved_show = self.tv_repository.save_show(show=show_with_metadata)
metadata_provider.download_show_poster_image(show=saved_show)
return saved_show
@@ -756,7 +757,8 @@ class TvService:
log.debug(f"Found show: {db_show.name} for metadata update.")
# old_poster_url = db_show.poster_url # poster_url removed from db_show
fresh_show_data = metadata_provider.get_show_metadata(id=db_show.external_id)
# Use stored original_language preference for metadata fetching
fresh_show_data = metadata_provider.get_show_metadata(id=db_show.external_id, language=db_show.original_language)
if not fresh_show_data:
log.warning(
f"Could not fetch fresh metadata for show {db_show.name} (External ID: {db_show.external_id}) from {db_show.metadata_provider}."

View File

@@ -16,29 +16,29 @@ else:
tmdbsimple.API_KEY = tmdb_api_key
@router.get("/tv/trending")
async def get_tmdb_trending_tv():
return Trending(media_type="tv").info()
async def get_tmdb_trending_tv(language: str = "en"):
return Trending(media_type="tv").info(language=language)
@router.get("/tv/search")
async def search_tmdb_tv(query: str, page: int = 1):
return Search().tv(page=page, query=query)
async def search_tmdb_tv(query: str, page: int = 1, language: str = "en"):
return Search().tv(page=page, query=query, language=language)
@router.get("/tv/shows/{show_id}")
async def get_tmdb_show(show_id: int):
return TV(show_id).info()
async def get_tmdb_show(show_id: int, language: str = "en"):
return TV(show_id).info(language=language)
@router.get("/tv/shows/{show_id}/{season_number}")
async def get_tmdb_season(season_number: int, show_id: int):
return TV_Seasons(season_number=season_number, tv_id=show_id).info()
async def get_tmdb_season(season_number: int, show_id: int, language: str = "en"):
return TV_Seasons(season_number=season_number, tv_id=show_id).info(language=language)
@router.get("/movies/trending")
async def get_tmdb_trending_movies():
return Trending(media_type="movie").info()
async def get_tmdb_trending_movies(language: str = "en"):
return Trending(media_type="movie").info(language=language)
@router.get("/movies/search")
async def search_tmdb_movies(query: str, page: int = 1):
return Search().movie(page=page, query=query)
async def search_tmdb_movies(query: str, page: int = 1, language: str = "en"):
return Search().movie(page=page, query=query, language=language)
@router.get("/movies/{movie_id}")
async def get_tmdb_movie(movie_id: int):
return Movies(movie_id).info()
async def get_tmdb_movie(movie_id: int, language: str = "en"):
return Movies(movie_id).info(language=language)

View File

@@ -1309,6 +1309,8 @@ export interface components {
added: boolean;
/** Vote Average */
vote_average?: number | null;
/** Original Language */
original_language?: string | null;
/** Id */
id?: string | null;
};
@@ -1334,6 +1336,8 @@ export interface components {
* @default Default
*/
library: string;
/** Original Language */
original_language?: string | null;
};
/** MovieRequest */
MovieRequest: {
@@ -1433,6 +1437,8 @@ export interface components {
* @default Default
*/
library: string;
/** Original Language */
original_language?: string | null;
/**
* Downloaded
* @default false
@@ -1689,6 +1695,8 @@ export interface components {
* @default Default
*/
library: string;
/** Original Language */
original_language?: string | null;
/** Seasons */
seasons: components['schemas']['Season'][];
};
@@ -2565,6 +2573,7 @@ export interface operations {
parameters: {
query: {
show_id: number;
language?: string | null;
metadata_provider?: 'tmdb' | 'tvdb';
};
header?: never;
@@ -3417,6 +3426,7 @@ export interface operations {
parameters: {
query: {
movie_id: number;
language?: string | null;
metadata_provider?: 'tmdb' | 'tvdb';
};
header?: never;

View File

@@ -22,7 +22,8 @@
params: {
query: {
show_id: result.external_id,
metadata_provider: result.metadata_provider as 'tmdb' | 'tvdb'
metadata_provider: result.metadata_provider as 'tmdb' | 'tvdb',
language: result.original_language ?? undefined
}
}
});
@@ -32,7 +33,8 @@
params: {
query: {
movie_id: result.external_id,
metadata_provider: result.metadata_provider as 'tmdb' | 'tvdb'
metadata_provider: result.metadata_provider as 'tmdb' | 'tvdb',
language: result.original_language ?? undefined
}
}
});