From d8a0ec66c3684aec68c4bd2a27f93b07d875bf3c Mon Sep 17 00:00:00 2001 From: natarelli22 <88474447+natarelli22@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:21:19 -0300 Subject: [PATCH] Support for handling Single Episode Torrents (#331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Description** As explained on #322, MediaManager currently only matches torrents that represent full seasons or season packs. As a result, valid episode-based releases — commonly returned by indexers such as EZTV — are filtered out during scoring and never considered for download. Initial changes to the season parsing logic allow these torrents to be discovered. However, additional changes are required beyond season parsing to properly support single-episode imports. This PR is intended as a work-in-progress / RFC to discuss the required changes and align on the correct approach before completing the implementation. **Things planned to do** [X] Update Web UI to better display episode-level details [ ] Update TV show import logic to handle single episode files, instead of assuming full season files (to avoid integrity errors when episodes are missing) [ ] Create episode file tables to store episode-level data, similar to season files [ ] Implement fetching and downloading logic for single-episode torrents **Notes / current limitations** At the moment, the database and import logic assume one file per season per quality, which works for season packs but not for episode-based releases. These changes are intentionally not completed yet and are part of the discussion this PR aims to start. **Request for feedback** This represents a significant change in how TV content is handled in MediaManager. Before proceeding further, feedback from @maxdorninger on the overall direction and next steps would be greatly appreciated. Once aligned, the remaining tasks can be implemented incrementally. --------- Co-authored-by: Maximilian Dorninger <97409287+maxdorninger@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- alembic/env.py | 4 +- ...dd_episode_column_to_indexerqueryresult.py | 46 +++ ...8e_add_overview_column_to_episode_table.py | 31 ++ media_manager/indexer/models.py | 1 + media_manager/indexer/schemas.py | 55 ++- media_manager/torrent/models.py | 2 +- media_manager/torrent/repository.py | 35 +- media_manager/torrent/service.py | 12 +- media_manager/tv/models.py | 21 +- media_manager/tv/repository.py | 192 ++++++--- media_manager/tv/router.py | 10 +- media_manager/tv/schemas.py | 22 +- media_manager/tv/service.py | 383 ++++++++++++++---- web/src/lib/api/api.d.ts | 75 ++-- .../download-custom-dialog.svelte | 164 ++++++++ .../download-selected-episodes-dialog.svelte | 188 +++++++++ .../download-selected-seasons-dialog.svelte | 189 +++++++++ .../components/torrents/torrent-table.svelte | 7 + web/src/lib/utils.ts | 11 + .../movies/[movieId=uuid]/+page.svelte | 2 +- .../dashboard/tv/[showId=uuid]/+page.svelte | 175 +++++++- .../[SeasonId=uuid]/+page.svelte | 55 ++- .../tv/[showId=uuid]/[SeasonId=uuid]/+page.ts | 4 +- 23 files changed, 1443 insertions(+), 241 deletions(-) create mode 100644 alembic/versions/3a8fbd71e2c2_create_episode_file_table_and_add_episode_column_to_indexerqueryresult.py create mode 100644 alembic/versions/9f3c1b2a4d8e_add_overview_column_to_episode_table.py create mode 100644 web/src/lib/components/download-dialogs/download-custom-dialog.svelte create mode 100644 web/src/lib/components/download-dialogs/download-selected-episodes-dialog.svelte create mode 100644 web/src/lib/components/download-dialogs/download-selected-seasons-dialog.svelte diff --git a/alembic/env.py b/alembic/env.py index 20de4f5..10a0de6 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -35,8 +35,8 @@ from media_manager.notification.models import Notification # noqa: E402 from media_manager.torrent.models import Torrent # noqa: E402 from media_manager.tv.models import ( # noqa: E402 Episode, + EpisodeFile, Season, - SeasonFile, SeasonRequest, Show, ) @@ -47,6 +47,7 @@ target_metadata = Base.metadata # noinspection PyStatementEffect __all__ = [ "Episode", + "EpisodeFile", "IndexerQueryResult", "Movie", "MovieFile", @@ -54,7 +55,6 @@ __all__ = [ "Notification", "OAuthAccount", "Season", - "SeasonFile", "SeasonRequest", "Show", "Torrent", 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 0000000..c86aea5 --- /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 0000000..df086c5 --- /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 fea717b..a6a52fa 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 02af87c..d0f61b1 100644 --- a/media_manager/indexer/schemas.py +++ b/media_manager/indexer/schemas.py @@ -54,14 +54,55 @@ class IndexerQueryResult(BaseModel): @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() + + # 1) S01E01 / S1E2 + m = re.search(r"s(\d{1,2})e\d{1,3}", title) + if m: + return [int(m.group(1))] + + # 2) Range S01-S03 / S1-S3 + m = re.search(r"s(\d{1,2})\s*(?:-|\u2013)\s*s?(\d{1,2})", title) + if m: + start, end = int(m.group(1)), int(m.group(2)) + if start <= end: + return list(range(start, end + 1)) + return [] + + # 3) Pack S01 / S1 + m = re.search(r"\bs(\d{1,2})\b", title) + if m: + return [int(m.group(1))] + + # 4) Season 01 / Season 1 + m = re.search(r"\bseason\s*(\d{1,2})\b", title) + if m: + return [int(m.group(1))] + + return [] + + @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 296c879..94b74fc 100644 --- a/media_manager/torrent/models.py +++ b/media_manager/torrent/models.py @@ -16,5 +16,5 @@ class Torrent(Base): hash: Mapped[str] 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 6e20ef4..0d3eb7c 100644 --- a/media_manager/torrent/repository.py +++ b/media_manager/torrent/repository.py @@ -3,17 +3,13 @@ from sqlalchemy import delete, select from media_manager.database import DbSessionDependency from media_manager.exceptions import NotFoundError from media_manager.movies.models import Movie, MovieFile -from media_manager.movies.schemas import ( - Movie as MovieSchema, -) -from media_manager.movies.schemas import ( - MovieFile as MovieFileSchema, -) +from media_manager.movies.schemas import Movie as MovieSchema +from media_manager.movies.schemas import 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.models import Episode, EpisodeFile, Season, Show +from media_manager.tv.schemas import EpisodeFile as EpisodeFileSchema from media_manager.tv.schemas import Show as ShowSchema @@ -21,19 +17,22 @@ class TorrentRepository: def __init__(self, db: DbSessionDependency) -> None: self.db = db - def get_seasons_files_of_torrent( + def get_episode_files_of_torrent( self, torrent_id: TorrentId - ) -> list[SeasonFileSchema]: - stmt = select(SeasonFile).where(SeasonFile.torrent_id == torrent_id) + ) -> list[EpisodeFileSchema]: + stmt = select(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id) result = self.db.execute(stmt).scalars().all() - return [SeasonFileSchema.model_validate(season_file) for season_file in result] + 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 +68,10 @@ class TorrentRepository: ) 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 d8c1bee..36f9980 100644 --- a/media_manager/torrent/service.py +++ b/media_manager/torrent/service.py @@ -5,7 +5,7 @@ from media_manager.movies.schemas import Movie, MovieFile 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 EpisodeFile, Show log = logging.getLogger(__name__) @@ -19,13 +19,13 @@ class TorrentService: self.torrent_repository = torrent_repository self.download_manager = download_manager or DownloadManager() - def get_season_files_of_torrent(self, torrent: Torrent) -> list[SeasonFile]: + def get_episode_files_of_torrent(self, torrent: Torrent) -> list[EpisodeFile]: """ - Returns all season files of a torrent - :param torrent: the torrent to get the season files of - :return: list of season files + 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_seasons_files_of_torrent( + return self.torrent_repository.get_episode_files_of_torrent( torrent_id=torrent.id ) diff --git a/media_manager/tv/models.py b/media_manager/tv/models.py index c72702b..ff557e4 100644 --- a/media_manager/tv/models.py +++ b/media_manager/tv/models.py @@ -48,9 +48,6 @@ class Season(Base): back_populates="season", cascade="all, delete" ) - season_files = relationship( - "SeasonFile", back_populates="season", cascade="all, delete" - ) season_requests = relationship( "SeasonRequest", back_populates="season", cascade="all, delete" ) @@ -66,15 +63,19 @@ class Episode(Base): number: Mapped[int] external_id: Mapped[int] title: Mapped[str] + overview: Mapped[str | None] = mapped_column(nullable=True) season: Mapped["Season"] = relationship(back_populates="episodes") + episode_files = relationship( + "EpisodeFile", back_populates="episode", cascade="all, delete" + ) -class SeasonFile(Base): - __tablename__ = "season_file" - __table_args__ = (PrimaryKeyConstraint("season_id", "file_path_suffix"),) - season_id: Mapped[UUID] = mapped_column( - ForeignKey(column="season.id", ondelete="CASCADE"), +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"), @@ -82,8 +83,8 @@ class SeasonFile(Base): file_path_suffix: Mapped[str] quality: Mapped[Quality] - torrent = relationship("Torrent", back_populates="season_files", uselist=False) - season = relationship("Season", back_populates="season_files", uselist=False) + torrent = relationship("Torrent", back_populates="episode_files", uselist=False) + episode = relationship("Episode", back_populates="episode_files", uselist=False) class SeasonRequest(Base): diff --git a/media_manager/tv/repository.py b/media_manager/tv/repository.py index 6aad169..ed0cc3d 100644 --- a/media_manager/tv/repository.py +++ b/media_manager/tv/repository.py @@ -1,8 +1,5 @@ from sqlalchemy import delete, func, select -from sqlalchemy.exc import ( - IntegrityError, - SQLAlchemyError, -) +from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.orm import Session, joinedload from media_manager.exceptions import ConflictError, NotFoundError @@ -10,32 +7,21 @@ 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 import log -from media_manager.tv.models import Episode, Season, SeasonFile, SeasonRequest, Show -from media_manager.tv.schemas import ( - Episode as EpisodeSchema, -) +from media_manager.tv.models import Episode, EpisodeFile, Season, SeasonRequest, Show +from media_manager.tv.schemas import Episode as EpisodeSchema +from media_manager.tv.schemas import EpisodeFile as EpisodeFileSchema from media_manager.tv.schemas import ( EpisodeId, + EpisodeNumber, SeasonId, SeasonNumber, SeasonRequestId, ShowId, ) -from media_manager.tv.schemas import ( - RichSeasonRequest as RichSeasonRequestSchema, -) -from media_manager.tv.schemas import ( - Season as SeasonSchema, -) -from media_manager.tv.schemas import ( - SeasonFile as SeasonFileSchema, -) -from media_manager.tv.schemas import ( - SeasonRequest as SeasonRequestSchema, -) -from media_manager.tv.schemas import ( - Show as ShowSchema, -) +from media_manager.tv.schemas import RichSeasonRequest as RichSeasonRequestSchema +from media_manager.tv.schemas import Season as SeasonSchema +from media_manager.tv.schemas import SeasonRequest as SeasonRequestSchema +from media_manager.tv.schemas import Show as ShowSchema class TvRepository: @@ -120,9 +106,7 @@ class TvRepository: def get_total_downloaded_episodes_count(self) -> int: try: - stmt = ( - select(func.count()).select_from(Episode).join(Season).join(SeasonFile) - ) + stmt = select(func.count(Episode.id)).select_from(Episode).join(EpisodeFile) return self.db.execute(stmt).scalar_one_or_none() except SQLAlchemyError: log.exception("Database error while calculating downloaded episodes count") @@ -173,6 +157,7 @@ class TvRepository: number=episode.number, external_id=episode.external_id, title=episode.title, + overview=episode.overview, ) for episode in season.episodes ], @@ -234,6 +219,43 @@ class TvRepository: log.exception(f"Database error while retrieving season {season_id}") 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: + msg = f"Episode with id {episode_id} not found." + raise NotFoundError(msg) + 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).where(Episode.id == episode_id) + + season = self.db.scalar(stmt) + + if not season: + msg = f"Season not found for episode {episode_id}" + raise NotFoundError(msg) + + return SeasonSchema.model_validate(season) + + except SQLAlchemyError as e: + log.error( + f"Database error while retrieving season for episode {episode_id}: {e}" + ) + raise + def add_season_request( self, season_request: SeasonRequestSchema ) -> SeasonRequestSchema: @@ -355,46 +377,46 @@ class TvRepository: log.exception("Database error while retrieving season requests") raise - def add_season_file(self, season_file: SeasonFileSchema) -> SeasonFileSchema: + def add_episode_file(self, episode_file: EpisodeFileSchema) -> EpisodeFileSchema: """ - Adds a season file record to the database. + Adds an episode file record to the database. - :param season_file: The SeasonFile object to add. - :return: The added SeasonFile object. + :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 = SeasonFile(**season_file.model_dump()) + db_model = EpisodeFile(**episode_file.model_dump()) try: self.db.add(db_model) self.db.commit() self.db.refresh(db_model) - return SeasonFileSchema.model_validate(db_model) - except IntegrityError: + return EpisodeFileSchema.model_validate(db_model) + except IntegrityError as e: self.db.rollback() - log.exception("Integrity error while adding season file") + log.error(f"Integrity error while adding episode file: {e}") raise - except SQLAlchemyError: + except SQLAlchemyError as e: self.db.rollback() - log.exception("Database error while adding season file") + log.error(f"Database error while adding episode file: {e}") raise - def remove_season_files_by_torrent_id(self, torrent_id: TorrentId) -> int: + def remove_episode_files_by_torrent_id(self, torrent_id: TorrentId) -> int: """ - Removes season file records associated with a given torrent ID. + Removes episode file records associated with a given torrent ID. - :param torrent_id: The ID of the torrent whose season files are to be removed. - :return: The number of season files removed. + :param torrent_id: The ID of the torrent whose episode files are to be removed. + :return: The number of episode files removed. :raises SQLAlchemyError: If a database error occurs. """ try: - stmt = delete(SeasonFile).where(SeasonFile.torrent_id == torrent_id) + stmt = delete(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id) result = self.db.execute(stmt) self.db.commit() except SQLAlchemyError: self.db.rollback() log.exception( - f"Database error removing season files for torrent_id {torrent_id}" + f"Database error removing episode files for torrent_id {torrent_id}" ) raise return result.rowcount @@ -420,23 +442,45 @@ class TvRepository: log.exception(f"Database error setting library for show {show_id}") raise - def get_season_files_by_season_id( + def get_episode_files_by_season_id( self, season_id: SeasonId - ) -> list[SeasonFileSchema]: + ) -> list[EpisodeFileSchema]: """ - Retrieve all season files for a given season ID. + Retrieve all episode files for a given season ID. :param season_id: The ID of the season. - :return: A list of SeasonFile objects. + :return: A list of EpisodeFile objects. :raises SQLAlchemyError: If a database error occurs. """ try: - stmt = select(SeasonFile).where(SeasonFile.season_id == season_id) + stmt = ( + select(EpisodeFile).join(Episode).where(Episode.season_id == season_id) + ) results = self.db.execute(stmt).scalars().all() - return [SeasonFileSchema.model_validate(sf) for sf in results] + return [EpisodeFileSchema.model_validate(ef) for ef in results] except SQLAlchemyError: log.exception( - f"Database error retrieving season files for season_id {season_id}" + f"Database error retrieving episode files for season_id {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 @@ -452,8 +496,9 @@ class TvRepository: 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() @@ -474,8 +519,9 @@ class TvRepository: select(Show) .distinct() .join(Season, Show.id == Season.show_id) - .join(SeasonFile, Season.id == SeasonFile.season_id) - .join(Torrent, SeasonFile.torrent_id == Torrent.id) + .join(Episode, Season.id == Episode.season_id) + .join(EpisodeFile, Episode.id == EpisodeFile.episode_id) + .join(Torrent, EpisodeFile.torrent_id == Torrent.id) .options(joinedload(Show.seasons).joinedload(Season.episodes)) .order_by(Show.name) ) @@ -497,8 +543,9 @@ class TvRepository: 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] @@ -508,6 +555,32 @@ class TvRepository: ) 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: @@ -734,11 +807,15 @@ class TvRepository: return SeasonSchema.model_validate(db_season) def update_episode_attributes( - self, episode_id: EpisodeId, title: str | None = None + self, + episode_id: EpisodeId, + title: str | None = None, + overview: str | None = None, ) -> EpisodeSchema: """ Update attributes of an existing episode. + :param overview: Tje new overview for the episode. :param episode_id: The ID of the episode to update. :param title: The new title for the episode. :param external_id: The new external ID for the episode. @@ -755,6 +832,9 @@ class TvRepository: if title is not None and db_episode.title != title: db_episode.title = title updated = True + if overview is not None and db_episode.overview != overview: + db_episode.overview = overview + updated = True if updated: self.db.commit() diff --git a/media_manager/tv/router.py b/media_manager/tv/router.py index 46f05d9..41fcd65 100644 --- a/media_manager/tv/router.py +++ b/media_manager/tv/router.py @@ -25,7 +25,7 @@ from media_manager.tv.dependencies import ( ) from media_manager.tv.schemas import ( CreateSeasonRequest, - PublicSeasonFile, + PublicEpisodeFile, PublicShow, RichSeasonRequest, RichShowTorrent, @@ -402,13 +402,13 @@ def get_season(season: season_dep) -> Season: "/seasons/{season_id}/files", dependencies=[Depends(current_active_user)], ) -def get_season_files( +def get_episode_files( season: season_dep, tv_service: tv_service_dep -) -> list[PublicSeasonFile]: +) -> list[PublicEpisodeFile]: """ - Get files associated with a specific season. + Get episode files associated with a specific season. """ - return tv_service.get_public_season_files_by_season_id(season=season) + return tv_service.get_public_episode_files_by_season_id(season=season) # ----------------------------------------------------------------------------- diff --git a/media_manager/tv/schemas.py b/media_manager/tv/schemas.py index d72125a..d1f2959 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): @@ -98,16 +99,16 @@ class RichSeasonRequest(SeasonRequest): season: Season -class SeasonFile(BaseModel): +class EpisodeFile(BaseModel): model_config = ConfigDict(from_attributes=True) - season_id: SeasonId + episode_id: EpisodeId quality: Quality torrent_id: TorrentId | None file_path_suffix: str -class PublicSeasonFile(SeasonFile): +class PublicEpisodeFile(EpisodeFile): downloaded: bool = False @@ -123,6 +124,7 @@ class RichSeasonTorrent(BaseModel): file_path_suffix: str seasons: list[SeasonNumber] + episodes: list[EpisodeNumber] class RichShowTorrent(BaseModel): @@ -135,6 +137,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 +161,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 39f4355..de417a4 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -41,24 +41,24 @@ from media_manager.torrent.utils import ( from media_manager.tv import log from media_manager.tv.repository import TvRepository from media_manager.tv.schemas import ( - Episode as EpisodeSchema, -) -from media_manager.tv.schemas import ( + Episode, + EpisodeFile, EpisodeId, + EpisodeNumber, + PublicEpisodeFile, PublicSeason, - PublicSeasonFile, PublicShow, RichSeasonRequest, RichSeasonTorrent, RichShowTorrent, Season, - SeasonFile, SeasonId, SeasonRequest, SeasonRequestId, Show, ShowId, ) +from media_manager.tv.schemas import Episode as EpisodeSchema class TvService: @@ -173,6 +173,7 @@ class TvService: 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: log.warning( @@ -181,24 +182,26 @@ class TvService: self.tv_repository.delete_show(show_id=show.id) - def get_public_season_files_by_season_id( + def get_public_episode_files_by_season_id( self, season: Season - ) -> list[PublicSeasonFile]: + ) -> list[PublicEpisodeFile]: """ - Get all public season files for a given season. + Get all public episode files for a given season. :param season: The season object. - :return: A list of public season files. + :return: A list of public episode files. """ - season_files = self.tv_repository.get_season_files_by_season_id( + episode_files = self.tv_repository.get_episode_files_by_season_id( season_id=season.id ) - public_season_files = [PublicSeasonFile.model_validate(x) for x in season_files] + public_episode_files = [ + PublicEpisodeFile.model_validate(x) for x in episode_files + ] result = [] - for season_file in public_season_files: - if self.season_file_exists_on_file(season_file=season_file): - season_file.downloaded = True - result.append(season_file) + for episode_file in public_episode_files: + if self.episode_file_exists_on_file(episode_file=episode_file): + episode_file.downloaded = True + result.append(episode_file) return result @overload @@ -334,11 +337,27 @@ class TvService: :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) + + for episode in public_season.episodes: + episode.downloaded = self.is_episode_downloaded( + episode=episode, + season=season, + show=show, + ) + + # A season is considered downloaded if it has episodes and all of them are downloaded, + # matching the behavior of is_season_downloaded. + public_season.downloaded = bool(public_season.episodes) and all( + episode.downloaded for episode in public_season.episodes + ) + public_seasons.append(public_season) + + public_show.seasons = public_seasons return public_show def get_show_by_id(self, show_id: ShowId) -> Show: @@ -350,33 +369,85 @@ class TvService: """ 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.lower() in file.name.lower() + 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: + def episode_file_exists_on_file(self, episode_file: EpisodeFile) -> bool: """ - Check if a season file exists on the filesystem. + Check if an episode file exists on the filesystem. - :param season_file: The season file to check. + :param episode_file: The episode file to check. :return: True if the file exists, False otherwise. """ - if season_file.torrent_id is None: + if episode_file.torrent_id is None: return True try: torrent_file = self.torrent_service.get_torrent_by_id( - torrent_id=season_file.torrent_id + torrent_id=episode_file.torrent_id ) if torrent_file.imported: @@ -409,6 +480,24 @@ class TvService: """ 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. @@ -430,10 +519,16 @@ class TvService: seasons = self.tv_repository.get_seasons_by_torrent_id( torrent_id=show_torrent.id ) - season_files = self.torrent_service.get_season_files_of_torrent( + episodes = self.tv_repository.get_episodes_by_torrent_id( + torrent_id=show_torrent.id + ) + episode_files = self.torrent_service.get_episode_files_of_torrent( torrent=show_torrent ) - file_path_suffix = season_files[0].file_path_suffix if season_files else "" + + 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, @@ -441,10 +536,12 @@ class TvService: quality=show_torrent.quality, imported=show_torrent.imported, seasons=seasons, + episodes=episodes if len(seasons) == 1 else [], file_path_suffix=file_path_suffix, usenet=show_torrent.usenet, ) rich_season_torrents.append(season_torrent) + return RichShowTorrent( show_id=show.id, name=show.name, @@ -487,24 +584,49 @@ class TvService: 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: + episode_ids = [] + missing_episodes = [] + for ep_number in indexer_result.episode: + ep_id = episodes.get(EpisodeNumber(ep_number)) + if ep_id is None: + missing_episodes.append(ep_number) + continue + episode_ids.append(ep_id) + if missing_episodes: + log.warning( + "Some episodes from indexer result were not found in season %s " + "for show %s and will be skipped: %s", + season.id, + show_id, + ", ".join(str(ep) for ep in missing_episodes), + ) + else: + episode_ids = [episode.id for episode in season.episodes] + + for episode_id in episode_ids: + episode_file = EpisodeFile( + episode_id=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 {episode_id} of season {season.id} and quality {indexer_result.quality} already exists, skipping." ) + self.tv_repository.remove_episode_files_by_torrent_id(show_torrent.id) self.torrent_service.cancel_download( torrent=show_torrent, delete_files=True ) 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) @@ -561,7 +683,7 @@ class TvService: available_torrents.sort() torrent = self.torrent_service.download(indexer_result=available_torrents[0]) - season_file = SeasonFile( + season_file = SeasonFile( # noqa: F821 season_id=season.id, quality=torrent.quality, torrent_id=torrent.id, @@ -653,12 +775,12 @@ class TvService: video_files: list[Path], subtitle_files: list[Path], file_path_suffix: str = "", - ) -> tuple[bool, int]: + ) -> tuple[bool, list[Episode]]: season_path = self.get_root_season_directory( show=show, season_number=season.number ) success = True - imported_episodes_count = 0 + imported_episodes = [] try: season_path.mkdir(parents=True, exist_ok=True) except Exception as e: @@ -677,7 +799,7 @@ class TvService: file_path_suffix=file_path_suffix, ) if imported: - imported_episodes_count += 1 + imported_episodes.append(episode) except Exception: # Send notification about missing episode file @@ -690,11 +812,72 @@ class TvService: log.warning( f"S{season.number}E{episode.number} not found when trying to import episode for show {show.name}." ) - return success, imported_episodes_count + return success, imported_episodes - def import_torrent_files(self, torrent: Torrent, show: Show) -> None: + 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_episode_files_from_torrent(self, torrent: Torrent, show: Show) -> None: """ - Organizes files from a torrent into the TV directory structure, mapping them to seasons and episodes. + 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 """ @@ -707,33 +890,68 @@ class TvService: f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files) ) - season_files = self.torrent_service.get_season_files_of_torrent(torrent=torrent) + 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(season_files)} season files associated with torrent {torrent.title}" + f"Found {len(episode_files)} episode files associated with torrent {torrent.title}" ) - for season_file in season_files: - season = self.get_season(season_id=season_file.season_id) - season_import_success, _imported_episodes_count = self.import_season( + 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}") + msg = f"Could not create path {season_path}" + raise Exception(msg) from e # noqa: TRY002 + + episoded_import_success = self.import_episode_files( show=show, season=season, + episode=episode, video_files=video_files, subtitle_files=subtitle_files, - file_path_suffix=season_file.file_path_suffix, + file_path_suffix=episode_file.file_path_suffix, ) - success.append(season_import_success) - if season_import_success: + success.append(episoded_import_success) + + if episoded_import_success: + imported_episodes_by_season.setdefault(season.number, []).append( + episode.number + ) + log.info( - f"Season {season.number} successfully imported from torrent {torrent.title}" + f"Episode {episode.number} from Season {season.number} successfully imported from torrent {torrent.title}" ) else: log.warning( - f"Season {season.number} failed to import from torrent {torrent.title}" + f"Episode {episode.number} from Season {season.number} failed to import from torrent {torrent.title}" ) - log.info( - f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors" - ) + 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 @@ -743,7 +961,11 @@ class TvService: if self.notification_service: self.notification_service.send_notification_to_all_providers( title="TV Show imported successfully", - message=f"Successfully imported {show.name} ({show.year}) from torrent {torrent.title}.", + message=( + f"Successfully imported {episodes_summary} " + f"of {show.name} ({show.year}) " + f"from torrent {torrent.title}." + ), ) else: if self.notification_service: @@ -752,6 +974,10 @@ class TvService: 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: @@ -823,6 +1049,7 @@ class TvService: 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 @@ -834,6 +1061,7 @@ class TvService: 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 @@ -849,6 +1077,7 @@ class TvService: 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 ] @@ -911,21 +1140,22 @@ class TvService: directory=new_source_path ) for season in tv_show.seasons: - success, imported_episode_count = self.import_season( + _success, imported_episodes = self.import_season( show=tv_show, season=season, video_files=video_files, subtitle_files=subtitle_files, file_path_suffix="IMPORTED", ) - season_file = SeasonFile( - season_id=season.id, - quality=Quality.unknown, - file_path_suffix="IMPORTED", - torrent_id=None, - ) - if success or imported_episode_count > (len(season.episodes) / 2): - self.tv_repository.add_season_file(season_file=season_file) + for episode in imported_episodes: + episode_file = EpisodeFile( + episode_id=episode.id, + quality=Quality.unknown, + file_path_suffix="IMPORTED", + torrent_id=None, + ) + + self.tv_repository.add_episode_file(episode_file=episode_file) def get_importable_tv_shows( self, metadata_provider: AbstractMetadataProvider @@ -1029,9 +1259,12 @@ 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) - except RuntimeError: - log.exception(f"Error importing torrent {t.title} for show {show.name}") + 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}", + 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 4258bd3..25f1c23 100644 --- a/web/src/lib/api/api.d.ts +++ b/web/src/lib/api/api.d.ts @@ -626,10 +626,10 @@ export interface paths { cookie?: never; }; /** - * Get Season Files - * @description Get files associated with a specific season. + * Get Episode Files + * @description Get episode files associated with a specific season. */ - get: operations['get_season_files_api_v1_tv_seasons__season_id__files_get']; + get: operations['get_episode_files_api_v1_tv_seasons__season_id__files_get']; put?: never; post?: never; delete?: never; @@ -1316,6 +1316,8 @@ export interface components { external_id: number; /** Title */ title: string; + /** Overview */ + overview?: string | null; }; /** ErrorModel */ ErrorModel: { @@ -1360,6 +1362,8 @@ export interface components { readonly quality: components['schemas']['Quality']; /** Season */ readonly season: number[]; + /** Episode */ + readonly episode: number[]; }; /** LibraryItem */ LibraryItem: { @@ -1504,6 +1508,45 @@ export interface components { /** Authorization Url */ authorization_url: string; }; + /** PublicEpisode */ + PublicEpisode: { + /** + * Id + * Format: uuid + */ + id: string; + /** Number */ + number: number; + /** + * Downloaded + * @default false + */ + downloaded: boolean; + /** Title */ + title: string; + /** Overview */ + overview?: string | null; + /** External Id */ + external_id: number; + }; + /** PublicEpisodeFile */ + PublicEpisodeFile: { + /** + * Episode Id + * Format: uuid + */ + episode_id: string; + quality: components['schemas']['Quality']; + /** Torrent Id */ + torrent_id: string | null; + /** File Path Suffix */ + file_path_suffix: string; + /** + * Downloaded + * @default false + */ + downloaded: boolean; + }; /** PublicMovie */ PublicMovie: { /** @@ -1580,25 +1623,7 @@ export interface components { /** External Id */ external_id: number; /** Episodes */ - episodes: components['schemas']['Episode'][]; - }; - /** PublicSeasonFile */ - PublicSeasonFile: { - /** - * Season Id - * Format: uuid - */ - season_id: string; - quality: components['schemas']['Quality']; - /** Torrent Id */ - torrent_id: string | null; - /** File Path Suffix */ - file_path_suffix: string; - /** - * Downloaded - * @default false - */ - downloaded: boolean; + episodes: components['schemas']['PublicEpisode'][]; }; /** PublicShow */ PublicShow: { @@ -1719,6 +1744,8 @@ export interface components { file_path_suffix: string; /** Seasons */ seasons: number[]; + /** Episodes */ + episodes: number[]; }; /** RichShowTorrent */ RichShowTorrent: { @@ -3232,7 +3259,7 @@ export interface operations { }; }; }; - get_season_files_api_v1_tv_seasons__season_id__files_get: { + get_episode_files_api_v1_tv_seasons__season_id__files_get: { parameters: { query?: never; header?: never; @@ -3250,7 +3277,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PublicSeasonFile'][]; + 'application/json': components['schemas']['PublicEpisodeFile'][]; }; }; /** @description Validation Error */ diff --git a/web/src/lib/components/download-dialogs/download-custom-dialog.svelte b/web/src/lib/components/download-dialogs/download-custom-dialog.svelte new file mode 100644 index 0000000..e9efc7e --- /dev/null +++ b/web/src/lib/components/download-dialogs/download-custom-dialog.svelte @@ -0,0 +1,164 @@ + + + +
+ + +
+ + +
+ +

+ The custom query completely overrides the default search logic. Make sure the torrent title + matches the episodes you want imported. +

+
+ + {#if torrentsError} +
+ An error occurred: {torrentsError} +
+ {/if} + + + {#snippet rowSnippet(torrent)} + {torrent.title} + {(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB + {torrent.usenet} + {torrent.usenet ? 'N/A' : torrent.seeders} + + {torrent.age ? formatSecondsToOptimalUnit(torrent.age) : torrent.usenet ? 'N/A' : ''} + + {torrent.score} + {torrent.indexer ?? 'unknown'} + + {#if torrent.flags} + {#each torrent.flags as flag (flag)} + {flag} + {/each} + {/if} + + + {torrent.season ?? '-'} + + + downloadTorrent(torrent.id)} + /> + + {/snippet} + +
diff --git a/web/src/lib/components/download-dialogs/download-selected-episodes-dialog.svelte b/web/src/lib/components/download-dialogs/download-selected-episodes-dialog.svelte new file mode 100644 index 0000000..a8fa035 --- /dev/null +++ b/web/src/lib/components/download-dialogs/download-selected-episodes-dialog.svelte @@ -0,0 +1,188 @@ + + + +
+

+ Selected episodes: + + {selectedEpisodeNumbers.length > 0 + ? selectedEpisodeNumbers + .map( + (e) => + `S${String(e.seasonNumber).padStart(2, '0')}E${String(e.episodeNumber).padStart(2, '0')}` + ) + .join(', ') + : 'None'} + +

+ + +
+ + {#if torrentsError} +
+ An error occurred: {torrentsError} +
+ {/if} + + + {#snippet rowSnippet(torrent)} + {torrent.title} + + {(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB + + {torrent.usenet} + {torrent.usenet ? 'N/A' : torrent.seeders} + + {torrent.age ? formatSecondsToOptimalUnit(torrent.age) : torrent.usenet ? 'N/A' : ''} + + {torrent.score} + {torrent.indexer ?? 'unknown'} + + {#if torrent.flags} + {#each torrent.flags as flag (flag)} + {flag} + {/each} + {/if} + + + downloadTorrent(torrent.id)} + /> + + {/snippet} + +
diff --git a/web/src/lib/components/download-dialogs/download-selected-seasons-dialog.svelte b/web/src/lib/components/download-dialogs/download-selected-seasons-dialog.svelte new file mode 100644 index 0000000..407a560 --- /dev/null +++ b/web/src/lib/components/download-dialogs/download-selected-seasons-dialog.svelte @@ -0,0 +1,189 @@ + + + +
+

+ Selected seasons: + + {selectedSeasonNumbers.length > 0 + ? selectedSeasonNumbers + .slice() + .sort((a, b) => a - b) + .map((n) => `S${String(n).padStart(2, '0')}`) + .join(', ') + : 'None'} + +

+ + +
+ + {#if torrentsError} +
+ An error occurred: {torrentsError} +
+ {/if} + + + {#snippet rowSnippet(torrent)} + {torrent.title} + + {(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB + + {torrent.usenet} + {torrent.usenet ? 'N/A' : torrent.seeders} + + {torrent.age ? formatSecondsToOptimalUnit(torrent.age) : torrent.usenet ? 'N/A' : ''} + + {torrent.score} + {torrent.indexer ?? 'unknown'} + + {#if torrent.flags} + {#each torrent.flags as flag (flag)} + {flag} + {/each} + {/if} + + + {torrent.season ?? '-'} + + + downloadTorrent(torrent.id)} + /> + + {/snippet} + +
diff --git a/web/src/lib/components/torrents/torrent-table.svelte b/web/src/lib/components/torrents/torrent-table.svelte index fc04717..db493bd 100644 --- a/web/src/lib/components/torrents/torrent-table.svelte +++ b/web/src/lib/components/torrents/torrent-table.svelte @@ -1,6 +1,7 @@ @@ -59,7 +65,7 @@

- {getFullyQualifiedMediaName(show)} Season {season.number} + {getFullyQualifiedMediaName(show)} - Season {season.number}

@@ -68,13 +74,20 @@
- - Overview - - -

- {show.overview} -

+ +
+ Series Overview +

+ {show.overview} +

+
+
+
+ Season Overview +

+ {season.overview} +

+
@@ -95,14 +108,18 @@ > + Episode Quality File Path Suffix Imported - {#each seasonFiles as file (file)} + {#each episodeFiles as file (file)} + + {episodeById[file.episode_id] ?? 'E??'} + {getTorrentQualityString(file.quality)} @@ -114,7 +131,11 @@ {:else} - You haven't downloaded this season yet. + + + You haven't downloaded episodes of this season yet. + + {/each} @@ -132,19 +153,23 @@ - + 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} diff --git a/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.ts b/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.ts index d42b132..ece2027 100644 --- a/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.ts +++ b/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.ts @@ -10,7 +10,7 @@ export const load: PageLoad = async ({ fetch, params }) => { } } }); - const seasonFiles = client.GET('/api/v1/tv/seasons/{season_id}/files', { + const episodeFiles = client.GET('/api/v1/tv/seasons/{season_id}/files', { fetch: fetch, params: { path: { @@ -19,7 +19,7 @@ export const load: PageLoad = async ({ fetch, params }) => { } }); return { - files: await seasonFiles.then((x) => x.data), + files: await episodeFiles.then((x) => x.data), season: await season.then((x) => x.data) }; };