From b48b65584fb7e653ba4e488863ac5327dc66edcf Mon Sep 17 00:00:00 2001 From: Caio Natarelli Date: Tue, 30 Dec 2025 21:35:40 -0300 Subject: [PATCH 01/10] season.overview retrieval and changes on show webui --- ...8e_add_overview_column_to_episode_table.py | 31 ++++++++ media_manager/metadataProvider/tmdb.py | 1 + media_manager/tv/models.py | 1 + media_manager/tv/repository.py | 1 + media_manager/tv/schemas.py | 1 + media_manager/tv/service.py | 3 + web/src/lib/api/api.d.ts | 4 + .../dashboard/tv/[showId=uuid]/+page.svelte | 77 +++++++++++++++---- .../[SeasonId=uuid]/+page.svelte | 36 ++++++--- 9 files changed, 130 insertions(+), 25 deletions(-) create mode 100644 alembic/versions/9f3c1b2a4d8e_add_overview_column_to_episode_table.py diff --git a/alembic/versions/9f3c1b2a4d8e_add_overview_column_to_episode_table.py b/alembic/versions/9f3c1b2a4d8e_add_overview_column_to_episode_table.py new file mode 100644 index 00000000..df086c51 --- /dev/null +++ b/alembic/versions/9f3c1b2a4d8e_add_overview_column_to_episode_table.py @@ -0,0 +1,31 @@ +"""add overview column to episode table + +Revision ID: 9f3c1b2a4d8e +Revises: 2c61f662ca9e +Create Date: 2025-12-29 21:45:00 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "9f3c1b2a4d8e" +down_revision: Union[str, None] = "2c61f662ca9e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add overview to episode table + op.add_column( + "episode", + sa.Column("overview", sa.Text(), nullable=True), + ) + +def downgrade() -> None: + op.drop_column("episode", "overview") + diff --git a/media_manager/metadataProvider/tmdb.py b/media_manager/metadataProvider/tmdb.py index 571ce3e4..b38b0ec7 100644 --- a/media_manager/metadataProvider/tmdb.py +++ b/media_manager/metadataProvider/tmdb.py @@ -266,6 +266,7 @@ def get_show_metadata(self, id: int = None, language: str | None = None) -> Show external_id=int(episode["id"]), title=episode["name"], number=EpisodeNumber(episode["episode_number"]), + overview=episode["overview"], ) ) diff --git a/media_manager/tv/models.py b/media_manager/tv/models.py index c72702b5..fcab038d 100644 --- a/media_manager/tv/models.py +++ b/media_manager/tv/models.py @@ -66,6 +66,7 @@ class Episode(Base): number: Mapped[int] external_id: Mapped[int] title: Mapped[str] + overview: Mapped[str] season: Mapped["Season"] = relationship(back_populates="episodes") diff --git a/media_manager/tv/repository.py b/media_manager/tv/repository.py index a6612cbe..5b7cf795 100644 --- a/media_manager/tv/repository.py +++ b/media_manager/tv/repository.py @@ -163,6 +163,7 @@ def save_show(self, show: ShowSchema) -> ShowSchema: number=episode.number, external_id=episode.external_id, title=episode.title, + overview=episode.overview, ) for episode in season.episodes ], diff --git a/media_manager/tv/schemas.py b/media_manager/tv/schemas.py index 6419b587..bd38299a 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): diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index c6536659..fc04ce37 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -824,6 +824,7 @@ def update_show_metadata( self.tv_repository.update_episode_attributes( episode_id=existing_episode.id, title=fresh_episode_data.title, + overview=fresh_episode_data.overview, ) else: # Add new episode @@ -835,6 +836,7 @@ def update_show_metadata( number=fresh_episode_data.number, external_id=fresh_episode_data.external_id, title=fresh_episode_data.title, + overview=fresh_episode_data.overview, ) self.tv_repository.add_episode_to_season( season_id=existing_season.id, episode_data=episode_schema @@ -852,6 +854,7 @@ def update_show_metadata( number=ep_data.number, external_id=ep_data.external_id, title=ep_data.title, + overview=ep_data.overview, ) ) diff --git a/web/src/lib/api/api.d.ts b/web/src/lib/api/api.d.ts index 838f7dcb..c34e5cbc 100644 --- a/web/src/lib/api/api.d.ts +++ b/web/src/lib/api/api.d.ts @@ -1229,6 +1229,8 @@ export interface components { external_id: number; /** Title */ title: string; + /** Overview */ + overview: string; }; /** ErrorModel */ ErrorModel: { @@ -1632,6 +1634,8 @@ export interface components { file_path_suffix: string; /** Seasons */ seasons: number[]; + /** Episodes */ + episodes: number[]; }; /** RichShowTorrent */ RichShowTorrent: { diff --git a/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte b/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte index c1c85b32..16bc2300 100644 --- a/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte +++ b/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte @@ -4,6 +4,7 @@ import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js'; import { goto } from '$app/navigation'; import { ImageOff } from 'lucide-svelte'; + import { Ellipsis } from 'lucide-svelte'; import * as Table from '$lib/components/ui/table/index.js'; import { getContext } from 'svelte'; import type { components } from '$lib/api/api'; @@ -27,6 +28,17 @@ let torrents: components['schemas']['RichShowTorrent'] = $derived(page.data.torrentsData); let user: () => components['schemas']['UserRead'] = getContext('user'); + let expandedSeasons = $state>(new Set()); + + function toggleSeason(seasonId: string) { + if (expandedSeasons.has(seasonId)) { + expandedSeasons.delete(seasonId); + } else { + expandedSeasons.add(seasonId); + } + expandedSeasons = new Set(expandedSeasons); + } + let continuousDownloadEnabled = $derived(show.continuous_download); async function toggle_continuous_download() { @@ -109,7 +121,7 @@ Overview -

+

{show.overview}

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

- {show.overview} -

+ +
+ + Series Overview + +

+ {show.overview} +

+
+
+
+ + Season Overview + +

+ {season.overview} +

+
+
@@ -132,19 +144,21 @@ - + A list of all episodes. - Number - Title + Number + Title + Overview {#each season.episodes as episode (episode.id)} - {episode.number} + E{String(episode.number).padStart(2, '0')} {episode.title} + {episode.overview} {/each} From fbc9571944b82af9c11f164e71113c7ebc31a5e9 Mon Sep 17 00:00:00 2001 From: Caio Natarelli Date: Tue, 30 Dec 2025 21:37:05 -0300 Subject: [PATCH 02/10] Changes on Movie webui to match TvShow design --- web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte b/web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte index ff30b8b2..d28ff949 100644 --- a/web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte +++ b/web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte @@ -80,7 +80,7 @@ Overview -

+

{movie.overview}

From f06ad498c16f1633eb666165a80c60d3bcd5e946 Mon Sep 17 00:00:00 2001 From: Caio Natarelli Date: Tue, 13 Jan 2026 22:46:39 -0300 Subject: [PATCH 03/10] create episode file table and add episode column to indexerqueryresult --- ...dd_episode_column_to_indexerqueryresult.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 alembic/versions/3a8fbd71e2c2_create_episode_file_table_and_add_episode_column_to_indexerqueryresult.py diff --git a/alembic/versions/3a8fbd71e2c2_create_episode_file_table_and_add_episode_column_to_indexerqueryresult.py b/alembic/versions/3a8fbd71e2c2_create_episode_file_table_and_add_episode_column_to_indexerqueryresult.py new file mode 100644 index 00000000..c86aea57 --- /dev/null +++ b/alembic/versions/3a8fbd71e2c2_create_episode_file_table_and_add_episode_column_to_indexerqueryresult.py @@ -0,0 +1,46 @@ +"""create episode file table and add episode column to indexerqueryresult + +Revision ID: 3a8fbd71e2c2 +Revises: 9f3c1b2a4d8e +Create Date: 2026-01-08 13:43:00 + +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy.dialects import postgresql +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "3a8fbd71e2c2" +down_revision: Union[str, None] = "9f3c1b2a4d8e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + + +def upgrade() -> None: + quality_enum = postgresql.ENUM("uhd", "fullhd", "hd", "sd", "unknown", name="quality", + create_type=False, + ) + # Create episode file table + op.create_table( + "episode_file", + sa.Column("episode_id", sa.UUID(), nullable=False), + sa.Column("torrent_id", sa.UUID(), nullable=True), + sa.Column("file_path_suffix", sa.String(), nullable=False), + sa.Column("quality", quality_enum, nullable=False), + sa.ForeignKeyConstraint(["episode_id"], ["episode.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["torrent_id"], ["torrent.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("episode_id", "file_path_suffix"), + ) + # Add episode column to indexerqueryresult + op.add_column( + "indexer_query_result", sa.Column("episode", postgresql.ARRAY(sa.Integer()), nullable=True), + ) + +def downgrade() -> None: + op.drop_table("episode_file") + op.drop_column("indexer_query_result", "episode") \ No newline at end of file From 1fb11bd12c792eee360d581531f0b397468c4645 Mon Sep 17 00:00:00 2001 From: Caio Natarelli Date: Tue, 13 Jan 2026 22:47:43 -0300 Subject: [PATCH 04/10] Changes to Indexer files to handle Season Packs and Single Episodes --- media_manager/indexer/models.py | 1 + media_manager/indexer/schemas.py | 61 +++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/media_manager/indexer/models.py b/media_manager/indexer/models.py index 5c50c758..4d1aa19a 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 fd509fec..001c88a6 100644 --- a/media_manager/indexer/schemas.py +++ b/media_manager/indexer/schemas.py @@ -52,16 +52,59 @@ def quality(self) -> Quality: @computed_field(return_type=list[int]) @property def season(self) -> list[int]: - pattern = r"\b[sS](\d+)\b" - matches = re.findall(pattern, self.title, re.IGNORECASE) - if matches.__len__() == 2: - result = [] - for i in range(int(matches[0]), int(matches[1]) + 1): - result.append(i) - elif matches.__len__() == 1: - result = [int(matches[0])] + title = self.title.lower() + result: list[int] = [] + + # 1) S01E01 / S1E2 + m = re.search(r"s(\d{1,2})e\d{1,3}", title) + if m: + result = [int(m.group(1))] + return result + + # 2) Range S01-S03 / S1-S3 + m = re.search(r"s(\d{1,2})\s*[-–]\s*s?(\d{1,2})", title) + if m: + start, end = int(m.group(1)), int(m.group(2)) + if start <= end: + result = list(range(start, end + 1)) + return result + + # 3) Pack S01 / S1 + m = re.search(r"\bs(\d{1,2})\b", title) + if m: + result = [int(m.group(1))] + return result + + # 4) Season 01 / Season 1 + m = re.search(r"\bseason\s*(\d{1,2})\b", title) + if m: + result = [int(m.group(1))] + return result + + return result + + @computed_field(return_type=list[int]) + @property + def episode(self) -> list[int]: + title = self.title.lower() + result: list[int] = [] + + pattern = r"s\d{1,2}e(\d{1,3})(?:\s*-\s*(?:s?\d{1,2}e)?(\d{1,3}))?" + match = re.search(pattern, title) + + if not match: + return result + + start = int(match.group(1)) + end = match.group(2) + + if end: + end = int(end) + if end >= start: + result = list(range(start, end + 1)) else: - result = [] + result = [start] + return result def __gt__(self, other) -> bool: From 565275bc48df742d739319debb89b25d2b9df5f0 Mon Sep 17 00:00:00 2001 From: Caio Natarelli Date: Tue, 13 Jan 2026 22:48:39 -0300 Subject: [PATCH 05/10] Changes to torrent files to handle EpisodeFile --- media_manager/torrent/models.py | 1 + media_manager/torrent/repository.py | 24 ++++++++++++++++-------- media_manager/torrent/service.py | 12 +++++++++++- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/media_manager/torrent/models.py b/media_manager/torrent/models.py index 296c879d..30050984 100644 --- a/media_manager/torrent/models.py +++ b/media_manager/torrent/models.py @@ -17,4 +17,5 @@ class Torrent(Base): usenet: Mapped[bool] season_files = relationship("SeasonFile", back_populates="torrent") + episode_files = relationship("EpisodeFile", back_populates="torrent") movie_files = relationship("MovieFile", back_populates="torrent") diff --git a/media_manager/torrent/repository.py b/media_manager/torrent/repository.py index a09451a7..be6ca7ac 100644 --- a/media_manager/torrent/repository.py +++ b/media_manager/torrent/repository.py @@ -3,8 +3,8 @@ from media_manager.database import DbSessionDependency from media_manager.torrent.models import Torrent from media_manager.torrent.schemas import TorrentId, Torrent as TorrentSchema -from media_manager.tv.models import SeasonFile, Show, Season -from media_manager.tv.schemas import SeasonFile as SeasonFileSchema, Show as ShowSchema +from media_manager.tv.models import SeasonFile, Show, Season, EpisodeFile, Episode +from media_manager.tv.schemas import SeasonFile as SeasonFileSchema, Show as ShowSchema, EpisodeFile as EpisodeFileSchema from media_manager.exceptions import NotFoundError from media_manager.movies.models import Movie, MovieFile from media_manager.movies.schemas import ( @@ -24,12 +24,20 @@ def get_seasons_files_of_torrent( result = self.db.execute(stmt).scalars().all() return [SeasonFileSchema.model_validate(season_file) for season_file in result] + def get_episode_files_of_torrent( + self, torrent_id: TorrentId + ) -> list[EpisodeFileSchema]: + stmt = select(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id) + result = self.db.execute(stmt).scalars().all() + return [EpisodeFileSchema.model_validate(episode_file) for episode_file in result] + def get_show_of_torrent(self, torrent_id: TorrentId) -> ShowSchema | None: stmt = ( select(Show) - .join(SeasonFile.season) - .join(Season.show) - .where(SeasonFile.torrent_id == torrent_id) + .join(Show.seasons) + .join(Season.episodes) + .join(Episode.episode_files) + .where(EpisodeFile.torrent_id == torrent_id) ) result = self.db.execute(stmt).unique().scalar_one_or_none() if result is None: @@ -64,10 +72,10 @@ def delete_torrent( ) self.db.execute(movie_files_stmt) - season_files_stmt = delete(SeasonFile).where( - SeasonFile.torrent_id == torrent_id + episode_files_stmt = delete(EpisodeFile).where( + EpisodeFile.torrent_id == torrent_id ) - self.db.execute(season_files_stmt) + self.db.execute(episode_files_stmt) self.db.delete(self.db.get(Torrent, torrent_id)) diff --git a/media_manager/torrent/service.py b/media_manager/torrent/service.py index 6181fdf6..9a83083c 100644 --- a/media_manager/torrent/service.py +++ b/media_manager/torrent/service.py @@ -4,7 +4,7 @@ from media_manager.torrent.manager import DownloadManager from media_manager.torrent.repository import TorrentRepository from media_manager.torrent.schemas import Torrent, TorrentId -from media_manager.tv.schemas import SeasonFile, Show +from media_manager.tv.schemas import SeasonFile, Show, EpisodeFile from media_manager.movies.schemas import Movie log = logging.getLogger(__name__) @@ -29,6 +29,16 @@ def get_season_files_of_torrent(self, torrent: Torrent) -> list[SeasonFile]: torrent_id=torrent.id ) + def get_episode_files_of_torrent(self, torrent: Torrent) -> list[EpisodeFile]: + """ + Returns all episode files of a torrent + :param torrent: the torrent to get the episode files of + :return: list of episode files + """ + return self.torrent_repository.get_episode_files_of_torrent( + torrent_id=torrent.id + ) + def get_show_of_torrent(self, torrent: Torrent) -> Show | None: """ Returns the show of a torrent From f71d0815d922bcf6d5eb1b47a4e971c52c03fd70 Mon Sep 17 00:00:00 2001 From: Caio Natarelli Date: Tue, 13 Jan 2026 22:49:31 -0300 Subject: [PATCH 06/10] Changes to Tv files to handle EpisodeFiles --- media_manager/tv/models.py | 18 ++ media_manager/tv/repository.py | 126 ++++++++++++- media_manager/tv/schemas.py | 23 ++- media_manager/tv/service.py | 316 ++++++++++++++++++++++++++++++--- 4 files changed, 448 insertions(+), 35 deletions(-) diff --git a/media_manager/tv/models.py b/media_manager/tv/models.py index fcab038d..c4d5afa2 100644 --- a/media_manager/tv/models.py +++ b/media_manager/tv/models.py @@ -69,6 +69,9 @@ class Episode(Base): overview: Mapped[str] season: Mapped["Season"] = relationship(back_populates="episodes") + episode_files = relationship( + "EpisodeFile", back_populates="episode", cascade="all, delete" + ) class SeasonFile(Base): @@ -87,6 +90,21 @@ class SeasonFile(Base): season = relationship("Season", back_populates="season_files", uselist=False) +class EpisodeFile(Base): + __tablename__ = "episode_file" + __table_args__ = (PrimaryKeyConstraint("episode_id", "file_path_suffix"),) + episode_id: Mapped[UUID] = mapped_column( + ForeignKey(column="episode.id", ondelete="CASCADE"), + ) + torrent_id: Mapped[UUID | None] = mapped_column( + ForeignKey(column="torrent.id", ondelete="SET NULL"), + ) + file_path_suffix: Mapped[str] + quality: Mapped[Quality] + + torrent = relationship("Torrent", back_populates="episode_files", uselist=False) + episode = relationship("Episode", back_populates="episode_files", uselist=False) + class SeasonRequest(Base): __tablename__ = "season_request" __table_args__ = (UniqueConstraint("season_id", "wanted_quality"),) diff --git a/media_manager/tv/repository.py b/media_manager/tv/repository.py index 95b2f5b7..5f72550c 100644 --- a/media_manager/tv/repository.py +++ b/media_manager/tv/repository.py @@ -8,7 +8,7 @@ from media_manager.torrent.models import Torrent from media_manager.torrent.schemas import TorrentId, Torrent as TorrentSchema from media_manager.tv import log -from media_manager.tv.models import Season, Show, Episode, SeasonRequest, SeasonFile +from media_manager.tv.models import Season, Show, Episode, SeasonRequest, SeasonFile, EpisodeFile from media_manager.exceptions import NotFoundError, ConflictError from media_manager.tv.schemas import ( Season as SeasonSchema, @@ -22,6 +22,8 @@ SeasonRequestId, RichSeasonRequest as RichSeasonRequestSchema, EpisodeId, + EpisodeFile as EpisodeFileSchema, + EpisodeNumber ) @@ -224,6 +226,49 @@ def get_season(self, season_id: SeasonId) -> SeasonSchema: log.error(f"Database error while retrieving season {season_id}: {e}") raise + def get_episode(self, episode_id: EpisodeId) -> EpisodeSchema: + """ + Retrieve an episode by its ID. + + :param episode_id: The ID of the episode to get. + :return: An Episode object. + :raises NotFoundError: If the episode with the given ID is not found. + :raises SQLAlchemyError: If a database error occurs. + """ + try: + episode = self.db.get(Episode, episode_id) + if not episode: + raise NotFoundError(f"Episode with id {episode_id} not found.") + return EpisodeSchema.model_validate(episode) + except SQLAlchemyError as e: + log.error(f"Database error while retrieving episode {episode_id}: {e}") + raise + + def get_season_by_episode(self, episode_id: EpisodeId) -> SeasonSchema: + try: + stmt = ( + select(Season) + .join(Season.episodes) + .join(Episode.episode_files) + .where(EpisodeFile.episode_id == episode_id) + ) + + season = self.db.scalar(stmt) + + if not season: + raise NotFoundError( + f"Season not found for episode {episode_id}" + ) + + return SeasonSchema.model_validate(season) + + except SQLAlchemyError as e: + log.error( + f"Database error while retrieving season for episode " + f"{episode_id}: {e}" + ) + raise + def add_season_request( self, season_request: SeasonRequestSchema ) -> SeasonRequestSchema: @@ -371,6 +416,30 @@ def add_season_file(self, season_file: SeasonFileSchema) -> SeasonFileSchema: log.error(f"Database error while adding season file: {e}") raise + def add_episode_file(self, episode_file: EpisodeFileSchema) -> EpisodeFileSchema: + """ + Adds a episode file record to the database. + + :param episode_file: The EpisodeFile object to add. + :return: The added EpisodeFile object. + :raises IntegrityError: If the record violates constraints. + :raises SQLAlchemyError: If a database error occurs. + """ + db_model = EpisodeFile(**episode_file.model_dump()) + try: + self.db.add(db_model) + self.db.commit() + self.db.refresh(db_model) + return EpisodeFileSchema.model_validate(db_model) + except IntegrityError as e: + self.db.rollback() + log.error(f"Integrity error while adding season file: {e}") + raise + except SQLAlchemyError as e: + self.db.rollback() + log.error(f"Database error while adding season file: {e}") + raise + def remove_season_files_by_torrent_id(self, torrent_id: TorrentId) -> int: """ Removes season file records associated with a given torrent ID. @@ -432,6 +501,24 @@ def get_season_files_by_season_id( ) raise + def get_episode_files_by_episode_id(self, episode_id: EpisodeId) -> list[EpisodeFileSchema]: + """ + Retrieve all episode files for a given episode ID. + + :param episode_id: The ID of the episode. + :return: A list of EpisodeFile objects. + :raises SQLAlchemyError: If a database error occurs. + """ + try: + stmt = select(EpisodeFile).where(EpisodeFile.episode_id == episode_id) + results = self.db.execute(stmt).scalars().all() + return [EpisodeFileSchema.model_validate(sf) for sf in results] + except SQLAlchemyError as e: + log.error( + f"Database error retrieving episode files for episode_id {episode_id}: {e}" + ) + raise + def get_torrents_by_show_id(self, show_id: ShowId) -> list[TorrentSchema]: """ Retrieve all torrents associated with a given show ID. @@ -444,8 +531,9 @@ def get_torrents_by_show_id(self, show_id: ShowId) -> list[TorrentSchema]: stmt = ( select(Torrent) .distinct() - .join(SeasonFile, SeasonFile.torrent_id == Torrent.id) - .join(Season, Season.id == SeasonFile.season_id) + .join(EpisodeFile, EpisodeFile.torrent_id == Torrent.id) + .join(Episode, Episode.id == EpisodeFile.episode_id) + .join(Season, Season.id == Episode.season_id) .where(Season.show_id == show_id) ) results = self.db.execute(stmt).scalars().unique().all() @@ -489,8 +577,9 @@ def get_seasons_by_torrent_id(self, torrent_id: TorrentId) -> list[SeasonNumber] stmt = ( select(Season.number) .distinct() - .join(SeasonFile, Season.id == SeasonFile.season_id) - .where(SeasonFile.torrent_id == torrent_id) + .join(Episode, Episode.season_id == Season.id) + .join(EpisodeFile, EpisodeFile.episode_id == Episode.id) + .where(EpisodeFile.torrent_id == torrent_id) ) results = self.db.execute(stmt).scalars().unique().all() return [SeasonNumber(x) for x in results] @@ -500,6 +589,33 @@ def get_seasons_by_torrent_id(self, torrent_id: TorrentId) -> list[SeasonNumber] ) raise + def get_episodes_by_torrent_id(self, torrent_id: TorrentId) -> list[EpisodeNumber]: + """ + Retrieve episode numbers associated with a given torrent ID. + + :param torrent_id: The ID of the torrent. + :return: A list of EpisodeNumber objects. + :raises SQLAlchemyError: If a database error occurs. + """ + try: + stmt = ( + select(Episode.number) + .join(EpisodeFile, EpisodeFile.episode_id == Episode.id) + .where(EpisodeFile.torrent_id == torrent_id) + .order_by(Episode.number) + ) + + episode_numbers = self.db.execute(stmt).scalars().all() + + return [EpisodeNumber(n) for n in sorted(set(episode_numbers))] + + except SQLAlchemyError as e: + log.error( + f"Database error retrieving episodes for torrent_id {torrent_id}: {e}" + ) + raise + + def get_season_request( self, season_request_id: SeasonRequestId ) -> SeasonRequestSchema: diff --git a/media_manager/tv/schemas.py b/media_manager/tv/schemas.py index bd38299a..4a71ebc6 100644 --- a/media_manager/tv/schemas.py +++ b/media_manager/tv/schemas.py @@ -109,6 +109,14 @@ class SeasonFile(BaseModel): torrent_id: TorrentId | None file_path_suffix: str +class EpisodeFile(BaseModel): + model_config = ConfigDict(from_attributes=True) + + episode_id: EpisodeId + quality: Quality + torrent_id: TorrentId | None + file_path_suffix: str + class PublicSeasonFile(SeasonFile): downloaded: bool = False @@ -126,6 +134,7 @@ class RichSeasonTorrent(BaseModel): file_path_suffix: str seasons: list[SeasonNumber] + episodes: list[EpisodeNumber] class RichShowTorrent(BaseModel): @@ -138,6 +147,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) @@ -150,7 +171,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 8e00299e..2d89d485 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -30,7 +30,9 @@ SeasonRequestId, RichSeasonRequest, EpisodeId, + Episode, Episode as EpisodeSchema, + EpisodeFile ) from media_manager.torrent.schemas import QualityStrings from media_manager.tv.repository import TvRepository @@ -167,15 +169,14 @@ def delete_show( for torrent in torrents: try: self.torrent_service.cancel_download(torrent, delete_files=True) + self.torrent_service.delete_torrent(torrent_id=torrent.id) log.info(f"Deleted torrent: {torrent.hash}") except Exception as e: log.warning(f"Failed to delete torrent {torrent.hash}: {e}") self.tv_repository.delete_show(show_id=show.id) - def get_public_season_files_by_season_id( - self, season: Season - ) -> list[PublicSeasonFile]: + def get_public_season_files_by_season_id(self, season: Season) -> list[PublicSeasonFile]: """ Get all public season files for a given season. @@ -323,11 +324,19 @@ def get_public_show_by_id(self, show: Show) -> PublicShow: :param show: The show object. :return: A public show. """ - seasons = [PublicSeason.model_validate(season) for season in show.seasons] - for season in seasons: - season.downloaded = self.is_season_downloaded(season_id=season.id) public_show = PublicShow.model_validate(show) - public_show.seasons = seasons + public_seasons: list[PublicSeason] = [] + + for season in show.seasons: + public_season = PublicSeason.model_validate(season) + public_season.downloaded = self.is_season_downloaded(season=season, show=show) + + for episode in public_season.episodes: + episode.downloaded = self.is_episode_downloaded(episode=episode, season=season, show=show) + + public_seasons.append(public_season) + + public_show.seasons = public_seasons return public_show def get_show_by_id(self, show_id: ShowId) -> Show: @@ -339,19 +348,67 @@ def get_show_by_id(self, show_id: ShowId) -> Show: """ return self.tv_repository.get_show_by_id(show_id=show_id) - def is_season_downloaded(self, season_id: SeasonId) -> bool: + def is_season_downloaded(self, season: Season, show: Show) -> bool: """ Check if a season is downloaded. - :param season_id: The ID of the season. + :param season: The season object. + :param show: The show object. :return: True if the season is downloaded, False otherwise. """ - season_files = self.tv_repository.get_season_files_by_season_id( - season_id=season_id + episodes = season.episodes + + if not episodes: + return False + + for episode in episodes: + if not self.is_episode_downloaded(episode=episode, season=season, show=show): + return False + return True + + def is_episode_downloaded(self, episode: Episode, season: Season, show: Show) -> bool: + """ + Check if an episode is downloaded and imported (file exists on disk). + + An episode is considered downloaded if: + - There is at least one EpisodeFile in the database AND + - A matching episode file exists in the season directory on disk. + + :param episode: The episode object. + :param season: The season object. + :param show: The show object. + :return: True if the episode is downloaded and imported, False otherwise. + """ + episode_files = self.tv_repository.get_episode_files_by_episode_id( + episode_id=episode.id ) - for season_file in season_files: - if self.season_file_exists_on_file(season_file=season_file): - return True + + if not episode_files: + return False + + season_dir = self.get_root_season_directory(show, season.number) + + if not season_dir.exists(): + return False + + episode_token = f"S{season.number:02d}E{episode.number:02d}" + + VIDEO_EXTENSIONS = {".mkv", ".mp4", ".avi", ".mov"} + + try: + for file in season_dir.iterdir(): + if ( + file.is_file() + and episode_token in file.name + and file.suffix.lower() in VIDEO_EXTENSIONS + ): + return True + + except OSError as e: + log.error( + f"Disk check failed for episode {episode.id} in {season_dir}: {e}" + ) + return False def season_file_exists_on_file(self, season_file: SeasonFile) -> bool: @@ -398,6 +455,24 @@ def get_season(self, season_id: SeasonId) -> Season: """ return self.tv_repository.get_season(season_id=season_id) + def get_episode(self, episode_id: EpisodeId) -> Episode: + """ + Get an episode by its ID. + + :param episode_id: The ID of the episode. + :return: The episode. + """ + return self.tv_repository.get_episode(episode_id=episode_id) + + def get_season_by_episode(self, episode_id: EpisodeId) -> Season: + """ + Get a season by the episode ID. + + :param episode_id: The ID of the episode. + :return: The season. + """ + return self.tv_repository.get_season_by_episode(episode_id=episode_id) + def get_all_season_requests(self) -> list[RichSeasonRequest]: """ Get all season requests. @@ -419,10 +494,13 @@ def get_torrents_for_show(self, show: Show) -> RichShowTorrent: seasons = self.tv_repository.get_seasons_by_torrent_id( torrent_id=show_torrent.id ) - season_files = self.torrent_service.get_season_files_of_torrent( - torrent=show_torrent + episodes = self.tv_repository.get_episodes_by_torrent_id( + torrent_id=show_torrent.id ) - file_path_suffix = season_files[0].file_path_suffix if season_files else "" + log.debug(f"episodes:{episodes}") + episode_files = self.torrent_service.get_episode_files_of_torrent(torrent=show_torrent) + + file_path_suffix = episode_files[0].file_path_suffix if episode_files else "" season_torrent = RichSeasonTorrent( torrent_id=show_torrent.id, torrent_title=show_torrent.title, @@ -430,17 +508,20 @@ def get_torrents_for_show(self, show: Show) -> RichShowTorrent: quality=show_torrent.quality, imported=show_torrent.imported, seasons=seasons, + episodes=episodes, file_path_suffix=file_path_suffix, usenet=show_torrent.usenet, ) rich_season_torrents.append(season_torrent) - return RichShowTorrent( + + richshowtorrent = RichShowTorrent( show_id=show.id, name=show.name, year=show.year, metadata_provider=show.metadata_provider, torrents=rich_season_torrents, ) + return richshowtorrent def get_all_shows_with_torrents(self) -> list[RichShowTorrent]: """ @@ -468,6 +549,7 @@ def download_torrent( indexer_result = self.indexer_service.get_result( result_id=public_indexer_result_id ) + log.debug(f"indexer_result:{indexer_result}") show_torrent = self.torrent_service.download(indexer_result=indexer_result) self.torrent_service.pause_download(torrent=show_torrent) @@ -476,16 +558,32 @@ def download_torrent( season = self.tv_repository.get_season_by_number( season_number=season_number, show_id=show_id ) - season_file = SeasonFile( - season_id=season.id, - quality=indexer_result.quality, - torrent_id=show_torrent.id, - file_path_suffix=override_show_file_path_suffix, - ) - self.tv_repository.add_season_file(season_file=season_file) + episodes = {episode.number: episode.id for episode in season.episodes} + + if indexer_result.episode: + for episode_number in indexer_result.episode: + current_episode_id = episodes.get(episode_number) + episode_file = EpisodeFile( + episode_id=current_episode_id, + quality=indexer_result.quality, + torrent_id=show_torrent.id, + file_path_suffix=override_show_file_path_suffix, + ) + self.tv_repository.add_episode_file(episode_file=episode_file) + else: + for episode in season.episodes: + current_episode_id = episode.id + episode_file = EpisodeFile( + episode_id=current_episode_id, + quality=indexer_result.quality, + torrent_id=show_torrent.id, + file_path_suffix=override_show_file_path_suffix, + ) + self.tv_repository.add_episode_file(episode_file=episode_file) + except IntegrityError: log.error( - f"Season file for season {season.id} and quality {indexer_result.quality} already exists, skipping." + f"Episode file for episode {current_episode_id} of season {season.id} and quality {indexer_result.quality} already exists, skipping." ) self.torrent_service.cancel_download( torrent=show_torrent, delete_files=True @@ -493,7 +591,7 @@ def download_torrent( raise else: log.info( - f"Successfully added season files for torrent {show_torrent.title} and show ID {show_id}" + f"Successfully added episode files for torrent {show_torrent.title} and show ID {show_id}" ) self.torrent_service.resume_download(torrent=show_torrent) @@ -682,6 +780,67 @@ def import_season( ) return success, imported_episodes_count + def import_episode_files( + self, + show: Show, + season: Season, + episode: Episode, + video_files: list[Path], + subtitle_files: list[Path], + file_path_suffix: str = "", + ) -> bool: + episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode.number:02d}" + if file_path_suffix != "": + episode_file_name += f" - {file_path_suffix}" + pattern = ( + r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode.number) + r"[. ].*" + ) + subtitle_pattern = pattern + r"[. ]([A-Za-z]{2})[. ]srt" + target_file_name = ( + self.get_root_season_directory(show=show, season_number=season.number) + / episode_file_name + ) + + # import subtitle + for subtitle_file in subtitle_files: + regex_result = re.search( + subtitle_pattern, subtitle_file.name, re.IGNORECASE + ) + if regex_result: + language_code = regex_result.group(1) + target_subtitle_file = target_file_name.with_suffix( + f".{language_code}.srt" + ) + import_file(target_file=target_subtitle_file, source_file=subtitle_file) + else: + log.debug( + f"Didn't find any pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}" + ) + + found_video = False + + # import episode videos + for file in video_files: + if re.search(pattern, file.name, re.IGNORECASE): + target_video_file = target_file_name.with_suffix(file.suffix) + import_file(target_file=target_video_file, source_file=file) + found_video = True + break + + if not found_video: + # Send notification about missing episode file + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Missing Episode File", + message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.", + ) + log.warning( + f"File for S{season.number}E{episode.number} not found when trying to import episode for show {show.name}." + ) + return False + + return True + def import_torrent_files(self, torrent: Torrent, show: Show) -> None: """ Organizes files from a torrent into the TV directory structure, mapping them to seasons and episodes. @@ -742,6 +901,104 @@ def import_torrent_files(self, torrent: Torrent, show: Show) -> None: message=f"Importing {show.name} ({show.year}) from torrent {torrent.title} completed with errors. Please check the logs for details.", ) + def import_episode_files_from_torrent(self, torrent: Torrent, show: Show) -> None: + """ + Organizes episodes files from a torrent into the TV directory structure, mapping them to seasons and episodes. + :param torrent: The Torrent object + :param show: The Show object + """ + + video_files, subtitle_files, all_files = get_files_for_import(torrent=torrent) + + success: list[bool] = [] + + log.debug( + f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files) + ) + + episode_files = self.torrent_service.get_episode_files_of_torrent(torrent=torrent) + if not episode_files: + log.warning( + f"No episode files associated with torrent {torrent.title}, skipping import." + ) + return + + log.info( + f"Found {len(episode_files)} episode files associated with torrent {torrent.title}" + ) + + imported_episodes_by_season: dict[int, list[int]] = {} + + for episode_file in episode_files: + season = self.get_season_by_episode(episode_id=episode_file.episode_id) + episode = self.get_episode(episode_file.episode_id) + + season_path = self.get_root_season_directory( + show=show, season_number=season.number + ) + if not season_path.exists(): + try: + season_path.mkdir(parents=True) + except Exception as e: + log.warning(f"Could not create path {season_path}: {e}") + raise Exception(f"Could not create path {season_path}") from e + + episoded_import_success = self.import_episode_files( + show=show, + season=season, + episode=episode, + video_files=video_files, + subtitle_files=subtitle_files, + file_path_suffix=episode_file.file_path_suffix, + ) + success.append(episoded_import_success) + + if episoded_import_success: + imported_episodes_by_season.setdefault(season.number, []).append(episode.number) + + log.info( + f"Episode {episode.number} from Season {season.number} successfully imported from torrent {torrent.title}" + ) + else: + log.warning( + f"Episode {episode.number} from Season {season.number} failed to import from torrent {torrent.title}" + ) + + success_messages: list[str] = [] + + for season_number, episodes in imported_episodes_by_season.items(): + episode_list = ",".join(str(e) for e in sorted(episodes)) + success_messages.append( + f"Episode(s): {episode_list} from Season {season_number}" + ) + + episodes_summary = "; ".join(success_messages) + + if all(success): + torrent.imported = True + self.torrent_service.torrent_repository.save_torrent(torrent=torrent) + + # Send successful season download notification + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="TV Show imported successfully", + message=( + f"Successfully imported {episodes_summary} " + f"of {show.name} ({show.year}) " + f"from torrent {torrent.title}." + ), + ) + else: + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Failed to import TV Show", + message=f"Importing {show.name} ({show.year}) from torrent {torrent.title} completed with errors. Please check the logs for details.", + ) + + log.info( + f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors" + ) + def update_show_metadata( self, db_show: Show, metadata_provider: AbstractMetadataProvider ) -> Show | None: @@ -1015,10 +1272,11 @@ def import_all_show_torrents() -> None: f"torrent {t.title} is not a tv torrent, skipping import." ) continue - tv_service.import_torrent_files(torrent=t, show=show) + tv_service.import_episode_files_from_torrent(torrent=t, show=show) except RuntimeError as e: log.error( - f"Error importing torrent {t.title} for show {show.name}: {e}" + f"Error importing torrent {t.title} for show {show.name}: {e}", + exc_info=True, ) log.info("Finished importing all torrents") db.commit() From 884c3c3073eb238aae41bc82e98e8872f15c447a Mon Sep 17 00:00:00 2001 From: Caio Natarelli Date: Wed, 11 Feb 2026 22:02:51 -0300 Subject: [PATCH 07/10] Removal of SeasonFile --- alembic/env.py | 4 +- media_manager/torrent/models.py | 1 - media_manager/torrent/repository.py | 11 +---- media_manager/torrent/service.py | 12 +----- media_manager/tv/models.py | 19 --------- media_manager/tv/repository.py | 62 +++++++++-------------------- media_manager/tv/router.py | 6 +-- media_manager/tv/schemas.py | 10 +---- media_manager/tv/service.py | 59 +++++++++++++-------------- web/src/lib/api/api.d.ts | 35 ++++++++++++---- 10 files changed, 84 insertions(+), 135 deletions(-) diff --git a/alembic/env.py b/alembic/env.py index 20de4f5e..10a0de61 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -35,8 +35,8 @@ 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 @@ # noinspection PyStatementEffect __all__ = [ "Episode", + "EpisodeFile", "IndexerQueryResult", "Movie", "MovieFile", @@ -54,7 +55,6 @@ "Notification", "OAuthAccount", "Season", - "SeasonFile", "SeasonRequest", "Show", "Torrent", diff --git a/media_manager/torrent/models.py b/media_manager/torrent/models.py index 30050984..94b74fcb 100644 --- a/media_manager/torrent/models.py +++ b/media_manager/torrent/models.py @@ -16,6 +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 d7ced8db..c748dd40 100644 --- a/media_manager/torrent/repository.py +++ b/media_manager/torrent/repository.py @@ -11,20 +11,13 @@ ) from media_manager.torrent.models import Torrent from media_manager.torrent.schemas import TorrentId, Torrent as TorrentSchema -from media_manager.tv.models import SeasonFile, Show, Season, EpisodeFile, Episode -from media_manager.tv.schemas import SeasonFile as SeasonFileSchema, Show as ShowSchema, EpisodeFile as EpisodeFileSchema +from media_manager.tv.models import Show, Season, EpisodeFile, Episode +from media_manager.tv.schemas import Show as ShowSchema, EpisodeFile as EpisodeFileSchema class TorrentRepository: def __init__(self, db: DbSessionDependency) -> None: self.db = db - def get_seasons_files_of_torrent( - self, torrent_id: TorrentId - ) -> list[SeasonFileSchema]: - stmt = select(SeasonFile).where(SeasonFile.torrent_id == torrent_id) - result = self.db.execute(stmt).scalars().all() - return [SeasonFileSchema.model_validate(season_file) for season_file in result] - def get_episode_files_of_torrent( self, torrent_id: TorrentId ) -> list[EpisodeFileSchema]: diff --git a/media_manager/torrent/service.py b/media_manager/torrent/service.py index 916a7b46..2d4be83b 100644 --- a/media_manager/torrent/service.py +++ b/media_manager/torrent/service.py @@ -5,7 +5,7 @@ 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, EpisodeFile +from media_manager.tv.schemas import Show, EpisodeFile from media_manager.movies.schemas import Movie log = logging.getLogger(__name__) @@ -20,16 +20,6 @@ def __init__( self.torrent_repository = torrent_repository self.download_manager = download_manager or DownloadManager() - def get_season_files_of_torrent(self, torrent: Torrent) -> list[SeasonFile]: - """ - Returns all season files of a torrent - :param torrent: the torrent to get the season files of - :return: list of season files - """ - return self.torrent_repository.get_seasons_files_of_torrent( - torrent_id=torrent.id - ) - def get_episode_files_of_torrent(self, torrent: Torrent) -> list[EpisodeFile]: """ Returns all episode files of a torrent diff --git a/media_manager/tv/models.py b/media_manager/tv/models.py index c4d5afa2..8b9e6448 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" ) @@ -74,22 +71,6 @@ class Episode(Base): ) -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"), - ) - torrent_id: Mapped[UUID | None] = mapped_column( - ForeignKey(column="torrent.id", ondelete="SET NULL"), - ) - file_path_suffix: Mapped[str] - quality: Mapped[Quality] - - torrent = relationship("Torrent", back_populates="season_files", uselist=False) - season = relationship("Season", back_populates="season_files", uselist=False) - - class EpisodeFile(Base): __tablename__ = "episode_file" __table_args__ = (PrimaryKeyConstraint("episode_id", "file_path_suffix"),) diff --git a/media_manager/tv/repository.py b/media_manager/tv/repository.py index e2aed2f5..bfe27415 100644 --- a/media_manager/tv/repository.py +++ b/media_manager/tv/repository.py @@ -10,7 +10,7 @@ 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 Season, Show, Episode, SeasonRequest, SeasonFile, EpisodeFile +from media_manager.tv.models import Season, Show, Episode, SeasonRequest, EpisodeFile from media_manager.tv.schemas import ( Episode as EpisodeSchema, ) @@ -29,7 +29,6 @@ Season as SeasonSchema, ) from media_manager.tv.schemas import ( - SeasonFile as SeasonFileSchema, EpisodeFile as EpisodeFileSchema, ) from media_manager.tv.schemas import ( @@ -123,7 +122,9 @@ def get_shows(self) -> list[ShowSchema]: def get_total_downloaded_episodes_count(self) -> int: try: stmt = ( - select(func.count()).select_from(Episode).join(Season).join(SeasonFile) + select(func.count(Episode.id)) + .select_from(Episode) + .join(EpisodeFile) ) return self.db.execute(stmt).scalar_one_or_none() except SQLAlchemyError: @@ -401,30 +402,6 @@ def get_season_requests(self) -> list[RichSeasonRequestSchema]: log.exception("Database error while retrieving season requests") raise - def add_season_file(self, season_file: SeasonFileSchema) -> SeasonFileSchema: - """ - Adds a season file record to the database. - - :param season_file: The SeasonFile object to add. - :return: The added SeasonFile object. - :raises IntegrityError: If the record violates constraints. - :raises SQLAlchemyError: If a database error occurs. - """ - db_model = SeasonFile(**season_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: - self.db.rollback() - log.exception("Integrity error while adding season file") - raise - except SQLAlchemyError: - self.db.rollback() - log.exception("Database error while adding season file") - raise - def add_episode_file(self, episode_file: EpisodeFileSchema) -> EpisodeFileSchema: """ Adds a episode file record to the database. @@ -449,22 +426,22 @@ def add_episode_file(self, episode_file: EpisodeFileSchema) -> EpisodeFileSchema log.error(f"Database error while adding season 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 @@ -490,23 +467,21 @@ def set_show_library(self, show_id: ShowId, library: str) -> None: log.exception(f"Database error setting library for show {show_id}") raise - def get_season_files_by_season_id( - self, season_id: SeasonId - ) -> list[SeasonFileSchema]: + def get_episode_files_by_season_id(self, season_id: SeasonId) -> 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).where(EpisodeFile.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 @@ -563,8 +538,9 @@ def get_all_shows_with_torrents(self) -> list[ShowSchema]: 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) ) diff --git a/media_manager/tv/router.py b/media_manager/tv/router.py index 46f05d90..340970e9 100644 --- a/media_manager/tv/router.py +++ b/media_manager/tv/router.py @@ -25,7 +25,7 @@ ) from media_manager.tv.schemas import ( CreateSeasonRequest, - PublicSeasonFile, + PublicEpisodeFile, PublicShow, RichSeasonRequest, RichShowTorrent, @@ -404,11 +404,11 @@ def get_season(season: season_dep) -> Season: ) def get_season_files( season: season_dep, tv_service: tv_service_dep -) -> list[PublicSeasonFile]: +) -> list[PublicEpisodeFile]: """ Get 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 05975854..d1f2959f 100644 --- a/media_manager/tv/schemas.py +++ b/media_manager/tv/schemas.py @@ -99,14 +99,6 @@ class RichSeasonRequest(SeasonRequest): season: Season -class SeasonFile(BaseModel): - model_config = ConfigDict(from_attributes=True) - - season_id: SeasonId - quality: Quality - torrent_id: TorrentId | None - file_path_suffix: str - class EpisodeFile(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -116,7 +108,7 @@ class EpisodeFile(BaseModel): file_path_suffix: str -class PublicSeasonFile(SeasonFile): +class PublicEpisodeFile(EpisodeFile): downloaded: bool = False diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index 3e6b316a..a316eaa5 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -45,16 +45,15 @@ ) from media_manager.tv.schemas import ( Episode, - EpisodeFile + EpisodeFile, EpisodeId, + PublicEpisodeFile, PublicSeason, - PublicSeasonFile, PublicShow, RichSeasonRequest, RichSeasonTorrent, RichShowTorrent, Season, - SeasonFile, SeasonId, SeasonRequest, SeasonRequestId, @@ -184,22 +183,22 @@ def delete_show( self.tv_repository.delete_show(show_id=show.id) - def get_public_season_files_by_season_id(self, season: Season) -> list[PublicSeasonFile]: + def get_public_episode_files_by_season_id(self, season: Season) -> 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 @@ -422,18 +421,18 @@ def is_episode_downloaded(self, episode: Episode, season: Season, show: Show) -> 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: @@ -1113,20 +1112,18 @@ def update_show_metadata( overview=ep_data.overview, ) ) - for ep_data in fresh_season_data.episodes - ] - - season_schema = Season( - id=SeasonId(fresh_season_data.id), - number=fresh_season_data.number, - name=fresh_season_data.name, - overview=fresh_season_data.overview, - external_id=fresh_season_data.external_id, - episodes=episodes_for_schema, - ) - self.tv_repository.add_season_to_show( - show_id=db_show.id, season_data=season_schema - ) + for ep_data in fresh_season_data.episodes: + season_schema = Season( + id=SeasonId(fresh_season_data.id), + number=fresh_season_data.number, + name=fresh_season_data.name, + overview=fresh_season_data.overview, + external_id=fresh_season_data.external_id, + episodes=episodes_for_schema, + ) + self.tv_repository.add_season_to_show( + show_id=db_show.id, season_data=season_schema + ) updated_show = self.tv_repository.get_show_by_id(show_id=db_show.id) diff --git a/web/src/lib/api/api.d.ts b/web/src/lib/api/api.d.ts index 25438663..8f32005f 100644 --- a/web/src/lib/api/api.d.ts +++ b/web/src/lib/api/api.d.ts @@ -1562,6 +1562,27 @@ export interface components { imported: boolean; }; /** PublicSeason */ + PublicEpisode: { + /** + * Id + * Format: uuid + */ + id: string; + /** Number */ + number: number; + /** + * Downloaded + * @default false + */ + downloaded: boolean; + /** Name */ + title: string; + /** Overview */ + overview: string; + /** External Id */ + external_id: number; + }; + /** PublicSeason */ PublicSeason: { /** * Id @@ -1582,15 +1603,15 @@ export interface components { /** External Id */ external_id: number; /** Episodes */ - episodes: components['schemas']['Episode'][]; + episodes: components['schemas']['PublicEpisode'][]; }; - /** PublicSeasonFile */ - PublicSeasonFile: { + /** PublicEpisodeFile */ + PublicEpisodeFile: { /** - * Season Id + * Episode Id * Format: uuid */ - season_id: string; + episode_id: string; quality: components['schemas']['Quality']; /** Torrent Id */ torrent_id: string | null; @@ -3236,7 +3257,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; @@ -3254,7 +3275,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PublicSeasonFile'][]; + 'application/json': components['schemas']['PublicEpisodeFile'][]; }; }; /** @description Validation Error */ From a570a0093359f83bfa6c46c620854918371bfcb7 Mon Sep 17 00:00:00 2001 From: Caio Natarelli Date: Thu, 12 Feb 2026 00:09:30 -0300 Subject: [PATCH 08/10] Fix on TvRepository --- media_manager/tv/repository.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/media_manager/tv/repository.py b/media_manager/tv/repository.py index bfe27415..b9e4db0e 100644 --- a/media_manager/tv/repository.py +++ b/media_manager/tv/repository.py @@ -476,7 +476,11 @@ def get_episode_files_by_season_id(self, season_id: SeasonId) -> list[EpisodeFil :raises SQLAlchemyError: If a database error occurs. """ try: - stmt = select(EpisodeFile).where(EpisodeFile.season_id == season_id) + stmt = ( + select(EpisodeFile) + .join(Episode) + .where(Episode.season_id == season_id) + ) results = self.db.execute(stmt).scalars().all() return [EpisodeFileSchema.model_validate(ef) for ef in results] except SQLAlchemyError: From 67648dff4ab636704ffcafc45153fee894e57c9e Mon Sep 17 00:00:00 2001 From: Caio Natarelli Date: Thu, 12 Feb 2026 00:10:24 -0300 Subject: [PATCH 09/10] Changes on WebUI to better handle Season and Episode downloads --- .../download-custom-dialog.svelte | 171 +++++++++++++++++ .../download-selected-episodes-dialog.svelte | 179 ++++++++++++++++++ .../download-selected-seasons-dialog.svelte | 175 +++++++++++++++++ .../components/torrents/torrent-table.svelte | 7 + web/src/lib/utils.ts | 11 ++ .../dashboard/tv/[showId=uuid]/+page.svelte | 105 +++++++++- .../[SeasonId=uuid]/+page.svelte | 19 +- 7 files changed, 659 insertions(+), 8 deletions(-) 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/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 00000000..46e62649 --- /dev/null +++ b/web/src/lib/components/download-dialogs/download-custom-dialog.svelte @@ -0,0 +1,171 @@ + + + +
+ + +
+ + +
+ +

+ 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 00000000..6d5459a3 --- /dev/null +++ b/web/src/lib/components/download-dialogs/download-selected-episodes-dialog.svelte @@ -0,0 +1,179 @@ + + + +
+

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

+ + +
+ + + {#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 00000000..2ad886c0 --- /dev/null +++ b/web/src/lib/components/download-dialogs/download-selected-seasons-dialog.svelte @@ -0,0 +1,175 @@ + + + +
+

+ 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 fc047173..db493bdb 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 +68,7 @@

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

@@ -107,14 +116,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)} From 6f2d8e090fa345f00cbbd850eae3d656c1816898 Mon Sep 17 00:00:00 2001 From: Caio Natarelli Date: Thu, 12 Feb 2026 00:18:56 -0300 Subject: [PATCH 10/10] fix on season details page --- .../dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.svelte b/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.svelte index 8b0dc740..84d0d541 100644 --- a/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.svelte +++ b/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonId=uuid]/+page.svelte @@ -139,7 +139,11 @@ {:else} - You haven't downloaded this season yet. + + + You haven't downloaded episodes of this season yet. + + {/each}