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