diff --git a/alembic/versions/add_air_date_to_season_and_movie.py b/alembic/versions/add_air_date_to_season_and_movie.py new file mode 100644 index 00000000..b2ea3b3c --- /dev/null +++ b/alembic/versions/add_air_date_to_season_and_movie.py @@ -0,0 +1,37 @@ +"""Add air_date to Season and Movie tables + +Revision ID: f1a2b3c4d5e6 +Revises: eb0bd3cc1852 +Create Date: 2025-11-02 21:49:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "f1a2b3c4d5e6" +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.""" + op.add_column( + "season", + sa.Column("air_date", sa.Date(), nullable=True), + ) + op.add_column( + "movie", + sa.Column("air_date", sa.Date(), nullable=True), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("season", "air_date") + op.drop_column("movie", "air_date") diff --git a/config.example.toml b/config.example.toml index 4498289c..e9368d70 100644 --- a/config.example.toml +++ b/config.example.toml @@ -18,6 +18,11 @@ torrent_directory = "/data/torrents" # this is where MediaManager will search fo # you probaly don't need to change this development = false +# Air date check settings - prevent downloading content that hasn't aired/been released yet +# This helps avoid fake releases on public indexers +prevent_unaired_tv_downloads = false +prevent_unaired_movie_downloads = false + # Custom Media Libraries # These paths should match your volume mounts in docker-compose.yaml # Example: if you mount "./movies:/media/movies" then use path = "/media/movies/subdirectory" diff --git a/media_manager/config.py b/media_manager/config.py index f1f6d44e..f0709185 100644 --- a/media_manager/config.py +++ b/media_manager/config.py @@ -48,6 +48,10 @@ class BasicConfig(BaseSettings): tv_libraries: list[LibraryItem] = [] movie_libraries: list[LibraryItem] = [] + # Air date check settings + prevent_unaired_tv_downloads: bool = False + prevent_unaired_movie_downloads: bool = False + class AllEncompassingConfig(BaseSettings): model_config = SettingsConfigDict( diff --git a/media_manager/metadataProvider/tmdb.py b/media_manager/metadataProvider/tmdb.py index 05246346..6c1252af 100644 --- a/media_manager/metadataProvider/tmdb.py +++ b/media_manager/metadataProvider/tmdb.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime import requests @@ -177,6 +178,14 @@ def get_show_metadata(self, id: int = None) -> Show: ) ) + # Parse air_date + air_date = None + if season_metadata.get("air_date"): + try: + air_date = datetime.strptime(season_metadata["air_date"], "%Y-%m-%d").date() + except (ValueError, TypeError): + log.warning(f"Could not parse air_date for season {season_metadata['season_number']}: {season_metadata.get('air_date')}") + season_list.append( Season( external_id=int(season_metadata["id"]), @@ -184,6 +193,7 @@ def get_show_metadata(self, id: int = None) -> Show: overview=season_metadata["overview"], number=SeasonNumber(season_metadata["season_number"]), episodes=episode_list, + air_date=air_date, ) ) @@ -262,11 +272,20 @@ def get_movie_metadata(self, id: int = None) -> Movie: movie_metadata["release_date"] ) + # Parse air_date (release_date for movies) + air_date = None + if movie_metadata.get("release_date"): + try: + air_date = datetime.strptime(movie_metadata["release_date"], "%Y-%m-%d").date() + except (ValueError, TypeError): + log.warning(f"Could not parse release_date for movie {id}: {movie_metadata.get('release_date')}") + movie = Movie( external_id=id, name=movie_metadata["title"], overview=movie_metadata["overview"], year=year, + air_date=air_date, metadata_provider=self.name, ) diff --git a/media_manager/metadataProvider/tvdb.py b/media_manager/metadataProvider/tvdb.py index 100b06db..50ad0b95 100644 --- a/media_manager/metadataProvider/tvdb.py +++ b/media_manager/metadataProvider/tvdb.py @@ -1,5 +1,6 @@ import requests import logging +from datetime import datetime import media_manager.metadataProvider.utils @@ -89,6 +90,15 @@ def get_show_metadata(self, id: int = None) -> Show: ) for episode in s["episodes"] ] + + # Parse air_date if available + air_date = None + if s.get("air_date"): + try: + air_date = datetime.strptime(s["air_date"], "%Y-%m-%d").date() + except (ValueError, TypeError): + log.warning(f"Could not parse air_date for season {s['number']}: {s.get('air_date')}") + seasons.append( Season( number=SeasonNumber(s["number"]), @@ -96,6 +106,7 @@ def get_show_metadata(self, id: int = None) -> Show: overview="TVDB doesn't provide Season Overviews", external_id=int(s["id"]), episodes=episodes, + air_date=air_date, ) ) try: @@ -259,17 +270,26 @@ def get_movie_metadata(self, id: int = None) -> Movie: :return: returns a Movie object :rtype: Movie """ - movie = self.__get_movie(id) + movie_data = self.__get_movie(id) try: - year = movie["year"] + year = movie_data["year"] except KeyError: year = None + # Parse air_date if available + air_date = None + if movie_data.get("release_date"): + try: + air_date = datetime.strptime(movie_data["release_date"], "%Y-%m-%d").date() + except (ValueError, TypeError): + log.warning(f"Could not parse release_date for movie {id}: {movie_data.get('release_date')}") + movie = Movie( - name=movie["name"], + name=movie_data["name"], overview="TVDB does not provide overviews", year=year, - external_id=movie["id"], + air_date=air_date, + external_id=movie_data["id"], metadata_provider=self.name, ) diff --git a/media_manager/movies/models.py b/media_manager/movies/models.py index 7db5f634..1d3594a2 100644 --- a/media_manager/movies/models.py +++ b/media_manager/movies/models.py @@ -1,4 +1,5 @@ from uuid import UUID +from datetime import date from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -18,6 +19,7 @@ class Movie(Base): name: Mapped[str] overview: Mapped[str] year: Mapped[int | None] + air_date: Mapped[date | None] library: Mapped[str] = mapped_column(default="") movie_requests: Mapped[list["MovieRequest"]] = relationship( "MovieRequest", back_populates="movie", cascade="all, delete-orphan" diff --git a/media_manager/movies/schemas.py b/media_manager/movies/schemas.py index 1bcff18b..f32e2f9d 100644 --- a/media_manager/movies/schemas.py +++ b/media_manager/movies/schemas.py @@ -1,6 +1,7 @@ import typing import uuid from uuid import UUID +from datetime import date from pydantic import BaseModel, Field, ConfigDict, model_validator @@ -19,6 +20,7 @@ class Movie(BaseModel): name: str overview: str year: int | None + air_date: date | None = None external_id: int metadata_provider: str diff --git a/media_manager/movies/service.py b/media_manager/movies/service.py index 828a4c0b..9e6570e7 100644 --- a/media_manager/movies/service.py +++ b/media_manager/movies/service.py @@ -1,5 +1,6 @@ import re from pathlib import Path +from datetime import date from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -421,6 +422,17 @@ def download_approved_movie_request( log.info(f"Downloading approved movie request {movie_request.id}") + # Check if movie has aired (if air date check is enabled) + config = AllEncompassingConfig() + if config.misc.prevent_unaired_movie_downloads: + if movie.air_date is not None: + today = date.today() + if movie.air_date > today: + log.info( + f"Skipping movie {movie.name} because it hasn't been released yet (release date: {movie.air_date})" + ) + return False + torrents = self.get_all_available_torrents_for_a_movie(movie_id=movie.id) available_torrents: list[IndexerQueryResult] = [] diff --git a/media_manager/tv/models.py b/media_manager/tv/models.py index 6f34fe68..f5c2096c 100644 --- a/media_manager/tv/models.py +++ b/media_manager/tv/models.py @@ -1,4 +1,5 @@ from uuid import UUID +from datetime import date from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -39,6 +40,7 @@ class Season(Base): external_id: Mapped[int] name: Mapped[str] overview: Mapped[str] + air_date: Mapped[date | None] show: Mapped["Show"] = relationship(back_populates="seasons") episodes: Mapped[list["Episode"]] = relationship( diff --git a/media_manager/tv/schemas.py b/media_manager/tv/schemas.py index cacb9631..eef10f20 100644 --- a/media_manager/tv/schemas.py +++ b/media_manager/tv/schemas.py @@ -1,6 +1,7 @@ import typing import uuid from uuid import UUID +from datetime import date from pydantic import BaseModel, Field, ConfigDict, model_validator @@ -36,6 +37,7 @@ class Season(BaseModel): overview: str external_id: int + air_date: date | None = None episodes: list[Episode] @@ -145,6 +147,7 @@ class PublicSeason(BaseModel): overview: str external_id: int + air_date: date | None = None episodes: list[Episode] diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index 2c7817f2..fc750a72 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -1,4 +1,5 @@ import re +from datetime import date from sqlalchemy.exc import IntegrityError @@ -467,6 +468,18 @@ def download_approved_season_request( log.info(f"Downloading approved season request {season_request.id}") season = self.get_season(season_id=season_request.season_id) + + # Check if season has aired (if air date check is enabled) + config = AllEncompassingConfig() + if config.misc.prevent_unaired_tv_downloads: + if season.air_date is not None: + today = date.today() + if season.air_date > today: + log.info( + f"Skipping season {season.number} of show {show.name} because it hasn't aired yet (air date: {season.air_date})" + ) + return False + torrents = self.get_all_available_torrents_for_a_season( season_number=season.number, show_id=show.id )