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) }; };