diff --git a/alembic/versions/3a8fbd71e2c2_create_episode_file_table_and_add_episode_column_to_indexerqueryresult.py b/alembic/versions/3a8fbd71e2c2_create_episode_file_table_and_add_episode_column_to_indexerqueryresult.py new file mode 100644 index 00000000..c86aea57 --- /dev/null +++ b/alembic/versions/3a8fbd71e2c2_create_episode_file_table_and_add_episode_column_to_indexerqueryresult.py @@ -0,0 +1,46 @@ +"""create episode file table and add episode column to indexerqueryresult + +Revision ID: 3a8fbd71e2c2 +Revises: 9f3c1b2a4d8e +Create Date: 2026-01-08 13:43:00 + +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy.dialects import postgresql +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "3a8fbd71e2c2" +down_revision: Union[str, None] = "9f3c1b2a4d8e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + + +def upgrade() -> None: + quality_enum = postgresql.ENUM("uhd", "fullhd", "hd", "sd", "unknown", name="quality", + create_type=False, + ) + # Create episode file table + op.create_table( + "episode_file", + sa.Column("episode_id", sa.UUID(), nullable=False), + sa.Column("torrent_id", sa.UUID(), nullable=True), + sa.Column("file_path_suffix", sa.String(), nullable=False), + sa.Column("quality", quality_enum, nullable=False), + sa.ForeignKeyConstraint(["episode_id"], ["episode.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["torrent_id"], ["torrent.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("episode_id", "file_path_suffix"), + ) + # Add episode column to indexerqueryresult + op.add_column( + "indexer_query_result", sa.Column("episode", postgresql.ARRAY(sa.Integer()), nullable=True), + ) + +def downgrade() -> None: + op.drop_table("episode_file") + op.drop_column("indexer_query_result", "episode") \ No newline at end of file diff --git a/alembic/versions/9f3c1b2a4d8e_add_overview_column_to_episode_table.py b/alembic/versions/9f3c1b2a4d8e_add_overview_column_to_episode_table.py new file mode 100644 index 00000000..df086c51 --- /dev/null +++ b/alembic/versions/9f3c1b2a4d8e_add_overview_column_to_episode_table.py @@ -0,0 +1,31 @@ +"""add overview column to episode table + +Revision ID: 9f3c1b2a4d8e +Revises: 2c61f662ca9e +Create Date: 2025-12-29 21:45:00 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "9f3c1b2a4d8e" +down_revision: Union[str, None] = "2c61f662ca9e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add overview to episode table + op.add_column( + "episode", + sa.Column("overview", sa.Text(), nullable=True), + ) + +def downgrade() -> None: + op.drop_column("episode", "overview") + diff --git a/media_manager/indexer/models.py b/media_manager/indexer/models.py index fea717b1..a6a52fa2 100644 --- a/media_manager/indexer/models.py +++ b/media_manager/indexer/models.py @@ -18,6 +18,7 @@ class IndexerQueryResult(Base): flags = mapped_column(ARRAY(String)) quality: Mapped[Quality] season = mapped_column(ARRAY(Integer)) + episode = mapped_column(ARRAY(Integer)) size = mapped_column(BigInteger) usenet: Mapped[bool] age: Mapped[int] diff --git a/media_manager/indexer/schemas.py b/media_manager/indexer/schemas.py index 29a87c9c..7d9dbd90 100644 --- a/media_manager/indexer/schemas.py +++ b/media_manager/indexer/schemas.py @@ -52,14 +52,59 @@ def quality(self) -> Quality: @computed_field @property def season(self) -> list[int]: - pattern = r"\bS(\d+)\b" - matches = re.findall(pattern, self.title, re.IGNORECASE) - if matches.__len__() == 2: - result = list(range(int(matches[0]), int(matches[1]) + 1)) - elif matches.__len__() == 1: - result = [int(matches[0])] + title = self.title.lower() + result: list[int] = [] + + # 1) S01E01 / S1E2 + m = re.search(r"s(\d{1,2})e\d{1,3}", title) + if m: + result = [int(m.group(1))] + return result + + # 2) Range S01-S03 / S1-S3 + m = re.search(r"s(\d{1,2})\s*[-–]\s*s?(\d{1,2})", title) + if m: + start, end = int(m.group(1)), int(m.group(2)) + if start <= end: + result = list(range(start, end + 1)) + return result + + # 3) Pack S01 / S1 + m = re.search(r"\bs(\d{1,2})\b", title) + if m: + result = [int(m.group(1))] + return result + + # 4) Season 01 / Season 1 + m = re.search(r"\bseason\s*(\d{1,2})\b", title) + if m: + result = [int(m.group(1))] + return result + + return result + + @computed_field(return_type=list[int]) + @property + def episode(self) -> list[int]: + title = self.title.lower() + result: list[int] = [] + + pattern = r"s\d{1,2}e(\d{1,3})(?:\s*-\s*(?:s?\d{1,2}e)?(\d{1,3}))?" + match = re.search(pattern, title) + + if not match: + return result + + start = int(match.group(1)) + end = match.group(2) + + if end: + end = int(end) + if end >= start: + result = list(range(start, end + 1)) else: - result = [] + result = [start] + return result def __gt__(self, other: "IndexerQueryResult") -> bool: diff --git a/media_manager/torrent/models.py b/media_manager/torrent/models.py index 296c879d..30050984 100644 --- a/media_manager/torrent/models.py +++ b/media_manager/torrent/models.py @@ -17,4 +17,5 @@ class Torrent(Base): usenet: Mapped[bool] season_files = relationship("SeasonFile", back_populates="torrent") + episode_files = relationship("EpisodeFile", back_populates="torrent") movie_files = relationship("MovieFile", back_populates="torrent") diff --git a/media_manager/torrent/repository.py b/media_manager/torrent/repository.py index 382e0df5..f66bc659 100644 --- a/media_manager/torrent/repository.py +++ b/media_manager/torrent/repository.py @@ -10,12 +10,9 @@ MovieFile as MovieFileSchema, ) from media_manager.torrent.models import Torrent -from media_manager.torrent.schemas import Torrent as TorrentSchema -from media_manager.torrent.schemas import TorrentId -from media_manager.tv.models import Season, SeasonFile, Show -from media_manager.tv.schemas import SeasonFile as SeasonFileSchema -from media_manager.tv.schemas import Show as ShowSchema - +from media_manager.torrent.schemas import TorrentId, Torrent as TorrentSchema +from media_manager.tv.models import SeasonFile, Show, Season, EpisodeFile, Episode +from media_manager.tv.schemas import SeasonFile as SeasonFileSchema, Show as ShowSchema, EpisodeFile as EpisodeFileSchema class TorrentRepository: def __init__(self, db: DbSessionDependency) -> None: @@ -28,12 +25,20 @@ def get_seasons_files_of_torrent( result = self.db.execute(stmt).scalars().all() return [SeasonFileSchema.model_validate(season_file) for season_file in result] + def get_episode_files_of_torrent( + self, torrent_id: TorrentId + ) -> list[EpisodeFileSchema]: + stmt = select(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id) + result = self.db.execute(stmt).scalars().all() + return [EpisodeFileSchema.model_validate(episode_file) for episode_file in result] + def get_show_of_torrent(self, torrent_id: TorrentId) -> ShowSchema | None: stmt = ( select(Show) - .join(SeasonFile.season) - .join(Season.show) - .where(SeasonFile.torrent_id == torrent_id) + .join(Show.seasons) + .join(Season.episodes) + .join(Episode.episode_files) + .where(EpisodeFile.torrent_id == torrent_id) ) result = self.db.execute(stmt).unique().scalar_one_or_none() if result is None: @@ -69,10 +74,10 @@ def delete_torrent( ) self.db.execute(movie_files_stmt) - season_files_stmt = delete(SeasonFile).where( - SeasonFile.torrent_id == torrent_id + episode_files_stmt = delete(EpisodeFile).where( + EpisodeFile.torrent_id == torrent_id ) - self.db.execute(season_files_stmt) + self.db.execute(episode_files_stmt) self.db.delete(self.db.get(Torrent, torrent_id)) diff --git a/media_manager/torrent/service.py b/media_manager/torrent/service.py index c5bcaaf2..1bc8622e 100644 --- a/media_manager/torrent/service.py +++ b/media_manager/torrent/service.py @@ -5,7 +5,8 @@ from media_manager.torrent.manager import DownloadManager from media_manager.torrent.repository import TorrentRepository from media_manager.torrent.schemas import Torrent, TorrentId -from media_manager.tv.schemas import SeasonFile, Show +from media_manager.tv.schemas import SeasonFile, Show, EpisodeFile +from media_manager.movies.schemas import Movie log = logging.getLogger(__name__) @@ -29,6 +30,16 @@ def get_season_files_of_torrent(self, torrent: Torrent) -> list[SeasonFile]: torrent_id=torrent.id ) + def get_episode_files_of_torrent(self, torrent: Torrent) -> list[EpisodeFile]: + """ + Returns all episode files of a torrent + :param torrent: the torrent to get the episode files of + :return: list of episode files + """ + return self.torrent_repository.get_episode_files_of_torrent( + torrent_id=torrent.id + ) + def get_show_of_torrent(self, torrent: Torrent) -> Show | None: """ Returns the show of a torrent diff --git a/media_manager/tv/models.py b/media_manager/tv/models.py index c72702b5..c4d5afa2 100644 --- a/media_manager/tv/models.py +++ b/media_manager/tv/models.py @@ -66,8 +66,12 @@ class Episode(Base): number: Mapped[int] external_id: Mapped[int] title: Mapped[str] + overview: Mapped[str] season: Mapped["Season"] = relationship(back_populates="episodes") + episode_files = relationship( + "EpisodeFile", back_populates="episode", cascade="all, delete" + ) class SeasonFile(Base): @@ -86,6 +90,21 @@ class SeasonFile(Base): season = relationship("Season", back_populates="season_files", uselist=False) +class EpisodeFile(Base): + __tablename__ = "episode_file" + __table_args__ = (PrimaryKeyConstraint("episode_id", "file_path_suffix"),) + episode_id: Mapped[UUID] = mapped_column( + ForeignKey(column="episode.id", ondelete="CASCADE"), + ) + torrent_id: Mapped[UUID | None] = mapped_column( + ForeignKey(column="torrent.id", ondelete="SET NULL"), + ) + file_path_suffix: Mapped[str] + quality: Mapped[Quality] + + torrent = relationship("Torrent", back_populates="episode_files", uselist=False) + episode = relationship("Episode", back_populates="episode_files", uselist=False) + class SeasonRequest(Base): __tablename__ = "season_request" __table_args__ = (UniqueConstraint("season_id", "wanted_quality"),) diff --git a/media_manager/tv/repository.py b/media_manager/tv/repository.py index e89ae57d..17f9bd01 100644 --- a/media_manager/tv/repository.py +++ b/media_manager/tv/repository.py @@ -10,12 +10,13 @@ from media_manager.torrent.schemas import Torrent as TorrentSchema from media_manager.torrent.schemas import TorrentId from media_manager.tv import log -from media_manager.tv.models import Episode, Season, SeasonFile, SeasonRequest, Show +from media_manager.tv.models import Season, Show, Episode, SeasonRequest, SeasonFile, EpisodeFile from media_manager.tv.schemas import ( Episode as EpisodeSchema, ) from media_manager.tv.schemas import ( EpisodeId, + EpisodeNumber, SeasonId, SeasonNumber, SeasonRequestId, @@ -29,6 +30,7 @@ ) from media_manager.tv.schemas import ( SeasonFile as SeasonFileSchema, + EpisodeFile as EpisodeFileSchema, ) from media_manager.tv.schemas import ( SeasonRequest as SeasonRequestSchema, @@ -175,6 +177,7 @@ def save_show(self, show: ShowSchema) -> ShowSchema: number=episode.number, external_id=episode.external_id, title=episode.title, + overview=episode.overview, ) for episode in season.episodes ], @@ -236,6 +239,49 @@ def get_season(self, season_id: SeasonId) -> SeasonSchema: log.error(f"Database error while retrieving season {season_id}: {e}") raise + def get_episode(self, episode_id: EpisodeId) -> EpisodeSchema: + """ + Retrieve an episode by its ID. + + :param episode_id: The ID of the episode to get. + :return: An Episode object. + :raises NotFoundError: If the episode with the given ID is not found. + :raises SQLAlchemyError: If a database error occurs. + """ + try: + episode = self.db.get(Episode, episode_id) + if not episode: + raise NotFoundError(f"Episode with id {episode_id} not found.") + return EpisodeSchema.model_validate(episode) + except SQLAlchemyError as e: + log.error(f"Database error while retrieving episode {episode_id}: {e}") + raise + + def get_season_by_episode(self, episode_id: EpisodeId) -> SeasonSchema: + try: + stmt = ( + select(Season) + .join(Season.episodes) + .join(Episode.episode_files) + .where(EpisodeFile.episode_id == episode_id) + ) + + season = self.db.scalar(stmt) + + if not season: + raise NotFoundError( + f"Season not found for episode {episode_id}" + ) + + return SeasonSchema.model_validate(season) + + except SQLAlchemyError as e: + log.error( + f"Database error while retrieving season for episode " + f"{episode_id}: {e}" + ) + raise + def add_season_request( self, season_request: SeasonRequestSchema ) -> SeasonRequestSchema: @@ -381,6 +427,30 @@ def add_season_file(self, season_file: SeasonFileSchema) -> SeasonFileSchema: log.error(f"Database error while adding season file: {e}") raise + def add_episode_file(self, episode_file: EpisodeFileSchema) -> EpisodeFileSchema: + """ + Adds a episode file record to the database. + + :param episode_file: The EpisodeFile object to add. + :return: The added EpisodeFile object. + :raises IntegrityError: If the record violates constraints. + :raises SQLAlchemyError: If a database error occurs. + """ + db_model = EpisodeFile(**episode_file.model_dump()) + try: + self.db.add(db_model) + self.db.commit() + self.db.refresh(db_model) + return EpisodeFileSchema.model_validate(db_model) + except IntegrityError as e: + self.db.rollback() + log.error(f"Integrity error while adding season file: {e}") + raise + except SQLAlchemyError as e: + self.db.rollback() + log.error(f"Database error while adding season file: {e}") + raise + def remove_season_files_by_torrent_id(self, torrent_id: TorrentId) -> int: """ Removes season file records associated with a given torrent ID. @@ -442,6 +512,24 @@ def get_season_files_by_season_id( ) raise + def get_episode_files_by_episode_id(self, episode_id: EpisodeId) -> list[EpisodeFileSchema]: + """ + Retrieve all episode files for a given episode ID. + + :param episode_id: The ID of the episode. + :return: A list of EpisodeFile objects. + :raises SQLAlchemyError: If a database error occurs. + """ + try: + stmt = select(EpisodeFile).where(EpisodeFile.episode_id == episode_id) + results = self.db.execute(stmt).scalars().all() + return [EpisodeFileSchema.model_validate(sf) for sf in results] + except SQLAlchemyError as e: + log.error( + f"Database error retrieving episode files for episode_id {episode_id}: {e}" + ) + raise + def get_torrents_by_show_id(self, show_id: ShowId) -> list[TorrentSchema]: """ Retrieve all torrents associated with a given show ID. @@ -454,8 +542,9 @@ def get_torrents_by_show_id(self, show_id: ShowId) -> list[TorrentSchema]: stmt = ( select(Torrent) .distinct() - .join(SeasonFile, SeasonFile.torrent_id == Torrent.id) - .join(Season, Season.id == SeasonFile.season_id) + .join(EpisodeFile, EpisodeFile.torrent_id == Torrent.id) + .join(Episode, Episode.id == EpisodeFile.episode_id) + .join(Season, Season.id == Episode.season_id) .where(Season.show_id == show_id) ) results = self.db.execute(stmt).scalars().unique().all() @@ -499,8 +588,9 @@ def get_seasons_by_torrent_id(self, torrent_id: TorrentId) -> list[SeasonNumber] stmt = ( select(Season.number) .distinct() - .join(SeasonFile, Season.id == SeasonFile.season_id) - .where(SeasonFile.torrent_id == torrent_id) + .join(Episode, Episode.season_id == Season.id) + .join(EpisodeFile, EpisodeFile.episode_id == Episode.id) + .where(EpisodeFile.torrent_id == torrent_id) ) results = self.db.execute(stmt).scalars().unique().all() return [SeasonNumber(x) for x in results] @@ -510,6 +600,33 @@ def get_seasons_by_torrent_id(self, torrent_id: TorrentId) -> list[SeasonNumber] ) raise + def get_episodes_by_torrent_id(self, torrent_id: TorrentId) -> list[EpisodeNumber]: + """ + Retrieve episode numbers associated with a given torrent ID. + + :param torrent_id: The ID of the torrent. + :return: A list of EpisodeNumber objects. + :raises SQLAlchemyError: If a database error occurs. + """ + try: + stmt = ( + select(Episode.number) + .join(EpisodeFile, EpisodeFile.episode_id == Episode.id) + .where(EpisodeFile.torrent_id == torrent_id) + .order_by(Episode.number) + ) + + episode_numbers = self.db.execute(stmt).scalars().all() + + return [EpisodeNumber(n) for n in sorted(set(episode_numbers))] + + except SQLAlchemyError as e: + log.error( + f"Database error retrieving episodes for torrent_id {torrent_id}: {e}" + ) + raise + + def get_season_request( self, season_request_id: SeasonRequestId ) -> SeasonRequestSchema: diff --git a/media_manager/tv/schemas.py b/media_manager/tv/schemas.py index d72125ad..05975854 100644 --- a/media_manager/tv/schemas.py +++ b/media_manager/tv/schemas.py @@ -24,6 +24,7 @@ class Episode(BaseModel): number: EpisodeNumber external_id: int title: str + overview: str | None = None class Season(BaseModel): @@ -106,6 +107,14 @@ class SeasonFile(BaseModel): torrent_id: TorrentId | None file_path_suffix: str +class EpisodeFile(BaseModel): + model_config = ConfigDict(from_attributes=True) + + episode_id: EpisodeId + quality: Quality + torrent_id: TorrentId | None + file_path_suffix: str + class PublicSeasonFile(SeasonFile): downloaded: bool = False @@ -123,6 +132,7 @@ class RichSeasonTorrent(BaseModel): file_path_suffix: str seasons: list[SeasonNumber] + episodes: list[EpisodeNumber] class RichShowTorrent(BaseModel): @@ -135,6 +145,18 @@ class RichShowTorrent(BaseModel): torrents: list[RichSeasonTorrent] +class PublicEpisode(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: EpisodeId + number: EpisodeNumber + + downloaded: bool = False + title: str + overview: str | None = None + + external_id: int + + class PublicSeason(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -147,7 +169,7 @@ class PublicSeason(BaseModel): external_id: int - episodes: list[Episode] + episodes: list[PublicEpisode] class PublicShow(BaseModel): diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index 9ccc3507..2154c51f 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -44,6 +44,8 @@ Episode as EpisodeSchema, ) from media_manager.tv.schemas import ( + Episode, + EpisodeFile EpisodeId, PublicSeason, PublicSeasonFile, @@ -173,15 +175,14 @@ def delete_show( for torrent in torrents: try: self.torrent_service.cancel_download(torrent, delete_files=True) + self.torrent_service.delete_torrent(torrent_id=torrent.id) log.info(f"Deleted torrent: {torrent.hash}") except Exception as e: log.warning(f"Failed to delete torrent {torrent.hash}: {e}") self.tv_repository.delete_show(show_id=show.id) - def get_public_season_files_by_season_id( - self, season: Season - ) -> list[PublicSeasonFile]: + def get_public_season_files_by_season_id(self, season: Season) -> list[PublicSeasonFile]: """ Get all public season files for a given season. @@ -332,11 +333,19 @@ def get_public_show_by_id(self, show: Show) -> PublicShow: :param show: The show object. :return: A public show. """ - seasons = [PublicSeason.model_validate(season) for season in show.seasons] - for season in seasons: - season.downloaded = self.is_season_downloaded(season_id=season.id) public_show = PublicShow.model_validate(show) - public_show.seasons = seasons + public_seasons: list[PublicSeason] = [] + + for season in show.seasons: + public_season = PublicSeason.model_validate(season) + public_season.downloaded = self.is_season_downloaded(season=season, show=show) + + for episode in public_season.episodes: + episode.downloaded = self.is_episode_downloaded(episode=episode, season=season, show=show) + + public_seasons.append(public_season) + + public_show.seasons = public_seasons return public_show def get_show_by_id(self, show_id: ShowId) -> Show: @@ -348,19 +357,67 @@ def get_show_by_id(self, show_id: ShowId) -> Show: """ return self.tv_repository.get_show_by_id(show_id=show_id) - def is_season_downloaded(self, season_id: SeasonId) -> bool: + def is_season_downloaded(self, season: Season, show: Show) -> bool: """ Check if a season is downloaded. - :param season_id: The ID of the season. + :param season: The season object. + :param show: The show object. :return: True if the season is downloaded, False otherwise. """ - season_files = self.tv_repository.get_season_files_by_season_id( - season_id=season_id + episodes = season.episodes + + if not episodes: + return False + + for episode in episodes: + if not self.is_episode_downloaded(episode=episode, season=season, show=show): + return False + return True + + def is_episode_downloaded(self, episode: Episode, season: Season, show: Show) -> bool: + """ + Check if an episode is downloaded and imported (file exists on disk). + + An episode is considered downloaded if: + - There is at least one EpisodeFile in the database AND + - A matching episode file exists in the season directory on disk. + + :param episode: The episode object. + :param season: The season object. + :param show: The show object. + :return: True if the episode is downloaded and imported, False otherwise. + """ + episode_files = self.tv_repository.get_episode_files_by_episode_id( + episode_id=episode.id ) - for season_file in season_files: - if self.season_file_exists_on_file(season_file=season_file): - return True + + if not episode_files: + return False + + season_dir = self.get_root_season_directory(show, season.number) + + if not season_dir.exists(): + return False + + episode_token = f"S{season.number:02d}E{episode.number:02d}" + + VIDEO_EXTENSIONS = {".mkv", ".mp4", ".avi", ".mov"} + + try: + for file in season_dir.iterdir(): + if ( + file.is_file() + and episode_token in file.name + and file.suffix.lower() in VIDEO_EXTENSIONS + ): + return True + + except OSError as e: + log.error( + f"Disk check failed for episode {episode.id} in {season_dir}: {e}" + ) + return False def season_file_exists_on_file(self, season_file: SeasonFile) -> bool: @@ -406,6 +463,24 @@ def get_season(self, season_id: SeasonId) -> Season: """ return self.tv_repository.get_season(season_id=season_id) + def get_episode(self, episode_id: EpisodeId) -> Episode: + """ + Get an episode by its ID. + + :param episode_id: The ID of the episode. + :return: The episode. + """ + return self.tv_repository.get_episode(episode_id=episode_id) + + def get_season_by_episode(self, episode_id: EpisodeId) -> Season: + """ + Get a season by the episode ID. + + :param episode_id: The ID of the episode. + :return: The season. + """ + return self.tv_repository.get_season_by_episode(episode_id=episode_id) + def get_all_season_requests(self) -> list[RichSeasonRequest]: """ Get all season requests. @@ -427,10 +502,13 @@ def get_torrents_for_show(self, show: Show) -> RichShowTorrent: seasons = self.tv_repository.get_seasons_by_torrent_id( torrent_id=show_torrent.id ) - season_files = self.torrent_service.get_season_files_of_torrent( - torrent=show_torrent + episodes = self.tv_repository.get_episodes_by_torrent_id( + torrent_id=show_torrent.id ) - file_path_suffix = season_files[0].file_path_suffix if season_files else "" + log.debug(f"episodes:{episodes}") + episode_files = self.torrent_service.get_episode_files_of_torrent(torrent=show_torrent) + + file_path_suffix = episode_files[0].file_path_suffix if episode_files else "" season_torrent = RichSeasonTorrent( torrent_id=show_torrent.id, torrent_title=show_torrent.title, @@ -438,17 +516,20 @@ def get_torrents_for_show(self, show: Show) -> RichShowTorrent: quality=show_torrent.quality, imported=show_torrent.imported, seasons=seasons, + episodes=episodes, file_path_suffix=file_path_suffix, usenet=show_torrent.usenet, ) rich_season_torrents.append(season_torrent) - return RichShowTorrent( + + richshowtorrent = RichShowTorrent( show_id=show.id, name=show.name, year=show.year, metadata_provider=show.metadata_provider, torrents=rich_season_torrents, ) + return richshowtorrent def get_all_shows_with_torrents(self) -> list[RichShowTorrent]: """ @@ -476,6 +557,7 @@ def download_torrent( indexer_result = self.indexer_service.get_result( result_id=public_indexer_result_id ) + log.debug(f"indexer_result:{indexer_result}") show_torrent = self.torrent_service.download(indexer_result=indexer_result) self.torrent_service.pause_download(torrent=show_torrent) @@ -484,16 +566,32 @@ def download_torrent( season = self.tv_repository.get_season_by_number( season_number=season_number, show_id=show_id ) - season_file = SeasonFile( - season_id=season.id, - quality=indexer_result.quality, - torrent_id=show_torrent.id, - file_path_suffix=override_show_file_path_suffix, - ) - self.tv_repository.add_season_file(season_file=season_file) + episodes = {episode.number: episode.id for episode in season.episodes} + + if indexer_result.episode: + for episode_number in indexer_result.episode: + current_episode_id = episodes.get(episode_number) + episode_file = EpisodeFile( + episode_id=current_episode_id, + quality=indexer_result.quality, + torrent_id=show_torrent.id, + file_path_suffix=override_show_file_path_suffix, + ) + self.tv_repository.add_episode_file(episode_file=episode_file) + else: + for episode in season.episodes: + current_episode_id = episode.id + episode_file = EpisodeFile( + episode_id=current_episode_id, + quality=indexer_result.quality, + torrent_id=show_torrent.id, + file_path_suffix=override_show_file_path_suffix, + ) + self.tv_repository.add_episode_file(episode_file=episode_file) + except IntegrityError: log.error( - f"Season file for season {season.id} and quality {indexer_result.quality} already exists, skipping." + f"Episode file for episode {current_episode_id} of season {season.id} and quality {indexer_result.quality} already exists, skipping." ) self.torrent_service.cancel_download( torrent=show_torrent, delete_files=True @@ -501,7 +599,7 @@ def download_torrent( raise else: log.info( - f"Successfully added season files for torrent {show_torrent.title} and show ID {show_id}" + f"Successfully added episode files for torrent {show_torrent.title} and show ID {show_id}" ) self.torrent_service.resume_download(torrent=show_torrent) @@ -689,6 +787,67 @@ def import_season( ) return success, imported_episodes_count + def import_episode_files( + self, + show: Show, + season: Season, + episode: Episode, + video_files: list[Path], + subtitle_files: list[Path], + file_path_suffix: str = "", + ) -> bool: + episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode.number:02d}" + if file_path_suffix != "": + episode_file_name += f" - {file_path_suffix}" + pattern = ( + r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode.number) + r"[. ].*" + ) + subtitle_pattern = pattern + r"[. ]([A-Za-z]{2})[. ]srt" + target_file_name = ( + self.get_root_season_directory(show=show, season_number=season.number) + / episode_file_name + ) + + # import subtitle + for subtitle_file in subtitle_files: + regex_result = re.search( + subtitle_pattern, subtitle_file.name, re.IGNORECASE + ) + if regex_result: + language_code = regex_result.group(1) + target_subtitle_file = target_file_name.with_suffix( + f".{language_code}.srt" + ) + import_file(target_file=target_subtitle_file, source_file=subtitle_file) + else: + log.debug( + f"Didn't find any pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}" + ) + + found_video = False + + # import episode videos + for file in video_files: + if re.search(pattern, file.name, re.IGNORECASE): + target_video_file = target_file_name.with_suffix(file.suffix) + import_file(target_file=target_video_file, source_file=file) + found_video = True + break + + if not found_video: + # Send notification about missing episode file + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Missing Episode File", + message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.", + ) + log.warning( + f"File for S{season.number}E{episode.number} not found when trying to import episode for show {show.name}." + ) + return False + + return True + def import_torrent_files(self, torrent: Torrent, show: Show) -> None: """ Organizes files from a torrent into the TV directory structure, mapping them to seasons and episodes. @@ -749,6 +908,104 @@ def import_torrent_files(self, torrent: Torrent, show: Show) -> None: message=f"Importing {show.name} ({show.year}) from torrent {torrent.title} completed with errors. Please check the logs for details.", ) + def import_episode_files_from_torrent(self, torrent: Torrent, show: Show) -> None: + """ + Organizes episodes files from a torrent into the TV directory structure, mapping them to seasons and episodes. + :param torrent: The Torrent object + :param show: The Show object + """ + + video_files, subtitle_files, all_files = get_files_for_import(torrent=torrent) + + success: list[bool] = [] + + log.debug( + f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files) + ) + + episode_files = self.torrent_service.get_episode_files_of_torrent(torrent=torrent) + if not episode_files: + log.warning( + f"No episode files associated with torrent {torrent.title}, skipping import." + ) + return + + log.info( + f"Found {len(episode_files)} episode files associated with torrent {torrent.title}" + ) + + imported_episodes_by_season: dict[int, list[int]] = {} + + for episode_file in episode_files: + season = self.get_season_by_episode(episode_id=episode_file.episode_id) + episode = self.get_episode(episode_file.episode_id) + + season_path = self.get_root_season_directory( + show=show, season_number=season.number + ) + if not season_path.exists(): + try: + season_path.mkdir(parents=True) + except Exception as e: + log.warning(f"Could not create path {season_path}: {e}") + raise Exception(f"Could not create path {season_path}") from e + + episoded_import_success = self.import_episode_files( + show=show, + season=season, + episode=episode, + video_files=video_files, + subtitle_files=subtitle_files, + file_path_suffix=episode_file.file_path_suffix, + ) + success.append(episoded_import_success) + + if episoded_import_success: + imported_episodes_by_season.setdefault(season.number, []).append(episode.number) + + log.info( + f"Episode {episode.number} from Season {season.number} successfully imported from torrent {torrent.title}" + ) + else: + log.warning( + f"Episode {episode.number} from Season {season.number} failed to import from torrent {torrent.title}" + ) + + success_messages: list[str] = [] + + for season_number, episodes in imported_episodes_by_season.items(): + episode_list = ",".join(str(e) for e in sorted(episodes)) + success_messages.append( + f"Episode(s): {episode_list} from Season {season_number}" + ) + + episodes_summary = "; ".join(success_messages) + + if all(success): + torrent.imported = True + self.torrent_service.torrent_repository.save_torrent(torrent=torrent) + + # Send successful season download notification + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="TV Show imported successfully", + message=( + f"Successfully imported {episodes_summary} " + f"of {show.name} ({show.year}) " + f"from torrent {torrent.title}." + ), + ) + else: + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Failed to import TV Show", + message=f"Importing {show.name} ({show.year}) from torrent {torrent.title} completed with errors. Please check the logs for details.", + ) + + log.info( + f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors" + ) + def update_show_metadata( self, db_show: Show, metadata_provider: AbstractMetadataProvider ) -> Show | None: @@ -820,6 +1077,7 @@ def update_show_metadata( self.tv_repository.update_episode_attributes( episode_id=existing_episode.id, title=fresh_episode_data.title, + overview=fresh_episode_data.overview, ) else: # Add new episode @@ -831,6 +1089,7 @@ def update_show_metadata( number=fresh_episode_data.number, external_id=fresh_episode_data.external_id, title=fresh_episode_data.title, + overview=fresh_episode_data.overview, ) self.tv_repository.add_episode_to_season( season_id=existing_season.id, episode_data=episode_schema @@ -840,12 +1099,16 @@ def update_show_metadata( log.debug( f"Adding new season {fresh_season_data.number} to show {db_show.name}" ) - episodes_for_schema = [ - EpisodeSchema( - id=EpisodeId(ep_data.id), - number=ep_data.number, - external_id=ep_data.external_id, - title=ep_data.title, + episodes_for_schema = [] + for ep_data in fresh_season_data.episodes: + episodes_for_schema.append( + EpisodeSchema( + id=EpisodeId(ep_data.id), + number=ep_data.number, + external_id=ep_data.external_id, + title=ep_data.title, + overview=ep_data.overview, + ) ) for ep_data in fresh_season_data.episodes ] @@ -1023,10 +1286,11 @@ def import_all_show_torrents() -> None: f"torrent {t.title} is not a tv torrent, skipping import." ) continue - tv_service.import_torrent_files(torrent=t, show=show) + tv_service.import_episode_files_from_torrent(torrent=t, show=show) except RuntimeError as e: log.error( - f"Error importing torrent {t.title} for show {show.name}: {e}" + f"Error importing torrent {t.title} for show {show.name}: {e}", + exc_info=True, ) log.info("Finished importing all torrents") db.commit() diff --git a/web/src/lib/api/api.d.ts b/web/src/lib/api/api.d.ts index b7b5bd81..87e83059 100644 --- a/web/src/lib/api/api.d.ts +++ b/web/src/lib/api/api.d.ts @@ -1316,6 +1316,8 @@ export interface components { external_id: number; /** Title */ title: string; + /** Overview */ + overview: string; }; /** ErrorModel */ ErrorModel: { @@ -1719,6 +1721,8 @@ export interface components { file_path_suffix: string; /** Seasons */ seasons: number[]; + /** Episodes */ + episodes: number[]; }; /** RichShowTorrent */ RichShowTorrent: { diff --git a/web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte b/web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte index 32224f52..3070c11d 100644 --- a/web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte +++ b/web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte @@ -80,7 +80,7 @@ Overview -

+

{movie.overview}

diff --git a/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte b/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte index aafd478f..50fd9fee 100644 --- a/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte +++ b/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte @@ -4,6 +4,7 @@ import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js'; import { goto } from '$app/navigation'; import { ImageOff } from 'lucide-svelte'; + import { Ellipsis } from 'lucide-svelte'; import * as Table from '$lib/components/ui/table/index.js'; import { getContext } from 'svelte'; import type { components } from '$lib/api/api'; @@ -27,6 +28,17 @@ let torrents: components['schemas']['RichShowTorrent'] = $derived(page.data.torrentsData); let user: () => components['schemas']['UserRead'] = getContext('user'); + let expandedSeasons = $state>(new Set()); + + function toggleSeason(seasonId: string) { + if (expandedSeasons.has(seasonId)) { + expandedSeasons.delete(seasonId); + } else { + expandedSeasons.add(seasonId); + } + expandedSeasons = new Set(expandedSeasons); + } + let continuousDownloadEnabled = $derived(show.continuous_download); async function toggle_continuous_download() { @@ -109,7 +121,7 @@ Overview -

+

{show.overview}

@@ -162,35 +174,72 @@ - + A list of all seasons. - Number - Exists on file - Title + Number + Exists on file + Title Overview + Details {#if show.seasons.length > 0} {#each show.seasons as season (season.id)} - goto( - resolve('/dashboard/tv/[showId]/[seasonId]', { - showId: show.id, - seasonId: season.id - }) - )} + class={`group cursor-pointer transition-colors hover:bg-muted/60 ${ + expandedSeasons.has(season.id) ? 'bg-muted/50' : 'bg-muted/10' + }`} + onclick={() => toggleSeason(season.id)} > - {season.number} + + S{String(season.number).padStart(2, '0')} + {season.name} {season.overview} - + + + + + {#if expandedSeasons.has(season.id)} + {#each season.episodes as episode (episode.id)} + + + E{String(episode.number).padStart(2, '0')} + + + {episode.title} + {episode.overview} + + {/each} + {/if} + + {/each} {:else} diff --git a/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.svelte b/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.svelte index d61b7d4c..f5df28b8 100644 --- a/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.svelte +++ b/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.svelte @@ -68,13 +68,25 @@
- - Overview - - -

- {show.overview} -

+ +
+ + Series Overview + +

+ {show.overview} +

+
+
+
+ + Season Overview + +

+ {season.overview} +

+
+
@@ -132,19 +144,21 @@ - + A list of all episodes. - Number - Title + Number + Title + Overview {#each season.episodes as episode (episode.id)} - {episode.number} + E{String(episode.number).padStart(2, '0')} {episode.title} + {episode.overview} {/each}