Compare commits
47 Commits
v1.12.3
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1683cfed35 | ||
|
|
f5253990e0 | ||
|
|
e529e0c0a3 | ||
|
|
46a9760376 | ||
|
|
8270d1d3ff | ||
|
|
6ef200c558 | ||
|
|
47d35d4bd7 | ||
|
|
cbd70bd6f3 | ||
|
|
d8405fd903 | ||
|
|
7824891557 | ||
|
|
a643c9426d | ||
|
|
c2645000e5 | ||
|
|
b16f2dce92 | ||
|
|
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 | ||
|
|
cd70ab8711 | ||
|
|
51b8794e4d |
31
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/metadata_relay"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
git config user.name github-actions[bot]
|
||||
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<br />
|
||||
<div align="center">
|
||||
<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>
|
||||
|
||||
<h3 align="center">MediaManager</h3>
|
||||
|
||||
@@ -30,14 +30,13 @@ from media_manager.auth.db import OAuthAccount, User # noqa: E402
|
||||
from media_manager.config import MediaManagerConfig # noqa: E402
|
||||
from media_manager.database import Base # noqa: E402
|
||||
from media_manager.indexer.models import IndexerQueryResult # noqa: E402
|
||||
from media_manager.movies.models import Movie, MovieFile, MovieRequest # noqa: E402
|
||||
from media_manager.movies.models import Movie, MovieFile # noqa: E402
|
||||
from media_manager.notification.models import Notification # noqa: E402
|
||||
from media_manager.torrent.models import Torrent # noqa: E402
|
||||
from media_manager.tv.models import ( # noqa: E402
|
||||
Episode,
|
||||
EpisodeFile,
|
||||
Season,
|
||||
SeasonFile,
|
||||
SeasonRequest,
|
||||
Show,
|
||||
)
|
||||
|
||||
@@ -47,15 +46,13 @@ target_metadata = Base.metadata
|
||||
# noinspection PyStatementEffect
|
||||
__all__ = [
|
||||
"Episode",
|
||||
"EpisodeFile",
|
||||
"IndexerQueryResult",
|
||||
"Movie",
|
||||
"MovieFile",
|
||||
"MovieRequest",
|
||||
"Notification",
|
||||
"OAuthAccount",
|
||||
"Season",
|
||||
"SeasonFile",
|
||||
"SeasonRequest",
|
||||
"Show",
|
||||
"Torrent",
|
||||
"User",
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
)
|
||||
|
||||
65
alembic/versions/e60ae827ed98_remove_requests.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""remove requests
|
||||
|
||||
Revision ID: e60ae827ed98
|
||||
Revises: a6f714d3c8b9
|
||||
Create Date: 2026-02-22 18:07:12.866130
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e60ae827ed98'
|
||||
down_revision: Union[str, None] = 'a6f714d3c8b9'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('movie_request')
|
||||
op.drop_table('season_request')
|
||||
op.alter_column('episode', 'overview',
|
||||
existing_type=sa.TEXT(),
|
||||
type_=sa.String(),
|
||||
existing_nullable=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
|
||||
op.create_table('season_request',
|
||||
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('season_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('wanted_quality', postgresql.ENUM('uhd', 'fullhd', 'hd', 'sd', 'unknown', name='quality'), autoincrement=False, nullable=False),
|
||||
sa.Column('min_quality', postgresql.ENUM('uhd', 'fullhd', 'hd', 'sd', 'unknown', name='quality'), autoincrement=False, nullable=False),
|
||||
sa.Column('requested_by_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('authorized', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||
sa.Column('authorized_by_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['authorized_by_id'], ['user.id'], name=op.f('season_request_authorized_by_id_fkey'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['requested_by_id'], ['user.id'], name=op.f('season_request_requested_by_id_fkey'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['season_id'], ['season.id'], name=op.f('season_request_season_id_fkey'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('season_request_pkey')),
|
||||
sa.UniqueConstraint('season_id', 'wanted_quality', name=op.f('season_request_season_id_wanted_quality_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
op.create_table('movie_request',
|
||||
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('movie_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('wanted_quality', postgresql.ENUM('uhd', 'fullhd', 'hd', 'sd', 'unknown', name='quality'), autoincrement=False, nullable=False),
|
||||
sa.Column('min_quality', postgresql.ENUM('uhd', 'fullhd', 'hd', 'sd', 'unknown', name='quality'), autoincrement=False, nullable=False),
|
||||
sa.Column('authorized', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||
sa.Column('requested_by_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('authorized_by_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['authorized_by_id'], ['user.id'], name=op.f('movie_request_authorized_by_id_fkey'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['movie_id'], ['movie.id'], name=op.f('movie_request_movie_id_fkey'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['requested_by_id'], ['user.id'], name=op.f('movie_request_requested_by_id_fkey'), ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('movie_request_pkey')),
|
||||
sa.UniqueConstraint('movie_id', 'wanted_quality', name=op.f('movie_request_movie_id_wanted_quality_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
@@ -138,7 +138,7 @@ negate = false
|
||||
|
||||
[[indexers.title_scoring_rules]]
|
||||
name = "avoid_cam"
|
||||
keywords = ["cam", "ts"]
|
||||
keywords = ["cam", "camrip", "bdscr", "ddc", "dvdscreener","dvdscr", "hdcam", "hdtc", "hdts", "scr", "screener","telesync", "ts", "webscreener", "tc", "telecine", "tvrip"]
|
||||
score_modifier = -10000
|
||||
negate = false
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ negate = false
|
||||
|
||||
[[indexers.title_scoring_rules]]
|
||||
name = "avoid_cam"
|
||||
keywords = ["cam", "ts"]
|
||||
keywords = ["cam", "camrip", "bdscr", "ddc", "dvdscreener","dvdscr", "hdcam", "hdtc", "hdts", "scr", "screener","telesync", "ts", "webscreener", "tc", "telecine", "tvrip"]
|
||||
score_modifier = -10000
|
||||
negate = false
|
||||
|
||||
|
||||
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))
|
||||
quality: Mapped[Quality]
|
||||
season = mapped_column(ARRAY(Integer))
|
||||
episode = mapped_column(ARRAY(Integer))
|
||||
size = mapped_column(BigInteger)
|
||||
usenet: Mapped[bool]
|
||||
age: Mapped[int]
|
||||
|
||||
@@ -35,10 +35,10 @@ class IndexerQueryResult(BaseModel):
|
||||
@computed_field
|
||||
@property
|
||||
def quality(self) -> Quality:
|
||||
high_quality_pattern = r"\b(4k)\b"
|
||||
medium_quality_pattern = r"\b(1080p)\b"
|
||||
low_quality_pattern = r"\b(720p)\b"
|
||||
very_low_quality_pattern = r"\b(480p|360p)\b"
|
||||
high_quality_pattern = r"\b(4k|2160p|uhd)\b"
|
||||
medium_quality_pattern = r"\b(1080p|full[ ._-]?hd)\b"
|
||||
low_quality_pattern = r"\b(720p|(?<!full[ ._-])hd(?![a-z]))\b"
|
||||
very_low_quality_pattern = r"\b(480p|360p|sd)\b"
|
||||
|
||||
if re.search(high_quality_pattern, self.title, re.IGNORECASE):
|
||||
return Quality.uhd
|
||||
@@ -54,14 +54,55 @@ class IndexerQueryResult(BaseModel):
|
||||
@computed_field
|
||||
@property
|
||||
def season(self) -> list[int]:
|
||||
pattern = r"\bS(\d+)\b"
|
||||
matches = re.findall(pattern, self.title, re.IGNORECASE)
|
||||
if matches.__len__() == 2:
|
||||
result = list(range(int(matches[0]), int(matches[1]) + 1))
|
||||
elif matches.__len__() == 1:
|
||||
result = [int(matches[0])]
|
||||
title = self.title.lower()
|
||||
|
||||
# 1) S01E01 / S1E2
|
||||
m = re.search(r"s(\d{1,2})e\d{1,3}", title)
|
||||
if m:
|
||||
return [int(m.group(1))]
|
||||
|
||||
# 2) Range S01-S03 / S1-S3
|
||||
m = re.search(r"s(\d{1,2})\s*(?:-|\u2013)\s*s?(\d{1,2})", title)
|
||||
if m:
|
||||
start, end = int(m.group(1)), int(m.group(2))
|
||||
if start <= end:
|
||||
return list(range(start, end + 1))
|
||||
return []
|
||||
|
||||
# 3) Pack S01 / S1
|
||||
m = re.search(r"\bs(\d{1,2})\b", title)
|
||||
if m:
|
||||
return [int(m.group(1))]
|
||||
|
||||
# 4) Season 01 / Season 1
|
||||
m = re.search(r"\bseason\s*(\d{1,2})\b", title)
|
||||
if m:
|
||||
return [int(m.group(1))]
|
||||
|
||||
return []
|
||||
|
||||
@computed_field(return_type=list[int])
|
||||
@property
|
||||
def episode(self) -> list[int]:
|
||||
title = self.title.lower()
|
||||
result: list[int] = []
|
||||
|
||||
pattern = r"s\d{1,2}e(\d{1,3})(?:\s*-\s*(?:s?\d{1,2}e)?(\d{1,3}))?"
|
||||
match = re.search(pattern, title)
|
||||
|
||||
if not match:
|
||||
return result
|
||||
|
||||
start = int(match.group(1))
|
||||
end = match.group(2)
|
||||
|
||||
if end:
|
||||
end = int(end)
|
||||
if end >= start:
|
||||
result = list(range(start, end + 1))
|
||||
else:
|
||||
result = []
|
||||
result = [start]
|
||||
|
||||
return result
|
||||
|
||||
def __gt__(self, other: "IndexerQueryResult") -> bool:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
@@ -23,7 +24,11 @@ def evaluate_indexer_query_result(
|
||||
log.debug(f"Applying rule {rule.name} to {query_result.title}")
|
||||
if (
|
||||
any(
|
||||
keyword.lower() in query_result.title.lower()
|
||||
re.search(
|
||||
rf"\b{re.escape(keyword)}\b",
|
||||
query_result.title,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for keyword in rule.keywords
|
||||
)
|
||||
and not rule.negate
|
||||
@@ -34,7 +39,11 @@ def evaluate_indexer_query_result(
|
||||
query_result.score += rule.score_modifier
|
||||
elif (
|
||||
not any(
|
||||
keyword.lower() in query_result.title.lower()
|
||||
re.search(
|
||||
rf"\b{re.escape(keyword)}\b",
|
||||
query_result.title,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for keyword in rule.keywords
|
||||
)
|
||||
and rule.negate
|
||||
@@ -155,5 +164,3 @@ def follow_redirects_to_final_torrent_url(
|
||||
)
|
||||
msg = "An error occurred during the request"
|
||||
raise RuntimeError(msg) from e
|
||||
|
||||
return current_url
|
||||
|
||||
@@ -83,3 +83,4 @@ def setup_logging() -> None:
|
||||
logging.getLogger("transmission_rpc").setLevel(logging.WARNING)
|
||||
logging.getLogger("qbittorrentapi").setLevel(logging.WARNING)
|
||||
logging.getLogger("sabnzbd_api").setLevel(logging.WARNING)
|
||||
logging.getLogger("taskiq").setLevel(logging.WARNING)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from asgi_correlation_id import CorrelationIdMiddleware
|
||||
@@ -9,6 +12,8 @@ from fastapi.staticfiles import StaticFiles
|
||||
from psycopg.errors import UniqueViolation
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from starlette.responses import FileResponse, RedirectResponse
|
||||
from taskiq.receiver import Receiver
|
||||
from taskiq_fastapi import populate_dependency_context
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
|
||||
import media_manager.movies.router as movies_router
|
||||
@@ -28,6 +33,7 @@ from media_manager.auth.users import (
|
||||
fastapi_users,
|
||||
)
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.database import init_engine
|
||||
from media_manager.exceptions import (
|
||||
ConflictError,
|
||||
InvalidConfigError,
|
||||
@@ -42,18 +48,24 @@ from media_manager.exceptions import (
|
||||
from media_manager.filesystem_checks import run_filesystem_checks
|
||||
from media_manager.logging import LOGGING_CONFIG, setup_logging
|
||||
from media_manager.notification.router import router as notification_router
|
||||
from media_manager.scheduler import setup_scheduler
|
||||
from media_manager.scheduler import (
|
||||
broker,
|
||||
build_scheduler_loop,
|
||||
import_all_movie_torrents_task,
|
||||
import_all_show_torrents_task,
|
||||
update_all_movies_metadata_task,
|
||||
update_all_non_ended_shows_metadata_task,
|
||||
)
|
||||
|
||||
setup_logging()
|
||||
|
||||
config = MediaManagerConfig()
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if config.misc.development:
|
||||
log.warning("Development Mode activated!")
|
||||
|
||||
scheduler = setup_scheduler(config)
|
||||
|
||||
run_filesystem_checks(config, log)
|
||||
|
||||
BASE_PATH = os.getenv("BASE_PATH", "")
|
||||
@@ -62,7 +74,57 @@ DISABLE_FRONTEND_MOUNT = os.getenv("DISABLE_FRONTEND_MOUNT", "").lower() == "tru
|
||||
FRONTEND_FOLLOW_SYMLINKS = os.getenv("FRONTEND_FOLLOW_SYMLINKS", "").lower() == "true"
|
||||
|
||||
log.info("Hello World!")
|
||||
app = FastAPI(root_path=BASE_PATH)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator:
|
||||
init_engine(config.database)
|
||||
broker_started = False
|
||||
started_sources: list = []
|
||||
finish_event: asyncio.Event | None = None
|
||||
receiver_task: asyncio.Task | None = None
|
||||
loop_task: asyncio.Task | None = None
|
||||
try:
|
||||
if not broker.is_worker_process:
|
||||
await broker.startup()
|
||||
broker_started = True
|
||||
populate_dependency_context(broker, app)
|
||||
scheduler_loop = build_scheduler_loop()
|
||||
for source in scheduler_loop.scheduler.sources:
|
||||
await source.startup()
|
||||
started_sources.append(source)
|
||||
finish_event = asyncio.Event()
|
||||
receiver = Receiver(broker, run_startup=False, max_async_tasks=10)
|
||||
receiver_task = asyncio.create_task(receiver.listen(finish_event))
|
||||
loop_task = asyncio.create_task(scheduler_loop.run(skip_first_run=True))
|
||||
try:
|
||||
await asyncio.gather(
|
||||
import_all_movie_torrents_task.kiq(),
|
||||
import_all_show_torrents_task.kiq(),
|
||||
update_all_movies_metadata_task.kiq(),
|
||||
update_all_non_ended_shows_metadata_task.kiq(),
|
||||
)
|
||||
except Exception:
|
||||
log.exception("Failed to submit initial background tasks during startup.")
|
||||
raise
|
||||
yield
|
||||
finally:
|
||||
if loop_task is not None:
|
||||
loop_task.cancel()
|
||||
try:
|
||||
await loop_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if finish_event is not None and receiver_task is not None:
|
||||
finish_event.set()
|
||||
await receiver_task
|
||||
for source in started_sources:
|
||||
await source.shutdown()
|
||||
if broker_started:
|
||||
await broker.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(root_path=BASE_PATH, lifespan=lifespan)
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||
origins = config.misc.cors_urls
|
||||
log.info(f"CORS URLs activated for following origins: {origins}")
|
||||
|
||||
@@ -3,7 +3,6 @@ from uuid import UUID
|
||||
from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from media_manager.auth.db import User
|
||||
from media_manager.database import Base
|
||||
from media_manager.torrent.models import Quality
|
||||
|
||||
@@ -22,10 +21,6 @@ class Movie(Base):
|
||||
original_language: Mapped[str | None] = mapped_column(default=None)
|
||||
imdb_id: Mapped[str | None] = mapped_column(default=None)
|
||||
|
||||
movie_requests: Mapped[list["MovieRequest"]] = relationship(
|
||||
"MovieRequest", back_populates="movie", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class MovieFile(Base):
|
||||
__tablename__ = "movie_file"
|
||||
@@ -42,31 +37,3 @@ class MovieFile(Base):
|
||||
)
|
||||
|
||||
torrent = relationship("Torrent", back_populates="movie_files", uselist=False)
|
||||
|
||||
|
||||
class MovieRequest(Base):
|
||||
__tablename__ = "movie_request"
|
||||
__table_args__ = (UniqueConstraint("movie_id", "wanted_quality"),)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True)
|
||||
movie_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey(column="movie.id", ondelete="CASCADE"),
|
||||
)
|
||||
wanted_quality: Mapped[Quality]
|
||||
min_quality: Mapped[Quality]
|
||||
|
||||
authorized: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
requested_by_id: Mapped[UUID | None] = mapped_column(
|
||||
ForeignKey(column="user.id", ondelete="SET NULL"),
|
||||
)
|
||||
authorized_by_id: Mapped[UUID | None] = mapped_column(
|
||||
ForeignKey(column="user.id", ondelete="SET NULL"),
|
||||
)
|
||||
|
||||
requested_by: Mapped["User|None"] = relationship(
|
||||
foreign_keys=[requested_by_id], uselist=False
|
||||
)
|
||||
authorized_by: Mapped["User|None"] = relationship(
|
||||
foreign_keys=[authorized_by_id], uselist=False
|
||||
)
|
||||
movie = relationship("Movie", back_populates="movie_requests", uselist=False)
|
||||
|
||||
@@ -5,10 +5,10 @@ from sqlalchemy.exc import (
|
||||
IntegrityError,
|
||||
SQLAlchemyError,
|
||||
)
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from media_manager.exceptions import ConflictError, NotFoundError
|
||||
from media_manager.movies.models import Movie, MovieFile, MovieRequest
|
||||
from media_manager.movies.models import Movie, MovieFile
|
||||
from media_manager.movies.schemas import (
|
||||
Movie as MovieSchema,
|
||||
)
|
||||
@@ -17,17 +17,10 @@ from media_manager.movies.schemas import (
|
||||
)
|
||||
from media_manager.movies.schemas import (
|
||||
MovieId,
|
||||
MovieRequestId,
|
||||
)
|
||||
from media_manager.movies.schemas import (
|
||||
MovieRequest as MovieRequestSchema,
|
||||
)
|
||||
from media_manager.movies.schemas import (
|
||||
MovieTorrent as MovieTorrentSchema,
|
||||
)
|
||||
from media_manager.movies.schemas import (
|
||||
RichMovieRequest as RichMovieRequestSchema,
|
||||
)
|
||||
from media_manager.torrent.models import Torrent
|
||||
from media_manager.torrent.schemas import TorrentId
|
||||
|
||||
@@ -173,46 +166,6 @@ class MovieRepository:
|
||||
log.exception(f"Database error while deleting movie {movie_id}")
|
||||
raise
|
||||
|
||||
def add_movie_request(
|
||||
self, movie_request: MovieRequestSchema
|
||||
) -> MovieRequestSchema:
|
||||
"""
|
||||
Adds a Movie to the MovieRequest table, which marks it as requested.
|
||||
|
||||
:param movie_request: The MovieRequest object to add.
|
||||
:return: The added MovieRequest object.
|
||||
:raises IntegrityError: If a similar request already exists or violates constraints.
|
||||
:raises SQLAlchemyError: If a database error occurs.
|
||||
"""
|
||||
log.debug(f"Adding movie request: {movie_request.model_dump_json()}")
|
||||
db_model = MovieRequest(
|
||||
id=movie_request.id,
|
||||
movie_id=movie_request.movie_id,
|
||||
requested_by_id=movie_request.requested_by.id
|
||||
if movie_request.requested_by
|
||||
else None,
|
||||
authorized_by_id=movie_request.authorized_by.id
|
||||
if movie_request.authorized_by
|
||||
else None,
|
||||
wanted_quality=movie_request.wanted_quality,
|
||||
min_quality=movie_request.min_quality,
|
||||
authorized=movie_request.authorized,
|
||||
)
|
||||
try:
|
||||
self.db.add(db_model)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_model)
|
||||
log.info(f"Successfully added movie request with id: {db_model.id}")
|
||||
return MovieRequestSchema.model_validate(db_model)
|
||||
except IntegrityError:
|
||||
self.db.rollback()
|
||||
log.exception("Integrity error while adding movie request")
|
||||
raise
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.exception("Database error while adding movie request")
|
||||
raise
|
||||
|
||||
def set_movie_library(self, movie_id: MovieId, library: str) -> None:
|
||||
"""
|
||||
Sets the library for a movie.
|
||||
@@ -234,49 +187,6 @@ class MovieRepository:
|
||||
log.exception(f"Database error setting library for movie {movie_id}")
|
||||
raise
|
||||
|
||||
def delete_movie_request(self, movie_request_id: MovieRequestId) -> None:
|
||||
"""
|
||||
Removes a MovieRequest by its ID.
|
||||
|
||||
:param movie_request_id: The ID of the movie request to delete.
|
||||
:raises NotFoundError: If the movie request is not found.
|
||||
:raises SQLAlchemyError: If a database error occurs.
|
||||
"""
|
||||
try:
|
||||
stmt = delete(MovieRequest).where(MovieRequest.id == movie_request_id)
|
||||
result = self.db.execute(stmt)
|
||||
if result.rowcount == 0:
|
||||
self.db.rollback()
|
||||
msg = f"movie request with id {movie_request_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
self.db.commit()
|
||||
# Successfully deleted movie request with id: {movie_request_id}
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.exception(
|
||||
f"Database error while deleting movie request {movie_request_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
def get_movie_requests(self) -> list[RichMovieRequestSchema]:
|
||||
"""
|
||||
Retrieve all movie requests.
|
||||
|
||||
:return: A list of RichMovieRequest objects.
|
||||
:raises SQLAlchemyError: If a database error occurs.
|
||||
"""
|
||||
try:
|
||||
stmt = select(MovieRequest).options(
|
||||
joinedload(MovieRequest.requested_by),
|
||||
joinedload(MovieRequest.authorized_by),
|
||||
joinedload(MovieRequest.movie),
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
return [RichMovieRequestSchema.model_validate(x) for x in results]
|
||||
except SQLAlchemyError:
|
||||
log.exception("Database error while retrieving movie requests")
|
||||
raise
|
||||
|
||||
def add_movie_file(self, movie_file: MovieFileSchema) -> MovieFileSchema:
|
||||
"""
|
||||
Adds a movie file record to the database.
|
||||
@@ -396,25 +306,6 @@ class MovieRepository:
|
||||
log.exception("Database error retrieving all movies with torrents")
|
||||
raise
|
||||
|
||||
def get_movie_request(self, movie_request_id: MovieRequestId) -> MovieRequestSchema:
|
||||
"""
|
||||
Retrieve a movie request by its ID.
|
||||
|
||||
:param movie_request_id: The ID of the movie request.
|
||||
:return: A MovieRequest object.
|
||||
:raises NotFoundError: If the movie request is not found.
|
||||
:raises SQLAlchemyError: If a database error occurs.
|
||||
"""
|
||||
try:
|
||||
request = self.db.get(MovieRequest, movie_request_id)
|
||||
if not request:
|
||||
msg = f"Movie request with id {movie_request_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
return MovieRequestSchema.model_validate(request)
|
||||
except SQLAlchemyError:
|
||||
log.exception(f"Database error retrieving movie request {movie_request_id}")
|
||||
raise
|
||||
|
||||
def get_movie_by_torrent_id(self, torrent_id: TorrentId) -> MovieSchema:
|
||||
"""
|
||||
Retrieve a movie by a torrent ID.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from media_manager.auth.schemas import UserRead
|
||||
from media_manager.auth.users import current_active_user, current_superuser
|
||||
from media_manager.config import LibraryItem, MediaManagerConfig
|
||||
from media_manager.exceptions import ConflictError, NotFoundError
|
||||
@@ -13,20 +11,14 @@ from media_manager.indexer.schemas import (
|
||||
)
|
||||
from media_manager.metadataProvider.dependencies import metadata_provider_dep
|
||||
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
|
||||
from media_manager.movies import log
|
||||
from media_manager.movies.dependencies import (
|
||||
movie_dep,
|
||||
movie_service_dep,
|
||||
)
|
||||
from media_manager.movies.schemas import (
|
||||
CreateMovieRequest,
|
||||
Movie,
|
||||
MovieRequest,
|
||||
MovieRequestBase,
|
||||
MovieRequestId,
|
||||
PublicMovie,
|
||||
PublicMovieFile,
|
||||
RichMovieRequest,
|
||||
RichMovieTorrent,
|
||||
)
|
||||
from media_manager.schemas import MediaImportSuggestion
|
||||
@@ -188,103 +180,6 @@ def get_available_libraries() -> list[LibraryItem]:
|
||||
return MediaManagerConfig().misc.movie_libraries
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# MOVIE REQUESTS
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/requests",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
)
|
||||
def get_all_movie_requests(movie_service: movie_service_dep) -> list[RichMovieRequest]:
|
||||
"""
|
||||
Get all movie requests.
|
||||
"""
|
||||
return movie_service.get_all_movie_requests()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/requests",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_movie_request(
|
||||
movie_service: movie_service_dep,
|
||||
movie_request: CreateMovieRequest,
|
||||
user: Annotated[UserRead, Depends(current_active_user)],
|
||||
) -> MovieRequest:
|
||||
"""
|
||||
Create a new movie request.
|
||||
"""
|
||||
log.info(
|
||||
f"User {user.email} is creating a movie request for {movie_request.movie_id}"
|
||||
)
|
||||
movie_request: MovieRequest = MovieRequest.model_validate(movie_request)
|
||||
movie_request.requested_by = user
|
||||
if user.is_superuser:
|
||||
movie_request.authorized = True
|
||||
movie_request.authorized_by = user
|
||||
|
||||
return movie_service.add_movie_request(movie_request=movie_request)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/requests/{movie_request_id}",
|
||||
)
|
||||
def update_movie_request(
|
||||
movie_service: movie_service_dep,
|
||||
movie_request_id: MovieRequestId,
|
||||
update_movie_request: MovieRequestBase,
|
||||
user: Annotated[UserRead, Depends(current_active_user)],
|
||||
) -> MovieRequest:
|
||||
"""
|
||||
Update an existing movie request.
|
||||
"""
|
||||
movie_request = movie_service.get_movie_request_by_id(
|
||||
movie_request_id=movie_request_id
|
||||
)
|
||||
if movie_request.requested_by.id != user.id or user.is_superuser:
|
||||
movie_request.min_quality = update_movie_request.min_quality
|
||||
movie_request.wanted_quality = update_movie_request.wanted_quality
|
||||
|
||||
return movie_service.update_movie_request(movie_request=movie_request)
|
||||
|
||||
|
||||
@router.patch("/requests/{movie_request_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def authorize_request(
|
||||
movie_service: movie_service_dep,
|
||||
movie_request_id: MovieRequestId,
|
||||
user: Annotated[UserRead, Depends(current_superuser)],
|
||||
authorized_status: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Authorize or de-authorize a movie request.
|
||||
"""
|
||||
movie_request = movie_service.get_movie_request_by_id(
|
||||
movie_request_id=movie_request_id
|
||||
)
|
||||
movie_request.authorized = authorized_status
|
||||
if authorized_status:
|
||||
movie_request.authorized_by = user
|
||||
else:
|
||||
movie_request.authorized_by = None
|
||||
movie_service.update_movie_request(movie_request=movie_request)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/requests/{movie_request_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(current_superuser)],
|
||||
)
|
||||
def delete_movie_request(
|
||||
movie_service: movie_service_dep, movie_request_id: MovieRequestId
|
||||
) -> None:
|
||||
"""
|
||||
Delete a movie request.
|
||||
"""
|
||||
movie_service.delete_movie_request(movie_request_id=movie_request_id)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# MOVIES - SINGLE RESOURCE
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -2,14 +2,12 @@ import typing
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from media_manager.auth.schemas import UserRead
|
||||
from media_manager.torrent.models import Quality
|
||||
from media_manager.torrent.schemas import TorrentId, TorrentStatus
|
||||
|
||||
MovieId = typing.NewType("MovieId", UUID)
|
||||
MovieRequestId = typing.NewType("MovieRequestId", UUID)
|
||||
|
||||
|
||||
class Movie(BaseModel):
|
||||
@@ -40,38 +38,6 @@ class PublicMovieFile(MovieFile):
|
||||
imported: bool = False
|
||||
|
||||
|
||||
class MovieRequestBase(BaseModel):
|
||||
min_quality: Quality
|
||||
wanted_quality: Quality
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ensure_wanted_quality_is_eq_or_gt_min_quality(self) -> "MovieRequestBase":
|
||||
if self.min_quality.value < self.wanted_quality.value:
|
||||
msg = "wanted_quality must be equal to or lower than minimum_quality."
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
|
||||
class CreateMovieRequest(MovieRequestBase):
|
||||
movie_id: MovieId
|
||||
|
||||
|
||||
class MovieRequest(MovieRequestBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: MovieRequestId = Field(default_factory=lambda: MovieRequestId(uuid.uuid4()))
|
||||
|
||||
movie_id: MovieId
|
||||
|
||||
requested_by: UserRead | None = None
|
||||
authorized: bool = False
|
||||
authorized_by: UserRead | None = None
|
||||
|
||||
|
||||
class RichMovieRequest(MovieRequest):
|
||||
movie: Movie
|
||||
|
||||
|
||||
class MovieTorrent(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -4,12 +4,9 @@ from pathlib import Path
|
||||
from typing import overload
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.database import SessionLocal, get_session
|
||||
from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError
|
||||
from media_manager.indexer.repository import IndexerRepository
|
||||
from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId
|
||||
from media_manager.indexer.service import IndexerService
|
||||
from media_manager.indexer.utils import evaluate_indexer_query_results
|
||||
@@ -25,20 +22,14 @@ from media_manager.movies.schemas import (
|
||||
Movie,
|
||||
MovieFile,
|
||||
MovieId,
|
||||
MovieRequest,
|
||||
MovieRequestId,
|
||||
PublicMovie,
|
||||
PublicMovieFile,
|
||||
RichMovieRequest,
|
||||
RichMovieTorrent,
|
||||
)
|
||||
from media_manager.notification.repository import NotificationRepository
|
||||
from media_manager.notification.service import NotificationService
|
||||
from media_manager.schemas import MediaImportSuggestion
|
||||
from media_manager.torrent.repository import TorrentRepository
|
||||
from media_manager.torrent.schemas import (
|
||||
Quality,
|
||||
QualityStrings,
|
||||
Torrent,
|
||||
TorrentStatus,
|
||||
)
|
||||
@@ -89,44 +80,6 @@ class MovieService:
|
||||
metadata_provider.download_movie_poster_image(movie=saved_movie)
|
||||
return saved_movie
|
||||
|
||||
def add_movie_request(self, movie_request: MovieRequest) -> MovieRequest:
|
||||
"""
|
||||
Add a new movie request.
|
||||
|
||||
:param movie_request: The movie request to add.
|
||||
:return: The added movie request.
|
||||
"""
|
||||
return self.movie_repository.add_movie_request(movie_request=movie_request)
|
||||
|
||||
def get_movie_request_by_id(self, movie_request_id: MovieRequestId) -> MovieRequest:
|
||||
"""
|
||||
Get a movie request by its ID.
|
||||
|
||||
:param movie_request_id: The ID of the movie request.
|
||||
:return: The movie request or None if not found.
|
||||
"""
|
||||
return self.movie_repository.get_movie_request(
|
||||
movie_request_id=movie_request_id
|
||||
)
|
||||
|
||||
def update_movie_request(self, movie_request: MovieRequest) -> MovieRequest:
|
||||
"""
|
||||
Update an existing movie request.
|
||||
|
||||
:param movie_request: The movie request to update.
|
||||
:return: The updated movie request.
|
||||
"""
|
||||
self.movie_repository.delete_movie_request(movie_request_id=movie_request.id)
|
||||
return self.movie_repository.add_movie_request(movie_request=movie_request)
|
||||
|
||||
def delete_movie_request(self, movie_request_id: MovieRequestId) -> None:
|
||||
"""
|
||||
Delete a movie request by its ID.
|
||||
|
||||
:param movie_request_id: The ID of the movie request to delete.
|
||||
"""
|
||||
self.movie_repository.delete_movie_request(movie_request_id=movie_request_id)
|
||||
|
||||
def delete_movie(
|
||||
self,
|
||||
movie: Movie,
|
||||
@@ -391,14 +344,6 @@ class MovieService:
|
||||
external_id=external_id, metadata_provider=metadata_provider
|
||||
)
|
||||
|
||||
def get_all_movie_requests(self) -> list[RichMovieRequest]:
|
||||
"""
|
||||
Get all movie requests.
|
||||
|
||||
:return: A list of rich movie requests.
|
||||
"""
|
||||
return self.movie_repository.get_movie_requests()
|
||||
|
||||
def set_movie_library(self, movie: Movie, library: str) -> None:
|
||||
self.movie_repository.set_movie_library(movie_id=movie.id, library=library)
|
||||
|
||||
@@ -471,65 +416,6 @@ class MovieService:
|
||||
self.torrent_service.resume_download(torrent=movie_torrent)
|
||||
return movie_torrent
|
||||
|
||||
def download_approved_movie_request(
|
||||
self, movie_request: MovieRequest, movie: Movie
|
||||
) -> bool:
|
||||
"""
|
||||
Download an approved movie request.
|
||||
|
||||
:param movie_request: The movie request to download.
|
||||
:param movie: The Movie object.
|
||||
:return: True if the download was successful, False otherwise.
|
||||
:raises ValueError: If the movie request is not authorized.
|
||||
"""
|
||||
if not movie_request.authorized:
|
||||
msg = "Movie request is not authorized"
|
||||
raise ValueError(msg)
|
||||
|
||||
log.info(f"Downloading approved movie request {movie_request.id}")
|
||||
|
||||
torrents = self.get_all_available_torrents_for_movie(movie=movie)
|
||||
available_torrents: list[IndexerQueryResult] = []
|
||||
|
||||
for torrent in torrents:
|
||||
if (
|
||||
(torrent.quality.value < movie_request.wanted_quality.value)
|
||||
or (torrent.quality.value > movie_request.min_quality.value)
|
||||
or (torrent.seeders < 3)
|
||||
):
|
||||
log.debug(
|
||||
f"Skipping torrent {torrent.title} with quality {torrent.quality} for movie {movie.id}, because it does not match the requested quality {movie_request.wanted_quality}"
|
||||
)
|
||||
else:
|
||||
available_torrents.append(torrent)
|
||||
log.debug(
|
||||
f"Taking torrent {torrent.title} with quality {torrent.quality} for movie {movie.id} into consideration"
|
||||
)
|
||||
|
||||
if len(available_torrents) == 0:
|
||||
log.warning(
|
||||
f"No torrents found for movie request {movie_request.id} with quality between {QualityStrings[movie_request.min_quality.name]} and {QualityStrings[movie_request.wanted_quality.name]}"
|
||||
)
|
||||
return False
|
||||
|
||||
available_torrents.sort()
|
||||
|
||||
torrent = self.torrent_service.download(indexer_result=available_torrents[0])
|
||||
movie_file = MovieFile(
|
||||
movie_id=movie.id,
|
||||
quality=torrent.quality,
|
||||
torrent_id=torrent.id,
|
||||
file_path_suffix=QualityStrings[torrent.quality.name].value.upper(),
|
||||
)
|
||||
try:
|
||||
self.movie_repository.add_movie_file(movie_file=movie_file)
|
||||
except IntegrityError:
|
||||
log.warning(
|
||||
f"Movie file for movie {movie.name} and torrent {torrent.title} already exists"
|
||||
)
|
||||
self.delete_movie_request(movie_request.id)
|
||||
return True
|
||||
|
||||
def get_movie_root_path(self, movie: Movie) -> Path:
|
||||
misc_config = MediaManagerConfig().misc
|
||||
movie_file_path = (
|
||||
@@ -723,7 +609,7 @@ class MovieService:
|
||||
)
|
||||
if not fresh_movie_data:
|
||||
log.warning(
|
||||
f"Could not fetch fresh metadata for movie: {db_movie.name} (ID: {db_movie.external_id})"
|
||||
f"Could not fetch fresh metadata for movie: {db_movie.name} ({db_movie.year})"
|
||||
)
|
||||
return None
|
||||
log.debug(f"Fetched fresh metadata for movie: {fresh_movie_data.name}")
|
||||
@@ -738,7 +624,9 @@ class MovieService:
|
||||
|
||||
updated_movie = self.movie_repository.get_movie_by_id(movie_id=db_movie.id)
|
||||
|
||||
log.info(f"Successfully updated metadata for movie ID: {db_movie.id}")
|
||||
log.info(
|
||||
f"Successfully updated metadata for movie: {db_movie.name} ({db_movie.year})"
|
||||
)
|
||||
metadata_provider.download_movie_poster_image(movie=updated_movie)
|
||||
return updated_movie
|
||||
|
||||
@@ -773,102 +661,29 @@ class MovieService:
|
||||
log.debug(f"Found {len(importable_movies)} importable movies.")
|
||||
return importable_movies
|
||||
|
||||
|
||||
def auto_download_all_approved_movie_requests() -> None:
|
||||
"""
|
||||
Auto download all approved movie requests.
|
||||
This is a standalone function as it creates its own DB session.
|
||||
"""
|
||||
db: Session = SessionLocal() if SessionLocal else next(get_session())
|
||||
movie_repository = MovieRepository(db=db)
|
||||
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
|
||||
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
|
||||
notification_service = NotificationService(
|
||||
notification_repository=NotificationRepository(db=db)
|
||||
)
|
||||
movie_service = MovieService(
|
||||
movie_repository=movie_repository,
|
||||
torrent_service=torrent_service,
|
||||
indexer_service=indexer_service,
|
||||
notification_service=notification_service,
|
||||
)
|
||||
|
||||
log.info("Auto downloading all approved movie requests")
|
||||
movie_requests = movie_repository.get_movie_requests()
|
||||
log.info(f"Found {len(movie_requests)} movie requests to process")
|
||||
count = 0
|
||||
|
||||
for movie_request in movie_requests:
|
||||
if movie_request.authorized:
|
||||
movie = movie_repository.get_movie_by_id(movie_id=movie_request.movie_id)
|
||||
if movie_service.download_approved_movie_request(
|
||||
movie_request=movie_request, movie=movie
|
||||
):
|
||||
count += 1
|
||||
else:
|
||||
log.info(
|
||||
f"Could not download movie request {movie_request.id} for movie {movie.name}"
|
||||
)
|
||||
|
||||
log.info(f"Auto downloaded {count} approved movie requests")
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def import_all_movie_torrents() -> None:
|
||||
with next(get_session()) as db:
|
||||
movie_repository = MovieRepository(db=db)
|
||||
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
|
||||
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
|
||||
notification_service = NotificationService(
|
||||
notification_repository=NotificationRepository(db=db)
|
||||
)
|
||||
movie_service = MovieService(
|
||||
movie_repository=movie_repository,
|
||||
torrent_service=torrent_service,
|
||||
indexer_service=indexer_service,
|
||||
notification_service=notification_service,
|
||||
)
|
||||
def import_all_torrents(self) -> None:
|
||||
log.info("Importing all torrents")
|
||||
torrents = torrent_service.get_all_torrents()
|
||||
torrents = self.torrent_service.get_all_torrents()
|
||||
log.info("Found %d torrents to import", len(torrents))
|
||||
for t in torrents:
|
||||
try:
|
||||
if not t.imported and t.status == TorrentStatus.finished:
|
||||
movie = torrent_service.get_movie_of_torrent(torrent=t)
|
||||
movie = self.torrent_service.get_movie_of_torrent(torrent=t)
|
||||
if movie is None:
|
||||
log.warning(
|
||||
f"torrent {t.title} is not a movie torrent, skipping import."
|
||||
)
|
||||
continue
|
||||
movie_service.import_torrent_files(torrent=t, movie=movie)
|
||||
self.import_torrent_files(torrent=t, movie=movie)
|
||||
except RuntimeError:
|
||||
log.exception(f"Failed to import torrent {t.title}")
|
||||
log.info("Finished importing all torrents")
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_all_movies_metadata() -> None:
|
||||
"""
|
||||
Updates the metadata of all movies.
|
||||
"""
|
||||
with next(get_session()) as db:
|
||||
movie_repository = MovieRepository(db=db)
|
||||
movie_service = MovieService(
|
||||
movie_repository=movie_repository,
|
||||
torrent_service=TorrentService(torrent_repository=TorrentRepository(db=db)),
|
||||
indexer_service=IndexerService(indexer_repository=IndexerRepository(db=db)),
|
||||
notification_service=NotificationService(
|
||||
notification_repository=NotificationRepository(db=db)
|
||||
),
|
||||
)
|
||||
|
||||
def update_all_metadata(self) -> None:
|
||||
"""Updates the metadata of all movies."""
|
||||
log.info("Updating metadata for all movies")
|
||||
|
||||
movies = movie_repository.get_movies()
|
||||
|
||||
movies = self.movie_repository.get_movies()
|
||||
log.info(f"Found {len(movies)} movies to update")
|
||||
|
||||
for movie in movies:
|
||||
try:
|
||||
if movie.metadata_provider == "tmdb":
|
||||
@@ -885,7 +700,6 @@ def update_all_movies_metadata() -> None:
|
||||
f"Error initializing metadata provider {movie.metadata_provider} for movie {movie.name}",
|
||||
)
|
||||
continue
|
||||
movie_service.update_movie_metadata(
|
||||
self.update_movie_metadata(
|
||||
db_movie=movie, metadata_provider=metadata_provider
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -1,67 +1,91 @@
|
||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
import asyncio
|
||||
import logging
|
||||
from urllib.parse import quote
|
||||
|
||||
import media_manager.database
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.movies.service import (
|
||||
auto_download_all_approved_movie_requests,
|
||||
import_all_movie_torrents,
|
||||
update_all_movies_metadata,
|
||||
)
|
||||
from media_manager.tv.service import (
|
||||
auto_download_all_approved_season_requests,
|
||||
import_all_show_torrents,
|
||||
update_all_non_ended_shows_metadata,
|
||||
import taskiq_fastapi
|
||||
from taskiq import TaskiqDepends, TaskiqScheduler
|
||||
from taskiq.cli.scheduler.run import SchedulerLoop
|
||||
from taskiq_postgresql import PostgresqlBroker
|
||||
from taskiq_postgresql.scheduler_source import PostgresqlSchedulerSource
|
||||
|
||||
from media_manager.movies.dependencies import get_movie_service
|
||||
from media_manager.movies.service import MovieService
|
||||
from media_manager.tv.dependencies import get_tv_service
|
||||
from media_manager.tv.service import TvService
|
||||
|
||||
|
||||
def _build_db_connection_string_for_taskiq() -> str:
|
||||
from media_manager.config import MediaManagerConfig
|
||||
|
||||
db_config = MediaManagerConfig().database
|
||||
user = quote(db_config.user, safe="")
|
||||
password = quote(db_config.password, safe="")
|
||||
dbname = quote(db_config.dbname, safe="")
|
||||
host = quote(str(db_config.host), safe="")
|
||||
port = quote(str(db_config.port), safe="")
|
||||
return f"postgresql://{user}:{password}@{host}:{port}/{dbname}"
|
||||
|
||||
|
||||
broker = PostgresqlBroker(
|
||||
dsn=_build_db_connection_string_for_taskiq,
|
||||
driver="psycopg",
|
||||
run_migrations=True,
|
||||
)
|
||||
|
||||
# Register FastAPI app with the broker so worker processes can resolve FastAPI
|
||||
# dependencies. Using a string reference avoids circular imports.
|
||||
taskiq_fastapi.init(broker, "media_manager.main:app")
|
||||
|
||||
def setup_scheduler(config: MediaManagerConfig) -> BackgroundScheduler:
|
||||
from media_manager.database import init_engine
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
init_engine(config.database)
|
||||
jobstores = {"default": SQLAlchemyJobStore(engine=media_manager.database.engine)}
|
||||
scheduler = BackgroundScheduler(jobstores=jobstores)
|
||||
every_15_minutes_trigger = CronTrigger(minute="*/15", hour="*")
|
||||
daily_trigger = CronTrigger(hour=0, minute=0, jitter=60 * 60 * 24 * 2)
|
||||
weekly_trigger = CronTrigger(
|
||||
day_of_week="mon", hour=0, minute=0, jitter=60 * 60 * 24 * 2
|
||||
|
||||
@broker.task
|
||||
async def import_all_movie_torrents_task(
|
||||
movie_service: MovieService = TaskiqDepends(get_movie_service),
|
||||
) -> None:
|
||||
log.info("Importing all Movie torrents")
|
||||
await asyncio.to_thread(movie_service.import_all_torrents)
|
||||
|
||||
|
||||
@broker.task
|
||||
async def import_all_show_torrents_task(
|
||||
tv_service: TvService = TaskiqDepends(get_tv_service),
|
||||
) -> None:
|
||||
log.info("Importing all Show torrents")
|
||||
await asyncio.to_thread(tv_service.import_all_torrents)
|
||||
|
||||
|
||||
@broker.task
|
||||
async def update_all_movies_metadata_task(
|
||||
movie_service: MovieService = TaskiqDepends(get_movie_service),
|
||||
) -> None:
|
||||
await asyncio.to_thread(movie_service.update_all_metadata)
|
||||
|
||||
|
||||
@broker.task
|
||||
async def update_all_non_ended_shows_metadata_task(
|
||||
tv_service: TvService = TaskiqDepends(get_tv_service),
|
||||
) -> None:
|
||||
await asyncio.to_thread(tv_service.update_all_non_ended_shows_metadata)
|
||||
|
||||
|
||||
# Maps each task to its cron schedule so PostgresqlSchedulerSource can seed
|
||||
# the taskiq_schedulers table on first startup.
|
||||
_STARTUP_SCHEDULES: dict[str, list[dict[str, str]]] = {
|
||||
import_all_movie_torrents_task.task_name: [{"cron": "*/2 * * * *"}],
|
||||
import_all_show_torrents_task.task_name: [{"cron": "*/2 * * * *"}],
|
||||
update_all_movies_metadata_task.task_name: [{"cron": "0 0 * * 1"}],
|
||||
update_all_non_ended_shows_metadata_task.task_name: [{"cron": "0 0 * * 1"}],
|
||||
}
|
||||
|
||||
|
||||
def build_scheduler_loop() -> SchedulerLoop:
|
||||
source = PostgresqlSchedulerSource(
|
||||
dsn=_build_db_connection_string_for_taskiq,
|
||||
driver="psycopg",
|
||||
broker=broker,
|
||||
run_migrations=True,
|
||||
startup_schedule=_STARTUP_SCHEDULES,
|
||||
)
|
||||
scheduler.add_job(
|
||||
import_all_movie_torrents,
|
||||
every_15_minutes_trigger,
|
||||
id="import_all_movie_torrents",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
import_all_show_torrents,
|
||||
every_15_minutes_trigger,
|
||||
id="import_all_show_torrents",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
auto_download_all_approved_season_requests,
|
||||
daily_trigger,
|
||||
id="auto_download_all_approved_season_requests",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
auto_download_all_approved_movie_requests,
|
||||
daily_trigger,
|
||||
id="auto_download_all_approved_movie_requests",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
update_all_movies_metadata,
|
||||
weekly_trigger,
|
||||
id="update_all_movies_metadata",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
update_all_non_ended_shows_metadata,
|
||||
weekly_trigger,
|
||||
id="update_all_non_ended_shows_metadata",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
return scheduler
|
||||
scheduler = TaskiqScheduler(broker=broker, sources=[source])
|
||||
return SchedulerLoop(scheduler)
|
||||
|
||||
@@ -57,23 +57,45 @@ class QbittorrentDownloadClient(AbstractDownloadClient):
|
||||
log.exception("Failed to log into qbittorrent")
|
||||
raise
|
||||
|
||||
try:
|
||||
self.api_client.torrents_create_category(
|
||||
name=self.config.category_name,
|
||||
save_path=self.config.category_save_path
|
||||
if self.config.category_save_path != ""
|
||||
else None,
|
||||
categories = self.api_client.torrents_categories()
|
||||
log.debug(f"Found following categories in qBittorrent: {categories}")
|
||||
if self.config.category_name in categories:
|
||||
category = categories.get(self.config.category_name)
|
||||
if category.get("savePath") == self.config.category_save_path:
|
||||
log.debug(
|
||||
f"Category '{self.config.category_name}' already exists in qBittorrent with the correct save path."
|
||||
)
|
||||
return
|
||||
# category exists but with a different save path, attempt to update it
|
||||
log.debug(
|
||||
f"Category '{self.config.category_name}' already exists in qBittorrent but with a different save path. Attempting to update it."
|
||||
)
|
||||
except Conflict409Error:
|
||||
try:
|
||||
self.api_client.torrents_edit_category(
|
||||
name=self.config.category_name,
|
||||
save_path=self.config.category_save_path
|
||||
if self.config.category_save_path != ""
|
||||
else None,
|
||||
save_path=self.config.category_save_path,
|
||||
)
|
||||
except Conflict409Error:
|
||||
log.exception(
|
||||
f"Attempt to update category '{self.config.category_name}' in qBittorrent with a different save"
|
||||
f" path failed. The configured save path and the save path saved in Qbittorrent differ,"
|
||||
f" manually update it in the qBittorrent WebUI or change the save path in the MediaManager"
|
||||
f" config to match the one in qBittorrent."
|
||||
)
|
||||
else:
|
||||
# create category if it doesn't exist
|
||||
log.debug(
|
||||
f"Category '{self.config.category_name}' does not exist in qBittorrent. Attempting to create it."
|
||||
)
|
||||
try:
|
||||
self.api_client.torrents_create_category(
|
||||
name=self.config.category_name,
|
||||
save_path=self.config.category_save_path,
|
||||
)
|
||||
except Conflict409Error:
|
||||
log.exception(
|
||||
f"Attempt to create category '{self.config.category_name}' in qBittorrent failed. The category already exists but was not found in the initial category list, manually check if the category exists in the qBittorrent WebUI or change the category name in the MediaManager config."
|
||||
)
|
||||
except Exception:
|
||||
log.exception("Error on updating MediaManager category in qBittorrent")
|
||||
|
||||
def download_torrent(self, indexer_result: IndexerQueryResult) -> Torrent:
|
||||
"""
|
||||
|
||||
@@ -16,5 +16,5 @@ class Torrent(Base):
|
||||
hash: Mapped[str]
|
||||
usenet: Mapped[bool]
|
||||
|
||||
season_files = relationship("SeasonFile", back_populates="torrent")
|
||||
episode_files = relationship("EpisodeFile", back_populates="torrent")
|
||||
movie_files = relationship("MovieFile", back_populates="torrent")
|
||||
|
||||
@@ -3,17 +3,13 @@ from sqlalchemy import delete, select
|
||||
from media_manager.database import DbSessionDependency
|
||||
from media_manager.exceptions import NotFoundError
|
||||
from media_manager.movies.models import Movie, MovieFile
|
||||
from media_manager.movies.schemas import (
|
||||
Movie as MovieSchema,
|
||||
)
|
||||
from media_manager.movies.schemas import (
|
||||
MovieFile as MovieFileSchema,
|
||||
)
|
||||
from media_manager.movies.schemas import Movie as MovieSchema
|
||||
from media_manager.movies.schemas import MovieFile as MovieFileSchema
|
||||
from media_manager.torrent.models import Torrent
|
||||
from media_manager.torrent.schemas import Torrent as TorrentSchema
|
||||
from media_manager.torrent.schemas import TorrentId
|
||||
from media_manager.tv.models import Season, SeasonFile, Show
|
||||
from media_manager.tv.schemas import SeasonFile as SeasonFileSchema
|
||||
from media_manager.tv.models import Episode, EpisodeFile, Season, Show
|
||||
from media_manager.tv.schemas import EpisodeFile as EpisodeFileSchema
|
||||
from media_manager.tv.schemas import Show as ShowSchema
|
||||
|
||||
|
||||
@@ -21,19 +17,22 @@ class TorrentRepository:
|
||||
def __init__(self, db: DbSessionDependency) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_seasons_files_of_torrent(
|
||||
def get_episode_files_of_torrent(
|
||||
self, torrent_id: TorrentId
|
||||
) -> list[SeasonFileSchema]:
|
||||
stmt = select(SeasonFile).where(SeasonFile.torrent_id == torrent_id)
|
||||
) -> list[EpisodeFileSchema]:
|
||||
stmt = select(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id)
|
||||
result = self.db.execute(stmt).scalars().all()
|
||||
return [SeasonFileSchema.model_validate(season_file) for season_file in result]
|
||||
return [
|
||||
EpisodeFileSchema.model_validate(episode_file) for episode_file in result
|
||||
]
|
||||
|
||||
def get_show_of_torrent(self, torrent_id: TorrentId) -> ShowSchema | None:
|
||||
stmt = (
|
||||
select(Show)
|
||||
.join(SeasonFile.season)
|
||||
.join(Season.show)
|
||||
.where(SeasonFile.torrent_id == torrent_id)
|
||||
.join(Show.seasons)
|
||||
.join(Season.episodes)
|
||||
.join(Episode.episode_files)
|
||||
.where(EpisodeFile.torrent_id == torrent_id)
|
||||
)
|
||||
result = self.db.execute(stmt).unique().scalar_one_or_none()
|
||||
if result is None:
|
||||
@@ -69,10 +68,10 @@ class TorrentRepository:
|
||||
)
|
||||
self.db.execute(movie_files_stmt)
|
||||
|
||||
season_files_stmt = delete(SeasonFile).where(
|
||||
SeasonFile.torrent_id == torrent_id
|
||||
episode_files_stmt = delete(EpisodeFile).where(
|
||||
EpisodeFile.torrent_id == torrent_id
|
||||
)
|
||||
self.db.execute(season_files_stmt)
|
||||
self.db.execute(episode_files_stmt)
|
||||
|
||||
self.db.delete(self.db.get(Torrent, torrent_id))
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from media_manager.movies.schemas import Movie, MovieFile
|
||||
from media_manager.torrent.manager import DownloadManager
|
||||
from media_manager.torrent.repository import TorrentRepository
|
||||
from media_manager.torrent.schemas import Torrent, TorrentId
|
||||
from media_manager.tv.schemas import SeasonFile, Show
|
||||
from media_manager.tv.schemas import EpisodeFile, Show
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,13 +19,13 @@ class TorrentService:
|
||||
self.torrent_repository = torrent_repository
|
||||
self.download_manager = download_manager or DownloadManager()
|
||||
|
||||
def get_season_files_of_torrent(self, torrent: Torrent) -> list[SeasonFile]:
|
||||
def get_episode_files_of_torrent(self, torrent: Torrent) -> list[EpisodeFile]:
|
||||
"""
|
||||
Returns all season files of a torrent
|
||||
:param torrent: the torrent to get the season files of
|
||||
:return: list of season files
|
||||
Returns all episode files of a torrent
|
||||
:param torrent: the torrent to get the episode files of
|
||||
:return: list of episode files
|
||||
"""
|
||||
return self.torrent_repository.get_seasons_files_of_torrent(
|
||||
return self.torrent_repository.get_episode_files_of_torrent(
|
||||
torrent_id=torrent.id
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ from uuid import UUID
|
||||
from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from media_manager.auth.db import User
|
||||
from media_manager.database import Base
|
||||
from media_manager.torrent.models import Quality
|
||||
|
||||
@@ -48,13 +47,6 @@ class Season(Base):
|
||||
back_populates="season", cascade="all, delete"
|
||||
)
|
||||
|
||||
season_files = relationship(
|
||||
"SeasonFile", back_populates="season", cascade="all, delete"
|
||||
)
|
||||
season_requests = relationship(
|
||||
"SeasonRequest", back_populates="season", cascade="all, delete"
|
||||
)
|
||||
|
||||
|
||||
class Episode(Base):
|
||||
__tablename__ = "episode"
|
||||
@@ -66,15 +58,19 @@ class Episode(Base):
|
||||
number: Mapped[int]
|
||||
external_id: Mapped[int]
|
||||
title: Mapped[str]
|
||||
overview: Mapped[str | None] = mapped_column(nullable=True)
|
||||
|
||||
season: Mapped["Season"] = relationship(back_populates="episodes")
|
||||
episode_files = relationship(
|
||||
"EpisodeFile", back_populates="episode", cascade="all, delete"
|
||||
)
|
||||
|
||||
|
||||
class SeasonFile(Base):
|
||||
__tablename__ = "season_file"
|
||||
__table_args__ = (PrimaryKeyConstraint("season_id", "file_path_suffix"),)
|
||||
season_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey(column="season.id", ondelete="CASCADE"),
|
||||
class EpisodeFile(Base):
|
||||
__tablename__ = "episode_file"
|
||||
__table_args__ = (PrimaryKeyConstraint("episode_id", "file_path_suffix"),)
|
||||
episode_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey(column="episode.id", ondelete="CASCADE"),
|
||||
)
|
||||
torrent_id: Mapped[UUID | None] = mapped_column(
|
||||
ForeignKey(column="torrent.id", ondelete="SET NULL"),
|
||||
@@ -82,31 +78,5 @@ class SeasonFile(Base):
|
||||
file_path_suffix: Mapped[str]
|
||||
quality: Mapped[Quality]
|
||||
|
||||
torrent = relationship("Torrent", back_populates="season_files", uselist=False)
|
||||
season = relationship("Season", back_populates="season_files", uselist=False)
|
||||
|
||||
|
||||
class SeasonRequest(Base):
|
||||
__tablename__ = "season_request"
|
||||
__table_args__ = (UniqueConstraint("season_id", "wanted_quality"),)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True)
|
||||
season_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey(column="season.id", ondelete="CASCADE"),
|
||||
)
|
||||
wanted_quality: Mapped[Quality]
|
||||
min_quality: Mapped[Quality]
|
||||
requested_by_id: Mapped[UUID | None] = mapped_column(
|
||||
ForeignKey(column="user.id", ondelete="SET NULL"),
|
||||
)
|
||||
authorized: Mapped[bool] = mapped_column(default=False)
|
||||
authorized_by_id: Mapped[UUID | None] = mapped_column(
|
||||
ForeignKey(column="user.id", ondelete="SET NULL"),
|
||||
)
|
||||
|
||||
requested_by: Mapped["User|None"] = relationship(
|
||||
foreign_keys=[requested_by_id], uselist=False
|
||||
)
|
||||
authorized_by: Mapped["User|None"] = relationship(
|
||||
foreign_keys=[authorized_by_id], uselist=False
|
||||
)
|
||||
season = relationship("Season", back_populates="season_requests", uselist=False)
|
||||
torrent = relationship("Torrent", back_populates="episode_files", uselist=False)
|
||||
episode = relationship("Episode", back_populates="episode_files", uselist=False)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.exc import (
|
||||
IntegrityError,
|
||||
SQLAlchemyError,
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from media_manager.exceptions import ConflictError, NotFoundError
|
||||
@@ -10,32 +7,18 @@ from media_manager.torrent.models import Torrent
|
||||
from media_manager.torrent.schemas import Torrent as TorrentSchema
|
||||
from media_manager.torrent.schemas import TorrentId
|
||||
from media_manager.tv import log
|
||||
from media_manager.tv.models import Episode, Season, SeasonFile, SeasonRequest, Show
|
||||
from media_manager.tv.schemas import (
|
||||
Episode as EpisodeSchema,
|
||||
)
|
||||
from media_manager.tv.models import Episode, EpisodeFile, Season, Show
|
||||
from media_manager.tv.schemas import Episode as EpisodeSchema
|
||||
from media_manager.tv.schemas import EpisodeFile as EpisodeFileSchema
|
||||
from media_manager.tv.schemas import (
|
||||
EpisodeId,
|
||||
EpisodeNumber,
|
||||
SeasonId,
|
||||
SeasonNumber,
|
||||
SeasonRequestId,
|
||||
ShowId,
|
||||
)
|
||||
from media_manager.tv.schemas import (
|
||||
RichSeasonRequest as RichSeasonRequestSchema,
|
||||
)
|
||||
from media_manager.tv.schemas import (
|
||||
Season as SeasonSchema,
|
||||
)
|
||||
from media_manager.tv.schemas import (
|
||||
SeasonFile as SeasonFileSchema,
|
||||
)
|
||||
from media_manager.tv.schemas import (
|
||||
SeasonRequest as SeasonRequestSchema,
|
||||
)
|
||||
from media_manager.tv.schemas import (
|
||||
Show as ShowSchema,
|
||||
)
|
||||
from media_manager.tv.schemas import Season as SeasonSchema
|
||||
from media_manager.tv.schemas import Show as ShowSchema
|
||||
|
||||
|
||||
class TvRepository:
|
||||
@@ -120,9 +103,7 @@ class TvRepository:
|
||||
|
||||
def get_total_downloaded_episodes_count(self) -> int:
|
||||
try:
|
||||
stmt = (
|
||||
select(func.count()).select_from(Episode).join(Season).join(SeasonFile)
|
||||
)
|
||||
stmt = select(func.count(Episode.id)).select_from(Episode).join(EpisodeFile)
|
||||
return self.db.execute(stmt).scalar_one_or_none()
|
||||
except SQLAlchemyError:
|
||||
log.exception("Database error while calculating downloaded episodes count")
|
||||
@@ -173,6 +154,7 @@ class TvRepository:
|
||||
number=episode.number,
|
||||
external_id=episode.external_id,
|
||||
title=episode.title,
|
||||
overview=episode.overview,
|
||||
)
|
||||
for episode in season.episodes
|
||||
],
|
||||
@@ -234,64 +216,40 @@ class TvRepository:
|
||||
log.exception(f"Database error while retrieving season {season_id}")
|
||||
raise
|
||||
|
||||
def add_season_request(
|
||||
self, season_request: SeasonRequestSchema
|
||||
) -> SeasonRequestSchema:
|
||||
def get_episode(self, episode_id: EpisodeId) -> EpisodeSchema:
|
||||
"""
|
||||
Adds a Season to the SeasonRequest table, which marks it as requested.
|
||||
Retrieve an episode by its ID.
|
||||
|
||||
:param season_request: The SeasonRequest object to add.
|
||||
:return: The added SeasonRequest object.
|
||||
:raises IntegrityError: If a similar request already exists or violates constraints.
|
||||
:raises SQLAlchemyError: If a database error occurs.
|
||||
"""
|
||||
db_model = SeasonRequest(
|
||||
id=season_request.id,
|
||||
season_id=season_request.season_id,
|
||||
wanted_quality=season_request.wanted_quality,
|
||||
min_quality=season_request.min_quality,
|
||||
requested_by_id=season_request.requested_by.id
|
||||
if season_request.requested_by
|
||||
else None,
|
||||
authorized=season_request.authorized,
|
||||
authorized_by_id=season_request.authorized_by.id
|
||||
if season_request.authorized_by
|
||||
else None,
|
||||
)
|
||||
try:
|
||||
self.db.add(db_model)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_model)
|
||||
return SeasonRequestSchema.model_validate(db_model)
|
||||
except IntegrityError:
|
||||
self.db.rollback()
|
||||
log.exception("Integrity error while adding season request")
|
||||
raise
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.exception("Database error while adding season request")
|
||||
raise
|
||||
|
||||
def delete_season_request(self, season_request_id: SeasonRequestId) -> None:
|
||||
"""
|
||||
Removes a SeasonRequest by its ID.
|
||||
|
||||
:param season_request_id: The ID of the season request to delete.
|
||||
:raises NotFoundError: If the season request is not found.
|
||||
: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:
|
||||
stmt = delete(SeasonRequest).where(SeasonRequest.id == season_request_id)
|
||||
result = self.db.execute(stmt)
|
||||
if result.rowcount == 0:
|
||||
self.db.rollback()
|
||||
msg = f"SeasonRequest with id {season_request_id} not found."
|
||||
episode = self.db.get(Episode, episode_id)
|
||||
if not episode:
|
||||
msg = f"Episode with id {episode_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
self.db.commit()
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.exception(
|
||||
f"Database error while deleting season request {season_request_id}"
|
||||
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
|
||||
|
||||
@@ -323,78 +281,46 @@ class TvRepository:
|
||||
)
|
||||
raise
|
||||
|
||||
def get_season_requests(self) -> list[RichSeasonRequestSchema]:
|
||||
def add_episode_file(self, episode_file: EpisodeFileSchema) -> EpisodeFileSchema:
|
||||
"""
|
||||
Retrieve all season requests.
|
||||
Adds an episode file record to the database.
|
||||
|
||||
:return: A list of RichSeasonRequest objects.
|
||||
:raises SQLAlchemyError: If a database error occurs.
|
||||
"""
|
||||
try:
|
||||
stmt = select(SeasonRequest).options(
|
||||
joinedload(SeasonRequest.requested_by),
|
||||
joinedload(SeasonRequest.authorized_by),
|
||||
joinedload(SeasonRequest.season).joinedload(Season.show),
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
return [
|
||||
RichSeasonRequestSchema(
|
||||
id=SeasonRequestId(x.id),
|
||||
min_quality=x.min_quality,
|
||||
wanted_quality=x.wanted_quality,
|
||||
season_id=SeasonId(x.season_id),
|
||||
show=x.season.show,
|
||||
season=x.season,
|
||||
requested_by=x.requested_by,
|
||||
authorized_by=x.authorized_by,
|
||||
authorized=x.authorized,
|
||||
)
|
||||
for x in results
|
||||
]
|
||||
except SQLAlchemyError:
|
||||
log.exception("Database error while retrieving season requests")
|
||||
raise
|
||||
|
||||
def add_season_file(self, season_file: SeasonFileSchema) -> SeasonFileSchema:
|
||||
"""
|
||||
Adds a season file record to the database.
|
||||
|
||||
:param season_file: The SeasonFile object to add.
|
||||
:return: The added SeasonFile object.
|
||||
:param episode_file: The EpisodeFile object to add.
|
||||
:return: The added EpisodeFile object.
|
||||
:raises IntegrityError: If the record violates constraints.
|
||||
:raises SQLAlchemyError: If a database error occurs.
|
||||
"""
|
||||
db_model = SeasonFile(**season_file.model_dump())
|
||||
db_model = EpisodeFile(**episode_file.model_dump())
|
||||
try:
|
||||
self.db.add(db_model)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_model)
|
||||
return SeasonFileSchema.model_validate(db_model)
|
||||
except IntegrityError:
|
||||
return EpisodeFileSchema.model_validate(db_model)
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
log.exception("Integrity error while adding season file")
|
||||
log.error(f"Integrity error while adding episode file: {e}")
|
||||
raise
|
||||
except SQLAlchemyError:
|
||||
except SQLAlchemyError as e:
|
||||
self.db.rollback()
|
||||
log.exception("Database error while adding season file")
|
||||
log.error(f"Database error while adding episode file: {e}")
|
||||
raise
|
||||
|
||||
def remove_season_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
|
||||
def remove_episode_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
|
||||
"""
|
||||
Removes season file records associated with a given torrent ID.
|
||||
Removes episode file records associated with a given torrent ID.
|
||||
|
||||
:param torrent_id: The ID of the torrent whose season files are to be removed.
|
||||
:return: The number of season files removed.
|
||||
:param torrent_id: The ID of the torrent whose episode files are to be removed.
|
||||
:return: The number of episode files removed.
|
||||
:raises SQLAlchemyError: If a database error occurs.
|
||||
"""
|
||||
try:
|
||||
stmt = delete(SeasonFile).where(SeasonFile.torrent_id == torrent_id)
|
||||
stmt = delete(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id)
|
||||
result = self.db.execute(stmt)
|
||||
self.db.commit()
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.exception(
|
||||
f"Database error removing season files for torrent_id {torrent_id}"
|
||||
f"Database error removing episode files for torrent_id {torrent_id}"
|
||||
)
|
||||
raise
|
||||
return result.rowcount
|
||||
@@ -420,23 +346,45 @@ class TvRepository:
|
||||
log.exception(f"Database error setting library for show {show_id}")
|
||||
raise
|
||||
|
||||
def get_season_files_by_season_id(
|
||||
def get_episode_files_by_season_id(
|
||||
self, season_id: SeasonId
|
||||
) -> list[SeasonFileSchema]:
|
||||
) -> list[EpisodeFileSchema]:
|
||||
"""
|
||||
Retrieve all season files for a given season ID.
|
||||
Retrieve all episode files for a given season ID.
|
||||
|
||||
:param season_id: The ID of the season.
|
||||
:return: A list of SeasonFile objects.
|
||||
:return: A list of EpisodeFile objects.
|
||||
:raises SQLAlchemyError: If a database error occurs.
|
||||
"""
|
||||
try:
|
||||
stmt = select(SeasonFile).where(SeasonFile.season_id == season_id)
|
||||
stmt = (
|
||||
select(EpisodeFile).join(Episode).where(Episode.season_id == season_id)
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().all()
|
||||
return [SeasonFileSchema.model_validate(sf) for sf in results]
|
||||
return [EpisodeFileSchema.model_validate(ef) for ef in results]
|
||||
except SQLAlchemyError:
|
||||
log.exception(
|
||||
f"Database error retrieving season files for season_id {season_id}"
|
||||
f"Database error retrieving episode files for season_id {season_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
def get_episode_files_by_episode_id(
|
||||
self, episode_id: EpisodeId
|
||||
) -> list[EpisodeFileSchema]:
|
||||
"""
|
||||
Retrieve all episode files for a given episode ID.
|
||||
|
||||
:param episode_id: The ID of the episode.
|
||||
:return: A list of EpisodeFile objects.
|
||||
:raises SQLAlchemyError: If a database error occurs.
|
||||
"""
|
||||
try:
|
||||
stmt = select(EpisodeFile).where(EpisodeFile.episode_id == episode_id)
|
||||
results = self.db.execute(stmt).scalars().all()
|
||||
return [EpisodeFileSchema.model_validate(sf) for sf in results]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(
|
||||
f"Database error retrieving episode files for episode_id {episode_id}: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -452,8 +400,9 @@ class TvRepository:
|
||||
stmt = (
|
||||
select(Torrent)
|
||||
.distinct()
|
||||
.join(SeasonFile, SeasonFile.torrent_id == Torrent.id)
|
||||
.join(Season, Season.id == SeasonFile.season_id)
|
||||
.join(EpisodeFile, EpisodeFile.torrent_id == Torrent.id)
|
||||
.join(Episode, Episode.id == EpisodeFile.episode_id)
|
||||
.join(Season, Season.id == Episode.season_id)
|
||||
.where(Season.show_id == show_id)
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
@@ -474,8 +423,9 @@ class TvRepository:
|
||||
select(Show)
|
||||
.distinct()
|
||||
.join(Season, Show.id == Season.show_id)
|
||||
.join(SeasonFile, Season.id == SeasonFile.season_id)
|
||||
.join(Torrent, SeasonFile.torrent_id == Torrent.id)
|
||||
.join(Episode, Season.id == Episode.season_id)
|
||||
.join(EpisodeFile, Episode.id == EpisodeFile.episode_id)
|
||||
.join(Torrent, EpisodeFile.torrent_id == Torrent.id)
|
||||
.options(joinedload(Show.seasons).joinedload(Season.episodes))
|
||||
.order_by(Show.name)
|
||||
)
|
||||
@@ -497,8 +447,9 @@ class TvRepository:
|
||||
stmt = (
|
||||
select(Season.number)
|
||||
.distinct()
|
||||
.join(SeasonFile, Season.id == SeasonFile.season_id)
|
||||
.where(SeasonFile.torrent_id == torrent_id)
|
||||
.join(Episode, Episode.season_id == Season.id)
|
||||
.join(EpisodeFile, EpisodeFile.episode_id == Episode.id)
|
||||
.where(EpisodeFile.torrent_id == torrent_id)
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
return [SeasonNumber(x) for x in results]
|
||||
@@ -508,27 +459,29 @@ class TvRepository:
|
||||
)
|
||||
raise
|
||||
|
||||
def get_season_request(
|
||||
self, season_request_id: SeasonRequestId
|
||||
) -> SeasonRequestSchema:
|
||||
def get_episodes_by_torrent_id(self, torrent_id: TorrentId) -> list[EpisodeNumber]:
|
||||
"""
|
||||
Retrieve a season request by its ID.
|
||||
Retrieve episode numbers associated with a given torrent ID.
|
||||
|
||||
:param season_request_id: The ID of the season request.
|
||||
:return: A SeasonRequest object.
|
||||
:raises NotFoundError: If the season request is not found.
|
||||
:param torrent_id: The ID of the torrent.
|
||||
:return: A list of EpisodeNumber objects.
|
||||
:raises SQLAlchemyError: If a database error occurs.
|
||||
"""
|
||||
try:
|
||||
request = self.db.get(SeasonRequest, season_request_id)
|
||||
if not request:
|
||||
log.warning(f"Season request with id {season_request_id} not found.")
|
||||
msg = f"Season request with id {season_request_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
return SeasonRequestSchema.model_validate(request)
|
||||
except SQLAlchemyError:
|
||||
log.exception(
|
||||
f"Database error retrieving season request {season_request_id}"
|
||||
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
|
||||
|
||||
@@ -731,14 +684,21 @@ class TvRepository:
|
||||
if updated:
|
||||
self.db.commit()
|
||||
self.db.refresh(db_season)
|
||||
log.debug(
|
||||
f"Updating existing season {db_season.number} for show {db_season.show.name}"
|
||||
)
|
||||
return SeasonSchema.model_validate(db_season)
|
||||
|
||||
def update_episode_attributes(
|
||||
self, episode_id: EpisodeId, title: str | None = None
|
||||
self,
|
||||
episode_id: EpisodeId,
|
||||
title: str | None = None,
|
||||
overview: str | None = None,
|
||||
) -> EpisodeSchema:
|
||||
"""
|
||||
Update attributes of an existing episode.
|
||||
|
||||
:param overview: Tje new overview for the episode.
|
||||
:param episode_id: The ID of the episode to update.
|
||||
:param title: The new title for the episode.
|
||||
:param external_id: The new external ID for the episode.
|
||||
@@ -755,8 +715,12 @@ class TvRepository:
|
||||
if title is not None and db_episode.title != title:
|
||||
db_episode.title = title
|
||||
updated = True
|
||||
if overview is not None and db_episode.overview != overview:
|
||||
db_episode.overview = overview
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
self.db.commit()
|
||||
self.db.refresh(db_episode)
|
||||
log.info(f"Updating existing episode {db_episode.number}")
|
||||
return EpisodeSchema.model_validate(db_episode)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from media_manager.auth.db import User
|
||||
from media_manager.auth.schemas import UserRead
|
||||
from media_manager.auth.users import current_active_user, current_superuser
|
||||
from media_manager.config import LibraryItem, MediaManagerConfig
|
||||
from media_manager.exceptions import MediaAlreadyExistsError, NotFoundError
|
||||
@@ -17,24 +14,18 @@ from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
|
||||
from media_manager.schemas import MediaImportSuggestion
|
||||
from media_manager.torrent.schemas import Torrent
|
||||
from media_manager.torrent.utils import get_importable_media_directories
|
||||
from media_manager.tv import log
|
||||
from media_manager.tv.dependencies import (
|
||||
season_dep,
|
||||
show_dep,
|
||||
tv_service_dep,
|
||||
)
|
||||
from media_manager.tv.schemas import (
|
||||
CreateSeasonRequest,
|
||||
PublicSeasonFile,
|
||||
PublicEpisodeFile,
|
||||
PublicShow,
|
||||
RichSeasonRequest,
|
||||
RichShowTorrent,
|
||||
Season,
|
||||
SeasonRequest,
|
||||
SeasonRequestId,
|
||||
Show,
|
||||
ShowId,
|
||||
UpdateSeasonRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -278,110 +269,6 @@ def get_a_shows_torrents(show: show_dep, tv_service: tv_service_dep) -> RichShow
|
||||
return tv_service.get_torrents_for_show(show=show)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SEASONS - REQUESTS
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/seasons/requests",
|
||||
status_code=status.HTTP_200_OK,
|
||||
dependencies=[Depends(current_active_user)],
|
||||
)
|
||||
def get_season_requests(tv_service: tv_service_dep) -> list[RichSeasonRequest]:
|
||||
"""
|
||||
Get all season requests.
|
||||
"""
|
||||
return tv_service.get_all_season_requests()
|
||||
|
||||
|
||||
@router.post("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def request_a_season(
|
||||
user: Annotated[User, Depends(current_active_user)],
|
||||
season_request: CreateSeasonRequest,
|
||||
tv_service: tv_service_dep,
|
||||
) -> None:
|
||||
"""
|
||||
Create a new season request.
|
||||
"""
|
||||
request: SeasonRequest = SeasonRequest.model_validate(season_request)
|
||||
request.requested_by = UserRead.model_validate(user)
|
||||
if user.is_superuser:
|
||||
request.authorized = True
|
||||
request.authorized_by = UserRead.model_validate(user)
|
||||
tv_service.add_season_request(request)
|
||||
return
|
||||
|
||||
|
||||
@router.put("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def update_request(
|
||||
tv_service: tv_service_dep,
|
||||
user: Annotated[User, Depends(current_active_user)],
|
||||
season_request: UpdateSeasonRequest,
|
||||
) -> None:
|
||||
"""
|
||||
Update an existing season request.
|
||||
"""
|
||||
updated_season_request: SeasonRequest = SeasonRequest.model_validate(season_request)
|
||||
request = tv_service.get_season_request_by_id(
|
||||
season_request_id=updated_season_request.id
|
||||
)
|
||||
if request.requested_by.id == user.id or user.is_superuser:
|
||||
updated_season_request.requested_by = UserRead.model_validate(user)
|
||||
tv_service.update_season_request(season_request=updated_season_request)
|
||||
return
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
def authorize_request(
|
||||
tv_service: tv_service_dep,
|
||||
user: Annotated[User, Depends(current_superuser)],
|
||||
season_request_id: SeasonRequestId,
|
||||
authorized_status: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Authorize or de-authorize a season request.
|
||||
"""
|
||||
season_request = tv_service.get_season_request_by_id(
|
||||
season_request_id=season_request_id
|
||||
)
|
||||
if not season_request:
|
||||
raise NotFoundError
|
||||
season_request.authorized_by = UserRead.model_validate(user)
|
||||
season_request.authorized = authorized_status
|
||||
if not authorized_status:
|
||||
season_request.authorized_by = None
|
||||
tv_service.update_season_request(season_request=season_request)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/seasons/requests/{request_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def delete_season_request(
|
||||
tv_service: tv_service_dep,
|
||||
user: Annotated[User, Depends(current_active_user)],
|
||||
request_id: SeasonRequestId,
|
||||
) -> None:
|
||||
"""
|
||||
Delete a season request.
|
||||
"""
|
||||
request = tv_service.get_season_request_by_id(season_request_id=request_id)
|
||||
if user.is_superuser or request.requested_by.id == user.id:
|
||||
tv_service.delete_season_request(season_request_id=request_id)
|
||||
log.info(f"User {user.id} deleted season request {request_id}.")
|
||||
return
|
||||
log.warning(
|
||||
f"User {user.id} tried to delete season request {request_id} but is not authorized."
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to delete this request",
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SEASONS
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -402,13 +289,13 @@ def get_season(season: season_dep) -> Season:
|
||||
"/seasons/{season_id}/files",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
)
|
||||
def get_season_files(
|
||||
def get_episode_files(
|
||||
season: season_dep, tv_service: tv_service_dep
|
||||
) -> list[PublicSeasonFile]:
|
||||
) -> list[PublicEpisodeFile]:
|
||||
"""
|
||||
Get files associated with a specific season.
|
||||
Get episode files associated with a specific season.
|
||||
"""
|
||||
return tv_service.get_public_season_files_by_season_id(season=season)
|
||||
return tv_service.get_public_episode_files_by_season_id(season=season)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -2,9 +2,8 @@ import typing
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from media_manager.auth.schemas import UserRead
|
||||
from media_manager.torrent.models import Quality
|
||||
from media_manager.torrent.schemas import TorrentId, TorrentStatus
|
||||
|
||||
@@ -14,7 +13,6 @@ EpisodeId = typing.NewType("EpisodeId", UUID)
|
||||
|
||||
SeasonNumber = typing.NewType("SeasonNumber", int)
|
||||
EpisodeNumber = typing.NewType("EpisodeNumber", int)
|
||||
SeasonRequestId = typing.NewType("SeasonRequestId", UUID)
|
||||
|
||||
|
||||
class Episode(BaseModel):
|
||||
@@ -24,6 +22,7 @@ class Episode(BaseModel):
|
||||
number: EpisodeNumber
|
||||
external_id: int
|
||||
title: str
|
||||
overview: str | None = None
|
||||
|
||||
|
||||
class Season(BaseModel):
|
||||
@@ -62,52 +61,16 @@ class Show(BaseModel):
|
||||
seasons: list[Season]
|
||||
|
||||
|
||||
class SeasonRequestBase(BaseModel):
|
||||
min_quality: Quality
|
||||
wanted_quality: Quality
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ensure_wanted_quality_is_eq_or_gt_min_quality(self) -> "SeasonRequestBase":
|
||||
if self.min_quality.value < self.wanted_quality.value:
|
||||
msg = "wanted_quality must be equal to or lower than minimum_quality."
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
|
||||
class CreateSeasonRequest(SeasonRequestBase):
|
||||
season_id: SeasonId
|
||||
|
||||
|
||||
class UpdateSeasonRequest(SeasonRequestBase):
|
||||
id: SeasonRequestId
|
||||
|
||||
|
||||
class SeasonRequest(SeasonRequestBase):
|
||||
class EpisodeFile(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: SeasonRequestId = Field(default_factory=lambda: SeasonRequestId(uuid.uuid4()))
|
||||
|
||||
season_id: SeasonId
|
||||
requested_by: UserRead | None = None
|
||||
authorized: bool = False
|
||||
authorized_by: UserRead | None = None
|
||||
|
||||
|
||||
class RichSeasonRequest(SeasonRequest):
|
||||
show: Show
|
||||
season: Season
|
||||
|
||||
|
||||
class SeasonFile(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
season_id: SeasonId
|
||||
episode_id: EpisodeId
|
||||
quality: Quality
|
||||
torrent_id: TorrentId | None
|
||||
file_path_suffix: str
|
||||
|
||||
|
||||
class PublicSeasonFile(SeasonFile):
|
||||
class PublicEpisodeFile(EpisodeFile):
|
||||
downloaded: bool = False
|
||||
|
||||
|
||||
@@ -123,6 +86,7 @@ class RichSeasonTorrent(BaseModel):
|
||||
|
||||
file_path_suffix: str
|
||||
seasons: list[SeasonNumber]
|
||||
episodes: list[EpisodeNumber]
|
||||
|
||||
|
||||
class RichShowTorrent(BaseModel):
|
||||
@@ -135,6 +99,18 @@ class RichShowTorrent(BaseModel):
|
||||
torrents: list[RichSeasonTorrent]
|
||||
|
||||
|
||||
class PublicEpisode(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: EpisodeId
|
||||
number: EpisodeNumber
|
||||
|
||||
downloaded: bool = False
|
||||
title: str
|
||||
overview: str | None = None
|
||||
|
||||
external_id: int
|
||||
|
||||
|
||||
class PublicSeason(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -147,7 +123,7 @@ class PublicSeason(BaseModel):
|
||||
|
||||
external_id: int
|
||||
|
||||
episodes: list[Episode]
|
||||
episodes: list[PublicEpisode]
|
||||
|
||||
|
||||
class PublicShow(BaseModel):
|
||||
|
||||
@@ -7,9 +7,7 @@ from typing import overload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.database import get_session
|
||||
from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError
|
||||
from media_manager.indexer.repository import IndexerRepository
|
||||
from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId
|
||||
from media_manager.indexer.service import IndexerService
|
||||
from media_manager.indexer.utils import evaluate_indexer_query_results
|
||||
@@ -19,13 +17,10 @@ from media_manager.metadataProvider.abstract_metadata_provider import (
|
||||
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
|
||||
from media_manager.metadataProvider.tmdb import TmdbMetadataProvider
|
||||
from media_manager.metadataProvider.tvdb import TvdbMetadataProvider
|
||||
from media_manager.notification.repository import NotificationRepository
|
||||
from media_manager.notification.service import NotificationService
|
||||
from media_manager.schemas import MediaImportSuggestion
|
||||
from media_manager.torrent.repository import TorrentRepository
|
||||
from media_manager.torrent.schemas import (
|
||||
Quality,
|
||||
QualityStrings,
|
||||
Torrent,
|
||||
TorrentStatus,
|
||||
)
|
||||
@@ -41,21 +36,17 @@ from media_manager.torrent.utils import (
|
||||
from media_manager.tv import log
|
||||
from media_manager.tv.repository import TvRepository
|
||||
from media_manager.tv.schemas import (
|
||||
Episode as EpisodeSchema,
|
||||
)
|
||||
from media_manager.tv.schemas import (
|
||||
Episode,
|
||||
EpisodeFile,
|
||||
EpisodeId,
|
||||
EpisodeNumber,
|
||||
PublicEpisodeFile,
|
||||
PublicSeason,
|
||||
PublicSeasonFile,
|
||||
PublicShow,
|
||||
RichSeasonRequest,
|
||||
RichSeasonTorrent,
|
||||
RichShowTorrent,
|
||||
Season,
|
||||
SeasonFile,
|
||||
SeasonId,
|
||||
SeasonRequest,
|
||||
SeasonRequestId,
|
||||
Show,
|
||||
ShowId,
|
||||
)
|
||||
@@ -94,28 +85,6 @@ class TvService:
|
||||
metadata_provider.download_show_poster_image(show=saved_show)
|
||||
return saved_show
|
||||
|
||||
def add_season_request(self, season_request: SeasonRequest) -> SeasonRequest:
|
||||
"""
|
||||
Add a new season request.
|
||||
|
||||
:param season_request: The season request to add.
|
||||
:return: The added season request.
|
||||
"""
|
||||
return self.tv_repository.add_season_request(season_request=season_request)
|
||||
|
||||
def get_season_request_by_id(
|
||||
self, season_request_id: SeasonRequestId
|
||||
) -> SeasonRequest | None:
|
||||
"""
|
||||
Get a season request by its ID.
|
||||
|
||||
:param season_request_id: The ID of the season request.
|
||||
:return: The season request or None if not found.
|
||||
"""
|
||||
return self.tv_repository.get_season_request(
|
||||
season_request_id=season_request_id
|
||||
)
|
||||
|
||||
def get_total_downloaded_episoded_count(self) -> int:
|
||||
"""
|
||||
Get total number of downloaded episodes.
|
||||
@@ -123,27 +92,9 @@ class TvService:
|
||||
|
||||
return self.tv_repository.get_total_downloaded_episodes_count()
|
||||
|
||||
def update_season_request(self, season_request: SeasonRequest) -> SeasonRequest:
|
||||
"""
|
||||
Update an existing season request.
|
||||
|
||||
:param season_request: The season request to update.
|
||||
:return: The updated season request.
|
||||
"""
|
||||
self.tv_repository.delete_season_request(season_request_id=season_request.id)
|
||||
return self.tv_repository.add_season_request(season_request=season_request)
|
||||
|
||||
def set_show_library(self, show: Show, library: str) -> None:
|
||||
self.tv_repository.set_show_library(show_id=show.id, library=library)
|
||||
|
||||
def delete_season_request(self, season_request_id: SeasonRequestId) -> None:
|
||||
"""
|
||||
Delete a season request by its ID.
|
||||
|
||||
:param season_request_id: The ID of the season request to delete.
|
||||
"""
|
||||
self.tv_repository.delete_season_request(season_request_id=season_request_id)
|
||||
|
||||
def delete_show(
|
||||
self,
|
||||
show: Show,
|
||||
@@ -173,6 +124,7 @@ class TvService:
|
||||
for torrent in torrents:
|
||||
try:
|
||||
self.torrent_service.cancel_download(torrent, delete_files=True)
|
||||
self.torrent_service.delete_torrent(torrent_id=torrent.id)
|
||||
log.info(f"Deleted torrent: {torrent.hash}")
|
||||
except Exception:
|
||||
log.warning(
|
||||
@@ -181,24 +133,26 @@ class TvService:
|
||||
|
||||
self.tv_repository.delete_show(show_id=show.id)
|
||||
|
||||
def get_public_season_files_by_season_id(
|
||||
def get_public_episode_files_by_season_id(
|
||||
self, season: Season
|
||||
) -> list[PublicSeasonFile]:
|
||||
) -> list[PublicEpisodeFile]:
|
||||
"""
|
||||
Get all public season files for a given season.
|
||||
Get all public episode files for a given season.
|
||||
|
||||
:param season: The season object.
|
||||
:return: A list of public season files.
|
||||
:return: A list of public episode files.
|
||||
"""
|
||||
season_files = self.tv_repository.get_season_files_by_season_id(
|
||||
episode_files = self.tv_repository.get_episode_files_by_season_id(
|
||||
season_id=season.id
|
||||
)
|
||||
public_season_files = [PublicSeasonFile.model_validate(x) for x in season_files]
|
||||
public_episode_files = [
|
||||
PublicEpisodeFile.model_validate(x) for x in episode_files
|
||||
]
|
||||
result = []
|
||||
for season_file in public_season_files:
|
||||
if self.season_file_exists_on_file(season_file=season_file):
|
||||
season_file.downloaded = True
|
||||
result.append(season_file)
|
||||
for episode_file in public_episode_files:
|
||||
if self.episode_file_exists_on_file(episode_file=episode_file):
|
||||
episode_file.downloaded = True
|
||||
result.append(episode_file)
|
||||
return result
|
||||
|
||||
@overload
|
||||
@@ -334,11 +288,27 @@ class TvService:
|
||||
:param show: The show object.
|
||||
:return: A public show.
|
||||
"""
|
||||
seasons = [PublicSeason.model_validate(season) for season in show.seasons]
|
||||
for season in seasons:
|
||||
season.downloaded = self.is_season_downloaded(season_id=season.id)
|
||||
public_show = PublicShow.model_validate(show)
|
||||
public_show.seasons = seasons
|
||||
public_seasons: list[PublicSeason] = []
|
||||
|
||||
for season in show.seasons:
|
||||
public_season = PublicSeason.model_validate(season)
|
||||
|
||||
for episode in public_season.episodes:
|
||||
episode.downloaded = self.is_episode_downloaded(
|
||||
episode=episode,
|
||||
season=season,
|
||||
show=show,
|
||||
)
|
||||
|
||||
# A season is considered downloaded if it has episodes and all of them are downloaded,
|
||||
# matching the behavior of is_season_downloaded.
|
||||
public_season.downloaded = bool(public_season.episodes) and all(
|
||||
episode.downloaded for episode in public_season.episodes
|
||||
)
|
||||
public_seasons.append(public_season)
|
||||
|
||||
public_show.seasons = public_seasons
|
||||
return public_show
|
||||
|
||||
def get_show_by_id(self, show_id: ShowId) -> Show:
|
||||
@@ -350,33 +320,85 @@ class TvService:
|
||||
"""
|
||||
return self.tv_repository.get_show_by_id(show_id=show_id)
|
||||
|
||||
def is_season_downloaded(self, season_id: SeasonId) -> bool:
|
||||
def is_season_downloaded(self, season: Season, show: Show) -> bool:
|
||||
"""
|
||||
Check if a season is downloaded.
|
||||
|
||||
:param season_id: The ID of the season.
|
||||
:param season: The season object.
|
||||
:param show: The show object.
|
||||
:return: True if the season is downloaded, False otherwise.
|
||||
"""
|
||||
season_files = self.tv_repository.get_season_files_by_season_id(
|
||||
season_id=season_id
|
||||
episodes = season.episodes
|
||||
|
||||
if not episodes:
|
||||
return False
|
||||
|
||||
for episode in episodes:
|
||||
if not self.is_episode_downloaded(
|
||||
episode=episode, season=season, show=show
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_episode_downloaded(
|
||||
self, episode: Episode, season: Season, show: Show
|
||||
) -> bool:
|
||||
"""
|
||||
Check if an episode is downloaded and imported (file exists on disk).
|
||||
|
||||
An episode is considered downloaded if:
|
||||
- There is at least one EpisodeFile in the database AND
|
||||
- A matching episode file exists in the season directory on disk.
|
||||
|
||||
:param episode: The episode object.
|
||||
:param season: The season object.
|
||||
:param show: The show object.
|
||||
:return: True if the episode is downloaded and imported, False otherwise.
|
||||
"""
|
||||
episode_files = self.tv_repository.get_episode_files_by_episode_id(
|
||||
episode_id=episode.id
|
||||
)
|
||||
for season_file in season_files:
|
||||
if self.season_file_exists_on_file(season_file=season_file):
|
||||
return True
|
||||
|
||||
if not episode_files:
|
||||
return False
|
||||
|
||||
season_dir = self.get_root_season_directory(show, season.number)
|
||||
|
||||
if not season_dir.exists():
|
||||
return False
|
||||
|
||||
episode_token = f"S{season.number:02d}E{episode.number:02d}"
|
||||
|
||||
video_extensions = {".mkv", ".mp4", ".avi", ".mov"}
|
||||
|
||||
try:
|
||||
for file in season_dir.iterdir():
|
||||
if (
|
||||
file.is_file()
|
||||
and episode_token.lower() in file.name.lower()
|
||||
and file.suffix.lower() in video_extensions
|
||||
):
|
||||
return True
|
||||
|
||||
except OSError as e:
|
||||
log.error(
|
||||
f"Disk check failed for episode {episode.id} in {season_dir}: {e}"
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def season_file_exists_on_file(self, season_file: SeasonFile) -> bool:
|
||||
def episode_file_exists_on_file(self, episode_file: EpisodeFile) -> bool:
|
||||
"""
|
||||
Check if a season file exists on the filesystem.
|
||||
Check if an episode file exists on the filesystem.
|
||||
|
||||
:param season_file: The season file to check.
|
||||
:param episode_file: The episode file to check.
|
||||
:return: True if the file exists, False otherwise.
|
||||
"""
|
||||
if season_file.torrent_id is None:
|
||||
if episode_file.torrent_id is None:
|
||||
return True
|
||||
try:
|
||||
torrent_file = self.torrent_service.get_torrent_by_id(
|
||||
torrent_id=season_file.torrent_id
|
||||
torrent_id=episode_file.torrent_id
|
||||
)
|
||||
|
||||
if torrent_file.imported:
|
||||
@@ -409,13 +431,23 @@ class TvService:
|
||||
"""
|
||||
return self.tv_repository.get_season(season_id=season_id)
|
||||
|
||||
def get_all_season_requests(self) -> list[RichSeasonRequest]:
|
||||
def get_episode(self, episode_id: EpisodeId) -> Episode:
|
||||
"""
|
||||
Get all season requests.
|
||||
Get an episode by its ID.
|
||||
|
||||
:return: A list of rich season requests.
|
||||
:param episode_id: The ID of the episode.
|
||||
:return: The episode.
|
||||
"""
|
||||
return self.tv_repository.get_season_requests()
|
||||
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_torrents_for_show(self, show: Show) -> RichShowTorrent:
|
||||
"""
|
||||
@@ -430,10 +462,16 @@ class TvService:
|
||||
seasons = self.tv_repository.get_seasons_by_torrent_id(
|
||||
torrent_id=show_torrent.id
|
||||
)
|
||||
season_files = self.torrent_service.get_season_files_of_torrent(
|
||||
episodes = self.tv_repository.get_episodes_by_torrent_id(
|
||||
torrent_id=show_torrent.id
|
||||
)
|
||||
episode_files = self.torrent_service.get_episode_files_of_torrent(
|
||||
torrent=show_torrent
|
||||
)
|
||||
file_path_suffix = season_files[0].file_path_suffix if season_files else ""
|
||||
|
||||
file_path_suffix = (
|
||||
episode_files[0].file_path_suffix if episode_files else ""
|
||||
)
|
||||
season_torrent = RichSeasonTorrent(
|
||||
torrent_id=show_torrent.id,
|
||||
torrent_title=show_torrent.title,
|
||||
@@ -441,10 +479,12 @@ class TvService:
|
||||
quality=show_torrent.quality,
|
||||
imported=show_torrent.imported,
|
||||
seasons=seasons,
|
||||
episodes=episodes if len(seasons) == 1 else [],
|
||||
file_path_suffix=file_path_suffix,
|
||||
usenet=show_torrent.usenet,
|
||||
)
|
||||
rich_season_torrents.append(season_torrent)
|
||||
|
||||
return RichShowTorrent(
|
||||
show_id=show.id,
|
||||
name=show.name,
|
||||
@@ -487,95 +527,54 @@ class TvService:
|
||||
season = self.tv_repository.get_season_by_number(
|
||||
season_number=season_number, show_id=show_id
|
||||
)
|
||||
season_file = SeasonFile(
|
||||
season_id=season.id,
|
||||
quality=indexer_result.quality,
|
||||
torrent_id=show_torrent.id,
|
||||
file_path_suffix=override_show_file_path_suffix,
|
||||
)
|
||||
self.tv_repository.add_season_file(season_file=season_file)
|
||||
episodes = {episode.number: episode.id for episode in season.episodes}
|
||||
|
||||
if indexer_result.episode:
|
||||
episode_ids = []
|
||||
missing_episodes = []
|
||||
for ep_number in indexer_result.episode:
|
||||
ep_id = episodes.get(EpisodeNumber(ep_number))
|
||||
if ep_id is None:
|
||||
missing_episodes.append(ep_number)
|
||||
continue
|
||||
episode_ids.append(ep_id)
|
||||
if missing_episodes:
|
||||
log.warning(
|
||||
"Some episodes from indexer result were not found in season %s "
|
||||
"for show %s and will be skipped: %s",
|
||||
season.id,
|
||||
show_id,
|
||||
", ".join(str(ep) for ep in missing_episodes),
|
||||
)
|
||||
else:
|
||||
episode_ids = [episode.id for episode in season.episodes]
|
||||
|
||||
for episode_id in episode_ids:
|
||||
episode_file = EpisodeFile(
|
||||
episode_id=episode_id,
|
||||
quality=indexer_result.quality,
|
||||
torrent_id=show_torrent.id,
|
||||
file_path_suffix=override_show_file_path_suffix,
|
||||
)
|
||||
self.tv_repository.add_episode_file(episode_file=episode_file)
|
||||
|
||||
except IntegrityError:
|
||||
log.error(
|
||||
f"Season file for season {season.id} and quality {indexer_result.quality} already exists, skipping."
|
||||
f"Episode file for episode {episode_id} of season {season.id} and quality {indexer_result.quality} already exists, skipping."
|
||||
)
|
||||
self.tv_repository.remove_episode_files_by_torrent_id(show_torrent.id)
|
||||
self.torrent_service.cancel_download(
|
||||
torrent=show_torrent, delete_files=True
|
||||
)
|
||||
raise
|
||||
else:
|
||||
log.info(
|
||||
f"Successfully added season files for torrent {show_torrent.title} and show ID {show_id}"
|
||||
f"Successfully added episode files for torrent {show_torrent.title} and show ID {show_id}"
|
||||
)
|
||||
self.torrent_service.resume_download(torrent=show_torrent)
|
||||
|
||||
return show_torrent
|
||||
|
||||
def download_approved_season_request(
|
||||
self, season_request: SeasonRequest, show: Show
|
||||
) -> bool:
|
||||
"""
|
||||
Download an approved season request.
|
||||
|
||||
:param season_request: The season request to download.
|
||||
:param show: The Show object.
|
||||
:return: True if the download was successful, False otherwise.
|
||||
:raises ValueError: If the season request is not authorized.
|
||||
"""
|
||||
if not season_request.authorized:
|
||||
msg = f"Season request {season_request.id} is not authorized for download"
|
||||
raise ValueError(msg)
|
||||
|
||||
log.info(f"Downloading approved season request {season_request.id}")
|
||||
|
||||
season = self.get_season(season_id=season_request.season_id)
|
||||
torrents = self.get_all_available_torrents_for_a_season(
|
||||
season_number=season.number, show_id=show.id
|
||||
)
|
||||
available_torrents: list[IndexerQueryResult] = []
|
||||
|
||||
for torrent in torrents:
|
||||
if (
|
||||
(torrent.quality.value < season_request.wanted_quality.value)
|
||||
or (torrent.quality.value > season_request.min_quality.value)
|
||||
or (torrent.seeders < 3)
|
||||
):
|
||||
log.info(
|
||||
f"Skipping torrent {torrent.title} with quality {torrent.quality} for season {season.id}, because it does not match the requested quality {season_request.wanted_quality}"
|
||||
)
|
||||
elif torrent.season != [season.number]:
|
||||
log.info(
|
||||
f"Skipping torrent {torrent.title} with quality {torrent.quality} for season {season.id}, because it contains to many/wrong seasons {torrent.season} (wanted: {season.number})"
|
||||
)
|
||||
else:
|
||||
available_torrents.append(torrent)
|
||||
log.info(
|
||||
f"Taking torrent {torrent.title} with quality {torrent.quality} for season {season.id} into consideration"
|
||||
)
|
||||
|
||||
if len(available_torrents) == 0:
|
||||
log.warning(
|
||||
f"No torrents matching criteria were found (wanted quality: {season_request.wanted_quality}, min_quality: {season_request.min_quality} for season {season.id})"
|
||||
)
|
||||
return False
|
||||
|
||||
available_torrents.sort()
|
||||
|
||||
torrent = self.torrent_service.download(indexer_result=available_torrents[0])
|
||||
season_file = SeasonFile(
|
||||
season_id=season.id,
|
||||
quality=torrent.quality,
|
||||
torrent_id=torrent.id,
|
||||
file_path_suffix=QualityStrings[torrent.quality.name].value.upper(),
|
||||
)
|
||||
try:
|
||||
self.tv_repository.add_season_file(season_file=season_file)
|
||||
except IntegrityError:
|
||||
log.warning(
|
||||
f"Season file for season {season.id} and quality {torrent.quality} already exists, skipping."
|
||||
)
|
||||
self.delete_season_request(season_request.id)
|
||||
return True
|
||||
|
||||
def get_root_show_directory(self, show: Show) -> Path:
|
||||
misc_config = MediaManagerConfig().misc
|
||||
show_directory_name = f"{remove_special_characters(show.name)} ({show.year}) [{show.metadata_provider}id-{show.external_id}]"
|
||||
@@ -653,12 +652,12 @@ class TvService:
|
||||
video_files: list[Path],
|
||||
subtitle_files: list[Path],
|
||||
file_path_suffix: str = "",
|
||||
) -> tuple[bool, int]:
|
||||
) -> tuple[bool, list[Episode]]:
|
||||
season_path = self.get_root_season_directory(
|
||||
show=show, season_number=season.number
|
||||
)
|
||||
success = True
|
||||
imported_episodes_count = 0
|
||||
imported_episodes = []
|
||||
try:
|
||||
season_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
@@ -677,7 +676,7 @@ class TvService:
|
||||
file_path_suffix=file_path_suffix,
|
||||
)
|
||||
if imported:
|
||||
imported_episodes_count += 1
|
||||
imported_episodes.append(episode)
|
||||
|
||||
except Exception:
|
||||
# Send notification about missing episode file
|
||||
@@ -690,11 +689,72 @@ class TvService:
|
||||
log.warning(
|
||||
f"S{season.number}E{episode.number} not found when trying to import episode for show {show.name}."
|
||||
)
|
||||
return success, imported_episodes_count
|
||||
return success, imported_episodes
|
||||
|
||||
def import_torrent_files(self, torrent: Torrent, show: Show) -> None:
|
||||
def import_episode_files(
|
||||
self,
|
||||
show: Show,
|
||||
season: Season,
|
||||
episode: Episode,
|
||||
video_files: list[Path],
|
||||
subtitle_files: list[Path],
|
||||
file_path_suffix: str = "",
|
||||
) -> bool:
|
||||
episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode.number:02d}"
|
||||
if file_path_suffix != "":
|
||||
episode_file_name += f" - {file_path_suffix}"
|
||||
pattern = (
|
||||
r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode.number) + r"[. ].*"
|
||||
)
|
||||
subtitle_pattern = pattern + r"[. ]([A-Za-z]{2})[. ]srt"
|
||||
target_file_name = (
|
||||
self.get_root_season_directory(show=show, season_number=season.number)
|
||||
/ episode_file_name
|
||||
)
|
||||
|
||||
# import subtitle
|
||||
for subtitle_file in subtitle_files:
|
||||
regex_result = re.search(
|
||||
subtitle_pattern, subtitle_file.name, re.IGNORECASE
|
||||
)
|
||||
if regex_result:
|
||||
language_code = regex_result.group(1)
|
||||
target_subtitle_file = target_file_name.with_suffix(
|
||||
f".{language_code}.srt"
|
||||
)
|
||||
import_file(target_file=target_subtitle_file, source_file=subtitle_file)
|
||||
else:
|
||||
log.debug(
|
||||
f"Didn't find any pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}"
|
||||
)
|
||||
|
||||
found_video = False
|
||||
|
||||
# import episode videos
|
||||
for file in video_files:
|
||||
if re.search(pattern, file.name, re.IGNORECASE):
|
||||
target_video_file = target_file_name.with_suffix(file.suffix)
|
||||
import_file(target_file=target_video_file, source_file=file)
|
||||
found_video = True
|
||||
break
|
||||
|
||||
if not found_video:
|
||||
# Send notification about missing episode file
|
||||
if self.notification_service:
|
||||
self.notification_service.send_notification_to_all_providers(
|
||||
title="Missing Episode File",
|
||||
message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.",
|
||||
)
|
||||
log.warning(
|
||||
f"File for S{season.number}E{episode.number} not found when trying to import episode for show {show.name}."
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def import_episode_files_from_torrent(self, torrent: Torrent, show: Show) -> None:
|
||||
"""
|
||||
Organizes files from a torrent into the TV directory structure, mapping them to seasons and episodes.
|
||||
Organizes episodes files from a torrent into the TV directory structure, mapping them to seasons and episodes.
|
||||
:param torrent: The Torrent object
|
||||
:param show: The Show object
|
||||
"""
|
||||
@@ -707,33 +767,68 @@ class TvService:
|
||||
f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files)
|
||||
)
|
||||
|
||||
season_files = self.torrent_service.get_season_files_of_torrent(torrent=torrent)
|
||||
episode_files = self.torrent_service.get_episode_files_of_torrent(
|
||||
torrent=torrent
|
||||
)
|
||||
if not episode_files:
|
||||
log.warning(
|
||||
f"No episode files associated with torrent {torrent.title}, skipping import."
|
||||
)
|
||||
return
|
||||
|
||||
log.info(
|
||||
f"Found {len(season_files)} season files associated with torrent {torrent.title}"
|
||||
f"Found {len(episode_files)} episode files associated with torrent {torrent.title}"
|
||||
)
|
||||
|
||||
for season_file in season_files:
|
||||
season = self.get_season(season_id=season_file.season_id)
|
||||
season_import_success, _imported_episodes_count = self.import_season(
|
||||
imported_episodes_by_season: dict[int, list[int]] = {}
|
||||
|
||||
for episode_file in episode_files:
|
||||
season = self.get_season_by_episode(episode_id=episode_file.episode_id)
|
||||
episode = self.get_episode(episode_file.episode_id)
|
||||
|
||||
season_path = self.get_root_season_directory(
|
||||
show=show, season_number=season.number
|
||||
)
|
||||
if not season_path.exists():
|
||||
try:
|
||||
season_path.mkdir(parents=True)
|
||||
except Exception as e:
|
||||
log.warning(f"Could not create path {season_path}: {e}")
|
||||
msg = f"Could not create path {season_path}"
|
||||
raise Exception(msg) from e # noqa: TRY002
|
||||
|
||||
episoded_import_success = self.import_episode_files(
|
||||
show=show,
|
||||
season=season,
|
||||
episode=episode,
|
||||
video_files=video_files,
|
||||
subtitle_files=subtitle_files,
|
||||
file_path_suffix=season_file.file_path_suffix,
|
||||
file_path_suffix=episode_file.file_path_suffix,
|
||||
)
|
||||
success.append(season_import_success)
|
||||
if season_import_success:
|
||||
success.append(episoded_import_success)
|
||||
|
||||
if episoded_import_success:
|
||||
imported_episodes_by_season.setdefault(season.number, []).append(
|
||||
episode.number
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Season {season.number} successfully imported from torrent {torrent.title}"
|
||||
f"Episode {episode.number} from Season {season.number} successfully imported from torrent {torrent.title}"
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
f"Season {season.number} failed to import from torrent {torrent.title}"
|
||||
f"Episode {episode.number} from Season {season.number} failed to import from torrent {torrent.title}"
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors"
|
||||
)
|
||||
success_messages: list[str] = []
|
||||
|
||||
for season_number, episodes in imported_episodes_by_season.items():
|
||||
episode_list = ",".join(str(e) for e in sorted(episodes))
|
||||
success_messages.append(
|
||||
f"Episode(s): {episode_list} from Season {season_number}"
|
||||
)
|
||||
|
||||
episodes_summary = "; ".join(success_messages)
|
||||
|
||||
if all(success):
|
||||
torrent.imported = True
|
||||
@@ -743,7 +838,11 @@ class TvService:
|
||||
if self.notification_service:
|
||||
self.notification_service.send_notification_to_all_providers(
|
||||
title="TV Show imported successfully",
|
||||
message=f"Successfully imported {show.name} ({show.year}) from torrent {torrent.title}.",
|
||||
message=(
|
||||
f"Successfully imported {episodes_summary} "
|
||||
f"of {show.name} ({show.year}) "
|
||||
f"from torrent {torrent.title}."
|
||||
),
|
||||
)
|
||||
else:
|
||||
if self.notification_service:
|
||||
@@ -752,6 +851,10 @@ class TvService:
|
||||
message=f"Importing {show.name} ({show.year}) from torrent {torrent.title} completed with errors. Please check the logs for details.",
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors"
|
||||
)
|
||||
|
||||
def update_show_metadata(
|
||||
self, db_show: Show, metadata_provider: AbstractMetadataProvider
|
||||
) -> Show | None:
|
||||
@@ -798,9 +901,7 @@ class TvService:
|
||||
existing_season = existing_season_external_ids[
|
||||
fresh_season_data.external_id
|
||||
]
|
||||
log.debug(
|
||||
f"Updating existing season {existing_season.number} for show {db_show.name}"
|
||||
)
|
||||
|
||||
self.tv_repository.update_season_attributes(
|
||||
season_id=existing_season.id,
|
||||
name=fresh_season_data.name,
|
||||
@@ -812,28 +913,28 @@ class TvService:
|
||||
ep.external_id: ep for ep in existing_season.episodes
|
||||
}
|
||||
for fresh_episode_data in fresh_season_data.episodes:
|
||||
if fresh_episode_data.number in existing_episode_external_ids:
|
||||
if fresh_episode_data.external_id in existing_episode_external_ids:
|
||||
# Update existing episode
|
||||
existing_episode = existing_episode_external_ids[
|
||||
fresh_episode_data.external_id
|
||||
]
|
||||
log.debug(
|
||||
f"Updating existing episode {existing_episode.number} for season {existing_season.number}"
|
||||
)
|
||||
|
||||
self.tv_repository.update_episode_attributes(
|
||||
episode_id=existing_episode.id,
|
||||
title=fresh_episode_data.title,
|
||||
overview=fresh_episode_data.overview,
|
||||
)
|
||||
else:
|
||||
# Add new episode
|
||||
log.debug(
|
||||
f"Adding new episode {fresh_episode_data.number} to season {existing_season.number}"
|
||||
)
|
||||
episode_schema = EpisodeSchema(
|
||||
episode_schema = Episode(
|
||||
id=EpisodeId(fresh_episode_data.id),
|
||||
number=fresh_episode_data.number,
|
||||
external_id=fresh_episode_data.external_id,
|
||||
title=fresh_episode_data.title,
|
||||
overview=fresh_episode_data.overview,
|
||||
)
|
||||
self.tv_repository.add_episode_to_season(
|
||||
season_id=existing_season.id, episode_data=episode_schema
|
||||
@@ -844,11 +945,12 @@ class TvService:
|
||||
f"Adding new season {fresh_season_data.number} to show {db_show.name}"
|
||||
)
|
||||
episodes_for_schema = [
|
||||
EpisodeSchema(
|
||||
Episode(
|
||||
id=EpisodeId(ep_data.id),
|
||||
number=ep_data.number,
|
||||
external_id=ep_data.external_id,
|
||||
title=ep_data.title,
|
||||
overview=ep_data.overview,
|
||||
)
|
||||
for ep_data in fresh_season_data.episodes
|
||||
]
|
||||
@@ -867,7 +969,7 @@ class TvService:
|
||||
|
||||
updated_show = self.tv_repository.get_show_by_id(show_id=db_show.id)
|
||||
|
||||
log.info(f"Successfully updated metadata for show ID: {db_show.id}")
|
||||
log.info(f"Successfully updated metadata for show: {updated_show.name}")
|
||||
metadata_provider.download_show_poster_image(show=updated_show)
|
||||
return updated_show
|
||||
|
||||
@@ -911,21 +1013,22 @@ class TvService:
|
||||
directory=new_source_path
|
||||
)
|
||||
for season in tv_show.seasons:
|
||||
success, imported_episode_count = self.import_season(
|
||||
_success, imported_episodes = self.import_season(
|
||||
show=tv_show,
|
||||
season=season,
|
||||
video_files=video_files,
|
||||
subtitle_files=subtitle_files,
|
||||
file_path_suffix="IMPORTED",
|
||||
)
|
||||
season_file = SeasonFile(
|
||||
season_id=season.id,
|
||||
quality=Quality.unknown,
|
||||
file_path_suffix="IMPORTED",
|
||||
torrent_id=None,
|
||||
)
|
||||
if success or imported_episode_count > (len(season.episodes) / 2):
|
||||
self.tv_repository.add_season_file(season_file=season_file)
|
||||
for episode in imported_episodes:
|
||||
episode_file = EpisodeFile(
|
||||
episode_id=episode.id,
|
||||
quality=Quality.unknown,
|
||||
file_path_suffix="IMPORTED",
|
||||
torrent_id=None,
|
||||
)
|
||||
|
||||
self.tv_repository.add_episode_file(episode_file=episode_file)
|
||||
|
||||
def get_importable_tv_shows(
|
||||
self, metadata_provider: AbstractMetadataProvider
|
||||
@@ -959,104 +1062,34 @@ class TvService:
|
||||
log.debug(f"Detected {len(import_suggestions)} importable TV shows.")
|
||||
return import_suggestions
|
||||
|
||||
|
||||
def auto_download_all_approved_season_requests() -> None:
|
||||
"""
|
||||
Auto download all approved season requests.
|
||||
This is a standalone function as it creates its own DB session.
|
||||
"""
|
||||
with next(get_session()) as db:
|
||||
tv_repository = TvRepository(db=db)
|
||||
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
|
||||
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
|
||||
notification_service = NotificationService(
|
||||
notification_repository=NotificationRepository(db=db)
|
||||
)
|
||||
tv_service = TvService(
|
||||
tv_repository=tv_repository,
|
||||
torrent_service=torrent_service,
|
||||
indexer_service=indexer_service,
|
||||
notification_service=notification_service,
|
||||
)
|
||||
|
||||
log.info("Auto downloading all approved season requests")
|
||||
season_requests = tv_repository.get_season_requests()
|
||||
log.info(f"Found {len(season_requests)} season requests to process")
|
||||
count = 0
|
||||
|
||||
for season_request in season_requests:
|
||||
if season_request.authorized:
|
||||
log.info(f"Processing season request {season_request.id} for download")
|
||||
show = tv_repository.get_show_by_season_id(
|
||||
season_id=season_request.season_id
|
||||
)
|
||||
if tv_service.download_approved_season_request(
|
||||
season_request=season_request, show=show
|
||||
):
|
||||
count += 1
|
||||
else:
|
||||
log.warning(
|
||||
f"Failed to download season request {season_request.id} for show {show.name}"
|
||||
)
|
||||
|
||||
log.info(f"Auto downloaded {count} approved season requests")
|
||||
db.commit()
|
||||
|
||||
|
||||
def import_all_show_torrents() -> None:
|
||||
with next(get_session()) as db:
|
||||
tv_repository = TvRepository(db=db)
|
||||
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
|
||||
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
|
||||
notification_service = NotificationService(
|
||||
notification_repository=NotificationRepository(db=db)
|
||||
)
|
||||
tv_service = TvService(
|
||||
tv_repository=tv_repository,
|
||||
torrent_service=torrent_service,
|
||||
indexer_service=indexer_service,
|
||||
notification_service=notification_service,
|
||||
)
|
||||
def import_all_torrents(self) -> None:
|
||||
log.info("Importing all torrents")
|
||||
torrents = torrent_service.get_all_torrents()
|
||||
torrents = self.torrent_service.get_all_torrents()
|
||||
log.info("Found %d torrents to import", len(torrents))
|
||||
for t in torrents:
|
||||
show = None
|
||||
try:
|
||||
if not t.imported and t.status == TorrentStatus.finished:
|
||||
show = torrent_service.get_show_of_torrent(torrent=t)
|
||||
show = self.torrent_service.get_show_of_torrent(torrent=t)
|
||||
if show is None:
|
||||
log.warning(
|
||||
f"torrent {t.title} is not a tv torrent, skipping import."
|
||||
)
|
||||
continue
|
||||
tv_service.import_torrent_files(torrent=t, show=show)
|
||||
except RuntimeError:
|
||||
log.exception(f"Error importing torrent {t.title} for show {show.name}")
|
||||
self.import_episode_files_from_torrent(torrent=t, show=show)
|
||||
except RuntimeError as e:
|
||||
show_name = show.name if show is not None else "<unknown>"
|
||||
log.error(
|
||||
f"Error importing torrent {t.title} for show {show_name}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
log.info("Finished importing all torrents")
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_all_non_ended_shows_metadata() -> None:
|
||||
"""
|
||||
Updates the metadata of all non-ended shows.
|
||||
"""
|
||||
with next(get_session()) as db:
|
||||
tv_repository = TvRepository(db=db)
|
||||
tv_service = TvService(
|
||||
tv_repository=tv_repository,
|
||||
torrent_service=TorrentService(torrent_repository=TorrentRepository(db=db)),
|
||||
indexer_service=IndexerService(indexer_repository=IndexerRepository(db=db)),
|
||||
notification_service=NotificationService(
|
||||
notification_repository=NotificationRepository(db=db)
|
||||
),
|
||||
)
|
||||
|
||||
def update_all_non_ended_shows_metadata(self) -> None:
|
||||
"""Updates the metadata of all non-ended shows."""
|
||||
log.info("Updating metadata for all non-ended shows")
|
||||
|
||||
shows = [show for show in tv_repository.get_shows() if not show.ended]
|
||||
|
||||
shows = [show for show in self.tv_repository.get_shows() if not show.ended]
|
||||
log.info(f"Found {len(shows)} non-ended shows to update")
|
||||
|
||||
for show in shows:
|
||||
try:
|
||||
if show.metadata_provider == "tmdb":
|
||||
@@ -1073,34 +1106,10 @@ def update_all_non_ended_shows_metadata() -> None:
|
||||
f"Error initializing metadata provider {show.metadata_provider} for show {show.name}"
|
||||
)
|
||||
continue
|
||||
updated_show = tv_service.update_show_metadata(
|
||||
updated_show = self.update_show_metadata(
|
||||
db_show=show, metadata_provider=metadata_provider
|
||||
)
|
||||
|
||||
# Automatically add season requests for new seasons
|
||||
existing_seasons = [x.id for x in show.seasons]
|
||||
new_seasons = [
|
||||
x for x in updated_show.seasons if x.id not in existing_seasons
|
||||
]
|
||||
|
||||
if show.continuous_download:
|
||||
for new_season in new_seasons:
|
||||
log.info(
|
||||
f"Automatically adding season request for new season {new_season.number} of show {updated_show.name}"
|
||||
)
|
||||
tv_service.add_season_request(
|
||||
SeasonRequest(
|
||||
min_quality=Quality.sd,
|
||||
wanted_quality=Quality.uhd,
|
||||
season_id=new_season.id,
|
||||
authorized=True,
|
||||
)
|
||||
)
|
||||
|
||||
if updated_show:
|
||||
log.debug(
|
||||
f"Added new seasons: {len(new_seasons)} to show: {updated_show.name}"
|
||||
)
|
||||
log.debug("Updated show metadata", extra={"show": updated_show.name})
|
||||
else:
|
||||
log.warning(f"Failed to update metadata for show: {show.name}")
|
||||
db.commit()
|
||||
|
||||
18
metadata_relay/uv.lock
generated
@@ -547,11 +547,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.21"
|
||||
version = "0.0.22"
|
||||
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 = [
|
||||
{ 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]]
|
||||
@@ -791,24 +791,24 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.2"
|
||||
version = "2.6.3"
|
||||
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 = [
|
||||
{ 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]]
|
||||
name = "uvicorn"
|
||||
version = "0.40.0"
|
||||
version = "0.41.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ 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 = [
|
||||
{ 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]
|
||||
|
||||
@@ -2,7 +2,7 @@ site_name: "MediaManager Documentation"
|
||||
theme:
|
||||
name: "material"
|
||||
logo: "assets/logo.svg"
|
||||
favicon: "assets/logo.svg"
|
||||
favicon: "assets/favicon.ico"
|
||||
features:
|
||||
- navigation.sections
|
||||
- navigation.expand
|
||||
@@ -68,3 +68,5 @@ nav:
|
||||
extra:
|
||||
version:
|
||||
provider: mike
|
||||
extra_css:
|
||||
- custom.css
|
||||
@@ -13,7 +13,7 @@ dependencies = [
|
||||
"httpx-oauth>=0.16.1",
|
||||
"jsonschema>=4.24.0",
|
||||
"patool>=4.0.1",
|
||||
"psycopg[binary]>=3.2.9",
|
||||
"psycopg[binary,pool]>=3.2.9",
|
||||
"pydantic>=2.11.5",
|
||||
"pydantic-settings[toml]>=2.9.1",
|
||||
"python-json-logger>=3.3.0",
|
||||
@@ -26,7 +26,9 @@ dependencies = [
|
||||
"typing-inspect>=0.9.0",
|
||||
"uvicorn>=0.34.2",
|
||||
"fastapi-utils>=0.8.0",
|
||||
"apscheduler>=3.11.0",
|
||||
"taskiq>=0.12.0",
|
||||
"taskiq-fastapi>=0.4.0",
|
||||
"taskiq-postgresql[psycopg]>=0.4.0",
|
||||
"alembic>=1.16.1",
|
||||
"pytest>=8.4.0",
|
||||
"pillow>=11.3.0",
|
||||
|
||||
@@ -41,4 +41,4 @@ ignore = [
|
||||
]
|
||||
|
||||
[lint.flake8-bugbear]
|
||||
extend-immutable-calls = ["fastapi.Depends", "fastapi.Path"]
|
||||
extend-immutable-calls = ["fastapi.Depends", "fastapi.Path", "taskiq.TaskiqDepends"]
|
||||
|
||||
1903
web/package-lock.json
generated
@@ -15,24 +15,24 @@
|
||||
"openapi": "npx openapi-typescript http://localhost:8000/openapi.json -o src/lib/api/api.d.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@exodus/schemasafe": "^1.3.0",
|
||||
"@fontsource/fira-mono": "^5.0.0",
|
||||
"@lucide/svelte": "^0.482.0",
|
||||
"@neoconfetti/svelte": "^2.0.0",
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"@sveltejs/adapter-auto": "^6.0.2",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/enhanced-img": "^0.6.1",
|
||||
"@sveltejs/kit": "^2.27.3",
|
||||
"@sveltejs/kit": "^2.51.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.1.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/table-core": "^8.21.3",
|
||||
"@typeschema/class-validator": "^0.2.0",
|
||||
"@typeschema/class-validator": "^0.3.0",
|
||||
"@vinejs/vine": "^1.8.0",
|
||||
"arktype": "^2.1.20",
|
||||
"autoprefixer": "^10.4.20",
|
||||
@@ -50,28 +50,28 @@
|
||||
"openapi-typescript": "^7.9.1",
|
||||
"paneforge": "^1.0.0-next.6",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"superstruct": "^2.0.2",
|
||||
"svelte": "^5.38.0",
|
||||
"svelte": "^5.53.0",
|
||||
"svelte-check": "^4.3.1",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
"sveltekit-superforms": "^2.27.1",
|
||||
"sveltekit-superforms": "^2.29.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-variants": "^0.2.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"valibot": "^0.42.1",
|
||||
"vaul-svelte": "^1.0.0-next.7",
|
||||
"vite": "^7.1.1",
|
||||
"vite": "^7.3.1",
|
||||
"yup": "^1.7.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"dependencies": {
|
||||
"animejs": "^4.2.2",
|
||||
"lucide-svelte": "^0.544.0",
|
||||
"lucide-svelte": "^0.574.0",
|
||||
"openapi-fetch": "^0.14.0",
|
||||
"uuid": "^11.1.0"
|
||||
}
|
||||
|
||||
593
web/src/lib/api/api.d.ts
vendored
@@ -530,74 +530,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/tv/seasons/requests': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Season Requests
|
||||
* @description Get all season requests.
|
||||
*/
|
||||
get: operations['get_season_requests_api_v1_tv_seasons_requests_get'];
|
||||
/**
|
||||
* Update Request
|
||||
* @description Update an existing season request.
|
||||
*/
|
||||
put: operations['update_request_api_v1_tv_seasons_requests_put'];
|
||||
/**
|
||||
* Request A Season
|
||||
* @description Create a new season request.
|
||||
*/
|
||||
post: operations['request_a_season_api_v1_tv_seasons_requests_post'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/tv/seasons/requests/{season_request_id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Authorize Request
|
||||
* @description Authorize or de-authorize a season request.
|
||||
*/
|
||||
patch: operations['authorize_request_api_v1_tv_seasons_requests__season_request_id__patch'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/tv/seasons/requests/{request_id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Delete Season Request
|
||||
* @description Delete a season request.
|
||||
*/
|
||||
delete: operations['delete_season_request_api_v1_tv_seasons_requests__request_id__delete'];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/tv/seasons/{season_id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -626,10 +558,10 @@ export interface paths {
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Season Files
|
||||
* @description Get files associated with a specific season.
|
||||
* Get Episode Files
|
||||
* @description Get episode files associated with a specific season.
|
||||
*/
|
||||
get: operations['get_season_files_api_v1_tv_seasons__season_id__files_get'];
|
||||
get: operations['get_episode_files_api_v1_tv_seasons__season_id__files_get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -896,58 +828,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/movies/requests': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get All Movie Requests
|
||||
* @description Get all movie requests.
|
||||
*/
|
||||
get: operations['get_all_movie_requests_api_v1_movies_requests_get'];
|
||||
put?: never;
|
||||
/**
|
||||
* Create Movie Request
|
||||
* @description Create a new movie request.
|
||||
*/
|
||||
post: operations['create_movie_request_api_v1_movies_requests_post'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/movies/requests/{movie_request_id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Update Movie Request
|
||||
* @description Update an existing movie request.
|
||||
*/
|
||||
put: operations['update_movie_request_api_v1_movies_requests__movie_request_id__put'];
|
||||
post?: never;
|
||||
/**
|
||||
* Delete Movie Request
|
||||
* @description Delete a movie request.
|
||||
*/
|
||||
delete: operations['delete_movie_request_api_v1_movies_requests__movie_request_id__delete'];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Authorize Request
|
||||
* @description Authorize or de-authorize a movie request.
|
||||
*/
|
||||
patch: operations['authorize_request_api_v1_movies_requests__movie_request_id__patch'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/movies/{movie_id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1283,26 +1163,6 @@ export interface components {
|
||||
/** Token */
|
||||
token: string;
|
||||
};
|
||||
/** CreateMovieRequest */
|
||||
CreateMovieRequest: {
|
||||
min_quality: components['schemas']['Quality'];
|
||||
wanted_quality: components['schemas']['Quality'];
|
||||
/**
|
||||
* Movie Id
|
||||
* Format: uuid
|
||||
*/
|
||||
movie_id: string;
|
||||
};
|
||||
/** CreateSeasonRequest */
|
||||
CreateSeasonRequest: {
|
||||
min_quality: components['schemas']['Quality'];
|
||||
wanted_quality: components['schemas']['Quality'];
|
||||
/**
|
||||
* Season Id
|
||||
* Format: uuid
|
||||
*/
|
||||
season_id: string;
|
||||
};
|
||||
/** Episode */
|
||||
Episode: {
|
||||
/**
|
||||
@@ -1316,6 +1176,8 @@ export interface components {
|
||||
external_id: number;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Overview */
|
||||
overview?: string | null;
|
||||
};
|
||||
/** ErrorModel */
|
||||
ErrorModel: {
|
||||
@@ -1360,6 +1222,8 @@ export interface components {
|
||||
readonly quality: components['schemas']['Quality'];
|
||||
/** Season */
|
||||
readonly season: number[];
|
||||
/** Episode */
|
||||
readonly episode: number[];
|
||||
};
|
||||
/** LibraryItem */
|
||||
LibraryItem: {
|
||||
@@ -1428,33 +1292,6 @@ export interface components {
|
||||
/** Imdb Id */
|
||||
imdb_id?: string | null;
|
||||
};
|
||||
/** MovieRequest */
|
||||
MovieRequest: {
|
||||
min_quality: components['schemas']['Quality'];
|
||||
wanted_quality: components['schemas']['Quality'];
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Movie Id
|
||||
* Format: uuid
|
||||
*/
|
||||
movie_id: string;
|
||||
requested_by?: components['schemas']['UserRead'] | null;
|
||||
/**
|
||||
* Authorized
|
||||
* @default false
|
||||
*/
|
||||
authorized: boolean;
|
||||
authorized_by?: components['schemas']['UserRead'] | null;
|
||||
};
|
||||
/** MovieRequestBase */
|
||||
MovieRequestBase: {
|
||||
min_quality: components['schemas']['Quality'];
|
||||
wanted_quality: components['schemas']['Quality'];
|
||||
};
|
||||
/** MovieTorrent */
|
||||
MovieTorrent: {
|
||||
/**
|
||||
@@ -1504,6 +1341,45 @@ export interface components {
|
||||
/** Authorization Url */
|
||||
authorization_url: string;
|
||||
};
|
||||
/** PublicEpisode */
|
||||
PublicEpisode: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Number */
|
||||
number: number;
|
||||
/**
|
||||
* Downloaded
|
||||
* @default false
|
||||
*/
|
||||
downloaded: boolean;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Overview */
|
||||
overview?: string | null;
|
||||
/** External Id */
|
||||
external_id: number;
|
||||
};
|
||||
/** PublicEpisodeFile */
|
||||
PublicEpisodeFile: {
|
||||
/**
|
||||
* Episode Id
|
||||
* Format: uuid
|
||||
*/
|
||||
episode_id: string;
|
||||
quality: components['schemas']['Quality'];
|
||||
/** Torrent Id */
|
||||
torrent_id: string | null;
|
||||
/** File Path Suffix */
|
||||
file_path_suffix: string;
|
||||
/**
|
||||
* Downloaded
|
||||
* @default false
|
||||
*/
|
||||
downloaded: boolean;
|
||||
};
|
||||
/** PublicMovie */
|
||||
PublicMovie: {
|
||||
/**
|
||||
@@ -1580,25 +1456,7 @@ export interface components {
|
||||
/** External Id */
|
||||
external_id: number;
|
||||
/** Episodes */
|
||||
episodes: components['schemas']['Episode'][];
|
||||
};
|
||||
/** PublicSeasonFile */
|
||||
PublicSeasonFile: {
|
||||
/**
|
||||
* Season Id
|
||||
* Format: uuid
|
||||
*/
|
||||
season_id: string;
|
||||
quality: components['schemas']['Quality'];
|
||||
/** Torrent Id */
|
||||
torrent_id: string | null;
|
||||
/** File Path Suffix */
|
||||
file_path_suffix: string;
|
||||
/**
|
||||
* Downloaded
|
||||
* @default false
|
||||
*/
|
||||
downloaded: boolean;
|
||||
episodes: components['schemas']['PublicEpisode'][];
|
||||
};
|
||||
/** PublicShow */
|
||||
PublicShow: {
|
||||
@@ -1637,29 +1495,6 @@ export interface components {
|
||||
* @enum {integer}
|
||||
*/
|
||||
Quality: 1 | 2 | 3 | 4 | 5;
|
||||
/** RichMovieRequest */
|
||||
RichMovieRequest: {
|
||||
min_quality: components['schemas']['Quality'];
|
||||
wanted_quality: components['schemas']['Quality'];
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Movie Id
|
||||
* Format: uuid
|
||||
*/
|
||||
movie_id: string;
|
||||
requested_by?: components['schemas']['UserRead'] | null;
|
||||
/**
|
||||
* Authorized
|
||||
* @default false
|
||||
*/
|
||||
authorized: boolean;
|
||||
authorized_by?: components['schemas']['UserRead'] | null;
|
||||
movie: components['schemas']['Movie'];
|
||||
};
|
||||
/** RichMovieTorrent */
|
||||
RichMovieTorrent: {
|
||||
/**
|
||||
@@ -1676,30 +1511,6 @@ export interface components {
|
||||
/** Torrents */
|
||||
torrents: components['schemas']['MovieTorrent'][];
|
||||
};
|
||||
/** RichSeasonRequest */
|
||||
RichSeasonRequest: {
|
||||
min_quality: components['schemas']['Quality'];
|
||||
wanted_quality: components['schemas']['Quality'];
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Season Id
|
||||
* Format: uuid
|
||||
*/
|
||||
season_id: string;
|
||||
requested_by?: components['schemas']['UserRead'] | null;
|
||||
/**
|
||||
* Authorized
|
||||
* @default false
|
||||
*/
|
||||
authorized: boolean;
|
||||
authorized_by?: components['schemas']['UserRead'] | null;
|
||||
show: components['schemas']['Show'];
|
||||
season: components['schemas']['Season'];
|
||||
};
|
||||
/** RichSeasonTorrent */
|
||||
RichSeasonTorrent: {
|
||||
/**
|
||||
@@ -1719,6 +1530,8 @@ export interface components {
|
||||
file_path_suffix: string;
|
||||
/** Seasons */
|
||||
seasons: number[];
|
||||
/** Episodes */
|
||||
episodes: number[];
|
||||
};
|
||||
/** RichShowTorrent */
|
||||
RichShowTorrent: {
|
||||
@@ -1819,16 +1632,6 @@ export interface components {
|
||||
* @enum {integer}
|
||||
*/
|
||||
TorrentStatus: 1 | 2 | 3 | 4;
|
||||
/** UpdateSeasonRequest */
|
||||
UpdateSeasonRequest: {
|
||||
min_quality: components['schemas']['Quality'];
|
||||
wanted_quality: components['schemas']['Quality'];
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
};
|
||||
/** UserCreate */
|
||||
UserCreate: {
|
||||
/**
|
||||
@@ -1903,6 +1706,10 @@ export interface components {
|
||||
msg: string;
|
||||
/** Error Type */
|
||||
type: string;
|
||||
/** Input */
|
||||
input?: unknown;
|
||||
/** Context */
|
||||
ctx?: Record<string, never>;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
@@ -3058,148 +2865,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_season_requests_api_v1_tv_seasons_requests_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['RichSeasonRequest'][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_request_api_v1_tv_seasons_requests_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['UpdateSeasonRequest'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
request_a_season_api_v1_tv_seasons_requests_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['CreateSeasonRequest'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
authorize_request_api_v1_tv_seasons_requests__season_request_id__patch: {
|
||||
parameters: {
|
||||
query?: {
|
||||
authorized_status?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
season_request_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_season_request_api_v1_tv_seasons_requests__request_id__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
request_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_season_api_v1_tv_seasons__season_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3232,7 +2897,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_season_files_api_v1_tv_seasons__season_id__files_get: {
|
||||
get_episode_files_api_v1_tv_seasons__season_id__files_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@@ -3250,7 +2915,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['PublicSeasonFile'][];
|
||||
'application/json': components['schemas']['PublicEpisodeFile'][];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
@@ -3714,154 +3379,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_all_movie_requests_api_v1_movies_requests_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['RichMovieRequest'][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_movie_request_api_v1_movies_requests_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['CreateMovieRequest'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['MovieRequest'];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_movie_request_api_v1_movies_requests__movie_request_id__put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
movie_request_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['MovieRequestBase'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['MovieRequest'];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_movie_request_api_v1_movies_requests__movie_request_id__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
movie_request_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
authorize_request_api_v1_movies_requests__movie_request_id__patch: {
|
||||
parameters: {
|
||||
query?: {
|
||||
authorized_status?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
movie_request_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_movie_by_id_api_v1_movies__movie_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -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>
|
||||
@@ -34,10 +34,6 @@
|
||||
{
|
||||
title: 'Torrents',
|
||||
url: resolve('/dashboard/tv/torrents', {})
|
||||
},
|
||||
{
|
||||
title: 'Requests',
|
||||
url: resolve('/dashboard/tv/requests', {})
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -54,10 +50,6 @@
|
||||
{
|
||||
title: 'Torrents',
|
||||
url: resolve('/dashboard/movies/torrents', {})
|
||||
},
|
||||
{
|
||||
title: 'Requests',
|
||||
url: resolve('/dashboard/movies/requests', {})
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
import { getFullyQualifiedMediaName, getTorrentQualityString } from '$lib/utils.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import client from '$lib/api';
|
||||
import type { components } from '$lib/api/api';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
let { movie }: { movie: components['schemas']['PublicMovie'] } = $props();
|
||||
let dialogOpen = $state(false);
|
||||
let minQuality = $state<string | undefined>(undefined);
|
||||
let wantedQuality = $state<string | undefined>(undefined);
|
||||
let isSubmittingRequest = $state(false);
|
||||
let submitRequestError = $state<string | null>(null);
|
||||
|
||||
const qualityValues: components['schemas']['Quality'][] = [1, 2, 3, 4];
|
||||
let qualityOptions = $derived(
|
||||
qualityValues.map((q) => ({ value: q.toString(), label: getTorrentQualityString(q) }))
|
||||
);
|
||||
let isFormInvalid = $derived(
|
||||
!minQuality || !wantedQuality || parseInt(wantedQuality) > parseInt(minQuality)
|
||||
);
|
||||
|
||||
async function handleRequestMovie() {
|
||||
isSubmittingRequest = true;
|
||||
submitRequestError = null;
|
||||
const { response } = await client.POST('/api/v1/movies/requests', {
|
||||
body: {
|
||||
movie_id: movie.id!,
|
||||
min_quality: parseInt(minQuality!) as components['schemas']['Quality'],
|
||||
wanted_quality: parseInt(wantedQuality!) as components['schemas']['Quality']
|
||||
}
|
||||
});
|
||||
isSubmittingRequest = false;
|
||||
|
||||
if (response.ok) {
|
||||
dialogOpen = false;
|
||||
minQuality = undefined;
|
||||
wantedQuality = undefined;
|
||||
toast.success('Movie request submitted successfully!');
|
||||
} else {
|
||||
toast.error('Failed to submit request');
|
||||
}
|
||||
await invalidateAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Trigger
|
||||
class={buttonVariants({ variant: 'default' })}
|
||||
onclick={() => {
|
||||
dialogOpen = true;
|
||||
}}
|
||||
>
|
||||
Request Movie
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class="max-h-[90vh] w-fit min-w-[clamp(300px,50vw,600px)] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Request {getFullyQualifiedMediaName(movie)}</Dialog.Title>
|
||||
<Dialog.Description>Select desired qualities to submit a request.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="grid gap-4 py-4">
|
||||
<!-- Min Quality Select -->
|
||||
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
|
||||
<Label class="text-right" for="min-quality">Min Quality</Label>
|
||||
<Select.Root bind:value={minQuality} type="single">
|
||||
<Select.Trigger class="w-full" id="min-quality">
|
||||
{minQuality ? getTorrentQualityString(parseInt(minQuality)) : 'Select Minimum Quality'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each qualityOptions as option (option.value)}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Wanted Quality Select -->
|
||||
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
|
||||
<Label class="text-right" for="wanted-quality">Wanted Quality</Label>
|
||||
<Select.Root bind:value={wantedQuality} type="single">
|
||||
<Select.Trigger class="w-full" id="wanted-quality">
|
||||
{wantedQuality
|
||||
? getTorrentQualityString(parseInt(wantedQuality))
|
||||
: 'Select Wanted Quality'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each qualityOptions as option (option.value)}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
{#if submitRequestError}
|
||||
<p class="col-span-full text-center text-sm text-red-500">{submitRequestError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button disabled={isSubmittingRequest} onclick={() => (dialogOpen = false)} variant="outline"
|
||||
>Cancel
|
||||
</Button>
|
||||
<Button disabled={isFormInvalid || isSubmittingRequest} onclick={handleRequestMovie}>
|
||||
{#if isSubmittingRequest}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting...
|
||||
{:else}
|
||||
Submit Request
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,155 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
import { getFullyQualifiedMediaName, getTorrentQualityString } from '$lib/utils.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import client from '$lib/api';
|
||||
import type { components } from '$lib/api/api';
|
||||
|
||||
let { show }: { show: components['schemas']['PublicShow'] } = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let selectedSeasonsIds = $state<string[]>([]);
|
||||
let minQuality = $state<string | undefined>(undefined);
|
||||
let wantedQuality = $state<string | undefined>(undefined);
|
||||
let isSubmittingRequest = $state(false);
|
||||
let submitRequestError = $state<string | null>(null);
|
||||
|
||||
const qualityValues: components['schemas']['Quality'][] = [1, 2, 3, 4];
|
||||
let qualityOptions = $derived(
|
||||
qualityValues.map((q) => ({ value: q.toString(), label: getTorrentQualityString(q) }))
|
||||
);
|
||||
let isFormInvalid = $derived(
|
||||
!selectedSeasonsIds ||
|
||||
selectedSeasonsIds.length === 0 ||
|
||||
!minQuality ||
|
||||
!wantedQuality ||
|
||||
parseInt(wantedQuality) > parseInt(minQuality)
|
||||
);
|
||||
|
||||
async function handleRequestSeason() {
|
||||
isSubmittingRequest = true;
|
||||
submitRequestError = null;
|
||||
|
||||
for (const id of selectedSeasonsIds) {
|
||||
const { response, error } = await client.POST('/api/v1/tv/seasons/requests', {
|
||||
body: {
|
||||
season_id: id,
|
||||
min_quality: parseInt(minQuality!) as components['schemas']['Quality'],
|
||||
wanted_quality: parseInt(wantedQuality!) as components['schemas']['Quality']
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error('Failed to submit request: ' + error);
|
||||
submitRequestError = `Failed to submit request for season ID ${id}: ${error}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!submitRequestError) {
|
||||
dialogOpen = false;
|
||||
// Reset form fields
|
||||
selectedSeasonsIds = [];
|
||||
minQuality = undefined;
|
||||
wantedQuality = undefined;
|
||||
toast.success('Season request(s) submitted successfully!');
|
||||
}
|
||||
|
||||
isSubmittingRequest = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Trigger
|
||||
class={buttonVariants({ variant: 'default' })}
|
||||
onclick={() => {
|
||||
dialogOpen = true;
|
||||
}}
|
||||
>
|
||||
Request Season
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class="max-h-[90vh] w-fit min-w-[clamp(300px,50vw,600px)] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Request a Season for {getFullyQualifiedMediaName(show)}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Select a season and desired qualities to submit a request.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="grid gap-4 py-4">
|
||||
<!-- Season Select -->
|
||||
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
|
||||
<Label class="text-right" for="season">Season</Label>
|
||||
<Select.Root bind:value={selectedSeasonsIds} type="multiple">
|
||||
<Select.Trigger class="w-full" id="season">
|
||||
{#each selectedSeasonsIds as seasonId (seasonId)}
|
||||
{#if show.seasons.find((season) => season.id === seasonId)}
|
||||
Season {show.seasons.find((season) => season.id === seasonId)?.number},
|
||||
{/if}
|
||||
{:else}
|
||||
Select one or more seasons
|
||||
{/each}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each show.seasons as season (season.id)}
|
||||
<Select.Item value={season.id || ''}>
|
||||
Season {season.number}{season.name ? `: ${season.name}` : ''}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Min Quality Select -->
|
||||
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
|
||||
<Label class="text-right" for="min-quality">Min Quality</Label>
|
||||
<Select.Root bind:value={minQuality} type="single">
|
||||
<Select.Trigger class="w-full" id="min-quality">
|
||||
{minQuality ? getTorrentQualityString(parseInt(minQuality)) : 'Select Minimum Quality'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each qualityOptions as option (option.value)}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Wanted Quality Select -->
|
||||
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
|
||||
<Label class="text-right" for="wanted-quality">Wanted Quality</Label>
|
||||
<Select.Root bind:value={wantedQuality} type="single">
|
||||
<Select.Trigger class="w-full" id="wanted-quality">
|
||||
{wantedQuality
|
||||
? getTorrentQualityString(parseInt(wantedQuality))
|
||||
: 'Select Wanted Quality'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each qualityOptions as option (option.value)}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
{#if submitRequestError}
|
||||
<p class="col-span-full text-center text-sm text-red-500">{submitRequestError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button disabled={isSubmittingRequest} onclick={() => (dialogOpen = false)} variant="outline"
|
||||
>Cancel
|
||||
</Button>
|
||||
<Button disabled={isFormInvalid || isSubmittingRequest} onclick={handleRequestSeason}>
|
||||
{#if isSubmittingRequest}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting...
|
||||
{:else}
|
||||
Submit Request
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,227 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getFullyQualifiedMediaName, getTorrentQualityString } from '$lib/utils.js';
|
||||
import CheckmarkX from '$lib/components/checkmark-x.svelte';
|
||||
import type { components } from '$lib/api/api';
|
||||
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { getContext } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import client from '$lib/api';
|
||||
|
||||
let {
|
||||
requests,
|
||||
filter = () => true,
|
||||
isShow = true
|
||||
}: {
|
||||
requests: (
|
||||
| components['schemas']['RichSeasonRequest']
|
||||
| components['schemas']['RichMovieRequest']
|
||||
)[];
|
||||
filter?: (
|
||||
request:
|
||||
| components['schemas']['RichSeasonRequest']
|
||||
| components['schemas']['RichMovieRequest']
|
||||
) => boolean;
|
||||
isShow: boolean;
|
||||
} = $props();
|
||||
const user: () => components['schemas']['UserRead'] = getContext('user');
|
||||
async function approveRequest(requestId: string, currentAuthorizedStatus: boolean) {
|
||||
let response;
|
||||
if (isShow) {
|
||||
const data = await client.PATCH('/api/v1/tv/seasons/requests/{season_request_id}', {
|
||||
params: {
|
||||
path: {
|
||||
season_request_id: requestId
|
||||
},
|
||||
query: {
|
||||
authorized_status: !currentAuthorizedStatus
|
||||
}
|
||||
}
|
||||
});
|
||||
response = data.response;
|
||||
} else {
|
||||
const data = await client.PATCH('/api/v1/movies/requests/{movie_request_id}', {
|
||||
params: {
|
||||
path: {
|
||||
movie_request_id: requestId
|
||||
},
|
||||
query: {
|
||||
authorized_status: !currentAuthorizedStatus
|
||||
}
|
||||
}
|
||||
});
|
||||
response = data.response;
|
||||
}
|
||||
if (response.ok) {
|
||||
toast.success(
|
||||
`Request ${!currentAuthorizedStatus ? 'approved' : 'unapproved'} successfully.`
|
||||
);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error(`Failed to update request status ${response.statusText}`, errorText);
|
||||
toast.error(`Failed to update request status: ${response.statusText}`);
|
||||
}
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
async function deleteRequest(requestId: string) {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Are you sure you want to delete this season request? This action cannot be undone.'
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let response;
|
||||
if (isShow) {
|
||||
const data = await client.DELETE('/api/v1/tv/seasons/requests/{request_id}', {
|
||||
params: {
|
||||
path: {
|
||||
request_id: requestId
|
||||
}
|
||||
}
|
||||
});
|
||||
response = data.response;
|
||||
} else {
|
||||
const data = await client.DELETE('/api/v1/movies/requests/{movie_request_id}', {
|
||||
params: {
|
||||
path: {
|
||||
movie_request_id: requestId
|
||||
}
|
||||
}
|
||||
});
|
||||
response = data.response;
|
||||
}
|
||||
if (response.ok) {
|
||||
toast.success('Request deleted successfully');
|
||||
} else {
|
||||
console.error(`Failed to delete request ${response.statusText}`, await response.text());
|
||||
toast.error('Failed to delete request');
|
||||
}
|
||||
await invalidateAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Table.Root>
|
||||
<Table.Caption>A list of all requests.</Table.Caption>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{isShow ? 'Show' : 'Movie'}</Table.Head>
|
||||
{#if isShow}
|
||||
<Table.Head>Season</Table.Head>
|
||||
{/if}
|
||||
<Table.Head>Minimum Quality</Table.Head>
|
||||
<Table.Head>Wanted Quality</Table.Head>
|
||||
<Table.Head>Requested by</Table.Head>
|
||||
<Table.Head>Approved</Table.Head>
|
||||
<Table.Head>Approved by</Table.Head>
|
||||
<Table.Head>Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each requests as request (request.id)}
|
||||
{#if filter(request)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
{#if isShow}
|
||||
<a
|
||||
href={resolve('/dashboard/tv/[showId]', {
|
||||
showId: (request as components['schemas']['RichSeasonRequest']).show.id!
|
||||
})}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{getFullyQualifiedMediaName(
|
||||
(request as components['schemas']['RichSeasonRequest']).show
|
||||
)}
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href={resolve('/dashboard/movies/[movieId]', {
|
||||
movieId: (request as components['schemas']['RichMovieRequest']).movie.id!
|
||||
})}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{getFullyQualifiedMediaName(
|
||||
(request as components['schemas']['RichMovieRequest']).movie
|
||||
)}
|
||||
</a>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{#if isShow}
|
||||
<Table.Cell>
|
||||
{(request as components['schemas']['RichSeasonRequest']).season.number}
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell>
|
||||
{getTorrentQualityString(request.min_quality)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{getTorrentQualityString(request.wanted_quality)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{request.requested_by?.email ?? 'N/A'}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<CheckmarkX state={request.authorized} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{request.authorized_by?.email ?? 'N/A'}
|
||||
</Table.Cell>
|
||||
<!-- TODO: ADD DIALOGUE TO MODIFY REQUEST -->
|
||||
<Table.Cell class="flex max-w-[150px] flex-col gap-1">
|
||||
{#if user().is_superuser}
|
||||
<Button
|
||||
class=""
|
||||
size="sm"
|
||||
onclick={() => approveRequest(request.id!, request.authorized)}
|
||||
>
|
||||
{request.authorized ? 'Unapprove' : 'Approve'}
|
||||
</Button>
|
||||
{#if isShow}
|
||||
<Button
|
||||
class=""
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() =>
|
||||
goto(
|
||||
resolve('/dashboard/tv/[showId]', {
|
||||
showId: (request as components['schemas']['RichSeasonRequest']).show.id!
|
||||
})
|
||||
)}
|
||||
>
|
||||
Download manually
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
class=""
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() =>
|
||||
goto(
|
||||
resolve('/dashboard/movies/[movieId]', {
|
||||
movieId: (request as components['schemas']['RichMovieRequest']).movie.id!
|
||||
})
|
||||
)}
|
||||
>
|
||||
Download manually
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if user().is_superuser || user().id === request.requested_by?.id}
|
||||
<Button variant="destructive" size="sm" onclick={() => deleteRequest(request.id!)}
|
||||
>Delete
|
||||
</Button>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={8} class="text-center">There are currently no requests.</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
convertTorrentSeasonRangeToIntegerRange,
|
||||
convertTorrentEpisodeRangeToIntegerRange,
|
||||
getTorrentQualityString,
|
||||
getTorrentStatusString
|
||||
} from '$lib/utils.js';
|
||||
@@ -59,6 +60,7 @@
|
||||
<Table.Head>Name</Table.Head>
|
||||
{#if isShow}
|
||||
<Table.Head>Seasons</Table.Head>
|
||||
<Table.Head>Episodes</Table.Head>
|
||||
{/if}
|
||||
<Table.Head>Download Status</Table.Head>
|
||||
<Table.Head>Quality</Table.Head>
|
||||
@@ -97,6 +99,11 @@
|
||||
(torrent as components['schemas']['RichSeasonTorrent']).seasons!
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{convertTorrentEpisodeRangeToIntegerRange(
|
||||
(torrent as components['schemas']['RichSeasonTorrent']).episodes!
|
||||
)}
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell>
|
||||
{getTorrentStatusString(torrent.status)}
|
||||
|
||||
|
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() {
|
||||
await client.POST('/api/v1/auth/cookie/logout');
|
||||
await goto(resolve('/login', {}));
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
import TorrentTable from '$lib/components/torrents/torrent-table.svelte';
|
||||
import MediaPicture from '$lib/components/media-picture.svelte';
|
||||
import DownloadMovieDialog from '$lib/components/download-dialogs/download-movie-dialog.svelte';
|
||||
import RequestMovieDialog from '$lib/components/requests/request-movie-dialog.svelte';
|
||||
import LibraryCombobox from '$lib/components/library-combobox.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
@@ -80,7 +79,7 @@
|
||||
<Card.Title>Overview</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="leading-7 not-first:mt-6">
|
||||
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
|
||||
{movie.overview}
|
||||
</p>
|
||||
</Card.Content>
|
||||
@@ -108,7 +107,6 @@
|
||||
{#if user().is_superuser}
|
||||
<DownloadMovieDialog {movie} />
|
||||
{/if}
|
||||
<RequestMovieDialog {movie} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
|
||||
import RequestsTable from '$lib/components/requests/requests-table.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let requests = $derived(page.data.requestsData);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Movie Requests - MediaManager</title>
|
||||
<meta content="View and manage movie download requests in MediaManager" name="description" />
|
||||
</svelte:head>
|
||||
|
||||
<header class="flex h-16 shrink-0 items-center gap-2">
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Sidebar.Trigger class="-ml-1" />
|
||||
<Separator class="mr-2 h-4" orientation="vertical" />
|
||||
<Breadcrumb.Root>
|
||||
<Breadcrumb.List>
|
||||
<Breadcrumb.Item class="hidden md:block">
|
||||
<Breadcrumb.Link href={resolve('/dashboard', {})}>MediaManager</Breadcrumb.Link>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Separator class="hidden md:block" />
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Link href={resolve('/dashboard', {})}>Home</Breadcrumb.Link>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Separator class="hidden md:block" />
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Link href={resolve('/dashboard/movies', {})}>Movies</Breadcrumb.Link>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Separator class="hidden md:block" />
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Page>Movie Requests</Breadcrumb.Page>
|
||||
</Breadcrumb.Item>
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto flex w-full flex-1 flex-col gap-4 p-4 md:max-w-[80em]">
|
||||
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
Movie Requests
|
||||
</h1>
|
||||
<RequestsTable {requests} isShow={false} />
|
||||
</main>
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import client from '$lib/api';
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
const { data } = await client.GET('/api/v1/movies/requests', { fetch: fetch });
|
||||
|
||||
return {
|
||||
requestsData: data
|
||||
};
|
||||
};
|
||||
@@ -4,15 +4,17 @@
|
||||
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ImageOff } from 'lucide-svelte';
|
||||
import { Ellipsis } from 'lucide-svelte';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { getContext } from 'svelte';
|
||||
import type { components } from '$lib/api/api';
|
||||
import { getFullyQualifiedMediaName } from '$lib/utils';
|
||||
import DownloadSeasonDialog from '$lib/components/download-dialogs/download-season-dialog.svelte';
|
||||
import DownloadSelectedSeasonsDialog from '$lib/components/download-dialogs/download-selected-seasons-dialog.svelte';
|
||||
import DownloadSelectedEpisodesDialog from '$lib/components/download-dialogs/download-selected-episodes-dialog.svelte';
|
||||
import DownloadCustomDialog from '$lib/components/download-dialogs/download-custom-dialog.svelte';
|
||||
import CheckmarkX from '$lib/components/checkmark-x.svelte';
|
||||
import { page } from '$app/state';
|
||||
import TorrentTable from '$lib/components/torrents/torrent-table.svelte';
|
||||
import RequestSeasonDialog from '$lib/components/requests/request-season-dialog.svelte';
|
||||
import MediaPicture from '$lib/components/media-picture.svelte';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -22,11 +24,85 @@
|
||||
import DeleteMediaDialog from '$lib/components/delete-media-dialog.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import client from '$lib/api';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
let show: components['schemas']['PublicShow'] = $derived(page.data.showData);
|
||||
let torrents: components['schemas']['RichShowTorrent'] = $derived(page.data.torrentsData);
|
||||
let user: () => components['schemas']['UserRead'] = getContext('user');
|
||||
|
||||
let expandedSeasons = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleSeason(seasonId: string) {
|
||||
if (expandedSeasons.has(seasonId)) {
|
||||
expandedSeasons.delete(seasonId);
|
||||
} else {
|
||||
expandedSeasons.add(seasonId);
|
||||
}
|
||||
expandedSeasons = new SvelteSet(expandedSeasons);
|
||||
}
|
||||
|
||||
let selectedSeasons = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleSeasonSelection(seasonId: string) {
|
||||
if (selectedSeasons.has(seasonId)) {
|
||||
selectedSeasons.delete(seasonId);
|
||||
} else {
|
||||
selectedSeasons.add(seasonId);
|
||||
}
|
||||
selectedSeasons = new SvelteSet(selectedSeasons);
|
||||
}
|
||||
|
||||
let selectedSeasonNumbers = $derived(
|
||||
show.seasons.filter((s) => selectedSeasons.has(s.id)).map((s) => s.number)
|
||||
);
|
||||
|
||||
let downloadButtonLabel = $derived(
|
||||
selectedSeasonNumbers.length === 0
|
||||
? 'Download Seasons'
|
||||
: `Download Season(s) ${selectedSeasonNumbers
|
||||
.slice()
|
||||
.sort((a, b) => a - b)
|
||||
.map((n) => `S${String(n).padStart(2, '0')}`)
|
||||
.join(', ')}`
|
||||
);
|
||||
|
||||
let selectedEpisodes = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleEpisodeSelection(episodeId: string) {
|
||||
if (selectedEpisodes.has(episodeId)) {
|
||||
selectedEpisodes.delete(episodeId);
|
||||
} else {
|
||||
selectedEpisodes.add(episodeId);
|
||||
}
|
||||
selectedEpisodes = new SvelteSet(selectedEpisodes);
|
||||
}
|
||||
|
||||
let selectedEpisodeNumbers = $derived(
|
||||
show.seasons.flatMap((season) =>
|
||||
season.episodes
|
||||
.filter((ep) => selectedEpisodes.has(ep.id))
|
||||
.map((ep) => ({
|
||||
seasonNumber: season.number,
|
||||
episodeNumber: ep.number
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
let episodeDownloadLabel = $derived(
|
||||
selectedEpisodeNumbers.length === 0
|
||||
? 'Download Episodes'
|
||||
: `Download Episode(s) ${selectedEpisodeNumbers
|
||||
.map(
|
||||
(e) =>
|
||||
`S${String(e.seasonNumber).padStart(2, '0')}E${String(e.episodeNumber).padStart(
|
||||
2,
|
||||
'0'
|
||||
)}`
|
||||
)
|
||||
.join(', ')}`
|
||||
);
|
||||
|
||||
let continuousDownloadEnabled = $derived(show.continuous_download);
|
||||
|
||||
async function toggle_continuous_download() {
|
||||
@@ -109,7 +185,7 @@
|
||||
<Card.Title>Overview</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="leading-7 not-first:mt-6">
|
||||
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
|
||||
{show.overview}
|
||||
</p>
|
||||
</Card.Content>
|
||||
@@ -146,9 +222,24 @@
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col items-center gap-4">
|
||||
{#if user().is_superuser}
|
||||
<DownloadSeasonDialog {show} />
|
||||
{#if selectedSeasonNumbers.length > 0}
|
||||
<DownloadSelectedSeasonsDialog
|
||||
{show}
|
||||
{selectedSeasonNumbers}
|
||||
triggerText={downloadButtonLabel}
|
||||
/>
|
||||
{/if}
|
||||
{#if selectedEpisodeNumbers.length > 0}
|
||||
<DownloadSelectedEpisodesDialog
|
||||
{show}
|
||||
{selectedEpisodeNumbers}
|
||||
triggerText={episodeDownloadLabel}
|
||||
/>
|
||||
{/if}
|
||||
{#if selectedSeasonNumbers.length === 0 && selectedEpisodeNumbers.length === 0}
|
||||
<DownloadCustomDialog {show} />
|
||||
{/if}
|
||||
{/if}
|
||||
<RequestSeasonDialog {show} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@@ -162,35 +253,87 @@
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="w-full overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Root class="w-full table-fixed">
|
||||
<Table.Caption>A list of all seasons.</Table.Caption>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Number</Table.Head>
|
||||
<Table.Head>Exists on file</Table.Head>
|
||||
<Table.Head>Title</Table.Head>
|
||||
<Table.Head class="w-[40px]"></Table.Head>
|
||||
<Table.Head class="w-[80px]">Number</Table.Head>
|
||||
<Table.Head class="w-[100px]">Exists on file</Table.Head>
|
||||
<Table.Head class="w-[240px]">Title</Table.Head>
|
||||
<Table.Head>Overview</Table.Head>
|
||||
<Table.Head class="w-[64px] text-center">Details</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if show.seasons.length > 0}
|
||||
{#each show.seasons as season (season.id)}
|
||||
<Table.Row
|
||||
onclick={() =>
|
||||
goto(
|
||||
resolve('/dashboard/tv/[showId]/[seasonId]', {
|
||||
showId: show.id,
|
||||
seasonId: season.id
|
||||
})
|
||||
)}
|
||||
class={`group cursor-pointer transition-colors hover:bg-muted/60 ${
|
||||
expandedSeasons.has(season.id) ? 'bg-muted/50' : 'bg-muted/10'
|
||||
}`}
|
||||
onclick={() => toggleSeason(season.id)}
|
||||
>
|
||||
<Table.Cell class="min-w-[10px] font-medium">{season.number}</Table.Cell>
|
||||
<Table.Cell class="w-[40px]">
|
||||
<Checkbox
|
||||
checked={selectedSeasons.has(season.id)}
|
||||
onCheckedChange={() => toggleSeasonSelection(season.id)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[10px] font-medium">
|
||||
S{String(season.number).padStart(2, '0')}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[10px] font-medium">
|
||||
<CheckmarkX state={season.downloaded} />
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[50px]">{season.name}</Table.Cell>
|
||||
<Table.Cell class="max-w-[300px] truncate">{season.overview}</Table.Cell>
|
||||
<Table.Cell class="w-[64px] text-center">
|
||||
<button
|
||||
class="inline-flex cursor-pointer items-center
|
||||
justify-center
|
||||
rounded-md p-1
|
||||
transition-colors
|
||||
hover:bg-muted/95
|
||||
focus-visible:ring-2
|
||||
focus-visible:ring-ring focus-visible:outline-none"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
goto(
|
||||
resolve('/dashboard/tv/[showId]/[seasonId]', {
|
||||
showId: show.id,
|
||||
seasonId: season.id
|
||||
})
|
||||
);
|
||||
}}
|
||||
aria-label="Season details"
|
||||
>
|
||||
<Ellipsis size={16} class="text-muted-foreground" />
|
||||
</button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{#if expandedSeasons.has(season.id)}
|
||||
{#each season.episodes as episode (episode.id)}
|
||||
<Table.Row class="bg-muted/20">
|
||||
<Table.Cell class="w-[40px]">
|
||||
<Checkbox
|
||||
checked={selectedEpisodes.has(episode.id)}
|
||||
onCheckedChange={() => toggleEpisodeSelection(episode.id)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[10px] font-medium">
|
||||
E{String(episode.number).padStart(2, '0')}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[10px] font-medium">
|
||||
<CheckmarkX state={episode.downloaded} />
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[50px]">{episode.title}</Table.Cell>
|
||||
<Table.Cell colspan={2} class="truncate">{episode.overview}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<Table.Row>
|
||||
|
||||
@@ -11,9 +11,15 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
|
||||
let seasonFiles: components['schemas']['PublicSeasonFile'][] = $derived(page.data.files);
|
||||
let episodeFiles: components['schemas']['PublicEpisodeFile'][] = $derived(page.data.files);
|
||||
let season: components['schemas']['Season'] = $derived(page.data.season);
|
||||
let show: components['schemas']['Show'] = $derived(page.data.showData);
|
||||
|
||||
let episodeById = $derived(
|
||||
Object.fromEntries(
|
||||
season.episodes.map((ep) => [ep.id, `E${String(ep.number).padStart(2, '0')}`])
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -59,7 +65,7 @@
|
||||
</div>
|
||||
</header>
|
||||
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
{getFullyQualifiedMediaName(show)} Season {season.number}
|
||||
{getFullyQualifiedMediaName(show)} - Season {season.number}
|
||||
</h1>
|
||||
<main class="mx-auto flex w-full flex-1 flex-col gap-4 p-4 md:max-w-[80em]">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-stretch">
|
||||
@@ -68,13 +74,20 @@
|
||||
</div>
|
||||
<div class="h-full w-full flex-auto rounded-xl md:w-1/4">
|
||||
<Card.Root class="h-full w-full">
|
||||
<Card.Header>
|
||||
<Card.Title>Overview</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="leading-7 not-first:mt-6">
|
||||
{show.overview}
|
||||
</p>
|
||||
<Card.Content class="flex flex-col gap-6">
|
||||
<div>
|
||||
<Card.Title class="mb-2 text-base">Series Overview</Card.Title>
|
||||
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
|
||||
{show.overview}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-border"></div>
|
||||
<div>
|
||||
<Card.Title class="mb-2 text-base">Season Overview</Card.Title>
|
||||
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
|
||||
{season.overview}
|
||||
</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@@ -95,14 +108,18 @@
|
||||
>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Episode</Table.Head>
|
||||
<Table.Head>Quality</Table.Head>
|
||||
<Table.Head>File Path Suffix</Table.Head>
|
||||
<Table.Head>Imported</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each seasonFiles as file (file)}
|
||||
{#each episodeFiles as file (file)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-[50px]">
|
||||
{episodeById[file.episode_id] ?? 'E??'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-[50px]">
|
||||
{getTorrentQualityString(file.quality)}
|
||||
</Table.Cell>
|
||||
@@ -114,7 +131,11 @@
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<span class="font-semibold">You haven't downloaded this season yet.</span>
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={4} class="text-center py-6 font-semibold">
|
||||
You haven't downloaded episodes of this season yet.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
@@ -132,19 +153,23 @@
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="w-full overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Root class="w-full table-fixed">
|
||||
<Table.Caption>A list of all episodes.</Table.Caption>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[100px]">Number</Table.Head>
|
||||
<Table.Head class="min-w-[50px]">Title</Table.Head>
|
||||
<Table.Head class="w-[80px]">Number</Table.Head>
|
||||
<Table.Head class="w-[240px]">Title</Table.Head>
|
||||
<Table.Head>Overview</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each season.episodes as episode (episode.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-[100px] font-medium">{episode.number}</Table.Cell>
|
||||
<Table.Cell class="w-[100px] font-medium"
|
||||
>E{String(episode.number).padStart(2, '0')}</Table.Cell
|
||||
>
|
||||
<Table.Cell class="min-w-[50px]">{episode.title}</Table.Cell>
|
||||
<Table.Cell class="truncate">{episode.overview}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
const seasonFiles = client.GET('/api/v1/tv/seasons/{season_id}/files', {
|
||||
const episodeFiles = client.GET('/api/v1/tv/seasons/{season_id}/files', {
|
||||
fetch: fetch,
|
||||
params: {
|
||||
path: {
|
||||
@@ -19,7 +19,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
}
|
||||
});
|
||||
return {
|
||||
files: await seasonFiles.then((x) => x.data),
|
||||
files: await episodeFiles.then((x) => x.data),
|
||||
season: await season.then((x) => x.data)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
|
||||
import RequestsTable from '$lib/components/requests/requests-table.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { components } from '$lib/api/api';
|
||||
|
||||
let requests: components['schemas']['RichSeasonRequest'][] = $derived(page.data.requestsData);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>TV Show Requests - MediaManager</title>
|
||||
<meta content="View and manage TV show download requests in MediaManager" name="description" />
|
||||
</svelte:head>
|
||||
|
||||
<header class="flex h-16 shrink-0 items-center gap-2">
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Sidebar.Trigger class="-ml-1" />
|
||||
<Separator class="mr-2 h-4" orientation="vertical" />
|
||||
<Breadcrumb.Root>
|
||||
<Breadcrumb.List>
|
||||
<Breadcrumb.Item class="hidden md:block">
|
||||
<Breadcrumb.Link href={resolve('/dashboard', {})}>MediaManager</Breadcrumb.Link>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Separator class="hidden md:block" />
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Link href={resolve('/dashboard', {})}>Home</Breadcrumb.Link>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Separator class="hidden md:block" />
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Link href={resolve('/dashboard/tv', {})}>Shows</Breadcrumb.Link>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Separator class="hidden md:block" />
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Page>Season Requests</Breadcrumb.Page>
|
||||
</Breadcrumb.Item>
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto flex w-full flex-1 flex-col gap-4 p-4 md:max-w-[80em]">
|
||||
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
Season Requests
|
||||
</h1>
|
||||
<RequestsTable {requests} isShow={true} />
|
||||
</main>
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import client from '$lib/api';
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
const { data } = await client.GET('/api/v1/tv/seasons/requests', { fetch: fetch });
|
||||
return {
|
||||
requestsData: 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 |