mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-17 15:43:28 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
75
web/src/lib/api/api.d.ts
vendored
75
web/src/lib/api/api.d.ts
vendored
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
|
||||
@@ -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', {}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user