Skip to content
Draft
4 changes: 2 additions & 2 deletions alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
from media_manager.torrent.models import Torrent # noqa: E402
from media_manager.tv.models import ( # noqa: E402
Episode,
EpisodeFile,
Season,
SeasonFile,
SeasonRequest,
Show,
)
Expand All @@ -47,14 +47,14 @@
# noinspection PyStatementEffect
__all__ = [
"Episode",
"EpisodeFile",
"IndexerQueryResult",
"Movie",
"MovieFile",
"MovieRequest",
"Notification",
"OAuthAccount",
"Season",
"SeasonFile",
"SeasonRequest",
"Show",
"Torrent",
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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")

1 change: 1 addition & 0 deletions media_manager/indexer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
59 changes: 52 additions & 7 deletions media_manager/indexer/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,59 @@ def quality(self) -> Quality:
@computed_field
@property
def season(self) -> list[int]:
pattern = r"\bS(\d+)\b"
matches = re.findall(pattern, self.title, re.IGNORECASE)
if matches.__len__() == 2:
result = list(range(int(matches[0]), int(matches[1]) + 1))
elif matches.__len__() == 1:
result = [int(matches[0])]
title = self.title.lower()
result: list[int] = []

# 1) S01E01 / S1E2
m = re.search(r"s(\d{1,2})e\d{1,3}", title)
if m:
result = [int(m.group(1))]
return result

# 2) Range S01-S03 / S1-S3
m = re.search(r"s(\d{1,2})\s*[-–]\s*s?(\d{1,2})", title)
if m:
start, end = int(m.group(1)), int(m.group(2))
if start <= end:
result = list(range(start, end + 1))
return result

# 3) Pack S01 / S1
m = re.search(r"\bs(\d{1,2})\b", title)
if m:
result = [int(m.group(1))]
return result

# 4) Season 01 / Season 1
m = re.search(r"\bseason\s*(\d{1,2})\b", title)
if m:
result = [int(m.group(1))]
return result

return result

@computed_field(return_type=list[int])
@property
def episode(self) -> list[int]:
title = self.title.lower()
result: list[int] = []

pattern = r"s\d{1,2}e(\d{1,3})(?:\s*-\s*(?:s?\d{1,2}e)?(\d{1,3}))?"
match = re.search(pattern, title)

if not match:
return result

start = int(match.group(1))
end = match.group(2)

if end:
end = int(end)
if end >= start:
result = list(range(start, end + 1))
else:
result = []
result = [start]

return result

def __gt__(self, other: "IndexerQueryResult") -> bool:
Expand Down
2 changes: 1 addition & 1 deletion media_manager/torrent/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
30 changes: 14 additions & 16 deletions media_manager/torrent/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,28 @@
MovieFile as MovieFileSchema,
)
from media_manager.torrent.models import Torrent
from media_manager.torrent.schemas import Torrent as TorrentSchema
from media_manager.torrent.schemas import TorrentId
from media_manager.tv.models import Season, SeasonFile, Show
from media_manager.tv.schemas import SeasonFile as SeasonFileSchema
from media_manager.tv.schemas import Show as ShowSchema

from media_manager.torrent.schemas import TorrentId, Torrent as TorrentSchema
from media_manager.tv.models import Show, Season, EpisodeFile, Episode
from media_manager.tv.schemas import Show as ShowSchema, EpisodeFile as EpisodeFileSchema

class TorrentRepository:
def __init__(self, db: DbSessionDependency) -> None:
self.db = db

def get_seasons_files_of_torrent(
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:
Expand Down Expand Up @@ -69,10 +67,10 @@ def delete_torrent(
)
self.db.execute(movie_files_stmt)

season_files_stmt = delete(SeasonFile).where(
SeasonFile.torrent_id == torrent_id
episode_files_stmt = delete(EpisodeFile).where(
EpisodeFile.torrent_id == torrent_id
)
self.db.execute(season_files_stmt)
self.db.execute(episode_files_stmt)

self.db.delete(self.db.get(Torrent, torrent_id))

Expand Down
13 changes: 7 additions & 6 deletions media_manager/torrent/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from media_manager.torrent.manager import DownloadManager
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.schemas import Torrent, TorrentId
from media_manager.tv.schemas import SeasonFile, Show
from media_manager.tv.schemas import Show, EpisodeFile
from media_manager.movies.schemas import Movie

log = logging.getLogger(__name__)

Expand All @@ -19,13 +20,13 @@ def __init__(
self.torrent_repository = torrent_repository
self.download_manager = download_manager or DownloadManager()

def get_season_files_of_torrent(self, torrent: Torrent) -> list[SeasonFile]:
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
)

Expand Down
22 changes: 11 additions & 11 deletions media_manager/tv/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -66,25 +63,28 @@ class Episode(Base):
number: Mapped[int]
external_id: Mapped[int]
title: Mapped[str]
overview: Mapped[str]

season: Mapped["Season"] = relationship(back_populates="episodes")
episode_files = relationship(
"EpisodeFile", back_populates="episode", cascade="all, delete"
)


class SeasonFile(Base):
__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"),
)
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):
__tablename__ = "season_request"
Expand Down
Loading