Compare commits
33 Commits
dependabot
...
migrate-da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c98ec2fa0 | ||
|
|
d8a0ec66c3 | ||
|
|
094d0e4eb7 | ||
|
|
4d7f596ffd | ||
|
|
300df14c8c | ||
|
|
2f102d6c5d | ||
|
|
3e696c463c | ||
|
|
5adb88f9e0 | ||
|
|
eb277dddac | ||
|
|
516d562bd8 | ||
|
|
dea75841b2 | ||
|
|
20e0dbf936 | ||
|
|
c8f2a4316e | ||
|
|
4836e3e188 | ||
|
|
7a6466ea9d | ||
|
|
b427aa5723 | ||
|
|
82aa01a650 | ||
|
|
bc3895ab40 | ||
|
|
b7ed529f77 | ||
|
|
370df4efa0 | ||
|
|
a3e85d6338 | ||
|
|
a2816f2dfb | ||
|
|
0026b891f5 | ||
|
|
b312d880b7 | ||
|
|
71e2a08535 | ||
|
|
f2bf1a2dae | ||
|
|
6b70980c2a | ||
|
|
e80a516c23 | ||
|
|
280e136209 | ||
|
|
5c62c9f5be | ||
|
|
1e46cdc03b | ||
|
|
18573fa7d9 | ||
|
|
6debd7a42d |
6
.github/dependabot.yml
vendored
@@ -23,3 +23,9 @@ updates:
|
|||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
|
- package-ecosystem: "uv"
|
||||||
|
directory: "/metadata_relay"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<br />
|
<br />
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://maxdorninger.github.io/MediaManager/">
|
<a href="https://maxdorninger.github.io/MediaManager/">
|
||||||
<img src="https://raw.githubusercontent.com/maxdorninger/MediaManager/refs/heads/master/web/static/logo.svg" alt="Logo" width="260" height="260">
|
<img src="https://raw.githubusercontent.com/maxdorninger/MediaManager/refs/heads/master/docs/assets/logo-with-text.svg" alt="Logo" width="800">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h3 align="center">MediaManager</h3>
|
<h3 align="center">MediaManager</h3>
|
||||||
|
|||||||
@@ -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.torrent.models import Torrent # noqa: E402
|
||||||
from media_manager.tv.models import ( # noqa: E402
|
from media_manager.tv.models import ( # noqa: E402
|
||||||
Episode,
|
Episode,
|
||||||
|
EpisodeFile,
|
||||||
Season,
|
Season,
|
||||||
SeasonFile,
|
|
||||||
SeasonRequest,
|
SeasonRequest,
|
||||||
Show,
|
Show,
|
||||||
)
|
)
|
||||||
@@ -47,6 +47,7 @@ target_metadata = Base.metadata
|
|||||||
# noinspection PyStatementEffect
|
# noinspection PyStatementEffect
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Episode",
|
"Episode",
|
||||||
|
"EpisodeFile",
|
||||||
"IndexerQueryResult",
|
"IndexerQueryResult",
|
||||||
"Movie",
|
"Movie",
|
||||||
"MovieFile",
|
"MovieFile",
|
||||||
@@ -54,7 +55,6 @@ __all__ = [
|
|||||||
"Notification",
|
"Notification",
|
||||||
"OAuthAccount",
|
"OAuthAccount",
|
||||||
"Season",
|
"Season",
|
||||||
"SeasonFile",
|
|
||||||
"SeasonRequest",
|
"SeasonRequest",
|
||||||
"Show",
|
"Show",
|
||||||
"Torrent",
|
"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")
|
||||||
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""migrate season files to episode files and drop the legacy table
|
||||||
|
|
||||||
|
Revision ID: a6f714d3c8b9
|
||||||
|
Revises: 16e78af9e5bf
|
||||||
|
Create Date: 2026-02-22 16:30:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "a6f714d3c8b9"
|
||||||
|
down_revision: Union[str, None] = "3a8fbd71e2c2"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Copy season_file records into episode_file and remove the legacy table."""
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO episode_file (episode_id, torrent_id, file_path_suffix, quality)
|
||||||
|
SELECT episode.id, season_file.torrent_id, season_file.file_path_suffix, season_file.quality
|
||||||
|
FROM season_file
|
||||||
|
JOIN season ON season.id = season_file.season_id
|
||||||
|
JOIN episode ON episode.season_id = season.id
|
||||||
|
LEFT JOIN episode_file ON
|
||||||
|
episode_file.episode_id = episode.id
|
||||||
|
AND episode_file.file_path_suffix = season_file.file_path_suffix
|
||||||
|
WHERE episode_file.episode_id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.drop_table("season_file")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Recreate season_file, repopulate it from episode_file, and keep both tables."""
|
||||||
|
quality_enum = postgresql.ENUM(
|
||||||
|
"uhd", "fullhd", "hd", "sd", "unknown", name="quality", create_type=False
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"season_file",
|
||||||
|
sa.Column("season_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(["season_id"], ["season.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["torrent_id"], ["torrent.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("season_id", "file_path_suffix"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO season_file (season_id, torrent_id, file_path_suffix, quality)
|
||||||
|
SELECT DISTINCT ON (episode.season_id, episode_file.file_path_suffix)
|
||||||
|
episode.season_id,
|
||||||
|
episode_file.torrent_id,
|
||||||
|
episode_file.file_path_suffix,
|
||||||
|
episode_file.quality
|
||||||
|
FROM episode_file
|
||||||
|
JOIN episode ON episode.id = episode_file.episode_id
|
||||||
|
ORDER BY episode.season_id, episode_file.file_path_suffix, episode_file.torrent_id, episode_file.quality
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
BIN
docs/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
docs/assets/logo-with-text.svg
Normal file
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 110 KiB |
22
docs/custom.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/*.md-header__button.md-logo {*/
|
||||||
|
/* margin-top: 0;*/
|
||||||
|
/* margin-bottom: 0;*/
|
||||||
|
/* padding-top: 0;*/
|
||||||
|
/* padding-bottom: 0;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*.md-header__button.md-logo img,*/
|
||||||
|
/*.md-header__button.md-logo svg {*/
|
||||||
|
/* height: 70%;*/
|
||||||
|
/* width: 70%;*/
|
||||||
|
/*}*/
|
||||||
|
/* Increase logo size */
|
||||||
|
.md-header__button.md-logo svg, .md-header__button.md-logo img {
|
||||||
|
height: 2.5rem; /* Increase height (default is usually ~1.2rem to 1.5rem) */
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust header height if necessary to fit the larger logo */
|
||||||
|
.md-header {
|
||||||
|
height: 4rem; /* Match or exceed your new logo height */
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ class IndexerQueryResult(Base):
|
|||||||
flags = mapped_column(ARRAY(String))
|
flags = mapped_column(ARRAY(String))
|
||||||
quality: Mapped[Quality]
|
quality: Mapped[Quality]
|
||||||
season = mapped_column(ARRAY(Integer))
|
season = mapped_column(ARRAY(Integer))
|
||||||
|
episode = mapped_column(ARRAY(Integer))
|
||||||
size = mapped_column(BigInteger)
|
size = mapped_column(BigInteger)
|
||||||
usenet: Mapped[bool]
|
usenet: Mapped[bool]
|
||||||
age: Mapped[int]
|
age: Mapped[int]
|
||||||
|
|||||||
@@ -54,14 +54,55 @@ class IndexerQueryResult(BaseModel):
|
|||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def season(self) -> list[int]:
|
def season(self) -> list[int]:
|
||||||
pattern = r"\bS(\d+)\b"
|
title = self.title.lower()
|
||||||
matches = re.findall(pattern, self.title, re.IGNORECASE)
|
|
||||||
if matches.__len__() == 2:
|
# 1) S01E01 / S1E2
|
||||||
result = list(range(int(matches[0]), int(matches[1]) + 1))
|
m = re.search(r"s(\d{1,2})e\d{1,3}", title)
|
||||||
elif matches.__len__() == 1:
|
if m:
|
||||||
result = [int(matches[0])]
|
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:
|
else:
|
||||||
result = []
|
result = [start]
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def __gt__(self, other: "IndexerQueryResult") -> bool:
|
def __gt__(self, other: "IndexerQueryResult") -> bool:
|
||||||
|
|||||||
@@ -16,5 +16,5 @@ class Torrent(Base):
|
|||||||
hash: Mapped[str]
|
hash: Mapped[str]
|
||||||
usenet: Mapped[bool]
|
usenet: Mapped[bool]
|
||||||
|
|
||||||
season_files = relationship("SeasonFile", back_populates="torrent")
|
episode_files = relationship("EpisodeFile", back_populates="torrent")
|
||||||
movie_files = relationship("MovieFile", 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.database import DbSessionDependency
|
||||||
from media_manager.exceptions import NotFoundError
|
from media_manager.exceptions import NotFoundError
|
||||||
from media_manager.movies.models import Movie, MovieFile
|
from media_manager.movies.models import Movie, MovieFile
|
||||||
from media_manager.movies.schemas import (
|
from media_manager.movies.schemas import Movie as MovieSchema
|
||||||
Movie as MovieSchema,
|
from media_manager.movies.schemas import MovieFile as MovieFileSchema
|
||||||
)
|
|
||||||
from media_manager.movies.schemas import (
|
|
||||||
MovieFile as MovieFileSchema,
|
|
||||||
)
|
|
||||||
from media_manager.torrent.models import Torrent
|
from media_manager.torrent.models import Torrent
|
||||||
from media_manager.torrent.schemas import Torrent as TorrentSchema
|
from media_manager.torrent.schemas import Torrent as TorrentSchema
|
||||||
from media_manager.torrent.schemas import TorrentId
|
from media_manager.torrent.schemas import TorrentId
|
||||||
from media_manager.tv.models import Season, SeasonFile, Show
|
from media_manager.tv.models import Episode, EpisodeFile, Season, Show
|
||||||
from media_manager.tv.schemas import SeasonFile as SeasonFileSchema
|
from media_manager.tv.schemas import EpisodeFile as EpisodeFileSchema
|
||||||
from media_manager.tv.schemas import Show as ShowSchema
|
from media_manager.tv.schemas import Show as ShowSchema
|
||||||
|
|
||||||
|
|
||||||
@@ -21,19 +17,22 @@ class TorrentRepository:
|
|||||||
def __init__(self, db: DbSessionDependency) -> None:
|
def __init__(self, db: DbSessionDependency) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def get_seasons_files_of_torrent(
|
def get_episode_files_of_torrent(
|
||||||
self, torrent_id: TorrentId
|
self, torrent_id: TorrentId
|
||||||
) -> list[SeasonFileSchema]:
|
) -> list[EpisodeFileSchema]:
|
||||||
stmt = select(SeasonFile).where(SeasonFile.torrent_id == torrent_id)
|
stmt = select(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id)
|
||||||
result = self.db.execute(stmt).scalars().all()
|
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:
|
def get_show_of_torrent(self, torrent_id: TorrentId) -> ShowSchema | None:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Show)
|
select(Show)
|
||||||
.join(SeasonFile.season)
|
.join(Show.seasons)
|
||||||
.join(Season.show)
|
.join(Season.episodes)
|
||||||
.where(SeasonFile.torrent_id == torrent_id)
|
.join(Episode.episode_files)
|
||||||
|
.where(EpisodeFile.torrent_id == torrent_id)
|
||||||
)
|
)
|
||||||
result = self.db.execute(stmt).unique().scalar_one_or_none()
|
result = self.db.execute(stmt).unique().scalar_one_or_none()
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -69,10 +68,10 @@ class TorrentRepository:
|
|||||||
)
|
)
|
||||||
self.db.execute(movie_files_stmt)
|
self.db.execute(movie_files_stmt)
|
||||||
|
|
||||||
season_files_stmt = delete(SeasonFile).where(
|
episode_files_stmt = delete(EpisodeFile).where(
|
||||||
SeasonFile.torrent_id == torrent_id
|
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))
|
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.manager import DownloadManager
|
||||||
from media_manager.torrent.repository import TorrentRepository
|
from media_manager.torrent.repository import TorrentRepository
|
||||||
from media_manager.torrent.schemas import Torrent, TorrentId
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -19,13 +19,13 @@ class TorrentService:
|
|||||||
self.torrent_repository = torrent_repository
|
self.torrent_repository = torrent_repository
|
||||||
self.download_manager = download_manager or DownloadManager()
|
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
|
Returns all episode files of a torrent
|
||||||
:param torrent: the torrent to get the season files of
|
:param torrent: the torrent to get the episode files of
|
||||||
:return: list of season files
|
: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
|
torrent_id=torrent.id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -48,9 +48,6 @@ class Season(Base):
|
|||||||
back_populates="season", cascade="all, delete"
|
back_populates="season", cascade="all, delete"
|
||||||
)
|
)
|
||||||
|
|
||||||
season_files = relationship(
|
|
||||||
"SeasonFile", back_populates="season", cascade="all, delete"
|
|
||||||
)
|
|
||||||
season_requests = relationship(
|
season_requests = relationship(
|
||||||
"SeasonRequest", back_populates="season", cascade="all, delete"
|
"SeasonRequest", back_populates="season", cascade="all, delete"
|
||||||
)
|
)
|
||||||
@@ -66,15 +63,19 @@ class Episode(Base):
|
|||||||
number: Mapped[int]
|
number: Mapped[int]
|
||||||
external_id: Mapped[int]
|
external_id: Mapped[int]
|
||||||
title: Mapped[str]
|
title: Mapped[str]
|
||||||
|
overview: Mapped[str | None] = mapped_column(nullable=True)
|
||||||
|
|
||||||
season: Mapped["Season"] = relationship(back_populates="episodes")
|
season: Mapped["Season"] = relationship(back_populates="episodes")
|
||||||
|
episode_files = relationship(
|
||||||
|
"EpisodeFile", back_populates="episode", cascade="all, delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SeasonFile(Base):
|
class EpisodeFile(Base):
|
||||||
__tablename__ = "season_file"
|
__tablename__ = "episode_file"
|
||||||
__table_args__ = (PrimaryKeyConstraint("season_id", "file_path_suffix"),)
|
__table_args__ = (PrimaryKeyConstraint("episode_id", "file_path_suffix"),)
|
||||||
season_id: Mapped[UUID] = mapped_column(
|
episode_id: Mapped[UUID] = mapped_column(
|
||||||
ForeignKey(column="season.id", ondelete="CASCADE"),
|
ForeignKey(column="episode.id", ondelete="CASCADE"),
|
||||||
)
|
)
|
||||||
torrent_id: Mapped[UUID | None] = mapped_column(
|
torrent_id: Mapped[UUID | None] = mapped_column(
|
||||||
ForeignKey(column="torrent.id", ondelete="SET NULL"),
|
ForeignKey(column="torrent.id", ondelete="SET NULL"),
|
||||||
@@ -82,8 +83,8 @@ class SeasonFile(Base):
|
|||||||
file_path_suffix: Mapped[str]
|
file_path_suffix: Mapped[str]
|
||||||
quality: Mapped[Quality]
|
quality: Mapped[Quality]
|
||||||
|
|
||||||
torrent = relationship("Torrent", back_populates="season_files", uselist=False)
|
torrent = relationship("Torrent", back_populates="episode_files", uselist=False)
|
||||||
season = relationship("Season", back_populates="season_files", uselist=False)
|
episode = relationship("Episode", back_populates="episode_files", uselist=False)
|
||||||
|
|
||||||
|
|
||||||
class SeasonRequest(Base):
|
class SeasonRequest(Base):
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.exc import (
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
IntegrityError,
|
|
||||||
SQLAlchemyError,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from media_manager.exceptions import ConflictError, NotFoundError
|
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 Torrent as TorrentSchema
|
||||||
from media_manager.torrent.schemas import TorrentId
|
from media_manager.torrent.schemas import TorrentId
|
||||||
from media_manager.tv import log
|
from media_manager.tv import log
|
||||||
from media_manager.tv.models import Episode, Season, SeasonFile, SeasonRequest, Show
|
from media_manager.tv.models import Episode, EpisodeFile, Season, SeasonRequest, Show
|
||||||
from media_manager.tv.schemas import (
|
from media_manager.tv.schemas import Episode as EpisodeSchema
|
||||||
Episode as EpisodeSchema,
|
from media_manager.tv.schemas import EpisodeFile as EpisodeFileSchema
|
||||||
)
|
|
||||||
from media_manager.tv.schemas import (
|
from media_manager.tv.schemas import (
|
||||||
EpisodeId,
|
EpisodeId,
|
||||||
|
EpisodeNumber,
|
||||||
SeasonId,
|
SeasonId,
|
||||||
SeasonNumber,
|
SeasonNumber,
|
||||||
SeasonRequestId,
|
SeasonRequestId,
|
||||||
ShowId,
|
ShowId,
|
||||||
)
|
)
|
||||||
from media_manager.tv.schemas import (
|
from media_manager.tv.schemas import RichSeasonRequest as RichSeasonRequestSchema
|
||||||
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 (
|
from media_manager.tv.schemas import Show as ShowSchema
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TvRepository:
|
class TvRepository:
|
||||||
@@ -120,9 +106,7 @@ class TvRepository:
|
|||||||
|
|
||||||
def get_total_downloaded_episodes_count(self) -> int:
|
def get_total_downloaded_episodes_count(self) -> int:
|
||||||
try:
|
try:
|
||||||
stmt = (
|
stmt = select(func.count(Episode.id)).select_from(Episode).join(EpisodeFile)
|
||||||
select(func.count()).select_from(Episode).join(Season).join(SeasonFile)
|
|
||||||
)
|
|
||||||
return self.db.execute(stmt).scalar_one_or_none()
|
return self.db.execute(stmt).scalar_one_or_none()
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
log.exception("Database error while calculating downloaded episodes count")
|
log.exception("Database error while calculating downloaded episodes count")
|
||||||
@@ -173,6 +157,7 @@ class TvRepository:
|
|||||||
number=episode.number,
|
number=episode.number,
|
||||||
external_id=episode.external_id,
|
external_id=episode.external_id,
|
||||||
title=episode.title,
|
title=episode.title,
|
||||||
|
overview=episode.overview,
|
||||||
)
|
)
|
||||||
for episode in season.episodes
|
for episode in season.episodes
|
||||||
],
|
],
|
||||||
@@ -234,6 +219,43 @@ class TvRepository:
|
|||||||
log.exception(f"Database error while retrieving season {season_id}")
|
log.exception(f"Database error while retrieving season {season_id}")
|
||||||
raise
|
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(
|
def add_season_request(
|
||||||
self, season_request: SeasonRequestSchema
|
self, season_request: SeasonRequestSchema
|
||||||
) -> SeasonRequestSchema:
|
) -> SeasonRequestSchema:
|
||||||
@@ -355,46 +377,46 @@ class TvRepository:
|
|||||||
log.exception("Database error while retrieving season requests")
|
log.exception("Database error while retrieving season requests")
|
||||||
raise
|
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.
|
:param episode_file: The EpisodeFile object to add.
|
||||||
:return: The added SeasonFile object.
|
:return: The added EpisodeFile object.
|
||||||
:raises IntegrityError: If the record violates constraints.
|
:raises IntegrityError: If the record violates constraints.
|
||||||
:raises SQLAlchemyError: If a database error occurs.
|
:raises SQLAlchemyError: If a database error occurs.
|
||||||
"""
|
"""
|
||||||
db_model = SeasonFile(**season_file.model_dump())
|
db_model = EpisodeFile(**episode_file.model_dump())
|
||||||
try:
|
try:
|
||||||
self.db.add(db_model)
|
self.db.add(db_model)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(db_model)
|
self.db.refresh(db_model)
|
||||||
return SeasonFileSchema.model_validate(db_model)
|
return EpisodeFileSchema.model_validate(db_model)
|
||||||
except IntegrityError:
|
except IntegrityError as e:
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
log.exception("Integrity error while adding season file")
|
log.error(f"Integrity error while adding episode file: {e}")
|
||||||
raise
|
raise
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError as e:
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
log.exception("Database error while adding season file")
|
log.error(f"Database error while adding episode file: {e}")
|
||||||
raise
|
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.
|
:param torrent_id: The ID of the torrent whose episode files are to be removed.
|
||||||
:return: The number of season files removed.
|
:return: The number of episode files removed.
|
||||||
:raises SQLAlchemyError: If a database error occurs.
|
:raises SQLAlchemyError: If a database error occurs.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
stmt = delete(SeasonFile).where(SeasonFile.torrent_id == torrent_id)
|
stmt = delete(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id)
|
||||||
result = self.db.execute(stmt)
|
result = self.db.execute(stmt)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
log.exception(
|
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
|
raise
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
@@ -420,23 +442,45 @@ class TvRepository:
|
|||||||
log.exception(f"Database error setting library for show {show_id}")
|
log.exception(f"Database error setting library for show {show_id}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def get_season_files_by_season_id(
|
def get_episode_files_by_season_id(
|
||||||
self, season_id: SeasonId
|
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.
|
: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.
|
:raises SQLAlchemyError: If a database error occurs.
|
||||||
"""
|
"""
|
||||||
try:
|
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()
|
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:
|
except SQLAlchemyError:
|
||||||
log.exception(
|
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
|
raise
|
||||||
|
|
||||||
@@ -452,8 +496,9 @@ class TvRepository:
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(Torrent)
|
select(Torrent)
|
||||||
.distinct()
|
.distinct()
|
||||||
.join(SeasonFile, SeasonFile.torrent_id == Torrent.id)
|
.join(EpisodeFile, EpisodeFile.torrent_id == Torrent.id)
|
||||||
.join(Season, Season.id == SeasonFile.season_id)
|
.join(Episode, Episode.id == EpisodeFile.episode_id)
|
||||||
|
.join(Season, Season.id == Episode.season_id)
|
||||||
.where(Season.show_id == show_id)
|
.where(Season.show_id == show_id)
|
||||||
)
|
)
|
||||||
results = self.db.execute(stmt).scalars().unique().all()
|
results = self.db.execute(stmt).scalars().unique().all()
|
||||||
@@ -474,8 +519,9 @@ class TvRepository:
|
|||||||
select(Show)
|
select(Show)
|
||||||
.distinct()
|
.distinct()
|
||||||
.join(Season, Show.id == Season.show_id)
|
.join(Season, Show.id == Season.show_id)
|
||||||
.join(SeasonFile, Season.id == SeasonFile.season_id)
|
.join(Episode, Season.id == Episode.season_id)
|
||||||
.join(Torrent, SeasonFile.torrent_id == Torrent.id)
|
.join(EpisodeFile, Episode.id == EpisodeFile.episode_id)
|
||||||
|
.join(Torrent, EpisodeFile.torrent_id == Torrent.id)
|
||||||
.options(joinedload(Show.seasons).joinedload(Season.episodes))
|
.options(joinedload(Show.seasons).joinedload(Season.episodes))
|
||||||
.order_by(Show.name)
|
.order_by(Show.name)
|
||||||
)
|
)
|
||||||
@@ -497,8 +543,9 @@ class TvRepository:
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(Season.number)
|
select(Season.number)
|
||||||
.distinct()
|
.distinct()
|
||||||
.join(SeasonFile, Season.id == SeasonFile.season_id)
|
.join(Episode, Episode.season_id == Season.id)
|
||||||
.where(SeasonFile.torrent_id == torrent_id)
|
.join(EpisodeFile, EpisodeFile.episode_id == Episode.id)
|
||||||
|
.where(EpisodeFile.torrent_id == torrent_id)
|
||||||
)
|
)
|
||||||
results = self.db.execute(stmt).scalars().unique().all()
|
results = self.db.execute(stmt).scalars().unique().all()
|
||||||
return [SeasonNumber(x) for x in results]
|
return [SeasonNumber(x) for x in results]
|
||||||
@@ -508,6 +555,32 @@ class TvRepository:
|
|||||||
)
|
)
|
||||||
raise
|
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(
|
def get_season_request(
|
||||||
self, season_request_id: SeasonRequestId
|
self, season_request_id: SeasonRequestId
|
||||||
) -> SeasonRequestSchema:
|
) -> SeasonRequestSchema:
|
||||||
@@ -734,11 +807,15 @@ class TvRepository:
|
|||||||
return SeasonSchema.model_validate(db_season)
|
return SeasonSchema.model_validate(db_season)
|
||||||
|
|
||||||
def update_episode_attributes(
|
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:
|
) -> EpisodeSchema:
|
||||||
"""
|
"""
|
||||||
Update attributes of an existing episode.
|
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 episode_id: The ID of the episode to update.
|
||||||
:param title: The new title for the episode.
|
:param title: The new title for the episode.
|
||||||
:param external_id: The new external ID 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:
|
if title is not None and db_episode.title != title:
|
||||||
db_episode.title = title
|
db_episode.title = title
|
||||||
updated = True
|
updated = True
|
||||||
|
if overview is not None and db_episode.overview != overview:
|
||||||
|
db_episode.overview = overview
|
||||||
|
updated = True
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from media_manager.tv.dependencies import (
|
|||||||
)
|
)
|
||||||
from media_manager.tv.schemas import (
|
from media_manager.tv.schemas import (
|
||||||
CreateSeasonRequest,
|
CreateSeasonRequest,
|
||||||
PublicSeasonFile,
|
PublicEpisodeFile,
|
||||||
PublicShow,
|
PublicShow,
|
||||||
RichSeasonRequest,
|
RichSeasonRequest,
|
||||||
RichShowTorrent,
|
RichShowTorrent,
|
||||||
@@ -402,13 +402,13 @@ def get_season(season: season_dep) -> Season:
|
|||||||
"/seasons/{season_id}/files",
|
"/seasons/{season_id}/files",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_season_files(
|
def get_episode_files(
|
||||||
season: season_dep, tv_service: tv_service_dep
|
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
|
number: EpisodeNumber
|
||||||
external_id: int
|
external_id: int
|
||||||
title: str
|
title: str
|
||||||
|
overview: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Season(BaseModel):
|
class Season(BaseModel):
|
||||||
@@ -98,16 +99,16 @@ class RichSeasonRequest(SeasonRequest):
|
|||||||
season: Season
|
season: Season
|
||||||
|
|
||||||
|
|
||||||
class SeasonFile(BaseModel):
|
class EpisodeFile(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
season_id: SeasonId
|
episode_id: EpisodeId
|
||||||
quality: Quality
|
quality: Quality
|
||||||
torrent_id: TorrentId | None
|
torrent_id: TorrentId | None
|
||||||
file_path_suffix: str
|
file_path_suffix: str
|
||||||
|
|
||||||
|
|
||||||
class PublicSeasonFile(SeasonFile):
|
class PublicEpisodeFile(EpisodeFile):
|
||||||
downloaded: bool = False
|
downloaded: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -123,6 +124,7 @@ class RichSeasonTorrent(BaseModel):
|
|||||||
|
|
||||||
file_path_suffix: str
|
file_path_suffix: str
|
||||||
seasons: list[SeasonNumber]
|
seasons: list[SeasonNumber]
|
||||||
|
episodes: list[EpisodeNumber]
|
||||||
|
|
||||||
|
|
||||||
class RichShowTorrent(BaseModel):
|
class RichShowTorrent(BaseModel):
|
||||||
@@ -135,6 +137,18 @@ class RichShowTorrent(BaseModel):
|
|||||||
torrents: list[RichSeasonTorrent]
|
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):
|
class PublicSeason(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -147,7 +161,7 @@ class PublicSeason(BaseModel):
|
|||||||
|
|
||||||
external_id: int
|
external_id: int
|
||||||
|
|
||||||
episodes: list[Episode]
|
episodes: list[PublicEpisode]
|
||||||
|
|
||||||
|
|
||||||
class PublicShow(BaseModel):
|
class PublicShow(BaseModel):
|
||||||
|
|||||||
@@ -41,24 +41,24 @@ from media_manager.torrent.utils import (
|
|||||||
from media_manager.tv import log
|
from media_manager.tv import log
|
||||||
from media_manager.tv.repository import TvRepository
|
from media_manager.tv.repository import TvRepository
|
||||||
from media_manager.tv.schemas import (
|
from media_manager.tv.schemas import (
|
||||||
Episode as EpisodeSchema,
|
Episode,
|
||||||
)
|
EpisodeFile,
|
||||||
from media_manager.tv.schemas import (
|
|
||||||
EpisodeId,
|
EpisodeId,
|
||||||
|
EpisodeNumber,
|
||||||
|
PublicEpisodeFile,
|
||||||
PublicSeason,
|
PublicSeason,
|
||||||
PublicSeasonFile,
|
|
||||||
PublicShow,
|
PublicShow,
|
||||||
RichSeasonRequest,
|
RichSeasonRequest,
|
||||||
RichSeasonTorrent,
|
RichSeasonTorrent,
|
||||||
RichShowTorrent,
|
RichShowTorrent,
|
||||||
Season,
|
Season,
|
||||||
SeasonFile,
|
|
||||||
SeasonId,
|
SeasonId,
|
||||||
SeasonRequest,
|
SeasonRequest,
|
||||||
SeasonRequestId,
|
SeasonRequestId,
|
||||||
Show,
|
Show,
|
||||||
ShowId,
|
ShowId,
|
||||||
)
|
)
|
||||||
|
from media_manager.tv.schemas import Episode as EpisodeSchema
|
||||||
|
|
||||||
|
|
||||||
class TvService:
|
class TvService:
|
||||||
@@ -173,6 +173,7 @@ class TvService:
|
|||||||
for torrent in torrents:
|
for torrent in torrents:
|
||||||
try:
|
try:
|
||||||
self.torrent_service.cancel_download(torrent, delete_files=True)
|
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}")
|
log.info(f"Deleted torrent: {torrent.hash}")
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning(
|
log.warning(
|
||||||
@@ -181,24 +182,26 @@ class TvService:
|
|||||||
|
|
||||||
self.tv_repository.delete_show(show_id=show.id)
|
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
|
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.
|
: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
|
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 = []
|
result = []
|
||||||
for season_file in public_season_files:
|
for episode_file in public_episode_files:
|
||||||
if self.season_file_exists_on_file(season_file=season_file):
|
if self.episode_file_exists_on_file(episode_file=episode_file):
|
||||||
season_file.downloaded = True
|
episode_file.downloaded = True
|
||||||
result.append(season_file)
|
result.append(episode_file)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -334,11 +337,27 @@ class TvService:
|
|||||||
:param show: The show object.
|
:param show: The show object.
|
||||||
:return: A public show.
|
: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 = 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
|
return public_show
|
||||||
|
|
||||||
def get_show_by_id(self, show_id: ShowId) -> 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)
|
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.
|
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.
|
:return: True if the season is downloaded, False otherwise.
|
||||||
"""
|
"""
|
||||||
season_files = self.tv_repository.get_season_files_by_season_id(
|
episodes = season.episodes
|
||||||
season_id=season_id
|
|
||||||
|
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):
|
if not episode_files:
|
||||||
return True
|
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
|
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.
|
:return: True if the file exists, False otherwise.
|
||||||
"""
|
"""
|
||||||
if season_file.torrent_id is None:
|
if episode_file.torrent_id is None:
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
torrent_file = self.torrent_service.get_torrent_by_id(
|
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:
|
if torrent_file.imported:
|
||||||
@@ -409,6 +480,24 @@ class TvService:
|
|||||||
"""
|
"""
|
||||||
return self.tv_repository.get_season(season_id=season_id)
|
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]:
|
def get_all_season_requests(self) -> list[RichSeasonRequest]:
|
||||||
"""
|
"""
|
||||||
Get all season requests.
|
Get all season requests.
|
||||||
@@ -430,10 +519,16 @@ class TvService:
|
|||||||
seasons = self.tv_repository.get_seasons_by_torrent_id(
|
seasons = self.tv_repository.get_seasons_by_torrent_id(
|
||||||
torrent_id=show_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
|
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(
|
season_torrent = RichSeasonTorrent(
|
||||||
torrent_id=show_torrent.id,
|
torrent_id=show_torrent.id,
|
||||||
torrent_title=show_torrent.title,
|
torrent_title=show_torrent.title,
|
||||||
@@ -441,10 +536,12 @@ class TvService:
|
|||||||
quality=show_torrent.quality,
|
quality=show_torrent.quality,
|
||||||
imported=show_torrent.imported,
|
imported=show_torrent.imported,
|
||||||
seasons=seasons,
|
seasons=seasons,
|
||||||
|
episodes=episodes if len(seasons) == 1 else [],
|
||||||
file_path_suffix=file_path_suffix,
|
file_path_suffix=file_path_suffix,
|
||||||
usenet=show_torrent.usenet,
|
usenet=show_torrent.usenet,
|
||||||
)
|
)
|
||||||
rich_season_torrents.append(season_torrent)
|
rich_season_torrents.append(season_torrent)
|
||||||
|
|
||||||
return RichShowTorrent(
|
return RichShowTorrent(
|
||||||
show_id=show.id,
|
show_id=show.id,
|
||||||
name=show.name,
|
name=show.name,
|
||||||
@@ -487,24 +584,49 @@ class TvService:
|
|||||||
season = self.tv_repository.get_season_by_number(
|
season = self.tv_repository.get_season_by_number(
|
||||||
season_number=season_number, show_id=show_id
|
season_number=season_number, show_id=show_id
|
||||||
)
|
)
|
||||||
season_file = SeasonFile(
|
episodes = {episode.number: episode.id for episode in season.episodes}
|
||||||
season_id=season.id,
|
|
||||||
quality=indexer_result.quality,
|
if indexer_result.episode:
|
||||||
torrent_id=show_torrent.id,
|
episode_ids = []
|
||||||
file_path_suffix=override_show_file_path_suffix,
|
missing_episodes = []
|
||||||
)
|
for ep_number in indexer_result.episode:
|
||||||
self.tv_repository.add_season_file(season_file=season_file)
|
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:
|
except IntegrityError:
|
||||||
log.error(
|
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(
|
self.torrent_service.cancel_download(
|
||||||
torrent=show_torrent, delete_files=True
|
torrent=show_torrent, delete_files=True
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
log.info(
|
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)
|
self.torrent_service.resume_download(torrent=show_torrent)
|
||||||
|
|
||||||
@@ -561,7 +683,7 @@ class TvService:
|
|||||||
available_torrents.sort()
|
available_torrents.sort()
|
||||||
|
|
||||||
torrent = self.torrent_service.download(indexer_result=available_torrents[0])
|
torrent = self.torrent_service.download(indexer_result=available_torrents[0])
|
||||||
season_file = SeasonFile(
|
season_file = SeasonFile( # noqa: F821
|
||||||
season_id=season.id,
|
season_id=season.id,
|
||||||
quality=torrent.quality,
|
quality=torrent.quality,
|
||||||
torrent_id=torrent.id,
|
torrent_id=torrent.id,
|
||||||
@@ -653,12 +775,12 @@ class TvService:
|
|||||||
video_files: list[Path],
|
video_files: list[Path],
|
||||||
subtitle_files: list[Path],
|
subtitle_files: list[Path],
|
||||||
file_path_suffix: str = "",
|
file_path_suffix: str = "",
|
||||||
) -> tuple[bool, int]:
|
) -> tuple[bool, list[Episode]]:
|
||||||
season_path = self.get_root_season_directory(
|
season_path = self.get_root_season_directory(
|
||||||
show=show, season_number=season.number
|
show=show, season_number=season.number
|
||||||
)
|
)
|
||||||
success = True
|
success = True
|
||||||
imported_episodes_count = 0
|
imported_episodes = []
|
||||||
try:
|
try:
|
||||||
season_path.mkdir(parents=True, exist_ok=True)
|
season_path.mkdir(parents=True, exist_ok=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -677,7 +799,7 @@ class TvService:
|
|||||||
file_path_suffix=file_path_suffix,
|
file_path_suffix=file_path_suffix,
|
||||||
)
|
)
|
||||||
if imported:
|
if imported:
|
||||||
imported_episodes_count += 1
|
imported_episodes.append(episode)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Send notification about missing episode file
|
# Send notification about missing episode file
|
||||||
@@ -690,11 +812,72 @@ class TvService:
|
|||||||
log.warning(
|
log.warning(
|
||||||
f"S{season.number}E{episode.number} not found when trying to import episode for show {show.name}."
|
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 torrent: The Torrent object
|
||||||
:param show: The Show object
|
:param show: The Show object
|
||||||
"""
|
"""
|
||||||
@@ -707,33 +890,68 @@ class TvService:
|
|||||||
f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files)
|
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(
|
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:
|
imported_episodes_by_season: dict[int, list[int]] = {}
|
||||||
season = self.get_season(season_id=season_file.season_id)
|
|
||||||
season_import_success, _imported_episodes_count = self.import_season(
|
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,
|
show=show,
|
||||||
season=season,
|
season=season,
|
||||||
|
episode=episode,
|
||||||
video_files=video_files,
|
video_files=video_files,
|
||||||
subtitle_files=subtitle_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)
|
success.append(episoded_import_success)
|
||||||
if season_import_success:
|
|
||||||
|
if episoded_import_success:
|
||||||
|
imported_episodes_by_season.setdefault(season.number, []).append(
|
||||||
|
episode.number
|
||||||
|
)
|
||||||
|
|
||||||
log.info(
|
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:
|
else:
|
||||||
log.warning(
|
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(
|
success_messages: list[str] = []
|
||||||
f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors"
|
|
||||||
)
|
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):
|
if all(success):
|
||||||
torrent.imported = True
|
torrent.imported = True
|
||||||
@@ -743,7 +961,11 @@ class TvService:
|
|||||||
if self.notification_service:
|
if self.notification_service:
|
||||||
self.notification_service.send_notification_to_all_providers(
|
self.notification_service.send_notification_to_all_providers(
|
||||||
title="TV Show imported successfully",
|
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:
|
else:
|
||||||
if self.notification_service:
|
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.",
|
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(
|
def update_show_metadata(
|
||||||
self, db_show: Show, metadata_provider: AbstractMetadataProvider
|
self, db_show: Show, metadata_provider: AbstractMetadataProvider
|
||||||
) -> Show | None:
|
) -> Show | None:
|
||||||
@@ -823,6 +1049,7 @@ class TvService:
|
|||||||
self.tv_repository.update_episode_attributes(
|
self.tv_repository.update_episode_attributes(
|
||||||
episode_id=existing_episode.id,
|
episode_id=existing_episode.id,
|
||||||
title=fresh_episode_data.title,
|
title=fresh_episode_data.title,
|
||||||
|
overview=fresh_episode_data.overview,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Add new episode
|
# Add new episode
|
||||||
@@ -834,6 +1061,7 @@ class TvService:
|
|||||||
number=fresh_episode_data.number,
|
number=fresh_episode_data.number,
|
||||||
external_id=fresh_episode_data.external_id,
|
external_id=fresh_episode_data.external_id,
|
||||||
title=fresh_episode_data.title,
|
title=fresh_episode_data.title,
|
||||||
|
overview=fresh_episode_data.overview,
|
||||||
)
|
)
|
||||||
self.tv_repository.add_episode_to_season(
|
self.tv_repository.add_episode_to_season(
|
||||||
season_id=existing_season.id, episode_data=episode_schema
|
season_id=existing_season.id, episode_data=episode_schema
|
||||||
@@ -849,6 +1077,7 @@ class TvService:
|
|||||||
number=ep_data.number,
|
number=ep_data.number,
|
||||||
external_id=ep_data.external_id,
|
external_id=ep_data.external_id,
|
||||||
title=ep_data.title,
|
title=ep_data.title,
|
||||||
|
overview=ep_data.overview,
|
||||||
)
|
)
|
||||||
for ep_data in fresh_season_data.episodes
|
for ep_data in fresh_season_data.episodes
|
||||||
]
|
]
|
||||||
@@ -911,21 +1140,22 @@ class TvService:
|
|||||||
directory=new_source_path
|
directory=new_source_path
|
||||||
)
|
)
|
||||||
for season in tv_show.seasons:
|
for season in tv_show.seasons:
|
||||||
success, imported_episode_count = self.import_season(
|
_success, imported_episodes = self.import_season(
|
||||||
show=tv_show,
|
show=tv_show,
|
||||||
season=season,
|
season=season,
|
||||||
video_files=video_files,
|
video_files=video_files,
|
||||||
subtitle_files=subtitle_files,
|
subtitle_files=subtitle_files,
|
||||||
file_path_suffix="IMPORTED",
|
file_path_suffix="IMPORTED",
|
||||||
)
|
)
|
||||||
season_file = SeasonFile(
|
for episode in imported_episodes:
|
||||||
season_id=season.id,
|
episode_file = EpisodeFile(
|
||||||
quality=Quality.unknown,
|
episode_id=episode.id,
|
||||||
file_path_suffix="IMPORTED",
|
quality=Quality.unknown,
|
||||||
torrent_id=None,
|
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)
|
|
||||||
|
self.tv_repository.add_episode_file(episode_file=episode_file)
|
||||||
|
|
||||||
def get_importable_tv_shows(
|
def get_importable_tv_shows(
|
||||||
self, metadata_provider: AbstractMetadataProvider
|
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."
|
f"torrent {t.title} is not a tv torrent, skipping import."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
tv_service.import_torrent_files(torrent=t, show=show)
|
tv_service.import_episode_files_from_torrent(torrent=t, show=show)
|
||||||
except RuntimeError:
|
except RuntimeError as e:
|
||||||
log.exception(f"Error importing torrent {t.title} for show {show.name}")
|
log.error(
|
||||||
|
f"Error importing torrent {t.title} for show {show.name}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
log.info("Finished importing all torrents")
|
log.info("Finished importing all torrents")
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
18
metadata_relay/uv.lock
generated
@@ -547,11 +547,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-multipart"
|
name = "python-multipart"
|
||||||
version = "0.0.21"
|
version = "0.0.22"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -791,24 +791,24 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.2"
|
version = "2.6.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.40.0"
|
version = "0.41.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "h11" },
|
{ name = "h11" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ site_name: "MediaManager Documentation"
|
|||||||
theme:
|
theme:
|
||||||
name: "material"
|
name: "material"
|
||||||
logo: "assets/logo.svg"
|
logo: "assets/logo.svg"
|
||||||
favicon: "assets/logo.svg"
|
favicon: "assets/favicon.ico"
|
||||||
features:
|
features:
|
||||||
- navigation.sections
|
- navigation.sections
|
||||||
- navigation.expand
|
- navigation.expand
|
||||||
@@ -68,3 +68,5 @@ nav:
|
|||||||
extra:
|
extra:
|
||||||
version:
|
version:
|
||||||
provider: mike
|
provider: mike
|
||||||
|
extra_css:
|
||||||
|
- custom.css
|
||||||
250
uv.lock
generated
@@ -8,16 +8,16 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alembic"
|
name = "alembic"
|
||||||
version = "1.17.2"
|
version = "1.18.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mako" },
|
{ name = "mako" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064, upload-time = "2025-11-14T20:35:04.057Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -201,11 +201,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/ea/fe/67e991cb1df3e9c94
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cachetools"
|
name = "cachetools"
|
||||||
version = "6.2.4"
|
version = "7.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/1d/ede8680603f6016887c062a2cf4fc8fdba905866a3ab8831aa8aa651320c/cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607", size = 31731, upload-time = "2025-12-15T18:24:53.744Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -326,58 +326,55 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "46.0.3"
|
version = "46.0.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
|
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
|
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -404,17 +401,18 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.128.0"
|
version = "0.129.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "starlette" },
|
{ name = "starlette" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -491,7 +489,7 @@ all = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-users"
|
name = "fastapi-users"
|
||||||
version = "15.0.3"
|
version = "15.0.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "email-validator" },
|
{ name = "email-validator" },
|
||||||
@@ -501,9 +499,9 @@ dependencies = [
|
|||||||
{ name = "pyjwt", extra = ["crypto"] },
|
{ name = "pyjwt", extra = ["crypto"] },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/35/7272d1c6c81828a1f1a4dca2d1731e8a428ae52c36404d78f7602d5fa044/fastapi_users-15.0.3.tar.gz", hash = "sha256:94b24f8889b51ca3d8da92a88bced2bca2764cb1dd21c7d6d838890ff57b6472", size = 121336, upload-time = "2025-12-19T09:41:09.488Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/4b/52/fadeae2c8435fb457a9cd91e402639fa5c9a25b16e6d204e043bf00cd875/fastapi_users-15.0.4.tar.gz", hash = "sha256:62657a4323de929cd98697b0fbdea77773ef271a6b57ef359080b9f773ebe144", size = 121394, upload-time = "2026-02-05T09:36:41.194Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/39/a0/81b33000d59eb265b88074c6a15c1fda6f9120581878c368a94e87638d96/fastapi_users-15.0.3-py3-none-any.whl", hash = "sha256:cea3da00ba1bfdd04ce61dcb4515a0914f19d9609d3ba68cf54367c876f380c3", size = 39031, upload-time = "2025-12-19T09:41:10.34Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/48/5fb2a18227ccbd5138515f21fc4fa8abcd9982238de43511d7f941e708db/fastapi_users-15.0.4-py3-none-any.whl", hash = "sha256:30940894825e1dd7b86f6013e4bc75eccc25ae8ce5261d1b180f6411bb28aff4", size = 39037, upload-time = "2026-02-05T09:36:42.195Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -983,60 +981,60 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "12.1.0"
|
version = "12.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
|
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
|
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
|
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
|
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
|
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1250,11 +1248,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyjwt"
|
name = "pyjwt"
|
||||||
version = "2.10.1"
|
version = "2.11.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -1298,11 +1296,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-multipart"
|
name = "python-multipart"
|
||||||
version = "0.0.21"
|
version = "0.0.22"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1626,14 +1624,14 @@ asyncio = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "0.50.0"
|
version = "0.52.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1800,24 +1798,24 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.2"
|
version = "2.6.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.40.0"
|
version = "0.41.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "h11" },
|
{ name = "h11" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
|||||||
862
web/package-lock.json
generated
@@ -21,18 +21,18 @@
|
|||||||
"@fontsource/fira-mono": "^5.0.0",
|
"@fontsource/fira-mono": "^5.0.0",
|
||||||
"@lucide/svelte": "^0.482.0",
|
"@lucide/svelte": "^0.482.0",
|
||||||
"@neoconfetti/svelte": "^2.0.0",
|
"@neoconfetti/svelte": "^2.0.0",
|
||||||
"@sinclair/typebox": "^0.34.38",
|
"@sinclair/typebox": "^0.34.48",
|
||||||
"@sveltejs/adapter-auto": "^6.0.2",
|
"@sveltejs/adapter-auto": "^6.0.2",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/enhanced-img": "^0.6.1",
|
"@sveltejs/enhanced-img": "^0.6.1",
|
||||||
"@sveltejs/kit": "^2.27.3",
|
"@sveltejs/kit": "^2.51.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.1.1",
|
"@sveltejs/vite-plugin-svelte": "^6.1.1",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/table-core": "^8.21.3",
|
"@tanstack/table-core": "^8.21.3",
|
||||||
"@typeschema/class-validator": "^0.2.0",
|
"@typeschema/class-validator": "^0.3.0",
|
||||||
"@vinejs/vine": "^1.8.0",
|
"@vinejs/vine": "^1.8.0",
|
||||||
"arktype": "^2.1.20",
|
"arktype": "^2.1.20",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
@@ -50,13 +50,13 @@
|
|||||||
"openapi-typescript": "^7.9.1",
|
"openapi-typescript": "^7.9.1",
|
||||||
"paneforge": "^1.0.0-next.6",
|
"paneforge": "^1.0.0-next.6",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
"superstruct": "^2.0.2",
|
"superstruct": "^2.0.2",
|
||||||
"svelte": "^5.38.0",
|
"svelte": "^5.53.0",
|
||||||
"svelte-check": "^4.3.1",
|
"svelte-check": "^4.3.1",
|
||||||
"svelte-sonner": "^1.0.7",
|
"svelte-sonner": "^1.0.7",
|
||||||
"sveltekit-superforms": "^2.27.1",
|
"sveltekit-superforms": "^2.29.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwind-variants": "^0.2.1",
|
"tailwind-variants": "^0.2.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
@@ -65,13 +65,13 @@
|
|||||||
"typescript-eslint": "^8.39.1",
|
"typescript-eslint": "^8.39.1",
|
||||||
"valibot": "^0.42.1",
|
"valibot": "^0.42.1",
|
||||||
"vaul-svelte": "^1.0.0-next.7",
|
"vaul-svelte": "^1.0.0-next.7",
|
||||||
"vite": "^7.1.1",
|
"vite": "^7.3.1",
|
||||||
"yup": "^1.7.0",
|
"yup": "^1.7.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"animejs": "^4.2.2",
|
"animejs": "^4.2.2",
|
||||||
"lucide-svelte": "^0.544.0",
|
"lucide-svelte": "^0.574.0",
|
||||||
"openapi-fetch": "^0.14.0",
|
"openapi-fetch": "^0.14.0",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
75
web/src/lib/api/api.d.ts
vendored
@@ -626,10 +626,10 @@ export interface paths {
|
|||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Get Season Files
|
* Get Episode Files
|
||||||
* @description Get files associated with a specific season.
|
* @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;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -1316,6 +1316,8 @@ export interface components {
|
|||||||
external_id: number;
|
external_id: number;
|
||||||
/** Title */
|
/** Title */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Overview */
|
||||||
|
overview?: string | null;
|
||||||
};
|
};
|
||||||
/** ErrorModel */
|
/** ErrorModel */
|
||||||
ErrorModel: {
|
ErrorModel: {
|
||||||
@@ -1360,6 +1362,8 @@ export interface components {
|
|||||||
readonly quality: components['schemas']['Quality'];
|
readonly quality: components['schemas']['Quality'];
|
||||||
/** Season */
|
/** Season */
|
||||||
readonly season: number[];
|
readonly season: number[];
|
||||||
|
/** Episode */
|
||||||
|
readonly episode: number[];
|
||||||
};
|
};
|
||||||
/** LibraryItem */
|
/** LibraryItem */
|
||||||
LibraryItem: {
|
LibraryItem: {
|
||||||
@@ -1504,6 +1508,45 @@ export interface components {
|
|||||||
/** Authorization Url */
|
/** Authorization Url */
|
||||||
authorization_url: string;
|
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 */
|
||||||
PublicMovie: {
|
PublicMovie: {
|
||||||
/**
|
/**
|
||||||
@@ -1580,25 +1623,7 @@ export interface components {
|
|||||||
/** External Id */
|
/** External Id */
|
||||||
external_id: number;
|
external_id: number;
|
||||||
/** Episodes */
|
/** Episodes */
|
||||||
episodes: components['schemas']['Episode'][];
|
episodes: components['schemas']['PublicEpisode'][];
|
||||||
};
|
|
||||||
/** 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;
|
|
||||||
};
|
};
|
||||||
/** PublicShow */
|
/** PublicShow */
|
||||||
PublicShow: {
|
PublicShow: {
|
||||||
@@ -1719,6 +1744,8 @@ export interface components {
|
|||||||
file_path_suffix: string;
|
file_path_suffix: string;
|
||||||
/** Seasons */
|
/** Seasons */
|
||||||
seasons: number[];
|
seasons: number[];
|
||||||
|
/** Episodes */
|
||||||
|
episodes: number[];
|
||||||
};
|
};
|
||||||
/** RichShowTorrent */
|
/** RichShowTorrent */
|
||||||
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: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -3250,7 +3277,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
'application/json': components['schemas']['PublicSeasonFile'][];
|
'application/json': components['schemas']['PublicEpisodeFile'][];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Validation Error */
|
/** @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">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
convertTorrentSeasonRangeToIntegerRange,
|
convertTorrentSeasonRangeToIntegerRange,
|
||||||
|
convertTorrentEpisodeRangeToIntegerRange,
|
||||||
getTorrentQualityString,
|
getTorrentQualityString,
|
||||||
getTorrentStatusString
|
getTorrentStatusString
|
||||||
} from '$lib/utils.js';
|
} from '$lib/utils.js';
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
<Table.Head>Name</Table.Head>
|
<Table.Head>Name</Table.Head>
|
||||||
{#if isShow}
|
{#if isShow}
|
||||||
<Table.Head>Seasons</Table.Head>
|
<Table.Head>Seasons</Table.Head>
|
||||||
|
<Table.Head>Episodes</Table.Head>
|
||||||
{/if}
|
{/if}
|
||||||
<Table.Head>Download Status</Table.Head>
|
<Table.Head>Download Status</Table.Head>
|
||||||
<Table.Head>Quality</Table.Head>
|
<Table.Head>Quality</Table.Head>
|
||||||
@@ -97,6 +99,11 @@
|
|||||||
(torrent as components['schemas']['RichSeasonTorrent']).seasons!
|
(torrent as components['schemas']['RichSeasonTorrent']).seasons!
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{convertTorrentEpisodeRangeToIntegerRange(
|
||||||
|
(torrent as components['schemas']['RichSeasonTorrent']).episodes!
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
{/if}
|
{/if}
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{getTorrentStatusString(torrent.status)}
|
{getTorrentStatusString(torrent.status)}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 110 KiB |
@@ -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() {
|
export async function handleLogout() {
|
||||||
await client.POST('/api/v1/auth/cookie/logout');
|
await client.POST('/api/v1/auth/cookie/logout');
|
||||||
await goto(resolve('/login', {}));
|
await goto(resolve('/login', {}));
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
<Card.Title>Overview</Card.Title>
|
<Card.Title>Overview</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<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}
|
{movie.overview}
|
||||||
</p>
|
</p>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
|
|||||||
@@ -4,11 +4,14 @@
|
|||||||
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
|
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { ImageOff } from 'lucide-svelte';
|
import { ImageOff } from 'lucide-svelte';
|
||||||
|
import { Ellipsis } from 'lucide-svelte';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import type { components } from '$lib/api/api';
|
import type { components } from '$lib/api/api';
|
||||||
import { getFullyQualifiedMediaName } from '$lib/utils';
|
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 CheckmarkX from '$lib/components/checkmark-x.svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import TorrentTable from '$lib/components/torrents/torrent-table.svelte';
|
import TorrentTable from '$lib/components/torrents/torrent-table.svelte';
|
||||||
@@ -22,11 +25,85 @@
|
|||||||
import DeleteMediaDialog from '$lib/components/delete-media-dialog.svelte';
|
import DeleteMediaDialog from '$lib/components/delete-media-dialog.svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import client from '$lib/api';
|
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 show: components['schemas']['PublicShow'] = $derived(page.data.showData);
|
||||||
let torrents: components['schemas']['RichShowTorrent'] = $derived(page.data.torrentsData);
|
let torrents: components['schemas']['RichShowTorrent'] = $derived(page.data.torrentsData);
|
||||||
let user: () => components['schemas']['UserRead'] = getContext('user');
|
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);
|
let continuousDownloadEnabled = $derived(show.continuous_download);
|
||||||
|
|
||||||
async function toggle_continuous_download() {
|
async function toggle_continuous_download() {
|
||||||
@@ -109,7 +186,7 @@
|
|||||||
<Card.Title>Overview</Card.Title>
|
<Card.Title>Overview</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<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}
|
{show.overview}
|
||||||
</p>
|
</p>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
@@ -146,7 +223,23 @@
|
|||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="flex flex-col items-center gap-4">
|
<Card.Content class="flex flex-col items-center gap-4">
|
||||||
{#if user().is_superuser}
|
{#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}
|
{/if}
|
||||||
<RequestSeasonDialog {show} />
|
<RequestSeasonDialog {show} />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
@@ -162,35 +255,87 @@
|
|||||||
</Card.Description>
|
</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="w-full overflow-x-auto">
|
<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.Caption>A list of all seasons.</Table.Caption>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Number</Table.Head>
|
<Table.Head class="w-[40px]"></Table.Head>
|
||||||
<Table.Head>Exists on file</Table.Head>
|
<Table.Head class="w-[80px]">Number</Table.Head>
|
||||||
<Table.Head>Title</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>Overview</Table.Head>
|
||||||
|
<Table.Head class="w-[64px] text-center">Details</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#if show.seasons.length > 0}
|
{#if show.seasons.length > 0}
|
||||||
{#each show.seasons as season (season.id)}
|
{#each show.seasons as season (season.id)}
|
||||||
<Table.Row
|
<Table.Row
|
||||||
onclick={() =>
|
class={`group cursor-pointer transition-colors hover:bg-muted/60 ${
|
||||||
goto(
|
expandedSeasons.has(season.id) ? 'bg-muted/50' : 'bg-muted/10'
|
||||||
resolve('/dashboard/tv/[showId]/[seasonId]', {
|
}`}
|
||||||
showId: show.id,
|
onclick={() => toggleSeason(season.id)}
|
||||||
seasonId: 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">
|
<Table.Cell class="min-w-[10px] font-medium">
|
||||||
<CheckmarkX state={season.downloaded} />
|
<CheckmarkX state={season.downloaded} />
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="min-w-[50px]">{season.name}</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="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>
|
</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}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
|
|||||||
@@ -11,9 +11,15 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
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 season: components['schemas']['Season'] = $derived(page.data.season);
|
||||||
let show: components['schemas']['Show'] = $derived(page.data.showData);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -59,7 +65,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
|
<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>
|
</h1>
|
||||||
<main class="mx-auto flex w-full flex-1 flex-col gap-4 p-4 md:max-w-[80em]">
|
<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">
|
<div class="flex flex-col gap-4 md:flex-row md:items-stretch">
|
||||||
@@ -68,13 +74,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="h-full w-full flex-auto rounded-xl md:w-1/4">
|
<div class="h-full w-full flex-auto rounded-xl md:w-1/4">
|
||||||
<Card.Root class="h-full w-full">
|
<Card.Root class="h-full w-full">
|
||||||
<Card.Header>
|
<Card.Content class="flex flex-col gap-6">
|
||||||
<Card.Title>Overview</Card.Title>
|
<div>
|
||||||
</Card.Header>
|
<Card.Title class="mb-2 text-base">Series Overview</Card.Title>
|
||||||
<Card.Content>
|
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
|
||||||
<p class="leading-7 not-first:mt-6">
|
{show.overview}
|
||||||
{show.overview}
|
</p>
|
||||||
</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.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,14 +108,18 @@
|
|||||||
>
|
>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
|
<Table.Head>Episode</Table.Head>
|
||||||
<Table.Head>Quality</Table.Head>
|
<Table.Head>Quality</Table.Head>
|
||||||
<Table.Head>File Path Suffix</Table.Head>
|
<Table.Head>File Path Suffix</Table.Head>
|
||||||
<Table.Head>Imported</Table.Head>
|
<Table.Head>Imported</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each seasonFiles as file (file)}
|
{#each episodeFiles as file (file)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
|
<Table.Cell class="w-[50px]">
|
||||||
|
{episodeById[file.episode_id] ?? 'E??'}
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell class="w-[50px]">
|
<Table.Cell class="w-[50px]">
|
||||||
{getTorrentQualityString(file.quality)}
|
{getTorrentQualityString(file.quality)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -114,7 +131,11 @@
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{:else}
|
{: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}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
@@ -132,19 +153,23 @@
|
|||||||
</Card.Description>
|
</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="w-full overflow-x-auto">
|
<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.Caption>A list of all episodes.</Table.Caption>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head class="w-[100px]">Number</Table.Head>
|
<Table.Head class="w-[80px]">Number</Table.Head>
|
||||||
<Table.Head class="min-w-[50px]">Title</Table.Head>
|
<Table.Head class="w-[240px]">Title</Table.Head>
|
||||||
|
<Table.Head>Overview</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each season.episodes as episode (episode.id)}
|
{#each season.episodes as episode (episode.id)}
|
||||||
<Table.Row>
|
<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="min-w-[50px]">{episode.title}</Table.Cell>
|
||||||
|
<Table.Cell class="truncate">{episode.overview}</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</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,
|
fetch: fetch,
|
||||||
params: {
|
params: {
|
||||||
path: {
|
path: {
|
||||||
@@ -19,7 +19,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
files: await seasonFiles.then((x) => x.data),
|
files: await episodeFiles.then((x) => x.data),
|
||||||
season: await season.then((x) => x.data)
|
season: await season.then((x) => x.data)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
1
web/static/logo-with-text.svg
Normal file
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 110 KiB |