Support for handling Single Episode Torrents (#331)

**Description**
As explained on #322, MediaManager currently only matches torrents that
represent full seasons or season packs.
As a result, valid episode-based releases — commonly returned by
indexers such as EZTV — are filtered out during scoring and never
considered for download.

Initial changes to the season parsing logic allow these torrents to be
discovered.
However, additional changes are required beyond season parsing to
properly support single-episode imports.

This PR is intended as a work-in-progress / RFC to discuss the required
changes and align on the correct approach before completing the
implementation.

**Things planned to do**
[X] Update Web UI to better display episode-level details
[ ] Update TV show import logic to handle single episode files, instead
of assuming full season files (to avoid integrity errors when episodes
are missing)
[ ] Create episode file tables to store episode-level data, similar to
season files
[ ] Implement fetching and downloading logic for single-episode torrents

**Notes / current limitations**
At the moment, the database and import logic assume one file per season
per quality, which works for season packs but not for episode-based
releases.

These changes are intentionally not completed yet and are part of the
discussion this PR aims to start.

**Request for feedback**
This represents a significant change in how TV content is handled in
MediaManager.
Before proceeding further, feedback from @maxdorninger on the overall
direction and next steps would be greatly appreciated.

Once aligned, the remaining tasks can be implemented incrementally.

---------

Co-authored-by: Maximilian Dorninger <97409287+maxdorninger@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
natarelli22
2026-02-22 11:21:19 -03:00
committed by GitHub
parent 094d0e4eb7
commit d8a0ec66c3
23 changed files with 1443 additions and 241 deletions

View File

@@ -35,8 +35,8 @@ from media_manager.notification.models import Notification # noqa: E402
from media_manager.torrent.models import Torrent # noqa: E402
from media_manager.tv.models import ( # noqa: E402
Episode,
EpisodeFile,
Season,
SeasonFile,
SeasonRequest,
Show,
)
@@ -47,6 +47,7 @@ target_metadata = Base.metadata
# noinspection PyStatementEffect
__all__ = [
"Episode",
"EpisodeFile",
"IndexerQueryResult",
"Movie",
"MovieFile",
@@ -54,7 +55,6 @@ __all__ = [
"Notification",
"OAuthAccount",
"Season",
"SeasonFile",
"SeasonRequest",
"Show",
"Torrent",

View File

@@ -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")

View File

@@ -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")

View File

@@ -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]

View File

@@ -54,14 +54,55 @@ class IndexerQueryResult(BaseModel):
@computed_field
@property
def season(self) -> list[int]:
pattern = r"\bS(\d+)\b"
matches = re.findall(pattern, self.title, re.IGNORECASE)
if matches.__len__() == 2:
result = list(range(int(matches[0]), int(matches[1]) + 1))
elif matches.__len__() == 1:
result = [int(matches[0])]
title = self.title.lower()
# 1) S01E01 / S1E2
m = re.search(r"s(\d{1,2})e\d{1,3}", title)
if m:
return [int(m.group(1))]
# 2) Range S01-S03 / S1-S3
m = re.search(r"s(\d{1,2})\s*(?:-|\u2013)\s*s?(\d{1,2})", title)
if m:
start, end = int(m.group(1)), int(m.group(2))
if start <= end:
return list(range(start, end + 1))
return []
# 3) Pack S01 / S1
m = re.search(r"\bs(\d{1,2})\b", title)
if m:
return [int(m.group(1))]
# 4) Season 01 / Season 1
m = re.search(r"\bseason\s*(\d{1,2})\b", title)
if m:
return [int(m.group(1))]
return []
@computed_field(return_type=list[int])
@property
def episode(self) -> list[int]:
title = self.title.lower()
result: list[int] = []
pattern = r"s\d{1,2}e(\d{1,3})(?:\s*-\s*(?:s?\d{1,2}e)?(\d{1,3}))?"
match = re.search(pattern, title)
if not match:
return result
start = int(match.group(1))
end = match.group(2)
if end:
end = int(end)
if end >= start:
result = list(range(start, end + 1))
else:
result = []
result = [start]
return result
def __gt__(self, other: "IndexerQueryResult") -> bool:

View File

@@ -16,5 +16,5 @@ class Torrent(Base):
hash: Mapped[str]
usenet: Mapped[bool]
season_files = relationship("SeasonFile", back_populates="torrent")
episode_files = relationship("EpisodeFile", back_populates="torrent")
movie_files = relationship("MovieFile", back_populates="torrent")

View File

@@ -3,17 +3,13 @@ from sqlalchemy import delete, select
from media_manager.database import DbSessionDependency
from media_manager.exceptions import NotFoundError
from media_manager.movies.models import Movie, MovieFile
from media_manager.movies.schemas import (
Movie as MovieSchema,
)
from media_manager.movies.schemas import (
MovieFile as MovieFileSchema,
)
from media_manager.movies.schemas import Movie as MovieSchema
from media_manager.movies.schemas import MovieFile as MovieFileSchema
from media_manager.torrent.models import Torrent
from media_manager.torrent.schemas import Torrent as TorrentSchema
from media_manager.torrent.schemas import TorrentId
from media_manager.tv.models import Season, SeasonFile, Show
from media_manager.tv.schemas import SeasonFile as SeasonFileSchema
from media_manager.tv.models import Episode, EpisodeFile, Season, Show
from media_manager.tv.schemas import EpisodeFile as EpisodeFileSchema
from media_manager.tv.schemas import Show as ShowSchema
@@ -21,19 +17,22 @@ class TorrentRepository:
def __init__(self, db: DbSessionDependency) -> None:
self.db = db
def get_seasons_files_of_torrent(
def get_episode_files_of_torrent(
self, torrent_id: TorrentId
) -> list[SeasonFileSchema]:
stmt = select(SeasonFile).where(SeasonFile.torrent_id == torrent_id)
) -> list[EpisodeFileSchema]:
stmt = select(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id)
result = self.db.execute(stmt).scalars().all()
return [SeasonFileSchema.model_validate(season_file) for season_file in result]
return [
EpisodeFileSchema.model_validate(episode_file) for episode_file in result
]
def get_show_of_torrent(self, torrent_id: TorrentId) -> ShowSchema | None:
stmt = (
select(Show)
.join(SeasonFile.season)
.join(Season.show)
.where(SeasonFile.torrent_id == torrent_id)
.join(Show.seasons)
.join(Season.episodes)
.join(Episode.episode_files)
.where(EpisodeFile.torrent_id == torrent_id)
)
result = self.db.execute(stmt).unique().scalar_one_or_none()
if result is None:
@@ -69,10 +68,10 @@ class TorrentRepository:
)
self.db.execute(movie_files_stmt)
season_files_stmt = delete(SeasonFile).where(
SeasonFile.torrent_id == torrent_id
episode_files_stmt = delete(EpisodeFile).where(
EpisodeFile.torrent_id == torrent_id
)
self.db.execute(season_files_stmt)
self.db.execute(episode_files_stmt)
self.db.delete(self.db.get(Torrent, torrent_id))

View File

@@ -5,7 +5,7 @@ from media_manager.movies.schemas import Movie, MovieFile
from media_manager.torrent.manager import DownloadManager
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.schemas import Torrent, TorrentId
from media_manager.tv.schemas import SeasonFile, Show
from media_manager.tv.schemas import EpisodeFile, Show
log = logging.getLogger(__name__)
@@ -19,13 +19,13 @@ class TorrentService:
self.torrent_repository = torrent_repository
self.download_manager = download_manager or DownloadManager()
def get_season_files_of_torrent(self, torrent: Torrent) -> list[SeasonFile]:
def get_episode_files_of_torrent(self, torrent: Torrent) -> list[EpisodeFile]:
"""
Returns all season files of a torrent
:param torrent: the torrent to get the season files of
:return: list of season files
Returns all episode files of a torrent
:param torrent: the torrent to get the episode files of
:return: list of episode files
"""
return self.torrent_repository.get_seasons_files_of_torrent(
return self.torrent_repository.get_episode_files_of_torrent(
torrent_id=torrent.id
)

View File

@@ -48,9 +48,6 @@ class Season(Base):
back_populates="season", cascade="all, delete"
)
season_files = relationship(
"SeasonFile", back_populates="season", cascade="all, delete"
)
season_requests = relationship(
"SeasonRequest", back_populates="season", cascade="all, delete"
)
@@ -66,15 +63,19 @@ class Episode(Base):
number: Mapped[int]
external_id: Mapped[int]
title: Mapped[str]
overview: Mapped[str | None] = mapped_column(nullable=True)
season: Mapped["Season"] = relationship(back_populates="episodes")
episode_files = relationship(
"EpisodeFile", back_populates="episode", cascade="all, delete"
)
class SeasonFile(Base):
__tablename__ = "season_file"
__table_args__ = (PrimaryKeyConstraint("season_id", "file_path_suffix"),)
season_id: Mapped[UUID] = mapped_column(
ForeignKey(column="season.id", ondelete="CASCADE"),
class EpisodeFile(Base):
__tablename__ = "episode_file"
__table_args__ = (PrimaryKeyConstraint("episode_id", "file_path_suffix"),)
episode_id: Mapped[UUID] = mapped_column(
ForeignKey(column="episode.id", ondelete="CASCADE"),
)
torrent_id: Mapped[UUID | None] = mapped_column(
ForeignKey(column="torrent.id", ondelete="SET NULL"),
@@ -82,8 +83,8 @@ class SeasonFile(Base):
file_path_suffix: Mapped[str]
quality: Mapped[Quality]
torrent = relationship("Torrent", back_populates="season_files", uselist=False)
season = relationship("Season", back_populates="season_files", uselist=False)
torrent = relationship("Torrent", back_populates="episode_files", uselist=False)
episode = relationship("Episode", back_populates="episode_files", uselist=False)
class SeasonRequest(Base):

View File

@@ -1,8 +1,5 @@
from sqlalchemy import delete, func, select
from sqlalchemy.exc import (
IntegrityError,
SQLAlchemyError,
)
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.orm import Session, joinedload
from media_manager.exceptions import ConflictError, NotFoundError
@@ -10,32 +7,21 @@ from media_manager.torrent.models import Torrent
from media_manager.torrent.schemas import Torrent as TorrentSchema
from media_manager.torrent.schemas import TorrentId
from media_manager.tv import log
from media_manager.tv.models import Episode, Season, SeasonFile, SeasonRequest, Show
from media_manager.tv.schemas import (
Episode as EpisodeSchema,
)
from media_manager.tv.models import Episode, EpisodeFile, Season, SeasonRequest, Show
from media_manager.tv.schemas import Episode as EpisodeSchema
from media_manager.tv.schemas import EpisodeFile as EpisodeFileSchema
from media_manager.tv.schemas import (
EpisodeId,
EpisodeNumber,
SeasonId,
SeasonNumber,
SeasonRequestId,
ShowId,
)
from media_manager.tv.schemas import (
RichSeasonRequest as RichSeasonRequestSchema,
)
from media_manager.tv.schemas import (
Season as SeasonSchema,
)
from media_manager.tv.schemas import (
SeasonFile as SeasonFileSchema,
)
from media_manager.tv.schemas import (
SeasonRequest as SeasonRequestSchema,
)
from media_manager.tv.schemas import (
Show as ShowSchema,
)
from media_manager.tv.schemas import RichSeasonRequest as RichSeasonRequestSchema
from media_manager.tv.schemas import Season as SeasonSchema
from media_manager.tv.schemas import SeasonRequest as SeasonRequestSchema
from media_manager.tv.schemas import Show as ShowSchema
class TvRepository:
@@ -120,9 +106,7 @@ class TvRepository:
def get_total_downloaded_episodes_count(self) -> int:
try:
stmt = (
select(func.count()).select_from(Episode).join(Season).join(SeasonFile)
)
stmt = select(func.count(Episode.id)).select_from(Episode).join(EpisodeFile)
return self.db.execute(stmt).scalar_one_or_none()
except SQLAlchemyError:
log.exception("Database error while calculating downloaded episodes count")
@@ -173,6 +157,7 @@ class TvRepository:
number=episode.number,
external_id=episode.external_id,
title=episode.title,
overview=episode.overview,
)
for episode in season.episodes
],
@@ -234,6 +219,43 @@ class TvRepository:
log.exception(f"Database error while retrieving season {season_id}")
raise
def get_episode(self, episode_id: EpisodeId) -> EpisodeSchema:
"""
Retrieve an episode by its ID.
:param episode_id: The ID of the episode to get.
:return: An Episode object.
:raises NotFoundError: If the episode with the given ID is not found.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
episode = self.db.get(Episode, episode_id)
if not episode:
msg = f"Episode with id {episode_id} not found."
raise NotFoundError(msg)
return EpisodeSchema.model_validate(episode)
except SQLAlchemyError as e:
log.error(f"Database error while retrieving episode {episode_id}: {e}")
raise
def get_season_by_episode(self, episode_id: EpisodeId) -> SeasonSchema:
try:
stmt = select(Season).join(Season.episodes).where(Episode.id == episode_id)
season = self.db.scalar(stmt)
if not season:
msg = f"Season not found for episode {episode_id}"
raise NotFoundError(msg)
return SeasonSchema.model_validate(season)
except SQLAlchemyError as e:
log.error(
f"Database error while retrieving season for episode {episode_id}: {e}"
)
raise
def add_season_request(
self, season_request: SeasonRequestSchema
) -> SeasonRequestSchema:
@@ -355,46 +377,46 @@ class TvRepository:
log.exception("Database error while retrieving season requests")
raise
def add_season_file(self, season_file: SeasonFileSchema) -> SeasonFileSchema:
def add_episode_file(self, episode_file: EpisodeFileSchema) -> EpisodeFileSchema:
"""
Adds a season file record to the database.
Adds an episode file record to the database.
:param season_file: The SeasonFile object to add.
:return: The added SeasonFile object.
:param episode_file: The EpisodeFile object to add.
:return: The added EpisodeFile object.
:raises IntegrityError: If the record violates constraints.
:raises SQLAlchemyError: If a database error occurs.
"""
db_model = SeasonFile(**season_file.model_dump())
db_model = EpisodeFile(**episode_file.model_dump())
try:
self.db.add(db_model)
self.db.commit()
self.db.refresh(db_model)
return SeasonFileSchema.model_validate(db_model)
except IntegrityError:
return EpisodeFileSchema.model_validate(db_model)
except IntegrityError as e:
self.db.rollback()
log.exception("Integrity error while adding season file")
log.error(f"Integrity error while adding episode file: {e}")
raise
except SQLAlchemyError:
except SQLAlchemyError as e:
self.db.rollback()
log.exception("Database error while adding season file")
log.error(f"Database error while adding episode file: {e}")
raise
def remove_season_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
def remove_episode_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
"""
Removes season file records associated with a given torrent ID.
Removes episode file records associated with a given torrent ID.
:param torrent_id: The ID of the torrent whose season files are to be removed.
:return: The number of season files removed.
:param torrent_id: The ID of the torrent whose episode files are to be removed.
:return: The number of episode files removed.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = delete(SeasonFile).where(SeasonFile.torrent_id == torrent_id)
stmt = delete(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id)
result = self.db.execute(stmt)
self.db.commit()
except SQLAlchemyError:
self.db.rollback()
log.exception(
f"Database error removing season files for torrent_id {torrent_id}"
f"Database error removing episode files for torrent_id {torrent_id}"
)
raise
return result.rowcount
@@ -420,23 +442,45 @@ class TvRepository:
log.exception(f"Database error setting library for show {show_id}")
raise
def get_season_files_by_season_id(
def get_episode_files_by_season_id(
self, season_id: SeasonId
) -> list[SeasonFileSchema]:
) -> list[EpisodeFileSchema]:
"""
Retrieve all season files for a given season ID.
Retrieve all episode files for a given season ID.
:param season_id: The ID of the season.
:return: A list of SeasonFile objects.
:return: A list of EpisodeFile objects.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = select(SeasonFile).where(SeasonFile.season_id == season_id)
stmt = (
select(EpisodeFile).join(Episode).where(Episode.season_id == season_id)
)
results = self.db.execute(stmt).scalars().all()
return [SeasonFileSchema.model_validate(sf) for sf in results]
return [EpisodeFileSchema.model_validate(ef) for ef in results]
except SQLAlchemyError:
log.exception(
f"Database error retrieving season files for season_id {season_id}"
f"Database error retrieving episode files for season_id {season_id}"
)
raise
def get_episode_files_by_episode_id(
self, episode_id: EpisodeId
) -> list[EpisodeFileSchema]:
"""
Retrieve all episode files for a given episode ID.
:param episode_id: The ID of the episode.
:return: A list of EpisodeFile objects.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = select(EpisodeFile).where(EpisodeFile.episode_id == episode_id)
results = self.db.execute(stmt).scalars().all()
return [EpisodeFileSchema.model_validate(sf) for sf in results]
except SQLAlchemyError as e:
log.error(
f"Database error retrieving episode files for episode_id {episode_id}: {e}"
)
raise
@@ -452,8 +496,9 @@ class TvRepository:
stmt = (
select(Torrent)
.distinct()
.join(SeasonFile, SeasonFile.torrent_id == Torrent.id)
.join(Season, Season.id == SeasonFile.season_id)
.join(EpisodeFile, EpisodeFile.torrent_id == Torrent.id)
.join(Episode, Episode.id == EpisodeFile.episode_id)
.join(Season, Season.id == Episode.season_id)
.where(Season.show_id == show_id)
)
results = self.db.execute(stmt).scalars().unique().all()
@@ -474,8 +519,9 @@ class TvRepository:
select(Show)
.distinct()
.join(Season, Show.id == Season.show_id)
.join(SeasonFile, Season.id == SeasonFile.season_id)
.join(Torrent, SeasonFile.torrent_id == Torrent.id)
.join(Episode, Season.id == Episode.season_id)
.join(EpisodeFile, Episode.id == EpisodeFile.episode_id)
.join(Torrent, EpisodeFile.torrent_id == Torrent.id)
.options(joinedload(Show.seasons).joinedload(Season.episodes))
.order_by(Show.name)
)
@@ -497,8 +543,9 @@ class TvRepository:
stmt = (
select(Season.number)
.distinct()
.join(SeasonFile, Season.id == SeasonFile.season_id)
.where(SeasonFile.torrent_id == torrent_id)
.join(Episode, Episode.season_id == Season.id)
.join(EpisodeFile, EpisodeFile.episode_id == Episode.id)
.where(EpisodeFile.torrent_id == torrent_id)
)
results = self.db.execute(stmt).scalars().unique().all()
return [SeasonNumber(x) for x in results]
@@ -508,6 +555,32 @@ class TvRepository:
)
raise
def get_episodes_by_torrent_id(self, torrent_id: TorrentId) -> list[EpisodeNumber]:
"""
Retrieve episode numbers associated with a given torrent ID.
:param torrent_id: The ID of the torrent.
:return: A list of EpisodeNumber objects.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = (
select(Episode.number)
.join(EpisodeFile, EpisodeFile.episode_id == Episode.id)
.where(EpisodeFile.torrent_id == torrent_id)
.order_by(Episode.number)
)
episode_numbers = self.db.execute(stmt).scalars().all()
return [EpisodeNumber(n) for n in sorted(set(episode_numbers))]
except SQLAlchemyError as e:
log.error(
f"Database error retrieving episodes for torrent_id {torrent_id}: {e}"
)
raise
def get_season_request(
self, season_request_id: SeasonRequestId
) -> SeasonRequestSchema:
@@ -734,11 +807,15 @@ class TvRepository:
return SeasonSchema.model_validate(db_season)
def update_episode_attributes(
self, episode_id: EpisodeId, title: str | None = None
self,
episode_id: EpisodeId,
title: str | None = None,
overview: str | None = None,
) -> EpisodeSchema:
"""
Update attributes of an existing episode.
:param overview: Tje new overview for the episode.
:param episode_id: The ID of the episode to update.
:param title: The new title for the episode.
:param external_id: The new external ID for the episode.
@@ -755,6 +832,9 @@ class TvRepository:
if title is not None and db_episode.title != title:
db_episode.title = title
updated = True
if overview is not None and db_episode.overview != overview:
db_episode.overview = overview
updated = True
if updated:
self.db.commit()

View File

@@ -25,7 +25,7 @@ from media_manager.tv.dependencies import (
)
from media_manager.tv.schemas import (
CreateSeasonRequest,
PublicSeasonFile,
PublicEpisodeFile,
PublicShow,
RichSeasonRequest,
RichShowTorrent,
@@ -402,13 +402,13 @@ def get_season(season: season_dep) -> Season:
"/seasons/{season_id}/files",
dependencies=[Depends(current_active_user)],
)
def get_season_files(
def get_episode_files(
season: season_dep, tv_service: tv_service_dep
) -> list[PublicSeasonFile]:
) -> list[PublicEpisodeFile]:
"""
Get files associated with a specific season.
Get episode files associated with a specific season.
"""
return tv_service.get_public_season_files_by_season_id(season=season)
return tv_service.get_public_episode_files_by_season_id(season=season)
# -----------------------------------------------------------------------------

View File

@@ -24,6 +24,7 @@ class Episode(BaseModel):
number: EpisodeNumber
external_id: int
title: str
overview: str | None = None
class Season(BaseModel):
@@ -98,16 +99,16 @@ class RichSeasonRequest(SeasonRequest):
season: Season
class SeasonFile(BaseModel):
class EpisodeFile(BaseModel):
model_config = ConfigDict(from_attributes=True)
season_id: SeasonId
episode_id: EpisodeId
quality: Quality
torrent_id: TorrentId | None
file_path_suffix: str
class PublicSeasonFile(SeasonFile):
class PublicEpisodeFile(EpisodeFile):
downloaded: bool = False
@@ -123,6 +124,7 @@ class RichSeasonTorrent(BaseModel):
file_path_suffix: str
seasons: list[SeasonNumber]
episodes: list[EpisodeNumber]
class RichShowTorrent(BaseModel):
@@ -135,6 +137,18 @@ class RichShowTorrent(BaseModel):
torrents: list[RichSeasonTorrent]
class PublicEpisode(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: EpisodeId
number: EpisodeNumber
downloaded: bool = False
title: str
overview: str | None = None
external_id: int
class PublicSeason(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -147,7 +161,7 @@ class PublicSeason(BaseModel):
external_id: int
episodes: list[Episode]
episodes: list[PublicEpisode]
class PublicShow(BaseModel):

View File

@@ -41,24 +41,24 @@ from media_manager.torrent.utils import (
from media_manager.tv import log
from media_manager.tv.repository import TvRepository
from media_manager.tv.schemas import (
Episode as EpisodeSchema,
)
from media_manager.tv.schemas import (
Episode,
EpisodeFile,
EpisodeId,
EpisodeNumber,
PublicEpisodeFile,
PublicSeason,
PublicSeasonFile,
PublicShow,
RichSeasonRequest,
RichSeasonTorrent,
RichShowTorrent,
Season,
SeasonFile,
SeasonId,
SeasonRequest,
SeasonRequestId,
Show,
ShowId,
)
from media_manager.tv.schemas import Episode as EpisodeSchema
class TvService:
@@ -173,6 +173,7 @@ class TvService:
for torrent in torrents:
try:
self.torrent_service.cancel_download(torrent, delete_files=True)
self.torrent_service.delete_torrent(torrent_id=torrent.id)
log.info(f"Deleted torrent: {torrent.hash}")
except Exception:
log.warning(
@@ -181,24 +182,26 @@ class TvService:
self.tv_repository.delete_show(show_id=show.id)
def get_public_season_files_by_season_id(
def get_public_episode_files_by_season_id(
self, season: Season
) -> list[PublicSeasonFile]:
) -> list[PublicEpisodeFile]:
"""
Get all public season files for a given season.
Get all public episode files for a given season.
:param season: The season object.
:return: A list of public season files.
:return: A list of public episode files.
"""
season_files = self.tv_repository.get_season_files_by_season_id(
episode_files = self.tv_repository.get_episode_files_by_season_id(
season_id=season.id
)
public_season_files = [PublicSeasonFile.model_validate(x) for x in season_files]
public_episode_files = [
PublicEpisodeFile.model_validate(x) for x in episode_files
]
result = []
for season_file in public_season_files:
if self.season_file_exists_on_file(season_file=season_file):
season_file.downloaded = True
result.append(season_file)
for episode_file in public_episode_files:
if self.episode_file_exists_on_file(episode_file=episode_file):
episode_file.downloaded = True
result.append(episode_file)
return result
@overload
@@ -334,11 +337,27 @@ class TvService:
:param show: The show object.
:return: A public show.
"""
seasons = [PublicSeason.model_validate(season) for season in show.seasons]
for season in seasons:
season.downloaded = self.is_season_downloaded(season_id=season.id)
public_show = PublicShow.model_validate(show)
public_show.seasons = seasons
public_seasons: list[PublicSeason] = []
for season in show.seasons:
public_season = PublicSeason.model_validate(season)
for episode in public_season.episodes:
episode.downloaded = self.is_episode_downloaded(
episode=episode,
season=season,
show=show,
)
# A season is considered downloaded if it has episodes and all of them are downloaded,
# matching the behavior of is_season_downloaded.
public_season.downloaded = bool(public_season.episodes) and all(
episode.downloaded for episode in public_season.episodes
)
public_seasons.append(public_season)
public_show.seasons = public_seasons
return public_show
def get_show_by_id(self, show_id: ShowId) -> Show:
@@ -350,33 +369,85 @@ class TvService:
"""
return self.tv_repository.get_show_by_id(show_id=show_id)
def is_season_downloaded(self, season_id: SeasonId) -> bool:
def is_season_downloaded(self, season: Season, show: Show) -> bool:
"""
Check if a season is downloaded.
:param season_id: The ID of the season.
:param season: The season object.
:param show: The show object.
:return: True if the season is downloaded, False otherwise.
"""
season_files = self.tv_repository.get_season_files_by_season_id(
season_id=season_id
episodes = season.episodes
if not episodes:
return False
for episode in episodes:
if not self.is_episode_downloaded(
episode=episode, season=season, show=show
):
return False
return True
def is_episode_downloaded(
self, episode: Episode, season: Season, show: Show
) -> bool:
"""
Check if an episode is downloaded and imported (file exists on disk).
An episode is considered downloaded if:
- There is at least one EpisodeFile in the database AND
- A matching episode file exists in the season directory on disk.
:param episode: The episode object.
:param season: The season object.
:param show: The show object.
:return: True if the episode is downloaded and imported, False otherwise.
"""
episode_files = self.tv_repository.get_episode_files_by_episode_id(
episode_id=episode.id
)
for season_file in season_files:
if self.season_file_exists_on_file(season_file=season_file):
return True
if not episode_files:
return False
season_dir = self.get_root_season_directory(show, season.number)
if not season_dir.exists():
return False
episode_token = f"S{season.number:02d}E{episode.number:02d}"
video_extensions = {".mkv", ".mp4", ".avi", ".mov"}
try:
for file in season_dir.iterdir():
if (
file.is_file()
and episode_token.lower() in file.name.lower()
and file.suffix.lower() in video_extensions
):
return True
except OSError as e:
log.error(
f"Disk check failed for episode {episode.id} in {season_dir}: {e}"
)
return False
def season_file_exists_on_file(self, season_file: SeasonFile) -> bool:
def episode_file_exists_on_file(self, episode_file: EpisodeFile) -> bool:
"""
Check if a season file exists on the filesystem.
Check if an episode file exists on the filesystem.
:param season_file: The season file to check.
:param episode_file: The episode file to check.
:return: True if the file exists, False otherwise.
"""
if season_file.torrent_id is None:
if episode_file.torrent_id is None:
return True
try:
torrent_file = self.torrent_service.get_torrent_by_id(
torrent_id=season_file.torrent_id
torrent_id=episode_file.torrent_id
)
if torrent_file.imported:
@@ -409,6 +480,24 @@ class TvService:
"""
return self.tv_repository.get_season(season_id=season_id)
def get_episode(self, episode_id: EpisodeId) -> Episode:
"""
Get an episode by its ID.
:param episode_id: The ID of the episode.
:return: The episode.
"""
return self.tv_repository.get_episode(episode_id=episode_id)
def get_season_by_episode(self, episode_id: EpisodeId) -> Season:
"""
Get a season by the episode ID.
:param episode_id: The ID of the episode.
:return: The season.
"""
return self.tv_repository.get_season_by_episode(episode_id=episode_id)
def get_all_season_requests(self) -> list[RichSeasonRequest]:
"""
Get all season requests.
@@ -430,10 +519,16 @@ class TvService:
seasons = self.tv_repository.get_seasons_by_torrent_id(
torrent_id=show_torrent.id
)
season_files = self.torrent_service.get_season_files_of_torrent(
episodes = self.tv_repository.get_episodes_by_torrent_id(
torrent_id=show_torrent.id
)
episode_files = self.torrent_service.get_episode_files_of_torrent(
torrent=show_torrent
)
file_path_suffix = season_files[0].file_path_suffix if season_files else ""
file_path_suffix = (
episode_files[0].file_path_suffix if episode_files else ""
)
season_torrent = RichSeasonTorrent(
torrent_id=show_torrent.id,
torrent_title=show_torrent.title,
@@ -441,10 +536,12 @@ class TvService:
quality=show_torrent.quality,
imported=show_torrent.imported,
seasons=seasons,
episodes=episodes if len(seasons) == 1 else [],
file_path_suffix=file_path_suffix,
usenet=show_torrent.usenet,
)
rich_season_torrents.append(season_torrent)
return RichShowTorrent(
show_id=show.id,
name=show.name,
@@ -487,24 +584,49 @@ class TvService:
season = self.tv_repository.get_season_by_number(
season_number=season_number, show_id=show_id
)
season_file = SeasonFile(
season_id=season.id,
quality=indexer_result.quality,
torrent_id=show_torrent.id,
file_path_suffix=override_show_file_path_suffix,
)
self.tv_repository.add_season_file(season_file=season_file)
episodes = {episode.number: episode.id for episode in season.episodes}
if indexer_result.episode:
episode_ids = []
missing_episodes = []
for ep_number in indexer_result.episode:
ep_id = episodes.get(EpisodeNumber(ep_number))
if ep_id is None:
missing_episodes.append(ep_number)
continue
episode_ids.append(ep_id)
if missing_episodes:
log.warning(
"Some episodes from indexer result were not found in season %s "
"for show %s and will be skipped: %s",
season.id,
show_id,
", ".join(str(ep) for ep in missing_episodes),
)
else:
episode_ids = [episode.id for episode in season.episodes]
for episode_id in episode_ids:
episode_file = EpisodeFile(
episode_id=episode_id,
quality=indexer_result.quality,
torrent_id=show_torrent.id,
file_path_suffix=override_show_file_path_suffix,
)
self.tv_repository.add_episode_file(episode_file=episode_file)
except IntegrityError:
log.error(
f"Season file for season {season.id} and quality {indexer_result.quality} already exists, skipping."
f"Episode file for episode {episode_id} of season {season.id} and quality {indexer_result.quality} already exists, skipping."
)
self.tv_repository.remove_episode_files_by_torrent_id(show_torrent.id)
self.torrent_service.cancel_download(
torrent=show_torrent, delete_files=True
)
raise
else:
log.info(
f"Successfully added season files for torrent {show_torrent.title} and show ID {show_id}"
f"Successfully added episode files for torrent {show_torrent.title} and show ID {show_id}"
)
self.torrent_service.resume_download(torrent=show_torrent)
@@ -561,7 +683,7 @@ class TvService:
available_torrents.sort()
torrent = self.torrent_service.download(indexer_result=available_torrents[0])
season_file = SeasonFile(
season_file = SeasonFile( # noqa: F821
season_id=season.id,
quality=torrent.quality,
torrent_id=torrent.id,
@@ -653,12 +775,12 @@ class TvService:
video_files: list[Path],
subtitle_files: list[Path],
file_path_suffix: str = "",
) -> tuple[bool, int]:
) -> tuple[bool, list[Episode]]:
season_path = self.get_root_season_directory(
show=show, season_number=season.number
)
success = True
imported_episodes_count = 0
imported_episodes = []
try:
season_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
@@ -677,7 +799,7 @@ class TvService:
file_path_suffix=file_path_suffix,
)
if imported:
imported_episodes_count += 1
imported_episodes.append(episode)
except Exception:
# Send notification about missing episode file
@@ -690,11 +812,72 @@ class TvService:
log.warning(
f"S{season.number}E{episode.number} not found when trying to import episode for show {show.name}."
)
return success, imported_episodes_count
return success, imported_episodes
def import_torrent_files(self, torrent: Torrent, show: Show) -> None:
def import_episode_files(
self,
show: Show,
season: Season,
episode: Episode,
video_files: list[Path],
subtitle_files: list[Path],
file_path_suffix: str = "",
) -> bool:
episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode.number:02d}"
if file_path_suffix != "":
episode_file_name += f" - {file_path_suffix}"
pattern = (
r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode.number) + r"[. ].*"
)
subtitle_pattern = pattern + r"[. ]([A-Za-z]{2})[. ]srt"
target_file_name = (
self.get_root_season_directory(show=show, season_number=season.number)
/ episode_file_name
)
# import subtitle
for subtitle_file in subtitle_files:
regex_result = re.search(
subtitle_pattern, subtitle_file.name, re.IGNORECASE
)
if regex_result:
language_code = regex_result.group(1)
target_subtitle_file = target_file_name.with_suffix(
f".{language_code}.srt"
)
import_file(target_file=target_subtitle_file, source_file=subtitle_file)
else:
log.debug(
f"Didn't find any pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}"
)
found_video = False
# import episode videos
for file in video_files:
if re.search(pattern, file.name, re.IGNORECASE):
target_video_file = target_file_name.with_suffix(file.suffix)
import_file(target_file=target_video_file, source_file=file)
found_video = True
break
if not found_video:
# Send notification about missing episode file
if self.notification_service:
self.notification_service.send_notification_to_all_providers(
title="Missing Episode File",
message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.",
)
log.warning(
f"File for S{season.number}E{episode.number} not found when trying to import episode for show {show.name}."
)
return False
return True
def import_episode_files_from_torrent(self, torrent: Torrent, show: Show) -> None:
"""
Organizes files from a torrent into the TV directory structure, mapping them to seasons and episodes.
Organizes episodes files from a torrent into the TV directory structure, mapping them to seasons and episodes.
:param torrent: The Torrent object
:param show: The Show object
"""
@@ -707,33 +890,68 @@ class TvService:
f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files)
)
season_files = self.torrent_service.get_season_files_of_torrent(torrent=torrent)
episode_files = self.torrent_service.get_episode_files_of_torrent(
torrent=torrent
)
if not episode_files:
log.warning(
f"No episode files associated with torrent {torrent.title}, skipping import."
)
return
log.info(
f"Found {len(season_files)} season files associated with torrent {torrent.title}"
f"Found {len(episode_files)} episode files associated with torrent {torrent.title}"
)
for season_file in season_files:
season = self.get_season(season_id=season_file.season_id)
season_import_success, _imported_episodes_count = self.import_season(
imported_episodes_by_season: dict[int, list[int]] = {}
for episode_file in episode_files:
season = self.get_season_by_episode(episode_id=episode_file.episode_id)
episode = self.get_episode(episode_file.episode_id)
season_path = self.get_root_season_directory(
show=show, season_number=season.number
)
if not season_path.exists():
try:
season_path.mkdir(parents=True)
except Exception as e:
log.warning(f"Could not create path {season_path}: {e}")
msg = f"Could not create path {season_path}"
raise Exception(msg) from e # noqa: TRY002
episoded_import_success = self.import_episode_files(
show=show,
season=season,
episode=episode,
video_files=video_files,
subtitle_files=subtitle_files,
file_path_suffix=season_file.file_path_suffix,
file_path_suffix=episode_file.file_path_suffix,
)
success.append(season_import_success)
if season_import_success:
success.append(episoded_import_success)
if episoded_import_success:
imported_episodes_by_season.setdefault(season.number, []).append(
episode.number
)
log.info(
f"Season {season.number} successfully imported from torrent {torrent.title}"
f"Episode {episode.number} from Season {season.number} successfully imported from torrent {torrent.title}"
)
else:
log.warning(
f"Season {season.number} failed to import from torrent {torrent.title}"
f"Episode {episode.number} from Season {season.number} failed to import from torrent {torrent.title}"
)
log.info(
f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors"
)
success_messages: list[str] = []
for season_number, episodes in imported_episodes_by_season.items():
episode_list = ",".join(str(e) for e in sorted(episodes))
success_messages.append(
f"Episode(s): {episode_list} from Season {season_number}"
)
episodes_summary = "; ".join(success_messages)
if all(success):
torrent.imported = True
@@ -743,7 +961,11 @@ class TvService:
if self.notification_service:
self.notification_service.send_notification_to_all_providers(
title="TV Show imported successfully",
message=f"Successfully imported {show.name} ({show.year}) from torrent {torrent.title}.",
message=(
f"Successfully imported {episodes_summary} "
f"of {show.name} ({show.year}) "
f"from torrent {torrent.title}."
),
)
else:
if self.notification_service:
@@ -752,6 +974,10 @@ class TvService:
message=f"Importing {show.name} ({show.year}) from torrent {torrent.title} completed with errors. Please check the logs for details.",
)
log.info(
f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors"
)
def update_show_metadata(
self, db_show: Show, metadata_provider: AbstractMetadataProvider
) -> Show | None:
@@ -823,6 +1049,7 @@ class TvService:
self.tv_repository.update_episode_attributes(
episode_id=existing_episode.id,
title=fresh_episode_data.title,
overview=fresh_episode_data.overview,
)
else:
# Add new episode
@@ -834,6 +1061,7 @@ class TvService:
number=fresh_episode_data.number,
external_id=fresh_episode_data.external_id,
title=fresh_episode_data.title,
overview=fresh_episode_data.overview,
)
self.tv_repository.add_episode_to_season(
season_id=existing_season.id, episode_data=episode_schema
@@ -849,6 +1077,7 @@ class TvService:
number=ep_data.number,
external_id=ep_data.external_id,
title=ep_data.title,
overview=ep_data.overview,
)
for ep_data in fresh_season_data.episodes
]
@@ -911,21 +1140,22 @@ class TvService:
directory=new_source_path
)
for season in tv_show.seasons:
success, imported_episode_count = self.import_season(
_success, imported_episodes = self.import_season(
show=tv_show,
season=season,
video_files=video_files,
subtitle_files=subtitle_files,
file_path_suffix="IMPORTED",
)
season_file = SeasonFile(
season_id=season.id,
quality=Quality.unknown,
file_path_suffix="IMPORTED",
torrent_id=None,
)
if success or imported_episode_count > (len(season.episodes) / 2):
self.tv_repository.add_season_file(season_file=season_file)
for episode in imported_episodes:
episode_file = EpisodeFile(
episode_id=episode.id,
quality=Quality.unknown,
file_path_suffix="IMPORTED",
torrent_id=None,
)
self.tv_repository.add_episode_file(episode_file=episode_file)
def get_importable_tv_shows(
self, metadata_provider: AbstractMetadataProvider
@@ -1029,9 +1259,12 @@ def import_all_show_torrents() -> None:
f"torrent {t.title} is not a tv torrent, skipping import."
)
continue
tv_service.import_torrent_files(torrent=t, show=show)
except RuntimeError:
log.exception(f"Error importing torrent {t.title} for show {show.name}")
tv_service.import_episode_files_from_torrent(torrent=t, show=show)
except RuntimeError as e:
log.error(
f"Error importing torrent {t.title} for show {show.name}: {e}",
exc_info=True,
)
log.info("Finished importing all torrents")
db.commit()

View File

@@ -626,10 +626,10 @@ export interface paths {
cookie?: never;
};
/**
* Get Season Files
* @description Get files associated with a specific season.
* Get Episode Files
* @description Get episode files associated with a specific season.
*/
get: operations['get_season_files_api_v1_tv_seasons__season_id__files_get'];
get: operations['get_episode_files_api_v1_tv_seasons__season_id__files_get'];
put?: never;
post?: never;
delete?: never;
@@ -1316,6 +1316,8 @@ export interface components {
external_id: number;
/** Title */
title: string;
/** Overview */
overview?: string | null;
};
/** ErrorModel */
ErrorModel: {
@@ -1360,6 +1362,8 @@ export interface components {
readonly quality: components['schemas']['Quality'];
/** Season */
readonly season: number[];
/** Episode */
readonly episode: number[];
};
/** LibraryItem */
LibraryItem: {
@@ -1504,6 +1508,45 @@ export interface components {
/** Authorization Url */
authorization_url: string;
};
/** PublicEpisode */
PublicEpisode: {
/**
* Id
* Format: uuid
*/
id: string;
/** Number */
number: number;
/**
* Downloaded
* @default false
*/
downloaded: boolean;
/** Title */
title: string;
/** Overview */
overview?: string | null;
/** External Id */
external_id: number;
};
/** PublicEpisodeFile */
PublicEpisodeFile: {
/**
* Episode Id
* Format: uuid
*/
episode_id: string;
quality: components['schemas']['Quality'];
/** Torrent Id */
torrent_id: string | null;
/** File Path Suffix */
file_path_suffix: string;
/**
* Downloaded
* @default false
*/
downloaded: boolean;
};
/** PublicMovie */
PublicMovie: {
/**
@@ -1580,25 +1623,7 @@ export interface components {
/** External Id */
external_id: number;
/** Episodes */
episodes: components['schemas']['Episode'][];
};
/** PublicSeasonFile */
PublicSeasonFile: {
/**
* Season Id
* Format: uuid
*/
season_id: string;
quality: components['schemas']['Quality'];
/** Torrent Id */
torrent_id: string | null;
/** File Path Suffix */
file_path_suffix: string;
/**
* Downloaded
* @default false
*/
downloaded: boolean;
episodes: components['schemas']['PublicEpisode'][];
};
/** PublicShow */
PublicShow: {
@@ -1719,6 +1744,8 @@ export interface components {
file_path_suffix: string;
/** Seasons */
seasons: number[];
/** Episodes */
episodes: number[];
};
/** RichShowTorrent */
RichShowTorrent: {
@@ -3232,7 +3259,7 @@ export interface operations {
};
};
};
get_season_files_api_v1_tv_seasons__season_id__files_get: {
get_episode_files_api_v1_tv_seasons__season_id__files_get: {
parameters: {
query?: never;
header?: never;
@@ -3250,7 +3277,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['PublicSeasonFile'][];
'application/json': components['schemas']['PublicEpisodeFile'][];
};
};
/** @description Validation Error */

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { toast } from 'svelte-sonner';
import { formatSecondsToOptimalUnit } from '$lib/utils.ts';
import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import client from '$lib/api';
import type { components } from '$lib/api/api';
import SelectFilePathSuffixDialog from '$lib/components/download-dialogs/select-file-path-suffix-dialog.svelte';
import { invalidateAll } from '$app/navigation';
import TorrentTable from '$lib/components/download-dialogs/torrent-table.svelte';
import DownloadDialogWrapper from '$lib/components/download-dialogs/download-dialog-wrapper.svelte';
import { getFullyQualifiedMediaName } from '$lib/utils';
let { show }: { show: components['schemas']['Show'] } = $props();
let dialogueState = $state(false);
let torrentsError: string | null = $state(null);
let queryOverride: string = $state('');
let filePathSuffix: string = $state('');
let torrentsPromise: any = $state();
let torrentsData: any[] | null = $state(null);
let isLoading: boolean = $state(false);
const tableColumnHeadings = [
{ name: 'Size', id: 'size' },
{ name: 'Usenet', id: 'usenet' },
{ name: 'Seeders', id: 'seeders' },
{ name: 'Age', id: 'age' },
{ name: 'Score', id: 'score' },
{ name: 'Indexer', id: 'indexer' },
{ name: 'Indexer Flags', id: 'flags' },
{ name: 'Seasons', id: 'season' }
];
async function downloadTorrent(result_id: string) {
torrentsError = null;
const { response } = await client.POST('/api/v1/tv/torrents', {
params: {
query: {
show_id: show.id!,
public_indexer_result_id: result_id,
override_file_path_suffix: filePathSuffix === '' ? undefined : filePathSuffix
}
}
});
if (response.status === 409) {
const errorMessage = `There already is a File using the Filepath Suffix '${filePathSuffix}'. Try again with a different Filepath Suffix.`;
console.warn(errorMessage);
torrentsError = errorMessage;
if (dialogueState) toast.info(errorMessage);
} else if (!response.ok) {
const errorMessage = `Failed to download torrent for show ${show.id}: ${response.statusText}`;
console.error(errorMessage);
torrentsError = errorMessage;
toast.error(errorMessage);
} else {
toast.success('Torrent download started successfully!');
}
await invalidateAll();
}
async function search() {
if (!queryOverride || queryOverride.trim() === '') {
toast.error('Please enter a custom query.');
return;
}
isLoading = true;
torrentsError = null;
torrentsData = null;
torrentsPromise = client
.GET('/api/v1/tv/torrents', {
params: {
query: {
show_id: show.id!,
search_query_override: queryOverride
}
}
})
.then((data) => data?.data)
.finally(() => (isLoading = false));
toast.info('Searching for torrents...');
torrentsData = await torrentsPromise;
if (!torrentsData || torrentsData.length === 0) {
toast.info('No torrents found.');
} else {
toast.success(`Found ${torrentsData.length} torrents.`);
}
}
</script>
<DownloadDialogWrapper
bind:open={dialogueState}
triggerText="Custom Download"
title="Custom Torrent Download"
description="Search and download torrents using a fully custom query string."
>
<div class="grid w-full items-center gap-1.5">
<Label for="query-override">Enter a custom query</Label>
<div class="flex w-full max-w-sm items-center space-x-2">
<Input
bind:value={queryOverride}
id="query-override"
type="text"
placeholder={`e.g. ${getFullyQualifiedMediaName(show)} S01 1080p BluRay`}
/>
<Button disabled={isLoading} class="w-fit" onclick={search}>Search</Button>
</div>
<p class="text-sm text-muted-foreground">
The custom query completely overrides the default search logic. Make sure the torrent title
matches the episodes you want imported.
</p>
</div>
{#if torrentsError}
<div class="my-2 w-full text-center text-red-500">
An error occurred: {torrentsError}
</div>
{/if}
<TorrentTable {torrentsPromise} columns={tableColumnHeadings}>
{#snippet rowSnippet(torrent)}
<Table.Cell class="font-medium">{torrent.title}</Table.Cell>
<Table.Cell>{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB</Table.Cell>
<Table.Cell>{torrent.usenet}</Table.Cell>
<Table.Cell>{torrent.usenet ? 'N/A' : torrent.seeders}</Table.Cell>
<Table.Cell>
{torrent.age ? formatSecondsToOptimalUnit(torrent.age) : torrent.usenet ? 'N/A' : ''}
</Table.Cell>
<Table.Cell>{torrent.score}</Table.Cell>
<Table.Cell>{torrent.indexer ?? 'unknown'}</Table.Cell>
<Table.Cell>
{#if torrent.flags}
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
{/if}
</Table.Cell>
<Table.Cell>
{torrent.season ?? '-'}
</Table.Cell>
<Table.Cell class="text-right">
<SelectFilePathSuffixDialog
bind:filePathSuffix
media={show}
callback={() => downloadTorrent(torrent.id)}
/>
</Table.Cell>
{/snippet}
</TorrentTable>
</DownloadDialogWrapper>

View File

@@ -0,0 +1,188 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import { formatSecondsToOptimalUnit } from '$lib/utils';
import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import client from '$lib/api';
import type { components } from '$lib/api/api';
import SelectFilePathSuffixDialog from '$lib/components/download-dialogs/select-file-path-suffix-dialog.svelte';
import { invalidateAll } from '$app/navigation';
import TorrentTable from '$lib/components/download-dialogs/torrent-table.svelte';
import DownloadDialogWrapper from '$lib/components/download-dialogs/download-dialog-wrapper.svelte';
let {
show,
selectedEpisodeNumbers,
triggerText = 'Download Episodes'
}: {
show: components['schemas']['Show'];
selectedEpisodeNumbers: { seasonNumber: number; episodeNumber: number }[];
triggerText?: string;
} = $props();
let dialogueState = $state(false);
let torrentsPromise: any = $state();
let torrentsError: string | null = $state(null);
let isLoading: boolean = $state(false);
let filePathSuffix: string = $state('');
const tableColumnHeadings = [
{ name: 'Size', id: 'size' },
{ name: 'Usenet', id: 'usenet' },
{ name: 'Seeders', id: 'seeders' },
{ name: 'Age', id: 'age' },
{ name: 'Score', id: 'score' },
{ name: 'Indexer', id: 'indexer' },
{ name: 'Indexer Flags', id: 'flags' }
];
function torrentMatchesSelectedEpisodes(
torrentTitle: string,
selectedEpisodes: { seasonNumber: number; episodeNumber: number }[]
) {
const normalizedTitle = torrentTitle.toLowerCase();
return selectedEpisodes.some((ep) => {
const s = String(ep.seasonNumber).padStart(2, '0');
const e = String(ep.episodeNumber).padStart(2, '0');
const patterns = [
`s${s}e${e}`,
`${s}x${e}`,
`season ${ep.seasonNumber} episode ${ep.episodeNumber}`
];
return patterns.some((pattern) => normalizedTitle.includes(pattern));
});
}
async function search() {
if (!selectedEpisodeNumbers || selectedEpisodeNumbers.length === 0) {
toast.error('No episodes selected.');
return;
}
isLoading = true;
torrentsError = null;
torrentsPromise = Promise.all(
selectedEpisodeNumbers.map((ep) =>
client
.GET('/api/v1/tv/torrents', {
params: {
query: {
show_id: show.id!,
season_number: ep.seasonNumber,
episode_number: ep.episodeNumber
}
}
})
.then((r) => r?.data ?? [])
)
)
.then((results) => results.flat())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.then((allTorrents: any[]) =>
allTorrents.filter((torrent) =>
torrentMatchesSelectedEpisodes(torrent.title, selectedEpisodeNumbers)
)
)
.finally(() => (isLoading = false));
try {
await torrentsPromise;
} catch (error: any) {
console.error(error);
torrentsError = error.message || 'An error occurred while searching for torrents.';
toast.error(torrentsError);
}
}
async function downloadTorrent(result_id: string) {
const { response } = await client.POST('/api/v1/tv/torrents', {
params: {
query: {
show_id: show.id!,
public_indexer_result_id: result_id,
override_file_path_suffix: filePathSuffix === '' ? undefined : filePathSuffix
}
}
});
if (!response.ok) {
toast.error('Download failed.');
} else {
toast.success('Download started.');
}
await invalidateAll();
}
</script>
<DownloadDialogWrapper
bind:open={dialogueState}
{triggerText}
title="Download Selected Episodes"
description="Search and download torrents for selected episodes."
>
<div class="flex flex-col gap-3">
<p class="text-sm text-muted-foreground">
Selected episodes:
<strong>
{selectedEpisodeNumbers.length > 0
? selectedEpisodeNumbers
.map(
(e) =>
`S${String(e.seasonNumber).padStart(2, '0')}E${String(e.episodeNumber).padStart(2, '0')}`
)
.join(', ')
: 'None'}
</strong>
</p>
<Button
class="w-fit"
disabled={isLoading || selectedEpisodeNumbers.length === 0}
onclick={search}
>
Search Torrents
</Button>
</div>
{#if torrentsError}
<div class="my-2 w-full text-center text-red-500">
An error occurred: {torrentsError}
</div>
{/if}
<TorrentTable {torrentsPromise} columns={tableColumnHeadings}>
{#snippet rowSnippet(torrent)}
<Table.Cell>{torrent.title}</Table.Cell>
<Table.Cell>
{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB
</Table.Cell>
<Table.Cell>{torrent.usenet}</Table.Cell>
<Table.Cell>{torrent.usenet ? 'N/A' : torrent.seeders}</Table.Cell>
<Table.Cell>
{torrent.age ? formatSecondsToOptimalUnit(torrent.age) : torrent.usenet ? 'N/A' : ''}
</Table.Cell>
<Table.Cell>{torrent.score}</Table.Cell>
<Table.Cell>{torrent.indexer ?? 'unknown'}</Table.Cell>
<Table.Cell>
{#if torrent.flags}
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<SelectFilePathSuffixDialog
bind:filePathSuffix
media={show}
callback={() => downloadTorrent(torrent.id)}
/>
</Table.Cell>
{/snippet}
</TorrentTable>
</DownloadDialogWrapper>

View File

@@ -0,0 +1,189 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import { formatSecondsToOptimalUnit } from '$lib/utils.ts';
import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import client from '$lib/api';
import type { components } from '$lib/api/api';
import SelectFilePathSuffixDialog from '$lib/components/download-dialogs/select-file-path-suffix-dialog.svelte';
import { invalidateAll } from '$app/navigation';
import TorrentTable from '$lib/components/download-dialogs/torrent-table.svelte';
import DownloadDialogWrapper from '$lib/components/download-dialogs/download-dialog-wrapper.svelte';
let {
show,
selectedSeasonNumbers,
triggerText = 'Download Selected Seasons'
}: {
show: components['schemas']['Show'];
selectedSeasonNumbers: number[];
triggerText?: string;
} = $props();
let dialogueState = $state(false);
let torrentsError: string | null = $state(null);
let filePathSuffix: string = $state('');
let torrentsPromise: any = $state();
let isLoading: boolean = $state(false);
const tableColumnHeadings = [
{ name: 'Size', id: 'size' },
{ name: 'Usenet', id: 'usenet' },
{ name: 'Seeders', id: 'seeders' },
{ name: 'Age', id: 'age' },
{ name: 'Score', id: 'score' },
{ name: 'Indexer', id: 'indexer' },
{ name: 'Indexer Flags', id: 'flags' },
{ name: 'Seasons', id: 'season' }
];
async function downloadTorrent(result_id: string) {
torrentsError = null;
const { response } = await client.POST('/api/v1/tv/torrents', {
params: {
query: {
show_id: show.id!,
public_indexer_result_id: result_id,
override_file_path_suffix: filePathSuffix === '' ? undefined : filePathSuffix
}
}
});
if (response.status === 409) {
const errorMessage = `Filepath Suffix '${filePathSuffix}' already exists.`;
torrentsError = errorMessage;
toast.error(errorMessage);
} else if (!response.ok) {
const errorMessage = `Failed to download torrent: ${response.statusText}`;
torrentsError = errorMessage;
toast.error(errorMessage);
} else {
toast.success('Torrent download started successfully!');
}
await invalidateAll();
}
function isEpisodeRelease(title: string) {
const lower = title.toLowerCase();
const episodePatterns = [
/s\d{1,2}e\d{1,2}/i,
/\d{1,2}x\d{1,2}/i,
/\be\d{1,2}\b/i,
/e\d{1,2}-e?\d{1,2}/i,
/vol\.?\s?\d+/i
];
return episodePatterns.some((regex) => regex.test(lower));
}
async function search() {
if (!selectedSeasonNumbers || selectedSeasonNumbers.length === 0) {
toast.error('No seasons selected.');
return;
}
isLoading = true;
torrentsError = null;
toast.info(`Searching torrents for seasons: ${selectedSeasonNumbers.join(', ')}`);
torrentsPromise = Promise.all(
selectedSeasonNumbers.map((seasonNumber) =>
client
.GET('/api/v1/tv/torrents', {
params: {
query: {
show_id: show.id!,
season_number: seasonNumber
}
}
})
.then((data) => data?.data ?? [])
)
)
.then((results) => results.flat())
.then((allTorrents) => allTorrents.filter((torrent) => !isEpisodeRelease(torrent.title)))
.finally(() => (isLoading = false));
try {
await torrentsPromise;
} catch (error: any) {
console.error(error);
torrentsError = error.message || 'An error occurred while searching for torrents.';
toast.error(torrentsError);
}
}
</script>
<DownloadDialogWrapper
bind:open={dialogueState}
{triggerText}
title="Download Selected Seasons"
description="Search and download torrents for the selected seasons."
>
<div class="flex flex-col gap-3">
<p class="text-sm text-muted-foreground">
Selected seasons:
<strong>
{selectedSeasonNumbers.length > 0
? selectedSeasonNumbers
.slice()
.sort((a, b) => a - b)
.map((n) => `S${String(n).padStart(2, '0')}`)
.join(', ')
: 'None'}
</strong>
</p>
<Button
class="w-fit"
disabled={isLoading || selectedSeasonNumbers.length === 0}
onclick={search}
>
Search Torrents
</Button>
</div>
{#if torrentsError}
<div class="my-2 w-full text-center text-red-500">
An error occurred: {torrentsError}
</div>
{/if}
<TorrentTable {torrentsPromise} columns={tableColumnHeadings}>
{#snippet rowSnippet(torrent)}
<Table.Cell class="font-medium">{torrent.title}</Table.Cell>
<Table.Cell>
{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB
</Table.Cell>
<Table.Cell>{torrent.usenet}</Table.Cell>
<Table.Cell>{torrent.usenet ? 'N/A' : torrent.seeders}</Table.Cell>
<Table.Cell>
{torrent.age ? formatSecondsToOptimalUnit(torrent.age) : torrent.usenet ? 'N/A' : ''}
</Table.Cell>
<Table.Cell>{torrent.score}</Table.Cell>
<Table.Cell>{torrent.indexer ?? 'unknown'}</Table.Cell>
<Table.Cell>
{#if torrent.flags}
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
{/if}
</Table.Cell>
<Table.Cell>
{torrent.season ?? '-'}
</Table.Cell>
<Table.Cell class="text-right">
<SelectFilePathSuffixDialog
bind:filePathSuffix
media={show}
callback={() => downloadTorrent(torrent.id)}
/>
</Table.Cell>
{/snippet}
</TorrentTable>
</DownloadDialogWrapper>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import {
convertTorrentSeasonRangeToIntegerRange,
convertTorrentEpisodeRangeToIntegerRange,
getTorrentQualityString,
getTorrentStatusString
} from '$lib/utils.js';
@@ -59,6 +60,7 @@
<Table.Head>Name</Table.Head>
{#if isShow}
<Table.Head>Seasons</Table.Head>
<Table.Head>Episodes</Table.Head>
{/if}
<Table.Head>Download Status</Table.Head>
<Table.Head>Quality</Table.Head>
@@ -97,6 +99,11 @@
(torrent as components['schemas']['RichSeasonTorrent']).seasons!
)}
</Table.Cell>
<Table.Cell>
{convertTorrentEpisodeRangeToIntegerRange(
(torrent as components['schemas']['RichSeasonTorrent']).episodes!
)}
</Table.Cell>
{/if}
<Table.Cell>
{getTorrentStatusString(torrent.status)}

View File

@@ -51,6 +51,17 @@ export function convertTorrentSeasonRangeToIntegerRange(seasons: number[]): stri
}
}
export function convertTorrentEpisodeRangeToIntegerRange(episodes: number[]): string {
if (episodes.length === 1) return episodes[0]?.toString() || 'unknown';
else if (episodes.length > 1) {
const lastEpisode = episodes.at(-1);
return episodes[0]?.toString() + '-' + (lastEpisode?.toString() || 'unknown');
} else {
console.log('Error parsing episode range: ' + episodes);
return 'Error parsing episode range: ' + episodes;
}
}
export async function handleLogout() {
await client.POST('/api/v1/auth/cookie/logout');
await goto(resolve('/login', {}));

View File

@@ -80,7 +80,7 @@
<Card.Title>Overview</Card.Title>
</Card.Header>
<Card.Content>
<p class="leading-7 not-first:mt-6">
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
{movie.overview}
</p>
</Card.Content>

View File

@@ -4,11 +4,14 @@
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';
import { getFullyQualifiedMediaName } from '$lib/utils';
import DownloadSeasonDialog from '$lib/components/download-dialogs/download-season-dialog.svelte';
import DownloadSelectedSeasonsDialog from '$lib/components/download-dialogs/download-selected-seasons-dialog.svelte';
import DownloadSelectedEpisodesDialog from '$lib/components/download-dialogs/download-selected-episodes-dialog.svelte';
import DownloadCustomDialog from '$lib/components/download-dialogs/download-custom-dialog.svelte';
import CheckmarkX from '$lib/components/checkmark-x.svelte';
import { page } from '$app/state';
import TorrentTable from '$lib/components/torrents/torrent-table.svelte';
@@ -22,11 +25,85 @@
import DeleteMediaDialog from '$lib/components/delete-media-dialog.svelte';
import { resolve } from '$app/paths';
import client from '$lib/api';
import { Checkbox } from '$lib/components/ui/checkbox';
import { SvelteSet } from 'svelte/reactivity';
let show: components['schemas']['PublicShow'] = $derived(page.data.showData);
let torrents: components['schemas']['RichShowTorrent'] = $derived(page.data.torrentsData);
let user: () => components['schemas']['UserRead'] = getContext('user');
let expandedSeasons = $state<Set<string>>(new Set());
function toggleSeason(seasonId: string) {
if (expandedSeasons.has(seasonId)) {
expandedSeasons.delete(seasonId);
} else {
expandedSeasons.add(seasonId);
}
expandedSeasons = new SvelteSet(expandedSeasons);
}
let selectedSeasons = $state<Set<string>>(new Set());
function toggleSeasonSelection(seasonId: string) {
if (selectedSeasons.has(seasonId)) {
selectedSeasons.delete(seasonId);
} else {
selectedSeasons.add(seasonId);
}
selectedSeasons = new SvelteSet(selectedSeasons);
}
let selectedSeasonNumbers = $derived(
show.seasons.filter((s) => selectedSeasons.has(s.id)).map((s) => s.number)
);
let downloadButtonLabel = $derived(
selectedSeasonNumbers.length === 0
? 'Download Seasons'
: `Download Season(s) ${selectedSeasonNumbers
.slice()
.sort((a, b) => a - b)
.map((n) => `S${String(n).padStart(2, '0')}`)
.join(', ')}`
);
let selectedEpisodes = $state<Set<string>>(new Set());
function toggleEpisodeSelection(episodeId: string) {
if (selectedEpisodes.has(episodeId)) {
selectedEpisodes.delete(episodeId);
} else {
selectedEpisodes.add(episodeId);
}
selectedEpisodes = new SvelteSet(selectedEpisodes);
}
let selectedEpisodeNumbers = $derived(
show.seasons.flatMap((season) =>
season.episodes
.filter((ep) => selectedEpisodes.has(ep.id))
.map((ep) => ({
seasonNumber: season.number,
episodeNumber: ep.number
}))
)
);
let episodeDownloadLabel = $derived(
selectedEpisodeNumbers.length === 0
? 'Download Episodes'
: `Download Episode(s) ${selectedEpisodeNumbers
.map(
(e) =>
`S${String(e.seasonNumber).padStart(2, '0')}E${String(e.episodeNumber).padStart(
2,
'0'
)}`
)
.join(', ')}`
);
let continuousDownloadEnabled = $derived(show.continuous_download);
async function toggle_continuous_download() {
@@ -109,7 +186,7 @@
<Card.Title>Overview</Card.Title>
</Card.Header>
<Card.Content>
<p class="leading-7 not-first:mt-6">
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
{show.overview}
</p>
</Card.Content>
@@ -146,7 +223,23 @@
</Card.Header>
<Card.Content class="flex flex-col items-center gap-4">
{#if user().is_superuser}
<DownloadSeasonDialog {show} />
{#if selectedSeasonNumbers.length > 0}
<DownloadSelectedSeasonsDialog
{show}
{selectedSeasonNumbers}
triggerText={downloadButtonLabel}
/>
{/if}
{#if selectedEpisodeNumbers.length > 0}
<DownloadSelectedEpisodesDialog
{show}
{selectedEpisodeNumbers}
triggerText={episodeDownloadLabel}
/>
{/if}
{#if selectedSeasonNumbers.length === 0 && selectedEpisodeNumbers.length === 0}
<DownloadCustomDialog {show} />
{/if}
{/if}
<RequestSeasonDialog {show} />
</Card.Content>
@@ -162,35 +255,87 @@
</Card.Description>
</Card.Header>
<Card.Content class="w-full overflow-x-auto">
<Table.Root>
<Table.Root class="w-full table-fixed">
<Table.Caption>A list of all seasons.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>Number</Table.Head>
<Table.Head>Exists on file</Table.Head>
<Table.Head>Title</Table.Head>
<Table.Head class="w-[40px]"></Table.Head>
<Table.Head class="w-[80px]">Number</Table.Head>
<Table.Head class="w-[100px]">Exists on file</Table.Head>
<Table.Head class="w-[240px]">Title</Table.Head>
<Table.Head>Overview</Table.Head>
<Table.Head class="w-[64px] text-center">Details</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if show.seasons.length > 0}
{#each show.seasons as season (season.id)}
<Table.Row
onclick={() =>
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)}
>
<Table.Cell class="min-w-[10px] font-medium">{season.number}</Table.Cell>
<Table.Cell class="w-[40px]">
<Checkbox
checked={selectedSeasons.has(season.id)}
onCheckedChange={() => toggleSeasonSelection(season.id)}
onclick={(e) => e.stopPropagation()}
/>
</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
S{String(season.number).padStart(2, '0')}
</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
<CheckmarkX state={season.downloaded} />
</Table.Cell>
<Table.Cell class="min-w-[50px]">{season.name}</Table.Cell>
<Table.Cell class="max-w-[300px] truncate">{season.overview}</Table.Cell>
<Table.Cell class="w-[64px] text-center">
<button
class="inline-flex cursor-pointer items-center
justify-center
rounded-md p-1
transition-colors
hover:bg-muted/95
focus-visible:ring-2
focus-visible:ring-ring focus-visible:outline-none"
onclick={(e) => {
e.stopPropagation();
goto(
resolve('/dashboard/tv/[showId]/[seasonId]', {
showId: show.id,
seasonId: season.id
})
);
}}
aria-label="Season details"
>
<Ellipsis size={16} class="text-muted-foreground" />
</button>
</Table.Cell>
</Table.Row>
{#if expandedSeasons.has(season.id)}
{#each season.episodes as episode (episode.id)}
<Table.Row class="bg-muted/20">
<Table.Cell class="w-[40px]">
<Checkbox
checked={selectedEpisodes.has(episode.id)}
onCheckedChange={() => toggleEpisodeSelection(episode.id)}
onclick={(e) => e.stopPropagation()}
/>
</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
E{String(episode.number).padStart(2, '0')}
</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
<CheckmarkX state={episode.downloaded} />
</Table.Cell>
<Table.Cell class="min-w-[50px]">{episode.title}</Table.Cell>
<Table.Cell colspan={2} class="truncate">{episode.overview}</Table.Cell>
</Table.Row>
{/each}
{/if}
{/each}
{:else}
<Table.Row>

View File

@@ -11,9 +11,15 @@
import { resolve } from '$app/paths';
import * as Card from '$lib/components/ui/card/index.js';
let seasonFiles: components['schemas']['PublicSeasonFile'][] = $derived(page.data.files);
let episodeFiles: components['schemas']['PublicEpisodeFile'][] = $derived(page.data.files);
let season: components['schemas']['Season'] = $derived(page.data.season);
let show: components['schemas']['Show'] = $derived(page.data.showData);
let episodeById = $derived(
Object.fromEntries(
season.episodes.map((ep) => [ep.id, `E${String(ep.number).padStart(2, '0')}`])
)
);
</script>
<svelte:head>
@@ -59,7 +65,7 @@
</div>
</header>
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
{getFullyQualifiedMediaName(show)} Season {season.number}
{getFullyQualifiedMediaName(show)} - Season {season.number}
</h1>
<main class="mx-auto flex w-full flex-1 flex-col gap-4 p-4 md:max-w-[80em]">
<div class="flex flex-col gap-4 md:flex-row md:items-stretch">
@@ -68,13 +74,20 @@
</div>
<div class="h-full w-full flex-auto rounded-xl md:w-1/4">
<Card.Root class="h-full w-full">
<Card.Header>
<Card.Title>Overview</Card.Title>
</Card.Header>
<Card.Content>
<p class="leading-7 not-first:mt-6">
{show.overview}
</p>
<Card.Content class="flex flex-col gap-6">
<div>
<Card.Title class="mb-2 text-base">Series Overview</Card.Title>
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
{show.overview}
</p>
</div>
<div class="border-t border-border"></div>
<div>
<Card.Title class="mb-2 text-base">Season Overview</Card.Title>
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
{season.overview}
</p>
</div>
</Card.Content>
</Card.Root>
</div>
@@ -95,14 +108,18 @@
>
<Table.Header>
<Table.Row>
<Table.Head>Episode</Table.Head>
<Table.Head>Quality</Table.Head>
<Table.Head>File Path Suffix</Table.Head>
<Table.Head>Imported</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each seasonFiles as file (file)}
{#each episodeFiles as file (file)}
<Table.Row>
<Table.Cell class="w-[50px]">
{episodeById[file.episode_id] ?? 'E??'}
</Table.Cell>
<Table.Cell class="w-[50px]">
{getTorrentQualityString(file.quality)}
</Table.Cell>
@@ -114,7 +131,11 @@
</Table.Cell>
</Table.Row>
{:else}
<span class="font-semibold">You haven't downloaded this season yet.</span>
<Table.Row>
<Table.Cell colspan={4} class="text-center py-6 font-semibold">
You haven't downloaded episodes of this season yet.
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
@@ -132,19 +153,23 @@
</Card.Description>
</Card.Header>
<Card.Content class="w-full overflow-x-auto">
<Table.Root>
<Table.Root class="w-full table-fixed">
<Table.Caption>A list of all episodes.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Number</Table.Head>
<Table.Head class="min-w-[50px]">Title</Table.Head>
<Table.Head class="w-[80px]">Number</Table.Head>
<Table.Head class="w-[240px]">Title</Table.Head>
<Table.Head>Overview</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each season.episodes as episode (episode.id)}
<Table.Row>
<Table.Cell class="w-[100px] font-medium">{episode.number}</Table.Cell>
<Table.Cell class="w-[100px] font-medium"
>E{String(episode.number).padStart(2, '0')}</Table.Cell
>
<Table.Cell class="min-w-[50px]">{episode.title}</Table.Cell>
<Table.Cell class="truncate">{episode.overview}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>

View File

@@ -10,7 +10,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
}
}
});
const seasonFiles = client.GET('/api/v1/tv/seasons/{season_id}/files', {
const episodeFiles = client.GET('/api/v1/tv/seasons/{season_id}/files', {
fetch: fetch,
params: {
path: {
@@ -19,7 +19,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
}
});
return {
files: await seasonFiles.then((x) => x.data),
files: await episodeFiles.then((x) => x.data),
season: await season.then((x) => x.data)
};
};