mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-17 15:13:24 +02:00
Merge branch 'master' into fork/strangeglyph/master
# Conflicts: # media_manager/main.py
This commit is contained in:
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
from sqlalchemy.orm import Mapped, relationship, mapped_column
|
||||
|
||||
from media_manager.database import Base, build_db_url
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
|
||||
|
||||
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
|
||||
@@ -30,7 +30,7 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
|
||||
|
||||
|
||||
engine = create_async_engine(
|
||||
build_db_url(**AllEncompassingConfig().database.model_dump()), echo=False
|
||||
build_db_url(**MediaManagerConfig().database.model_dump()), echo=False
|
||||
)
|
||||
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi_users.router import get_oauth_router
|
||||
from httpx_oauth.oauth2 import OAuth2
|
||||
from sqlalchemy import select
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.auth.db import User
|
||||
from media_manager.auth.schemas import UserRead, AuthMetadata
|
||||
from media_manager.auth.users import (
|
||||
@@ -50,7 +50,7 @@ def get_openid_router():
|
||||
)
|
||||
|
||||
|
||||
openid_config = AllEncompassingConfig().auth.openid_connect
|
||||
openid_config = MediaManagerConfig().auth.openid_connect
|
||||
|
||||
|
||||
@users_router.get(
|
||||
|
||||
@@ -20,11 +20,11 @@ from sqlalchemy import select, func
|
||||
import media_manager.notification.utils
|
||||
from media_manager.auth.db import User, get_user_db, get_async_session
|
||||
from media_manager.auth.schemas import UserUpdate, UserCreate
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
config = AllEncompassingConfig().auth
|
||||
config = MediaManagerConfig().auth
|
||||
SECRET = config.token_secret
|
||||
LIFETIME = config.session_lifetime
|
||||
|
||||
@@ -66,7 +66,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
async def on_after_forgot_password(
|
||||
self, user: User, token: str, request: Optional[Request] = None
|
||||
):
|
||||
link = f"{AllEncompassingConfig().misc.frontend_url}web/login/reset-password?token={token}"
|
||||
link = f"{MediaManagerConfig().misc.frontend_url}web/login/reset-password?token={token}"
|
||||
log.info(f"User {user.id} has forgot their password. Reset Link: {link}")
|
||||
|
||||
if not config.email_password_resets:
|
||||
@@ -128,7 +128,7 @@ async def create_default_admin_user():
|
||||
stmt = select(func.count(User.id))
|
||||
result = await session.execute(stmt)
|
||||
user_count = result.scalar()
|
||||
config = AllEncompassingConfig()
|
||||
config = MediaManagerConfig()
|
||||
if user_count == 0:
|
||||
log.info(
|
||||
"No users found in database. Creating default admin user..."
|
||||
@@ -184,7 +184,7 @@ def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]:
|
||||
class RedirectingCookieTransport(CookieTransport):
|
||||
async def get_login_response(self, token: str) -> Response:
|
||||
response = RedirectResponse(
|
||||
str(AllEncompassingConfig().misc.frontend_url) + "web/dashboard",
|
||||
str(MediaManagerConfig().misc.frontend_url) + "web/dashboard",
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
)
|
||||
return self._set_login_cookie(response, token)
|
||||
|
||||
@@ -49,7 +49,7 @@ class BasicConfig(BaseSettings):
|
||||
movie_libraries: list[LibraryItem] = []
|
||||
|
||||
|
||||
class AllEncompassingConfig(BaseSettings):
|
||||
class MediaManagerConfig(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
toml_file=config_path,
|
||||
case_sensitive=False,
|
||||
|
||||
@@ -1,64 +1,120 @@
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from psycopg.errors import UniqueViolation
|
||||
|
||||
|
||||
class MediaAlreadyExists(Exception):
|
||||
"""Raised when a show already exists"""
|
||||
class MediaManagerException(Exception):
|
||||
"""Base exception for MediaManager errors."""
|
||||
|
||||
def __init__(self, message: str = "An error occurred."):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
class MediaAlreadyExists(MediaManagerException):
|
||||
"""Raised when a media entity already exists (HTTP 409)."""
|
||||
|
||||
def __init__(
|
||||
self, message: str = "Entity with this ID or other identifier already exists"
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
"""Custom exception for when an entity is not found."""
|
||||
class NotFoundError(MediaManagerException):
|
||||
"""Raised when an entity is not found (HTTP 404)."""
|
||||
|
||||
def __init__(self, message: str = "The requested entity was not found."):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidConfigError(Exception):
|
||||
"""Custom exception for when an entity is not found."""
|
||||
class InvalidConfigError(MediaManagerException):
|
||||
"""Raised when the server is improperly configured (HTTP 500)."""
|
||||
|
||||
def __init__(self, message: str = "The server is improperly configured."):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BadRequestError(MediaManagerException):
|
||||
"""Raised for invalid client requests (HTTP 400)."""
|
||||
|
||||
def __init__(self, message: str = "Bad request."):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class UnauthorizedError(MediaManagerException):
|
||||
"""Raised for authentication failures (HTTP 401)."""
|
||||
|
||||
def __init__(self, message: str = "Unauthorized."):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ForbiddenError(MediaManagerException):
|
||||
"""Raised for forbidden actions (HTTP 403)."""
|
||||
|
||||
def __init__(self, message: str = "Forbidden."):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ConflictError(MediaManagerException):
|
||||
"""Raised for resource conflicts (HTTP 409)."""
|
||||
|
||||
def __init__(self, message: str = "Conflict."):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class UnprocessableEntityError(MediaManagerException):
|
||||
"""Raised for validation errors (HTTP 422)."""
|
||||
|
||||
def __init__(self, message: str = "Unprocessable entity."):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# Exception handlers
|
||||
async def media_already_exists_exception_handler(
|
||||
request: Request, exc: MediaAlreadyExists | Exception
|
||||
request: Request, exc: MediaAlreadyExists
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": exc.message},
|
||||
)
|
||||
return JSONResponse(status_code=409, content={"detail": exc.message})
|
||||
|
||||
|
||||
async def not_found_error_exception_handler(
|
||||
request: Request, exc: NotFoundError | Exception
|
||||
request: Request, exc: NotFoundError
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": exc.message},
|
||||
)
|
||||
return JSONResponse(status_code=404, content={"detail": exc.message})
|
||||
|
||||
|
||||
async def invalid_config_error_exception_handler(
|
||||
request: Request, exc: InvalidConfigError | Exception
|
||||
request: Request, exc: InvalidConfigError
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": exc.message},
|
||||
)
|
||||
return JSONResponse(status_code=500, content={"detail": exc.message})
|
||||
|
||||
|
||||
async def bad_request_error_handler(
|
||||
request: Request, exc: BadRequestError
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(status_code=400, content={"detail": exc.message})
|
||||
|
||||
|
||||
async def unauthorized_error_handler(
|
||||
request: Request, exc: UnauthorizedError
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(status_code=401, content={"detail": exc.message})
|
||||
|
||||
|
||||
async def forbidden_error_handler(
|
||||
request: Request, exc: ForbiddenError
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(status_code=403, content={"detail": exc.message})
|
||||
|
||||
|
||||
async def conflict_error_handler(request: Request, exc: ConflictError) -> JSONResponse:
|
||||
return JSONResponse(status_code=409, content={"detail": exc.message})
|
||||
|
||||
|
||||
async def unprocessable_entity_error_handler(
|
||||
request: Request, exc: UnprocessableEntityError
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(status_code=422, content={"detail": exc.message})
|
||||
|
||||
|
||||
async def sqlalchemy_integrity_error_handler(
|
||||
@@ -70,3 +126,15 @@ async def sqlalchemy_integrity_error_handler(
|
||||
"detail": "The entity to create already exists or is in a conflict with others."
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app):
|
||||
app.add_exception_handler(NotFoundError, not_found_error_exception_handler)
|
||||
app.add_exception_handler(
|
||||
MediaAlreadyExists, media_already_exists_exception_handler
|
||||
)
|
||||
app.add_exception_handler(
|
||||
InvalidConfigError, invalid_config_error_exception_handler
|
||||
)
|
||||
app.add_exception_handler(IntegrityError, sqlalchemy_integrity_error_handler)
|
||||
app.add_exception_handler(UniqueViolation, sqlalchemy_integrity_error_handler)
|
||||
|
||||
45
media_manager/filesystem_checks.py
Normal file
45
media_manager/filesystem_checks.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_filesystem_checks(config, log):
|
||||
log.info("Creating directories if they don't exist...")
|
||||
config.misc.tv_directory.mkdir(parents=True, exist_ok=True)
|
||||
config.misc.movie_directory.mkdir(parents=True, exist_ok=True)
|
||||
config.misc.torrent_directory.mkdir(parents=True, exist_ok=True)
|
||||
config.misc.image_directory.mkdir(parents=True, exist_ok=True)
|
||||
log.info("Conducting filesystem tests...")
|
||||
test_dir = config.misc.tv_directory / Path(".media_manager_test_dir")
|
||||
test_dir.mkdir(parents=True, exist_ok=True)
|
||||
test_dir.rmdir()
|
||||
log.info(f"Successfully created test dir in TV directory at: {test_dir}")
|
||||
test_dir = config.misc.movie_directory / Path(".media_manager_test_dir")
|
||||
test_dir.mkdir(parents=True, exist_ok=True)
|
||||
test_dir.rmdir()
|
||||
log.info(f"Successfully created test dir in Movie directory at: {test_dir}")
|
||||
test_dir = config.misc.image_directory / Path(".media_manager_test_dir")
|
||||
test_dir.touch()
|
||||
test_dir.unlink()
|
||||
log.info(f"Successfully created test file in Image directory at: {test_dir}")
|
||||
test_dir = config.misc.tv_directory / Path(".media_manager_test_dir")
|
||||
test_dir.mkdir(parents=True, exist_ok=True)
|
||||
torrent_dir = config.misc.torrent_directory / Path(".media_manager_test_dir")
|
||||
torrent_dir.mkdir(parents=True, exist_ok=True)
|
||||
test_torrent_file = torrent_dir / Path(".media_manager.test.torrent")
|
||||
test_torrent_file.touch()
|
||||
test_hardlink = test_dir / Path(".media_manager.test.hardlink")
|
||||
try:
|
||||
test_hardlink.hardlink_to(test_torrent_file)
|
||||
if not test_hardlink.samefile(test_torrent_file):
|
||||
log.critical("Hardlink creation failed!")
|
||||
log.info("Successfully created test hardlink in TV directory")
|
||||
except OSError as e:
|
||||
log.error(
|
||||
f"Hardlink creation failed, falling back to copying files. Error: {e}"
|
||||
)
|
||||
shutil.copy(src=test_torrent_file, dst=test_hardlink)
|
||||
finally:
|
||||
test_hardlink.unlink()
|
||||
test_torrent_file.unlink()
|
||||
torrent_dir.rmdir()
|
||||
test_dir.rmdir()
|
||||
@@ -7,7 +7,7 @@ import requests
|
||||
from media_manager.indexer.indexers.generic import GenericIndexer
|
||||
from media_manager.indexer.indexers.torznab_mixin import TorznabMixin
|
||||
from media_manager.indexer.schemas import IndexerQueryResult
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.movies.schemas import Movie
|
||||
from media_manager.tv.schemas import Show
|
||||
|
||||
@@ -21,7 +21,7 @@ class Jackett(GenericIndexer, TorznabMixin):
|
||||
|
||||
"""
|
||||
super().__init__(name="jackett")
|
||||
config = AllEncompassingConfig().indexers.jackett
|
||||
config = MediaManagerConfig().indexers.jackett
|
||||
self.api_key = config.api_key
|
||||
self.url = config.url
|
||||
self.indexers = config.indexers
|
||||
|
||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
||||
from requests import Session
|
||||
|
||||
from media_manager.indexer.indexers.generic import GenericIndexer
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.indexer.indexers.torznab_mixin import TorznabMixin
|
||||
from media_manager.indexer.schemas import IndexerQueryResult
|
||||
from media_manager.movies.schemas import Movie
|
||||
@@ -36,7 +36,7 @@ class Prowlarr(GenericIndexer, TorznabMixin):
|
||||
A subclass of GenericIndexer for interacting with the Prowlarr API.
|
||||
"""
|
||||
super().__init__(name="prowlarr")
|
||||
self.config = AllEncompassingConfig().indexers.prowlarr
|
||||
self.config = MediaManagerConfig().indexers.prowlarr
|
||||
|
||||
def _call_prowlarr_api(self, path: str, parameters: dict = None):
|
||||
url = f"{self.config.url}/api/v1{path}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.indexer.indexers.generic import GenericIndexer
|
||||
from media_manager.indexer.indexers.jackett import Jackett
|
||||
from media_manager.indexer.indexers.prowlarr import Prowlarr
|
||||
@@ -15,7 +15,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
class IndexerService:
|
||||
def __init__(self, indexer_repository: IndexerRepository):
|
||||
config = AllEncompassingConfig()
|
||||
config = MediaManagerConfig()
|
||||
self.repository = indexer_repository
|
||||
self.indexers: list[GenericIndexer] = []
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.indexer.config import ScoringRuleSet
|
||||
from media_manager.indexer.schemas import IndexerQueryResult
|
||||
from media_manager.movies.schemas import Movie
|
||||
@@ -15,8 +15,8 @@ log = logging.getLogger(__name__)
|
||||
def evaluate_indexer_query_result(
|
||||
query_result: IndexerQueryResult, ruleset: ScoringRuleSet
|
||||
) -> (IndexerQueryResult, bool):
|
||||
title_rules = AllEncompassingConfig().indexers.title_scoring_rules
|
||||
indexer_flag_rules = AllEncompassingConfig().indexers.indexer_flag_scoring_rules
|
||||
title_rules = MediaManagerConfig().indexers.title_scoring_rules
|
||||
indexer_flag_rules = MediaManagerConfig().indexers.indexer_flag_scoring_rules
|
||||
for rule_name in ruleset.rule_names:
|
||||
for rule in title_rules:
|
||||
if rule.name == rule_name:
|
||||
@@ -80,7 +80,7 @@ def evaluate_indexer_query_results(
|
||||
query_results: list[IndexerQueryResult], media: Show | Movie, is_tv: bool
|
||||
) -> list[IndexerQueryResult]:
|
||||
scoring_rulesets: list[ScoringRuleSet] = (
|
||||
AllEncompassingConfig().indexers.scoring_rule_sets
|
||||
MediaManagerConfig().indexers.scoring_rule_sets
|
||||
)
|
||||
for ruleset in scoring_rulesets:
|
||||
if (
|
||||
|
||||
73
media_manager/logging.py
Normal file
73
media_manager/logging.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from logging.config import dictConfig
|
||||
from pythonjsonlogger.json import JsonFormatter
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class ISOJsonFormatter(JsonFormatter):
|
||||
def formatTime(self, record, datefmt=None):
|
||||
dt = datetime.fromtimestamp(record.created, tz=timezone.utc)
|
||||
return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
LOG_LEVEL = os.getenv("MEDIAMANAGER_LOG_LEVEL", "INFO").upper()
|
||||
LOG_FILE = Path(os.getenv("LOG_FILE", "/app/config/media_manager.log"))
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"default": {
|
||||
"format": "%(asctime)s - %(levelname)s - %(name)s - %(funcName)s(): %(message)s"
|
||||
},
|
||||
"json": {
|
||||
"()": ISOJsonFormatter,
|
||||
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
"rename_fields": {
|
||||
"levelname": "level",
|
||||
"asctime": "timestamp",
|
||||
"name": "module",
|
||||
},
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "default",
|
||||
"stream": sys.stdout,
|
||||
},
|
||||
"file": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"formatter": "json",
|
||||
"filename": str(LOG_FILE),
|
||||
"maxBytes": 10485760,
|
||||
"backupCount": 5,
|
||||
"encoding": "utf-8",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": LOG_LEVEL,
|
||||
"handlers": ["console", "file"],
|
||||
},
|
||||
"loggers": {
|
||||
"uvicorn": {"handlers": ["console", "file"], "level": "DEBUG"},
|
||||
"uvicorn.access": {"handlers": ["console", "file"], "level": "DEBUG"},
|
||||
"fastapi": {"handlers": ["console", "file"], "level": "DEBUG"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def setup_logging():
|
||||
dictConfig(LOGGING_CONFIG)
|
||||
logging.basicConfig(
|
||||
level=LOG_LEVEL,
|
||||
format="%(asctime)s - %(levelname)s - %(name)s - %(funcName)s(): %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
logging.getLogger("requests").setLevel(logging.WARNING)
|
||||
logging.getLogger("transmission_rpc").setLevel(logging.WARNING)
|
||||
logging.getLogger("qbittorrentapi").setLevel(logging.WARNING)
|
||||
logging.getLogger("sabnzbd_api").setLevel(logging.WARNING)
|
||||
@@ -1,109 +1,26 @@
|
||||
import logging
|
||||
from media_manager.logging import setup_logging, LOGGING_CONFIG
|
||||
from media_manager.scheduler import setup_scheduler
|
||||
from media_manager.filesystem_checks import run_filesystem_checks
|
||||
from media_manager.config import MediaManagerConfig
|
||||
import uvicorn
|
||||
import os
|
||||
import sys
|
||||
from logging.config import dictConfig
|
||||
from pythonjsonlogger.json import JsonFormatter
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class ISOJsonFormatter(JsonFormatter):
|
||||
def formatTime(self, record, datefmt=None):
|
||||
dt = datetime.fromtimestamp(record.created, tz=timezone.utc)
|
||||
return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
LOG_LEVEL = os.getenv("MEDIAMANAGER_LOG_LEVEL", "INFO").upper()
|
||||
LOG_FILE = Path(os.getenv("LOG_FILE", "/app/config/media_manager.log"))
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"default": {
|
||||
"format": "%(asctime)s - %(levelname)s - %(name)s - %(funcName)s(): %(message)s"
|
||||
},
|
||||
"json": {
|
||||
"()": ISOJsonFormatter,
|
||||
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
"rename_fields": {
|
||||
"levelname": "level",
|
||||
"asctime": "timestamp",
|
||||
"name": "module",
|
||||
},
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "default",
|
||||
"stream": sys.stdout,
|
||||
},
|
||||
"file": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"formatter": "json",
|
||||
"filename": str(LOG_FILE),
|
||||
"maxBytes": 10485760,
|
||||
"backupCount": 5,
|
||||
"encoding": "utf-8",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": LOG_LEVEL,
|
||||
"handlers": ["console", "file"],
|
||||
},
|
||||
"loggers": {
|
||||
"uvicorn": {"handlers": ["console", "file"], "level": "DEBUG"},
|
||||
"uvicorn.access": {"handlers": ["console", "file"], "level": "DEBUG"},
|
||||
"fastapi": {"handlers": ["console", "file"], "level": "DEBUG"},
|
||||
},
|
||||
}
|
||||
dictConfig(LOGGING_CONFIG)
|
||||
|
||||
logging.basicConfig(
|
||||
level=LOG_LEVEL,
|
||||
format="%(asctime)s - %(levelname)s - %(name)s - %(funcName)s(): %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
logging.getLogger("requests").setLevel(logging.WARNING)
|
||||
logging.getLogger("transmission_rpc").setLevel(logging.WARNING)
|
||||
logging.getLogger("qbittorrentapi").setLevel(logging.WARNING)
|
||||
logging.getLogger("sabnzbd_api").setLevel(logging.WARNING)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from psycopg.errors import UniqueViolation # noqa: E402
|
||||
from sqlalchemy.exc import IntegrityError # noqa: E402
|
||||
from media_manager.config import AllEncompassingConfig # noqa: E402
|
||||
import media_manager.torrent.router as torrent_router # noqa: E402
|
||||
import media_manager.movies.router as movies_router # noqa: E402
|
||||
import media_manager.tv.router as tv_router # noqa: E402
|
||||
from media_manager.tv.service import ( # noqa: E402
|
||||
auto_download_all_approved_season_requests,
|
||||
import_all_show_torrents,
|
||||
update_all_non_ended_shows_metadata,
|
||||
)
|
||||
from media_manager.movies.service import ( # noqa: E402
|
||||
import_all_movie_torrents,
|
||||
update_all_movies_metadata,
|
||||
auto_download_all_approved_movie_requests,
|
||||
)
|
||||
from media_manager.notification.router import router as notification_router # noqa: E402
|
||||
import uvicorn # noqa: E402
|
||||
from fastapi.staticfiles import StaticFiles # noqa: E402
|
||||
from media_manager.auth.router import users_router as custom_users_router # noqa: E402
|
||||
from media_manager.auth.router import auth_metadata_router # noqa: E402
|
||||
from media_manager.auth.schemas import UserCreate, UserRead, UserUpdate # noqa: E402
|
||||
from media_manager.auth.router import get_openid_router # noqa: E402
|
||||
|
||||
from media_manager.auth.users import ( # noqa: E402
|
||||
from fastapi import FastAPI, APIRouter
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.responses import RedirectResponse, FileResponse, Response
|
||||
from media_manager.auth.users import (
|
||||
bearer_auth_backend,
|
||||
fastapi_users,
|
||||
cookie_auth_backend,
|
||||
create_default_admin_user,
|
||||
)
|
||||
from media_manager.exceptions import ( # noqa: E402
|
||||
from media_manager.auth.router import (
|
||||
users_router as custom_users_router,
|
||||
auth_metadata_router,
|
||||
get_openid_router,
|
||||
)
|
||||
from media_manager.auth.schemas import UserCreate, UserRead, UserUpdate
|
||||
from media_manager.exceptions import (
|
||||
NotFoundError,
|
||||
not_found_error_exception_handler,
|
||||
MediaAlreadyExists,
|
||||
@@ -111,101 +28,28 @@ from media_manager.exceptions import ( # noqa: E402
|
||||
InvalidConfigError,
|
||||
invalid_config_error_exception_handler,
|
||||
sqlalchemy_integrity_error_handler,
|
||||
ConflictError,
|
||||
conflict_error_handler,
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from psycopg.errors import UniqueViolation
|
||||
import media_manager.torrent.router as torrent_router
|
||||
import media_manager.movies.router as movies_router
|
||||
import media_manager.tv.router as tv_router
|
||||
from media_manager.notification.router import router as notification_router
|
||||
import logging
|
||||
|
||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore # noqa: E402
|
||||
from starlette.responses import FileResponse, RedirectResponse # noqa: E402
|
||||
setup_logging()
|
||||
|
||||
import media_manager.database # noqa: E402
|
||||
import shutil # noqa: E402
|
||||
from fastapi import FastAPI, APIRouter # noqa: E402
|
||||
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware # noqa: E402
|
||||
from starlette.responses import Response # noqa: E402
|
||||
from contextlib import asynccontextmanager # noqa: E402
|
||||
from apscheduler.schedulers.background import BackgroundScheduler # noqa: E402
|
||||
from apscheduler.triggers.cron import CronTrigger # noqa: E402
|
||||
from media_manager.database import init_engine # noqa: E402
|
||||
|
||||
config = AllEncompassingConfig()
|
||||
config = MediaManagerConfig()
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
if config.misc.development:
|
||||
log.warning("Development Mode activated!")
|
||||
else:
|
||||
log.info("Development Mode not activated!")
|
||||
|
||||
scheduler = setup_scheduler(config, log)
|
||||
|
||||
def hourly_tasks():
|
||||
log.info(f"Hourly tasks are running at {datetime.now()}")
|
||||
auto_download_all_approved_season_requests()
|
||||
import_all_show_torrents()
|
||||
import_all_movie_torrents()
|
||||
|
||||
|
||||
def weekly_tasks():
|
||||
log.info(f"Weekly tasks are running at {datetime.now()}")
|
||||
update_all_non_ended_shows_metadata()
|
||||
update_all_movies_metadata()
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: Create default admin user if needed
|
||||
await create_default_admin_user()
|
||||
yield
|
||||
# Shutdown
|
||||
scheduler.shutdown()
|
||||
|
||||
run_filesystem_checks(config, log)
|
||||
|
||||
BASE_PATH = os.getenv("BASE_PATH", "")
|
||||
FRONTEND_FILES_DIR = os.getenv("FRONTEND_FILES_DIR")
|
||||
@@ -213,45 +57,24 @@ DISABLE_FRONTEND_MOUNT = os.getenv("DISABLE_FRONTEND_MOUNT", "").lower() == "tru
|
||||
FRONTEND_FOLLOW_SYMLINKS = os.getenv("FRONTEND_FOLLOW_SYMLINKS", "").lower() == "true"
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan, root_path=BASE_PATH)
|
||||
app = FastAPI(root_path=BASE_PATH)
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||
|
||||
origins = config.misc.cors_urls
|
||||
log.info(f"CORS URLs activated for following origins: {origins}")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=[
|
||||
"GET",
|
||||
"PUT",
|
||||
"POST",
|
||||
"DELETE",
|
||||
"PATCH",
|
||||
"HEAD",
|
||||
"OPTIONS",
|
||||
],
|
||||
allow_methods=["GET", "PUT", "POST", "DELETE", "PATCH", "HEAD", "OPTIONS"],
|
||||
)
|
||||
|
||||
api_app = APIRouter(prefix="/api/v1")
|
||||
|
||||
# ----------------------------
|
||||
# Hello World Router
|
||||
# ----------------------------
|
||||
|
||||
|
||||
@api_app.get("/health")
|
||||
async def hello_world() -> dict:
|
||||
"""
|
||||
A simple endpoint to check if the API is running.
|
||||
"""
|
||||
return {"message": "Hello World!", "version": os.getenv("PUBLIC_VERSION")}
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Standard Auth Routers
|
||||
# ----------------------------
|
||||
|
||||
api_app.include_router(
|
||||
fastapi_users.get_auth_router(bearer_auth_backend),
|
||||
prefix="/auth/jwt",
|
||||
@@ -268,32 +91,19 @@ api_app.include_router(
|
||||
tags=["auth"],
|
||||
)
|
||||
api_app.include_router(
|
||||
fastapi_users.get_reset_password_router(),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
fastapi_users.get_reset_password_router(), prefix="/auth", tags=["auth"]
|
||||
)
|
||||
api_app.include_router(
|
||||
fastapi_users.get_verify_router(UserRead),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
fastapi_users.get_verify_router(UserRead), prefix="/auth", tags=["auth"]
|
||||
)
|
||||
|
||||
# ----------------------------
|
||||
# User Management Routers
|
||||
# ----------------------------
|
||||
|
||||
api_app.include_router(custom_users_router, tags=["users"])
|
||||
api_app.include_router(
|
||||
fastapi_users.get_users_router(UserRead, UserUpdate),
|
||||
prefix="/users",
|
||||
tags=["users"],
|
||||
)
|
||||
|
||||
# ----------------------------
|
||||
# OpenID Connect Routers
|
||||
# ----------------------------
|
||||
|
||||
api_app.include_router(auth_metadata_router, tags=["openid"])
|
||||
|
||||
if get_openid_router():
|
||||
api_app.include_router(get_openid_router(), tags=["openid"], prefix="/auth/oauth")
|
||||
|
||||
@@ -304,36 +114,23 @@ api_app.include_router(
|
||||
notification_router, prefix="/notification", tags=["notification"]
|
||||
)
|
||||
|
||||
# serve static image files
|
||||
app.mount(
|
||||
"/api/v1/static/image",
|
||||
StaticFiles(directory=config.misc.image_directory),
|
||||
name="static-images",
|
||||
)
|
||||
|
||||
app.include_router(api_app)
|
||||
|
||||
# ----------------------------
|
||||
# Frontend mounting (disabled in development)
|
||||
# ----------------------------
|
||||
|
||||
# handle static frontend files
|
||||
if not DISABLE_FRONTEND_MOUNT:
|
||||
app.mount(
|
||||
"/web",
|
||||
StaticFiles(
|
||||
directory=FRONTEND_FILES_DIR,
|
||||
html=True,
|
||||
follow_symlink=FRONTEND_FOLLOW_SYMLINKS,
|
||||
),
|
||||
name="frontend",
|
||||
"/web", StaticFiles(directory=FRONTEND_FILES_DIR, html=True, follow_symlink=FRONTEND_FOLLOW_SYMLINKS), name="frontend"
|
||||
)
|
||||
log.info(f"Mounted frontend at /web from {FRONTEND_FILES_DIR}")
|
||||
log.debug(f"Mounted frontend at /web from {FRONTEND_FILES_DIR}")
|
||||
else:
|
||||
log.info("Frontend mounting disabled (DISABLE_FRONTEND_MOUNT is set)")
|
||||
|
||||
# ----------------------------
|
||||
# Redirects to frontend
|
||||
# ----------------------------
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
@@ -350,17 +147,7 @@ async def login():
|
||||
return RedirectResponse(url="/web/")
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Custom Exception Handlers
|
||||
# ----------------------------
|
||||
|
||||
app.add_exception_handler(NotFoundError, not_found_error_exception_handler)
|
||||
app.add_exception_handler(MediaAlreadyExists, media_already_exists_exception_handler)
|
||||
app.add_exception_handler(InvalidConfigError, invalid_config_error_exception_handler)
|
||||
app.add_exception_handler(IntegrityError, sqlalchemy_integrity_error_handler)
|
||||
app.add_exception_handler(UniqueViolation, sqlalchemy_integrity_error_handler)
|
||||
|
||||
|
||||
# this will serve the custom 404 page for frontend routes, so SvelteKit can handle routing
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request, exc):
|
||||
if not DISABLE_FRONTEND_MOUNT and any(
|
||||
@@ -370,69 +157,13 @@ async def not_found_handler(request, exc):
|
||||
return Response(content="Not Found", status_code=404)
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Hello World
|
||||
# ----------------------------
|
||||
|
||||
log.info("Hello World!")
|
||||
|
||||
# ----------------------------
|
||||
# Startup filesystem checks
|
||||
# ----------------------------
|
||||
try:
|
||||
log.info("Creating directories if they don't exist...")
|
||||
config.misc.tv_directory.mkdir(parents=True, exist_ok=True)
|
||||
config.misc.movie_directory.mkdir(parents=True, exist_ok=True)
|
||||
config.misc.torrent_directory.mkdir(parents=True, exist_ok=True)
|
||||
config.misc.image_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log.info("Conducting filesystem tests...")
|
||||
test_dir = config.misc.tv_directory / Path(".media_manager_test_dir")
|
||||
test_dir.mkdir(parents=True, exist_ok=True)
|
||||
test_dir.rmdir()
|
||||
log.info(f"Successfully created test dir in TV directory at: {test_dir}")
|
||||
|
||||
test_dir = config.misc.movie_directory / Path(".media_manager_test_dir")
|
||||
test_dir.mkdir(parents=True, exist_ok=True)
|
||||
test_dir.rmdir()
|
||||
log.info(f"Successfully created test dir in Movie directory at: {test_dir}")
|
||||
|
||||
test_dir = config.misc.image_directory / Path(".media_manager_test_dir")
|
||||
test_dir.touch()
|
||||
test_dir.unlink()
|
||||
log.info(f"Successfully created test file in Image directory at: {test_dir}")
|
||||
|
||||
# check if hardlink creation works
|
||||
test_dir = config.misc.tv_directory / Path(".media_manager_test_dir")
|
||||
test_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
torrent_dir = config.misc.torrent_directory / Path(".media_manager_test_dir")
|
||||
torrent_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
test_torrent_file = torrent_dir / Path(".media_manager.test.torrent")
|
||||
test_torrent_file.touch()
|
||||
|
||||
test_hardlink = test_dir / Path(".media_manager.test.hardlink")
|
||||
try:
|
||||
test_hardlink.hardlink_to(test_torrent_file)
|
||||
if not test_hardlink.samefile(test_torrent_file):
|
||||
log.critical("Hardlink creation failed!")
|
||||
log.info("Successfully created test hardlink in TV directory")
|
||||
except OSError as e:
|
||||
log.error(
|
||||
f"Hardlink creation failed, falling back to copying files. Error: {e}"
|
||||
)
|
||||
shutil.copy(src=test_torrent_file, dst=test_hardlink)
|
||||
finally:
|
||||
test_hardlink.unlink()
|
||||
test_torrent_file.unlink()
|
||||
torrent_dir.rmdir()
|
||||
test_dir.rmdir()
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error creating test directory: {e}")
|
||||
raise
|
||||
|
||||
# Register exception handlers for custom exceptions
|
||||
app.add_exception_handler(NotFoundError, not_found_error_exception_handler)
|
||||
app.add_exception_handler(MediaAlreadyExists, media_already_exists_exception_handler)
|
||||
app.add_exception_handler(InvalidConfigError, invalid_config_error_exception_handler)
|
||||
app.add_exception_handler(IntegrityError, sqlalchemy_integrity_error_handler)
|
||||
app.add_exception_handler(UniqueViolation, sqlalchemy_integrity_error_handler)
|
||||
app.add_exception_handler(ConflictError, conflict_error_handler)
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
|
||||
@@ -4,13 +4,13 @@ from abc import ABC, abstractmethod
|
||||
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
|
||||
from media_manager.tv.schemas import Show
|
||||
from media_manager.movies.schemas import Movie
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AbstractMetadataProvider(ABC):
|
||||
storage_path = AllEncompassingConfig().misc.image_directory
|
||||
storage_path = MediaManagerConfig().misc.image_directory
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
@@ -18,11 +18,11 @@ class AbstractMetadataProvider(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_show_metadata(self, id: int = None) -> Show:
|
||||
def get_show_metadata(self, id: int = None, language: str | None = None) -> Show:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def get_movie_metadata(self, id: int = None) -> Movie:
|
||||
def get_movie_metadata(self, id: int = None, language: str | None = None) -> Movie:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import requests
|
||||
|
||||
import media_manager.metadataProvider.utils
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.metadataProvider.abstractMetaDataProvider import (
|
||||
AbstractMetadataProvider,
|
||||
)
|
||||
@@ -22,7 +22,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
name = "tmdb"
|
||||
|
||||
def __init__(self):
|
||||
config = AllEncompassingConfig().metadata.tmdb
|
||||
config = MediaManagerConfig().metadata.tmdb
|
||||
self.url = config.tmdb_relay_url
|
||||
self.primary_languages = config.primary_languages
|
||||
self.default_language = config.default_language
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import requests
|
||||
import logging
|
||||
|
||||
|
||||
import media_manager.metadataProvider.utils
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.metadataProvider.abstractMetaDataProvider import (
|
||||
AbstractMetadataProvider,
|
||||
)
|
||||
@@ -11,7 +10,6 @@ from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
|
||||
from media_manager.tv.schemas import Episode, Season, Show, SeasonNumber
|
||||
from media_manager.movies.schemas import Movie
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -19,7 +17,7 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
name = "tvdb"
|
||||
|
||||
def __init__(self):
|
||||
config = AllEncompassingConfig().metadata.tvdb
|
||||
config = MediaManagerConfig().metadata.tvdb
|
||||
self.url = config.tvdb_relay_url
|
||||
|
||||
def __get_show(self, id: int) -> dict:
|
||||
@@ -64,11 +62,13 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
log.warning(f"image for show {show.name} could not be downloaded")
|
||||
return False
|
||||
|
||||
def get_show_metadata(self, id: int = None) -> Show:
|
||||
def get_show_metadata(self, id: int = None, language: str | None = None) -> Show:
|
||||
"""
|
||||
|
||||
:param id: the external id of the show
|
||||
:type id: int
|
||||
:param language: does nothing, TVDB does not support multiple languages
|
||||
:type language: str | None
|
||||
:return: returns a ShowMetadata object
|
||||
:rtype: ShowMetadata
|
||||
"""
|
||||
@@ -116,8 +116,7 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
year = series["year"]
|
||||
except KeyError:
|
||||
year = None
|
||||
# NOTE: the TVDB API is fucking shit and seems to be very poorly documentated, I can't for the life of me
|
||||
# figure out which statuses this fucking api returns
|
||||
|
||||
show = Show(
|
||||
name=series["name"],
|
||||
overview=series["overview"],
|
||||
@@ -147,8 +146,8 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
|
||||
formatted_results.append(
|
||||
MetaDataProviderSearchResult(
|
||||
poster_path=result["image_url"],
|
||||
overview=result["overview"],
|
||||
poster_path=result.get("image_url"),
|
||||
overview=result.get("overview"),
|
||||
name=result["name"],
|
||||
external_id=result["tvdb_id"],
|
||||
year=year,
|
||||
@@ -173,8 +172,11 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
|
||||
formatted_results.append(
|
||||
MetaDataProviderSearchResult(
|
||||
poster_path=result["image"],
|
||||
overview=result["overview"],
|
||||
poster_path="https://artworks.thetvdb.com"
|
||||
+ result.get("image")
|
||||
if result.get("image")
|
||||
else None,
|
||||
overview=result.get("overview"),
|
||||
name=result["name"],
|
||||
external_id=result["id"],
|
||||
year=year,
|
||||
@@ -190,35 +192,7 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
def search_movie(
|
||||
self, query: str | None = None
|
||||
) -> list[MetaDataProviderSearchResult]:
|
||||
if query is None:
|
||||
results = self.__get_trending_movies()
|
||||
results = results[0:20]
|
||||
log.debug(f"got {len(results)} results from TVDB search")
|
||||
formatted_results = []
|
||||
for result in results:
|
||||
result = self.__get_movie(result["id"])
|
||||
try:
|
||||
try:
|
||||
year = result["year"]
|
||||
except KeyError:
|
||||
year = None
|
||||
|
||||
formatted_results.append(
|
||||
MetaDataProviderSearchResult(
|
||||
poster_path=result["image"],
|
||||
overview="TVDB does not provide overviews",
|
||||
name=result["name"],
|
||||
external_id=result["id"],
|
||||
year=year,
|
||||
metadata_provider=self.name,
|
||||
added=False,
|
||||
vote_average=None,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Error processing search result: {e}")
|
||||
return formatted_results
|
||||
else:
|
||||
if query:
|
||||
results = self.__search_movie(query=query)
|
||||
results = results[0:20]
|
||||
log.debug(f"got {len(results)} results from TVDB search")
|
||||
@@ -237,8 +211,8 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
|
||||
formatted_results.append(
|
||||
MetaDataProviderSearchResult(
|
||||
poster_path=result["image_url"],
|
||||
overview="TVDB does not provide overviews",
|
||||
poster_path=result.get("image_url"),
|
||||
overview=result.get("overview"),
|
||||
name=result["name"],
|
||||
external_id=result["tvdb_id"],
|
||||
year=year,
|
||||
@@ -250,6 +224,37 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
except Exception as e:
|
||||
log.warning(f"Error processing search result: {e}")
|
||||
return formatted_results
|
||||
else:
|
||||
results = self.__get_trending_movies()
|
||||
results = results[0:20]
|
||||
log.debug(f"got {len(results)} results from TVDB search")
|
||||
formatted_results = []
|
||||
for result in results:
|
||||
result = self.__get_movie(result["id"])
|
||||
try:
|
||||
try:
|
||||
year = result["year"]
|
||||
except KeyError:
|
||||
year = None
|
||||
|
||||
formatted_results.append(
|
||||
MetaDataProviderSearchResult(
|
||||
poster_path="https://artworks.thetvdb.com"
|
||||
+ result.get("image")
|
||||
if result.get("image")
|
||||
else None,
|
||||
overview=result.get("overview"),
|
||||
name=result["name"],
|
||||
external_id=result["id"],
|
||||
year=year,
|
||||
metadata_provider=self.name,
|
||||
added=False,
|
||||
vote_average=None,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Error processing search result: {e}")
|
||||
return formatted_results
|
||||
|
||||
def download_movie_poster_image(self, movie: Movie) -> bool:
|
||||
movie_metadata = self.__get_movie(movie.external_id)
|
||||
@@ -266,11 +271,13 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
log.warning(f"image for show {movie.name} could not be downloaded")
|
||||
return False
|
||||
|
||||
def get_movie_metadata(self, id: int = None) -> Movie:
|
||||
def get_movie_metadata(self, id: int = None, language: str | None = None) -> Movie:
|
||||
"""
|
||||
|
||||
:param id: the external id of the movie
|
||||
:type id: int
|
||||
:param language: does nothing, TVDB does not support multiple languages
|
||||
:type language: str | None
|
||||
:return: returns a Movie object
|
||||
:rtype: Movie
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.exc import (
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
import logging
|
||||
|
||||
from media_manager.exceptions import NotFoundError
|
||||
from media_manager.exceptions import NotFoundError, ConflictError
|
||||
from media_manager.movies.models import Movie, MovieRequest, MovieFile
|
||||
from media_manager.movies.schemas import (
|
||||
Movie as MovieSchema,
|
||||
@@ -130,7 +130,7 @@ class MovieRepository:
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
log.error(f"Integrity error while saving movie {movie.name}: {e}")
|
||||
raise ValueError(
|
||||
raise ConflictError(
|
||||
f"Movie with this primary key or unique constraint violation: {e.orig}"
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
|
||||
@@ -5,40 +5,132 @@ from fastapi import APIRouter, Depends, status, HTTPException
|
||||
|
||||
from media_manager.auth.schemas import UserRead
|
||||
from media_manager.auth.users import current_active_user, current_superuser
|
||||
from media_manager.config import LibraryItem, AllEncompassingConfig
|
||||
from media_manager.config import LibraryItem, MediaManagerConfig
|
||||
from media_manager.exceptions import ConflictError
|
||||
from media_manager.indexer.schemas import (
|
||||
IndexerQueryResultId,
|
||||
IndexerQueryResult,
|
||||
)
|
||||
from media_manager.metadataProvider.dependencies import metadata_provider_dep
|
||||
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
|
||||
from media_manager.schemas import MediaImportSuggestion
|
||||
from media_manager.torrent.utils import get_importable_media_directories
|
||||
from media_manager.torrent.schemas import Torrent
|
||||
from media_manager.movies import log
|
||||
from media_manager.movies.dependencies import (
|
||||
movie_service_dep,
|
||||
movie_dep,
|
||||
)
|
||||
from media_manager.movies.schemas import (
|
||||
Movie,
|
||||
MovieRequest,
|
||||
MovieId,
|
||||
RichMovieTorrent,
|
||||
PublicMovie,
|
||||
PublicMovieFile,
|
||||
CreateMovieRequest,
|
||||
MovieRequestId,
|
||||
RichMovieRequest,
|
||||
MovieRequestBase,
|
||||
)
|
||||
from media_manager.movies.dependencies import (
|
||||
movie_service_dep,
|
||||
movie_dep,
|
||||
)
|
||||
from media_manager.metadataProvider.dependencies import metadata_provider_dep
|
||||
from media_manager.movies.schemas import MovieRequestBase
|
||||
from media_manager.schemas import MediaImportSuggestion
|
||||
from media_manager.torrent.schemas import Torrent
|
||||
from media_manager.torrent.utils import get_importable_media_directories
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# METADATA & SEARCH
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# --------------------------------
|
||||
# CREATE AND DELETE MOVIES
|
||||
# --------------------------------
|
||||
|
||||
@router.get(
|
||||
"/search",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[MetaDataProviderSearchResult],
|
||||
)
|
||||
def search_for_movie(
|
||||
query: str,
|
||||
movie_service: movie_service_dep,
|
||||
metadata_provider: metadata_provider_dep,
|
||||
):
|
||||
"""
|
||||
Search for a movie on the configured metadata provider.
|
||||
"""
|
||||
return movie_service.search_for_movie(
|
||||
query=query, metadata_provider=metadata_provider
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recommended",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[MetaDataProviderSearchResult],
|
||||
)
|
||||
def get_popular_movies(
|
||||
movie_service: movie_service_dep,
|
||||
metadata_provider: metadata_provider_dep,
|
||||
):
|
||||
"""
|
||||
Get a list of recommended/popular movies from the metadata provider.
|
||||
"""
|
||||
return movie_service.get_popular_movies(metadata_provider=metadata_provider)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# IMPORTING
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/importable",
|
||||
status_code=status.HTTP_200_OK,
|
||||
dependencies=[Depends(current_superuser)],
|
||||
response_model=list[MediaImportSuggestion],
|
||||
)
|
||||
def get_all_importable_movies(
|
||||
movie_service: movie_service_dep, metadata_provider: metadata_provider_dep
|
||||
):
|
||||
"""
|
||||
Get a list of unknown movies that were detected in the movie directory and are importable.
|
||||
"""
|
||||
return movie_service.get_importable_movies(metadata_provider=metadata_provider)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/importable/{movie_id}",
|
||||
dependencies=[Depends(current_superuser)],
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def import_detected_movie(
|
||||
movie_service: movie_service_dep, movie: movie_dep, directory: str
|
||||
):
|
||||
"""
|
||||
Import a detected movie from the specified directory into the library.
|
||||
"""
|
||||
source_directory = Path(directory)
|
||||
if source_directory not in get_importable_media_directories(
|
||||
MediaManagerConfig().misc.movie_directory
|
||||
):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No such directory")
|
||||
success = movie_service.import_existing_movie(
|
||||
movie=movie, source_directory=source_directory
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Error on importing")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# MOVIES
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[PublicMovie],
|
||||
)
|
||||
def get_all_movies(movie_service: movie_service_dep):
|
||||
"""
|
||||
Get all movies in the library.
|
||||
"""
|
||||
return movie_service.get_all_movies()
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -58,138 +150,61 @@ def add_a_movie(
|
||||
movie_id: int,
|
||||
language: str | None = None,
|
||||
):
|
||||
"""
|
||||
Add a new movie to the library.
|
||||
"""
|
||||
try:
|
||||
movie = movie_service.add_movie(
|
||||
external_id=movie_id,
|
||||
metadata_provider=metadata_provider,
|
||||
language=language,
|
||||
)
|
||||
except ValueError:
|
||||
except ConflictError:
|
||||
movie = movie_service.get_movie_by_external_id(
|
||||
external_id=movie_id, metadata_provider=metadata_provider.name
|
||||
)
|
||||
return movie
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{movie_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(current_superuser)],
|
||||
)
|
||||
def delete_a_movie(
|
||||
movie_service: movie_service_dep,
|
||||
movie: movie_dep,
|
||||
delete_files_on_disk: bool = False,
|
||||
delete_torrents: bool = False,
|
||||
):
|
||||
movie_service.delete_movie(
|
||||
movie_id=movie.id,
|
||||
delete_files_on_disk=delete_files_on_disk,
|
||||
delete_torrents=delete_torrents,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------
|
||||
# GET MOVIES
|
||||
# --------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/importable",
|
||||
status_code=status.HTTP_200_OK,
|
||||
dependencies=[Depends(current_superuser)],
|
||||
response_model=list[MediaImportSuggestion],
|
||||
)
|
||||
def get_all_importable_movies(
|
||||
movie_service: movie_service_dep, metadata_provider: metadata_provider_dep
|
||||
):
|
||||
"""
|
||||
get a list of unknown movies that were detected in the movie directory and are importable
|
||||
"""
|
||||
return movie_service.get_importable_movies(metadata_provider=metadata_provider)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/importable/{movie_id}",
|
||||
dependencies=[Depends(current_superuser)],
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def import_detected_movie(
|
||||
movie_service: movie_service_dep, movie_id: MovieId, directory: str
|
||||
):
|
||||
"""
|
||||
get a list of unknown movies that were detected in the movie directory and are importable
|
||||
"""
|
||||
source_directory = Path(directory)
|
||||
if source_directory not in get_importable_media_directories(
|
||||
AllEncompassingConfig().misc.movie_directory
|
||||
):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No such directory")
|
||||
movie = movie_service.get_movie_by_id(movie_id=movie_id)
|
||||
success = movie_service.import_existing_movie(
|
||||
movie=movie, source_directory=source_directory
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Error on importing")
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[PublicMovie],
|
||||
)
|
||||
def get_all_movies(movie_service: movie_service_dep):
|
||||
return movie_service.get_all_movies()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/libraries",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[LibraryItem],
|
||||
)
|
||||
def get_available_libraries():
|
||||
return AllEncompassingConfig().misc.movie_libraries
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[MetaDataProviderSearchResult],
|
||||
)
|
||||
def search_for_movie(
|
||||
query: str,
|
||||
movie_service: movie_service_dep,
|
||||
metadata_provider: metadata_provider_dep,
|
||||
):
|
||||
return movie_service.search_for_movie(
|
||||
query=query, metadata_provider=metadata_provider
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recommended",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[MetaDataProviderSearchResult],
|
||||
)
|
||||
def get_popular_movies(
|
||||
movie_service: movie_service_dep,
|
||||
metadata_provider: metadata_provider_dep,
|
||||
):
|
||||
return movie_service.get_popular_movies(metadata_provider=metadata_provider)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/torrents",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[RichMovieTorrent],
|
||||
)
|
||||
def get_all_movies_with_torrents(movie_service: movie_service_dep):
|
||||
"""
|
||||
Get all movies that are associated with torrents.
|
||||
"""
|
||||
return movie_service.get_all_movies_with_torrents()
|
||||
|
||||
|
||||
# --------------------------------
|
||||
@router.get(
|
||||
"/libraries",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[LibraryItem],
|
||||
)
|
||||
def get_available_libraries():
|
||||
"""
|
||||
Get available Movie libraries from configuration.
|
||||
"""
|
||||
return MediaManagerConfig().misc.movie_libraries
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# MOVIE REQUESTS
|
||||
# --------------------------------
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/requests",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[RichMovieRequest],
|
||||
)
|
||||
def get_all_movie_requests(movie_service: movie_service_dep):
|
||||
"""
|
||||
Get all movie requests.
|
||||
"""
|
||||
return movie_service.get_all_movie_requests()
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -202,6 +217,9 @@ def create_movie_request(
|
||||
movie_request: CreateMovieRequest,
|
||||
user: Annotated[UserRead, Depends(current_active_user)],
|
||||
):
|
||||
"""
|
||||
Create a new movie request.
|
||||
"""
|
||||
log.info(
|
||||
f"User {user.email} is creating a movie request for {movie_request.movie_id}"
|
||||
)
|
||||
@@ -214,15 +232,6 @@ def create_movie_request(
|
||||
return movie_service.add_movie_request(movie_request=movie_request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/requests",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[RichMovieRequest],
|
||||
)
|
||||
def get_all_movie_requests(movie_service: movie_service_dep):
|
||||
return movie_service.get_all_movie_requests()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/requests/{movie_request_id}",
|
||||
response_model=MovieRequest,
|
||||
@@ -233,6 +242,9 @@ def update_movie_request(
|
||||
update_movie_request: MovieRequestBase,
|
||||
user: Annotated[UserRead, Depends(current_active_user)],
|
||||
):
|
||||
"""
|
||||
Update an existing movie request.
|
||||
"""
|
||||
movie_request = movie_service.get_movie_request_by_id(
|
||||
movie_request_id=movie_request_id
|
||||
)
|
||||
@@ -251,7 +263,7 @@ def authorize_request(
|
||||
authorized_status: bool = False,
|
||||
):
|
||||
"""
|
||||
updates the request flag to true
|
||||
Authorize or de-authorize a movie request.
|
||||
"""
|
||||
movie_request = movie_service.get_movie_request_by_id(
|
||||
movie_request_id=movie_request_id
|
||||
@@ -272,12 +284,15 @@ def authorize_request(
|
||||
def delete_movie_request(
|
||||
movie_service: movie_service_dep, movie_request_id: MovieRequestId
|
||||
):
|
||||
"""
|
||||
Delete a movie request.
|
||||
"""
|
||||
movie_service.delete_movie_request(movie_request_id=movie_request_id)
|
||||
|
||||
|
||||
# --------------------------------
|
||||
# TORRENTS
|
||||
# --------------------------------
|
||||
# -----------------------------------------------------------------------------
|
||||
# MOVIES - SINGLE RESOURCE
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -285,8 +300,62 @@ def delete_movie_request(
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=PublicMovie,
|
||||
)
|
||||
def get_movie_by_id(movie_service: movie_service_dep, movie_id: MovieId):
|
||||
return movie_service.get_public_movie_by_id(movie_id=movie_id)
|
||||
def get_movie_by_id(movie_service: movie_service_dep, movie: movie_dep):
|
||||
"""
|
||||
Get details for a specific movie.
|
||||
"""
|
||||
return movie_service.get_public_movie_by_id(movie=movie)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{movie_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(current_superuser)],
|
||||
)
|
||||
def delete_a_movie(
|
||||
movie_service: movie_service_dep,
|
||||
movie: movie_dep,
|
||||
delete_files_on_disk: bool = False,
|
||||
delete_torrents: bool = False,
|
||||
):
|
||||
"""
|
||||
Delete a movie from the library.
|
||||
"""
|
||||
movie_service.delete_movie(
|
||||
movie=movie,
|
||||
delete_files_on_disk=delete_files_on_disk,
|
||||
delete_torrents=delete_torrents,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{movie_id}/library",
|
||||
dependencies=[Depends(current_superuser)],
|
||||
response_model=None,
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def set_library(
|
||||
movie: movie_dep,
|
||||
movie_service: movie_service_dep,
|
||||
library: str,
|
||||
) -> None:
|
||||
"""
|
||||
Set the library path for a Movie.
|
||||
"""
|
||||
movie_service.set_movie_library(movie=movie, library=library)
|
||||
return
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{movie_id}/files",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[PublicMovieFile],
|
||||
)
|
||||
def get_movie_files_by_movie_id(movie_service: movie_service_dep, movie: movie_dep):
|
||||
"""
|
||||
Get files associated with a specific movie.
|
||||
"""
|
||||
return movie_service.get_public_movie_files(movie=movie)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -294,13 +363,16 @@ def get_movie_by_id(movie_service: movie_service_dep, movie_id: MovieId):
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[IndexerQueryResult],
|
||||
)
|
||||
def get_all_available_torrents_for_a_movie(
|
||||
def search_for_torrents_for_movie(
|
||||
movie_service: movie_service_dep,
|
||||
movie_id: MovieId,
|
||||
movie: movie_dep,
|
||||
search_query_override: str | None = None,
|
||||
):
|
||||
return movie_service.get_all_available_torrents_for_a_movie(
|
||||
movie_id=movie_id, search_query_override=search_query_override
|
||||
"""
|
||||
Search for torrents for a specific movie.
|
||||
"""
|
||||
return movie_service.get_all_available_torrents_for_movie(
|
||||
movie=movie, search_query_override=search_query_override
|
||||
)
|
||||
|
||||
|
||||
@@ -312,39 +384,15 @@ def get_all_available_torrents_for_a_movie(
|
||||
)
|
||||
def download_torrent_for_movie(
|
||||
movie_service: movie_service_dep,
|
||||
movie_id: MovieId,
|
||||
movie: movie_dep,
|
||||
public_indexer_result_id: IndexerQueryResultId,
|
||||
override_file_path_suffix: str = "",
|
||||
):
|
||||
"""
|
||||
Trigger a download for a specific torrent for a movie.
|
||||
"""
|
||||
return movie_service.download_torrent(
|
||||
public_indexer_result_id=public_indexer_result_id,
|
||||
movie_id=movie_id,
|
||||
movie=movie,
|
||||
override_movie_file_path_suffix=override_file_path_suffix,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{movie_id}/files",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[PublicMovieFile],
|
||||
)
|
||||
def get_movie_files_by_movie_id(movie_service: movie_service_dep, movie_id: MovieId):
|
||||
return movie_service.get_public_movie_files_by_movie_id(movie_id=movie_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{movie_id}/library",
|
||||
dependencies=[Depends(current_superuser)],
|
||||
response_model=None,
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def set_library(
|
||||
movie_id: MovieId,
|
||||
movie_service: movie_service_dep,
|
||||
library: str,
|
||||
) -> None:
|
||||
"""
|
||||
Sets the library of a movie.
|
||||
"""
|
||||
movie_service.set_movie_library(movie_id=movie_id, library=library)
|
||||
return
|
||||
|
||||
@@ -5,8 +5,8 @@ from pathlib import Path
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.exceptions import InvalidConfigError
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.exceptions import InvalidConfigError, NotFoundError
|
||||
from media_manager.indexer.repository import IndexerRepository
|
||||
from media_manager.database import SessionLocal, get_session
|
||||
from media_manager.indexer.schemas import IndexerQueryResult
|
||||
@@ -15,7 +15,7 @@ from media_manager.indexer.utils import evaluate_indexer_query_results
|
||||
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
|
||||
from media_manager.notification.service import NotificationService
|
||||
from media_manager.schemas import MediaImportSuggestion
|
||||
from media_manager.torrent.schemas import Torrent, TorrentStatus, Quality
|
||||
from media_manager.torrent.schemas import Torrent, TorrentStatus
|
||||
from media_manager.torrent.service import TorrentService
|
||||
from media_manager.movies import log
|
||||
from media_manager.movies.schemas import (
|
||||
@@ -31,15 +31,14 @@ from media_manager.movies.schemas import (
|
||||
)
|
||||
from media_manager.torrent.schemas import QualityStrings
|
||||
from media_manager.movies.repository import MovieRepository
|
||||
from media_manager.exceptions import NotFoundError
|
||||
from media_manager.torrent.repository import TorrentRepository
|
||||
from media_manager.torrent.utils import (
|
||||
import_file,
|
||||
get_files_for_import,
|
||||
remove_special_characters,
|
||||
get_importable_media_directories,
|
||||
extract_external_id_from_string,
|
||||
remove_special_chars_and_parentheses,
|
||||
extract_external_id_from_string,
|
||||
)
|
||||
from media_manager.indexer.service import IndexerService
|
||||
from media_manager.metadataProvider.abstractMetaDataProvider import (
|
||||
@@ -78,6 +77,9 @@ class MovieService:
|
||||
movie_with_metadata = metadata_provider.get_movie_metadata(
|
||||
id=external_id, language=language
|
||||
)
|
||||
if not movie_with_metadata:
|
||||
return None
|
||||
|
||||
saved_movie = self.movie_repository.save_movie(movie=movie_with_metadata)
|
||||
metadata_provider.download_movie_poster_image(movie=saved_movie)
|
||||
return saved_movie
|
||||
@@ -124,35 +126,35 @@ class MovieService:
|
||||
|
||||
def delete_movie(
|
||||
self,
|
||||
movie_id: MovieId,
|
||||
movie: Movie,
|
||||
delete_files_on_disk: bool = False,
|
||||
delete_torrents: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Delete a movie from the database, optionally deleting files and torrents.
|
||||
|
||||
:param movie_id: The ID of the movie to delete.
|
||||
:param movie: The movie to delete.
|
||||
:param delete_files_on_disk: Whether to delete the movie's files from disk.
|
||||
:param delete_torrents: Whether to delete associated torrents from the torrent client.
|
||||
"""
|
||||
if delete_files_on_disk or delete_torrents:
|
||||
movie = self.movie_repository.get_movie_by_id(movie_id=movie_id)
|
||||
|
||||
log.debug(f"Deleting ID: {movie.id} - Name: {movie.name}")
|
||||
|
||||
if delete_files_on_disk:
|
||||
# Get the movie's directory path
|
||||
movie_dir = self.get_movie_root_path(movie=movie)
|
||||
|
||||
log.debug(f"Attempt to delete movie directory: {movie_dir}")
|
||||
if movie_dir.exists() and movie_dir.is_dir():
|
||||
shutil.rmtree(movie_dir)
|
||||
log.info(f"Deleted movie directory: {movie_dir}")
|
||||
try:
|
||||
shutil.rmtree(movie_dir)
|
||||
log.info(f"Deleted movie directory: {movie_dir}")
|
||||
except OSError as e:
|
||||
log.error(
|
||||
f"Deleting movie directory: {movie_dir} : {e.strerror}"
|
||||
)
|
||||
|
||||
if delete_torrents:
|
||||
# Get all torrents associated with this movie
|
||||
torrents = self.movie_repository.get_torrents_by_movie_id(
|
||||
movie_id=movie_id
|
||||
movie_id=movie.id
|
||||
)
|
||||
for torrent in torrents:
|
||||
try:
|
||||
@@ -164,25 +166,22 @@ class MovieService:
|
||||
log.warning(f"Failed to delete torrent {torrent.hash}: {e}")
|
||||
|
||||
# Delete from database
|
||||
self.movie_repository.delete_movie(movie_id=movie_id)
|
||||
self.movie_repository.delete_movie(movie_id=movie.id)
|
||||
|
||||
def get_public_movie_files_by_movie_id(
|
||||
self, movie_id: MovieId
|
||||
) -> list[PublicMovieFile]:
|
||||
def get_public_movie_files(self, movie: Movie) -> list[PublicMovieFile]:
|
||||
"""
|
||||
Get all public movie files for a given movie ID.
|
||||
Get all public movie files for a given movie.
|
||||
|
||||
:param movie_id: The ID of the movie.
|
||||
:param movie: The movie object.
|
||||
:return: A list of public movie files.
|
||||
"""
|
||||
movie_files = self.movie_repository.get_movie_files_by_movie_id(
|
||||
movie_id=movie_id
|
||||
movie_id=movie.id
|
||||
)
|
||||
public_movie_files = [PublicMovieFile.model_validate(x) for x in movie_files]
|
||||
result = []
|
||||
for movie_file in public_movie_files:
|
||||
if self.movie_file_exists_on_file(movie_file=movie_file):
|
||||
movie_file.downloaded = True
|
||||
movie_file.imported = self.movie_file_exists_on_file(movie_file=movie_file)
|
||||
result.append(movie_file)
|
||||
return result
|
||||
|
||||
@@ -212,26 +211,24 @@ class MovieService:
|
||||
elif movie_id:
|
||||
try:
|
||||
self.movie_repository.get_movie_by_id(movie_id=movie_id)
|
||||
return True
|
||||
except NotFoundError:
|
||||
return False
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
"External ID and metadata provider or Movie ID must be provided"
|
||||
"Either external_id and metadata_provider or movie_id must be provided"
|
||||
)
|
||||
|
||||
def get_all_available_torrents_for_a_movie(
|
||||
self, movie_id: MovieId, search_query_override: str = None
|
||||
def get_all_available_torrents_for_movie(
|
||||
self, movie: Movie, search_query_override: str = None
|
||||
) -> list[IndexerQueryResult]:
|
||||
"""
|
||||
Get all available torrents for a given movie.
|
||||
|
||||
:param movie_id: The ID of the movie.
|
||||
:param movie: The movie object.
|
||||
:param search_query_override: Optional override for the search query.
|
||||
:return: A list of indexer query results.
|
||||
"""
|
||||
log.debug(f"getting all available torrents for movie {movie_id}")
|
||||
movie = self.movie_repository.get_movie_by_id(movie_id=movie_id)
|
||||
if search_query_override:
|
||||
torrents = self.indexer_service.search(
|
||||
query=search_query_override, is_tv=False
|
||||
@@ -302,17 +299,16 @@ class MovieService:
|
||||
|
||||
return filtered_results
|
||||
|
||||
def get_public_movie_by_id(self, movie_id: MovieId) -> PublicMovie:
|
||||
def get_public_movie_by_id(self, movie: Movie) -> PublicMovie:
|
||||
"""
|
||||
Get a public movie by its ID.
|
||||
Get a public movie from a Movie object.
|
||||
|
||||
:param movie_id: The ID of the movie.
|
||||
:param movie: The movie object.
|
||||
:return: A public movie.
|
||||
"""
|
||||
movie = self.movie_repository.get_movie_by_id(movie_id=movie_id)
|
||||
torrents = self.get_torrents_for_movie(movie=movie).torrents
|
||||
public_movie = PublicMovie.model_validate(movie)
|
||||
public_movie.downloaded = self.is_movie_downloaded(movie_id=movie.id)
|
||||
public_movie.downloaded = self.is_movie_downloaded(movie=movie)
|
||||
public_movie.torrents = torrents
|
||||
return public_movie
|
||||
|
||||
@@ -325,15 +321,15 @@ class MovieService:
|
||||
"""
|
||||
return self.movie_repository.get_movie_by_id(movie_id=movie_id)
|
||||
|
||||
def is_movie_downloaded(self, movie_id: MovieId) -> bool:
|
||||
def is_movie_downloaded(self, movie: Movie) -> bool:
|
||||
"""
|
||||
Check if a movie is downloaded.
|
||||
|
||||
:param movie_id: The ID of the movie.
|
||||
:param movie: The movie object.
|
||||
:return: True if the movie is downloaded, False otherwise.
|
||||
"""
|
||||
movie_files = self.movie_repository.get_movie_files_by_movie_id(
|
||||
movie_id=movie_id
|
||||
movie_id=movie.id
|
||||
)
|
||||
for movie_file in movie_files:
|
||||
if self.movie_file_exists_on_file(movie_file=movie_file):
|
||||
@@ -379,8 +375,8 @@ class MovieService:
|
||||
"""
|
||||
return self.movie_repository.get_movie_requests()
|
||||
|
||||
def set_movie_library(self, movie_id: MovieId, library: str) -> None:
|
||||
self.movie_repository.set_movie_library(movie_id=movie_id, library=library)
|
||||
def set_movie_library(self, movie: Movie, library: str) -> None:
|
||||
self.movie_repository.set_movie_library(movie_id=movie.id, library=library)
|
||||
|
||||
def get_torrents_for_movie(self, movie: Movie) -> RichMovieTorrent:
|
||||
"""
|
||||
@@ -412,24 +408,24 @@ class MovieService:
|
||||
def download_torrent(
|
||||
self,
|
||||
public_indexer_result_id: IndexerQueryResultId,
|
||||
movie_id: MovieId,
|
||||
movie: Movie,
|
||||
override_movie_file_path_suffix: str = "",
|
||||
) -> Torrent:
|
||||
"""
|
||||
Download a torrent for a given indexer result and movie.
|
||||
|
||||
:param public_indexer_result_id: The ID of the indexer result.
|
||||
:param movie_id: The ID of the movie.
|
||||
:param movie: The movie object.
|
||||
:param override_movie_file_path_suffix: Optional override for the file path suffix.
|
||||
:return: The downloaded torrent.
|
||||
"""
|
||||
indexer_result = self.indexer_service.get_result(
|
||||
result_id=public_indexer_result_id
|
||||
indexer_query_result_id=public_indexer_result_id
|
||||
)
|
||||
movie_torrent = self.torrent_service.download(indexer_result=indexer_result)
|
||||
self.torrent_service.pause_download(torrent=movie_torrent)
|
||||
movie_file = MovieFile(
|
||||
movie_id=movie_id,
|
||||
movie_id=movie.id,
|
||||
quality=indexer_result.quality,
|
||||
torrent_id=movie_torrent.id,
|
||||
file_path_suffix=override_movie_file_path_suffix,
|
||||
@@ -437,8 +433,8 @@ class MovieService:
|
||||
try:
|
||||
self.movie_repository.add_movie_file(movie_file=movie_file)
|
||||
except IntegrityError:
|
||||
log.error(
|
||||
f"Movie file for movie {movie_id} and quality {indexer_result.quality} already exists."
|
||||
log.warning(
|
||||
f"Movie file for movie {movie.name} and torrent {movie_torrent.title} already exists"
|
||||
)
|
||||
self.torrent_service.cancel_download(
|
||||
torrent=movie_torrent, delete_files=True
|
||||
@@ -446,7 +442,7 @@ class MovieService:
|
||||
raise
|
||||
else:
|
||||
log.info(
|
||||
f"Added movie file for movie {movie_id} and quality {indexer_result.quality}."
|
||||
f"Added movie file for movie {movie.name} and torrent {movie_torrent.title}"
|
||||
)
|
||||
self.torrent_service.resume_download(torrent=movie_torrent)
|
||||
return movie_torrent
|
||||
@@ -463,16 +459,11 @@ class MovieService:
|
||||
:raises ValueError: If the movie request is not authorized.
|
||||
"""
|
||||
if not movie_request.authorized:
|
||||
log.error(
|
||||
f"Movie request {movie_request.id} is not authorized for download"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Movie request {movie_request.id} is not authorized for download"
|
||||
)
|
||||
raise ValueError("Movie request is not authorized")
|
||||
|
||||
log.info(f"Downloading approved movie request {movie_request.id}")
|
||||
|
||||
torrents = self.get_all_available_torrents_for_a_movie(movie_id=movie.id)
|
||||
torrents = self.get_all_available_torrents_for_movie(movie=movie)
|
||||
available_torrents: list[IndexerQueryResult] = []
|
||||
|
||||
for torrent in torrents:
|
||||
@@ -481,18 +472,18 @@ class MovieService:
|
||||
or (torrent.quality.value > movie_request.min_quality.value)
|
||||
or (torrent.seeders < 3)
|
||||
):
|
||||
log.info(
|
||||
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.info(
|
||||
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 matching criteria were found (wanted quality: {movie_request.wanted_quality}, min_quality: {movie_request.min_quality} for movie {movie.id})"
|
||||
f"No torrents found for movie request {movie_request.id} with quality between {QualityStrings[movie_request.min_quality]} and {QualityStrings[movie_request.wanted_quality]}"
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -509,13 +500,13 @@ class MovieService:
|
||||
self.movie_repository.add_movie_file(movie_file=movie_file)
|
||||
except IntegrityError:
|
||||
log.warning(
|
||||
f"Movie file for movie {movie.id} and quality {torrent.quality} already exists, skipping."
|
||||
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 = AllEncompassingConfig().misc
|
||||
misc_config = MediaManagerConfig().misc
|
||||
movie_file_path = (
|
||||
misc_config.movie_directory
|
||||
/ f"{remove_special_characters(movie.name)} ({movie.year}) [{movie.metadata_provider}id-{movie.external_id}]"
|
||||
@@ -533,7 +524,7 @@ class MovieService:
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
f"Movie library {movie.library} not found in config, using default movie directory."
|
||||
f"Library {movie.library} not found in config, using default library"
|
||||
)
|
||||
return movie_file_path
|
||||
|
||||
@@ -553,8 +544,8 @@ class MovieService:
|
||||
try:
|
||||
movie_root_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
log.warning(f"Could not create path {movie_root_path}: {e}")
|
||||
raise e
|
||||
log.error(f"Failed to create directory {movie_root_path}: {e}")
|
||||
return False
|
||||
|
||||
# import movie video
|
||||
if video_files:
|
||||
@@ -595,13 +586,15 @@ class MovieService:
|
||||
if len(video_files) != 1:
|
||||
# Send notification about multiple video files found
|
||||
if self.notification_service:
|
||||
self.notification_service.send_notification_to_all_providers(
|
||||
title="Multiple Video Files Found",
|
||||
message=f"Found {len(video_files)} video files in movie torrent '{torrent.title}' for {movie.name} ({movie.year}). Only the first will be imported. Manual intervention recommended.",
|
||||
self.notification_service.send_notification(
|
||||
title="Manual Import Required",
|
||||
body=f"Multiple video files found for movie {movie.name}. Please import manually.",
|
||||
)
|
||||
log.error(
|
||||
"Found multiple video files in movie torrent, only the first will be imported. Manual intervention is recommended."
|
||||
f"Found {len(video_files)} video files for movie {movie.name}, expected 1. Skipping auto import."
|
||||
)
|
||||
return
|
||||
|
||||
log.debug(
|
||||
f"Importing these {len(video_files)} video files and {len(subtitle_files)} subtitle files"
|
||||
)
|
||||
@@ -628,19 +621,19 @@ class MovieService:
|
||||
self.torrent_service.torrent_repository.save_torrent(torrent=torrent)
|
||||
|
||||
if self.notification_service:
|
||||
self.notification_service.send_notification_to_all_providers(
|
||||
self.notification_service.send_notification(
|
||||
title="Movie Downloaded",
|
||||
message=f"Successfully downloaded: {movie.name} ({movie.year}) from torrent {torrent.title}.",
|
||||
body=f"Movie {movie.name} has been successfully downloaded and imported.",
|
||||
)
|
||||
else:
|
||||
log.error(
|
||||
f"Importing files for torrent {torrent.title} encountered errors."
|
||||
f"Failed to import files for torrent {torrent.title}. Check logs for details."
|
||||
)
|
||||
|
||||
if self.notification_service:
|
||||
self.notification_service.send_notification_to_all_providers(
|
||||
title="Movie import failed",
|
||||
message=f"There were errors importing: {movie.name} ({movie.year}) from torrent {torrent.title}. Please check the logs for details.",
|
||||
self.notification_service.send_notification(
|
||||
title="Import Failed",
|
||||
body=f"Failed to import files for movie {movie.name}. Please check logs.",
|
||||
)
|
||||
|
||||
log.info(f"Finished importing files for torrent {torrent.title}")
|
||||
@@ -649,13 +642,15 @@ class MovieService:
|
||||
self, movie: Path, metadata_provider: AbstractMetadataProvider
|
||||
) -> MediaImportSuggestion:
|
||||
search_result = self.search_for_movie(
|
||||
remove_special_chars_and_parentheses(movie.name), metadata_provider
|
||||
query=remove_special_chars_and_parentheses(movie.name),
|
||||
metadata_provider=metadata_provider,
|
||||
)
|
||||
import_candidates = MediaImportSuggestion(
|
||||
directory=movie, candidates=search_result
|
||||
directory=movie,
|
||||
candidates=search_result,
|
||||
)
|
||||
log.debug(
|
||||
f"Found {len(import_candidates.candidates)} candidates for {import_candidates.directory}"
|
||||
f"Found {len(search_result)} candidates for {movie.name} in {movie.parent}"
|
||||
)
|
||||
return import_candidates
|
||||
|
||||
@@ -664,9 +659,7 @@ class MovieService:
|
||||
try:
|
||||
source_directory.rename(new_source_path)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Failed to rename directory '{source_directory}' to '{new_source_path}': {e}"
|
||||
)
|
||||
log.error(f"Failed to rename {source_directory} to {new_source_path}: {e}")
|
||||
raise Exception("Failed to rename directory") from e
|
||||
|
||||
video_files, subtitle_files, all_files = get_files_for_import(
|
||||
@@ -685,7 +678,6 @@ class MovieService:
|
||||
movie_id=movie.id,
|
||||
file_path_suffix="IMPORTED",
|
||||
torrent_id=None,
|
||||
quality=Quality.unknown,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -709,9 +701,9 @@ class MovieService:
|
||||
)
|
||||
if not fresh_movie_data:
|
||||
log.warning(
|
||||
f"Could not fetch fresh metadata for movie {db_movie.name} (External ID: {db_movie.external_id}) from {db_movie.metadata_provider}."
|
||||
f"Could not fetch fresh metadata for movie: {db_movie.name} (ID: {db_movie.external_id})"
|
||||
)
|
||||
return db_movie
|
||||
return None
|
||||
log.debug(f"Fetched fresh metadata for movie: {fresh_movie_data.name}")
|
||||
|
||||
self.movie_repository.update_movie_attributes(
|
||||
@@ -731,7 +723,7 @@ class MovieService:
|
||||
def get_importable_movies(
|
||||
self, metadata_provider: AbstractMetadataProvider
|
||||
) -> list[MediaImportSuggestion]:
|
||||
movie_root_path = AllEncompassingConfig().misc.movie_directory
|
||||
movie_root_path = MediaManagerConfig().misc.movie_directory
|
||||
importable_movies: list[MediaImportSuggestion] = []
|
||||
candidate_dirs = get_importable_media_directories(movie_root_path)
|
||||
|
||||
@@ -788,8 +780,8 @@ def auto_download_all_approved_movie_requests() -> None:
|
||||
):
|
||||
count += 1
|
||||
else:
|
||||
log.warning(
|
||||
f"Failed to download movie request {movie_request.id} for movie {movie.name}"
|
||||
log.info(
|
||||
f"Could not download movie request {movie_request.id} for movie {movie.name}"
|
||||
)
|
||||
|
||||
log.info(f"Auto downloaded {count} approved movie requests")
|
||||
@@ -822,7 +814,8 @@ def import_all_movie_torrents() -> None:
|
||||
movie_service.import_torrent_files(torrent=t, movie=movie)
|
||||
except RuntimeError as e:
|
||||
log.error(
|
||||
f"Error importing torrent {t.title} for movie {movie.name}: {e}"
|
||||
f"Failed to import torrent {t.title}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
log.info("Finished importing all torrents")
|
||||
db.commit()
|
||||
@@ -862,14 +855,7 @@ def update_all_movies_metadata() -> None:
|
||||
f"Error initializing metadata provider {movie.metadata_provider} for movie {movie.name}: {str(e)}"
|
||||
)
|
||||
continue
|
||||
updated_movie = movie_service.update_movie_metadata(
|
||||
movie_service.update_movie_metadata(
|
||||
db_movie=movie, metadata_provider=metadata_provider
|
||||
)
|
||||
|
||||
if updated_movie:
|
||||
log.info(
|
||||
f"Successfully updated metadata for movie: {updated_movie.name}"
|
||||
)
|
||||
else:
|
||||
log.warning(f"Failed to update metadata for movie: {movie.name}")
|
||||
db.commit()
|
||||
|
||||
@@ -20,7 +20,7 @@ from media_manager.notification.service_providers.ntfy import (
|
||||
from media_manager.notification.service_providers.pushover import (
|
||||
PushoverNotificationServiceProvider,
|
||||
)
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,7 +31,7 @@ class NotificationManager:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = AllEncompassingConfig().notifications
|
||||
self.config = MediaManagerConfig().notifications
|
||||
self.providers: List[AbstractNotificationServiceProvider] = []
|
||||
self._initialize_providers()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.exc import (
|
||||
from sqlalchemy.orm import Session
|
||||
import logging
|
||||
|
||||
from media_manager.exceptions import NotFoundError, MediaAlreadyExists
|
||||
from media_manager.exceptions import NotFoundError, ConflictError
|
||||
from media_manager.notification.models import Notification
|
||||
from media_manager.notification.schemas import (
|
||||
NotificationId,
|
||||
@@ -24,7 +24,7 @@ class NotificationRepository:
|
||||
result = self.db.get(Notification, id)
|
||||
|
||||
if not result:
|
||||
raise NotFoundError
|
||||
raise NotFoundError(f"Notification with id {id} not found.")
|
||||
|
||||
return NotificationSchema.model_validate(result)
|
||||
|
||||
@@ -69,7 +69,7 @@ class NotificationRepository:
|
||||
self.db.commit()
|
||||
except IntegrityError as e:
|
||||
log.error(f"Could not save notification, Error: {e}")
|
||||
raise MediaAlreadyExists(
|
||||
raise ConflictError(
|
||||
f"Notification with id {notification.id} already exists."
|
||||
)
|
||||
return
|
||||
|
||||
@@ -3,12 +3,12 @@ from media_manager.notification.schemas import MessageNotification
|
||||
from media_manager.notification.service_providers.abstractNotificationServiceProvider import (
|
||||
AbstractNotificationServiceProvider,
|
||||
)
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
|
||||
|
||||
class EmailNotificationServiceProvider(AbstractNotificationServiceProvider):
|
||||
def __init__(self):
|
||||
self.config = AllEncompassingConfig().notifications.email_notifications
|
||||
self.config = MediaManagerConfig().notifications.email_notifications
|
||||
|
||||
def send_notification(self, message: MessageNotification) -> bool:
|
||||
subject = "MediaManager - " + message.title
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import requests
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.notification.schemas import MessageNotification
|
||||
from media_manager.notification.service_providers.abstractNotificationServiceProvider import (
|
||||
AbstractNotificationServiceProvider,
|
||||
@@ -13,7 +13,7 @@ class GotifyNotificationServiceProvider(AbstractNotificationServiceProvider):
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = AllEncompassingConfig().notifications.gotify
|
||||
self.config = MediaManagerConfig().notifications.gotify
|
||||
|
||||
def send_notification(self, message: MessageNotification) -> bool:
|
||||
response = requests.post(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import requests
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.notification.schemas import MessageNotification
|
||||
from media_manager.notification.service_providers.abstractNotificationServiceProvider import (
|
||||
AbstractNotificationServiceProvider,
|
||||
@@ -13,7 +13,7 @@ class NtfyNotificationServiceProvider(AbstractNotificationServiceProvider):
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = AllEncompassingConfig().notifications.ntfy
|
||||
self.config = MediaManagerConfig().notifications.ntfy
|
||||
|
||||
def send_notification(self, message: MessageNotification) -> bool:
|
||||
response = requests.post(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import requests
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.notification.schemas import MessageNotification
|
||||
from media_manager.notification.service_providers.abstractNotificationServiceProvider import (
|
||||
AbstractNotificationServiceProvider,
|
||||
@@ -9,7 +9,7 @@ from media_manager.notification.service_providers.abstractNotificationServicePro
|
||||
|
||||
class PushoverNotificationServiceProvider(AbstractNotificationServiceProvider):
|
||||
def __init__(self):
|
||||
self.config = AllEncompassingConfig().notifications.pushover
|
||||
self.config = MediaManagerConfig().notifications.pushover
|
||||
|
||||
def send_notification(self, message: MessageNotification) -> bool:
|
||||
response = requests.post(
|
||||
|
||||
@@ -3,13 +3,13 @@ import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_email(subject: str, html: str, addressee: str) -> None:
|
||||
email_conf = AllEncompassingConfig().notifications.smtp_config
|
||||
email_conf = MediaManagerConfig().notifications.smtp_config
|
||||
message = MIMEMultipart()
|
||||
message["From"] = email_conf.from_email
|
||||
message["To"] = addressee
|
||||
|
||||
65
media_manager/scheduler.py
Normal file
65
media_manager/scheduler.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||
import media_manager.database
|
||||
from media_manager.tv.service import (
|
||||
auto_download_all_approved_season_requests,
|
||||
import_all_show_torrents,
|
||||
update_all_non_ended_shows_metadata,
|
||||
)
|
||||
from media_manager.movies.service import (
|
||||
import_all_movie_torrents,
|
||||
update_all_movies_metadata,
|
||||
auto_download_all_approved_movie_requests,
|
||||
)
|
||||
|
||||
|
||||
def setup_scheduler(config, log):
|
||||
from media_manager.database import init_engine
|
||||
|
||||
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
|
||||
)
|
||||
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
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import qbittorrentapi
|
||||
from qbittorrentapi import Conflict409Error
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.indexer.schemas import IndexerQueryResult
|
||||
from media_manager.torrent.download_clients.abstractDownloadClient import (
|
||||
AbstractDownloadClient,
|
||||
@@ -44,7 +44,7 @@ class QbittorrentDownloadClient(AbstractDownloadClient):
|
||||
UNKNOWN_STATE = ("unknown",)
|
||||
|
||||
def __init__(self):
|
||||
self.config = AllEncompassingConfig().torrents.qbittorrent
|
||||
self.config = MediaManagerConfig().torrents.qbittorrent
|
||||
self.api_client = qbittorrentapi.Client(
|
||||
host=self.config.host,
|
||||
port=self.config.port,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.indexer.schemas import IndexerQueryResult
|
||||
from media_manager.torrent.download_clients.abstractDownloadClient import (
|
||||
AbstractDownloadClient,
|
||||
@@ -27,7 +27,7 @@ class SabnzbdDownloadClient(AbstractDownloadClient):
|
||||
UNKNOWN_STATE = ("Unknown",)
|
||||
|
||||
def __init__(self):
|
||||
self.config = AllEncompassingConfig().torrents.sabnzbd
|
||||
self.config = MediaManagerConfig().torrents.sabnzbd
|
||||
self.client = sabnzbd_api.SabnzbdClient(
|
||||
host=self.config.host,
|
||||
port=str(self.config.port),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import transmission_rpc
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.indexer.schemas import IndexerQueryResult
|
||||
from media_manager.torrent.download_clients.abstractDownloadClient import (
|
||||
AbstractDownloadClient,
|
||||
@@ -27,7 +27,7 @@ class TransmissionDownloadClient(AbstractDownloadClient):
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.config = AllEncompassingConfig().torrents.transmission
|
||||
self.config = MediaManagerConfig().torrents.transmission
|
||||
try:
|
||||
self._client = transmission_rpc.Client(
|
||||
host=self.config.host,
|
||||
@@ -52,7 +52,7 @@ class TransmissionDownloadClient(AbstractDownloadClient):
|
||||
"""
|
||||
torrent_hash = get_torrent_hash(torrent=indexer_result)
|
||||
download_dir = (
|
||||
AllEncompassingConfig().misc.torrent_directory / indexer_result.title
|
||||
MediaManagerConfig().misc.torrent_directory / indexer_result.title
|
||||
)
|
||||
try:
|
||||
self._client.add_torrent(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from enum import Enum
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.indexer.schemas import IndexerQueryResult
|
||||
from media_manager.torrent.download_clients.abstractDownloadClient import (
|
||||
AbstractDownloadClient,
|
||||
@@ -33,7 +33,7 @@ class DownloadManager:
|
||||
def __init__(self):
|
||||
self._torrent_client: AbstractDownloadClient | None = None
|
||||
self._usenet_client: AbstractDownloadClient | None = None
|
||||
self.config = AllEncompassingConfig().torrents
|
||||
self.config = MediaManagerConfig().torrents
|
||||
self._initialize_clients()
|
||||
|
||||
def _initialize_clients(self) -> None:
|
||||
|
||||
@@ -11,7 +11,7 @@ import requests
|
||||
import libtorrent
|
||||
from requests.exceptions import InvalidSchema
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.indexer.schemas import IndexerQueryResult
|
||||
from media_manager.indexer.utils import follow_redirects_to_final_torrent_url
|
||||
from media_manager.torrent.schemas import Torrent
|
||||
@@ -62,7 +62,7 @@ def extract_archives(files):
|
||||
|
||||
|
||||
def get_torrent_filepath(torrent: Torrent):
|
||||
return AllEncompassingConfig().misc.torrent_directory / torrent.title
|
||||
return MediaManagerConfig().misc.torrent_directory / torrent.title
|
||||
|
||||
|
||||
def import_file(target_file: Path, source_file: Path):
|
||||
@@ -128,7 +128,7 @@ def get_torrent_hash(torrent: IndexerQueryResult) -> str:
|
||||
:return: The hash of the torrent.
|
||||
"""
|
||||
torrent_filepath = (
|
||||
AllEncompassingConfig().misc.torrent_directory / f"{torrent.title}.torrent"
|
||||
MediaManagerConfig().misc.torrent_directory / f"{torrent.title}.torrent"
|
||||
)
|
||||
if torrent_filepath.exists():
|
||||
log.warning(f"Torrent file already exists at: {torrent_filepath}")
|
||||
@@ -149,7 +149,7 @@ def get_torrent_hash(torrent: IndexerQueryResult) -> str:
|
||||
final_url = follow_redirects_to_final_torrent_url(
|
||||
initial_url=torrent.download_url,
|
||||
session=requests.Session(),
|
||||
timeout=AllEncompassingConfig().indexers.prowlarr.timeout_seconds,
|
||||
timeout=MediaManagerConfig().indexers.prowlarr.timeout_seconds,
|
||||
)
|
||||
torrent_hash = str(libtorrent.parse_magnet_uri(final_url).info_hash)
|
||||
return torrent_hash
|
||||
@@ -217,8 +217,8 @@ def remove_special_chars_and_parentheses(title: str) -> str:
|
||||
|
||||
def get_importable_media_directories(path: Path) -> list[Path]:
|
||||
libraries = []
|
||||
libraries.extend(AllEncompassingConfig().misc.movie_libraries)
|
||||
libraries.extend(AllEncompassingConfig().misc.tv_libraries)
|
||||
libraries.extend(MediaManagerConfig().misc.movie_libraries)
|
||||
libraries.extend(MediaManagerConfig().misc.tv_libraries)
|
||||
|
||||
library_paths = {Path(library.path).absolute() for library in libraries}
|
||||
|
||||
|
||||
@@ -2,20 +2,20 @@ from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.exc import (
|
||||
IntegrityError,
|
||||
SQLAlchemyError,
|
||||
) # Keep SQLAlchemyError for broader exception handling
|
||||
)
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from media_manager.torrent.models import Torrent
|
||||
from media_manager.torrent.schemas import TorrentId, Torrent as TorrentSchema
|
||||
from media_manager.tv import log
|
||||
from media_manager.tv.models import Season, Show, Episode, SeasonRequest, SeasonFile
|
||||
from media_manager.exceptions import NotFoundError, MediaAlreadyExists
|
||||
from media_manager.exceptions import NotFoundError, ConflictError
|
||||
from media_manager.tv.schemas import (
|
||||
Season as SeasonSchema,
|
||||
SeasonId,
|
||||
Show as ShowSchema,
|
||||
ShowId,
|
||||
Episode as EpisodeSchema, # Added EpisodeSchema import
|
||||
Episode as EpisodeSchema,
|
||||
SeasonRequest as SeasonRequestSchema,
|
||||
SeasonFile as SeasonFileSchema,
|
||||
SeasonNumber,
|
||||
@@ -98,7 +98,7 @@ class TvRepository:
|
||||
try:
|
||||
stmt = select(Show).options(
|
||||
joinedload(Show.seasons).joinedload(Season.episodes)
|
||||
) # Eager load seasons and episodes
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
return [ShowSchema.model_validate(show) for show in results]
|
||||
except SQLAlchemyError as e:
|
||||
@@ -178,7 +178,7 @@ class TvRepository:
|
||||
return ShowSchema.model_validate(db_show)
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
raise MediaAlreadyExists(
|
||||
raise ConflictError(
|
||||
f"Show with this primary key or unique constraint violation: {e.orig}"
|
||||
) from e
|
||||
except SQLAlchemyError as e:
|
||||
@@ -382,7 +382,7 @@ class TvRepository:
|
||||
stmt = delete(SeasonFile).where(SeasonFile.torrent_id == torrent_id)
|
||||
result = self.db.execute(stmt)
|
||||
self.db.commit()
|
||||
deleted_count = result.rowcount # rowcount is an int, not a callable
|
||||
deleted_count = result.rowcount
|
||||
return deleted_count
|
||||
except SQLAlchemyError as e:
|
||||
self.db.rollback()
|
||||
@@ -584,7 +584,6 @@ class TvRepository:
|
||||
episodes=[
|
||||
Episode(
|
||||
id=ep_schema.id,
|
||||
# season_id will be implicitly set by SQLAlchemy relationship
|
||||
number=ep_schema.number,
|
||||
external_id=ep_schema.external_id,
|
||||
title=ep_schema.title,
|
||||
@@ -646,7 +645,7 @@ class TvRepository:
|
||||
ended: bool | None = None,
|
||||
continuous_download: bool | None = None,
|
||||
imdb_id: str | None = None,
|
||||
) -> ShowSchema: # Removed poster_url from params
|
||||
) -> ShowSchema:
|
||||
"""
|
||||
Update attributes of an existing show.
|
||||
|
||||
|
||||
@@ -6,16 +6,23 @@ from fastapi import APIRouter, Depends, status, HTTPException
|
||||
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 AllEncompassingConfig, LibraryItem
|
||||
from media_manager.config import MediaManagerConfig, LibraryItem
|
||||
from media_manager.exceptions import MediaAlreadyExists
|
||||
from media_manager.indexer.schemas import (
|
||||
IndexerQueryResultId,
|
||||
IndexerQueryResult,
|
||||
)
|
||||
from media_manager.metadataProvider.dependencies import metadata_provider_dep
|
||||
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
|
||||
from media_manager.torrent.utils import get_importable_media_directories
|
||||
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.exceptions import MediaAlreadyExists
|
||||
from media_manager.tv.dependencies import (
|
||||
season_dep,
|
||||
show_dep,
|
||||
tv_service_dep,
|
||||
)
|
||||
from media_manager.tv.schemas import (
|
||||
Show,
|
||||
SeasonRequest,
|
||||
@@ -29,21 +36,94 @@ from media_manager.tv.schemas import (
|
||||
RichSeasonRequest,
|
||||
Season,
|
||||
)
|
||||
from media_manager.schemas import MediaImportSuggestion
|
||||
|
||||
from media_manager.tv.dependencies import (
|
||||
season_dep,
|
||||
show_dep,
|
||||
tv_service_dep,
|
||||
)
|
||||
from media_manager.metadataProvider.dependencies import metadata_provider_dep
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# METADATA & SEARCH
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# --------------------------------
|
||||
# CREATE AND DELETE SHOWS
|
||||
# --------------------------------
|
||||
|
||||
@router.get(
|
||||
"/search",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[MetaDataProviderSearchResult],
|
||||
)
|
||||
def search_metadata_providers_for_a_show(
|
||||
tv_service: tv_service_dep, query: str, metadata_provider: metadata_provider_dep
|
||||
):
|
||||
"""
|
||||
Search for a show on the configured metadata provider.
|
||||
"""
|
||||
return tv_service.search_for_show(query=query, metadata_provider=metadata_provider)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recommended",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[MetaDataProviderSearchResult],
|
||||
)
|
||||
def get_recommended_shows(
|
||||
tv_service: tv_service_dep, metadata_provider: metadata_provider_dep
|
||||
):
|
||||
"""
|
||||
Get a list of recommended/popular shows from the metadata provider.
|
||||
"""
|
||||
return tv_service.get_popular_shows(metadata_provider=metadata_provider)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# IMPORTING
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/importable",
|
||||
status_code=status.HTTP_200_OK,
|
||||
dependencies=[Depends(current_superuser)],
|
||||
response_model=list[MediaImportSuggestion],
|
||||
)
|
||||
def get_all_importable_shows(
|
||||
tv_service: tv_service_dep, metadata_provider: metadata_provider_dep
|
||||
):
|
||||
"""
|
||||
Get a list of unknown shows that were detected in the TV directory and are importable.
|
||||
"""
|
||||
return tv_service.get_importable_tv_shows(metadata_provider=metadata_provider)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/importable/{show_id}",
|
||||
dependencies=[Depends(current_superuser)],
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def import_detected_show(tv_service: tv_service_dep, tv_show: show_dep, directory: str):
|
||||
"""
|
||||
Import a detected show from the specified directory into the library.
|
||||
"""
|
||||
source_directory = Path(directory)
|
||||
if source_directory not in get_importable_media_directories(
|
||||
MediaManagerConfig().misc.tv_directory
|
||||
):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No such directory")
|
||||
tv_service.import_existing_tv_show(
|
||||
tv_show=tv_show, source_directory=source_directory
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SHOWS
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/shows", dependencies=[Depends(current_active_user)], response_model=list[Show]
|
||||
)
|
||||
def get_all_shows(tv_service: tv_service_dep):
|
||||
"""
|
||||
Get all shows in the library.
|
||||
"""
|
||||
return tv_service.get_all_shows()
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -63,6 +143,9 @@ def add_a_show(
|
||||
show_id: int,
|
||||
language: str | None = None,
|
||||
):
|
||||
"""
|
||||
Add a new show to the library.
|
||||
"""
|
||||
try:
|
||||
show = tv_service.add_show(
|
||||
external_id=show_id,
|
||||
@@ -77,14 +160,45 @@ def add_a_show(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/episodes/count",
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=int,
|
||||
description="Total number of episodes downloaded",
|
||||
"/shows/torrents",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[RichShowTorrent],
|
||||
)
|
||||
def get_total_count_of_downloaded_episodes(tv_service: tv_service_dep):
|
||||
return tv_service.get_total_downloaded_episoded_count()
|
||||
def get_shows_with_torrents(tv_service: tv_service_dep):
|
||||
"""
|
||||
Get all shows that are associated with torrents.
|
||||
"""
|
||||
result = tv_service.get_all_shows_with_torrents()
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/shows/libraries",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[LibraryItem],
|
||||
)
|
||||
def get_available_libraries():
|
||||
"""
|
||||
Get available TV libraries from configuration.
|
||||
"""
|
||||
return MediaManagerConfig().misc.tv_libraries
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SHOWS - INDIVIDUAL
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/shows/{show_id}",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=PublicShow,
|
||||
)
|
||||
def get_a_show(show: show_dep, tv_service: tv_service_dep) -> PublicShow:
|
||||
"""
|
||||
Get details for a specific show.
|
||||
"""
|
||||
return tv_service.get_public_show_by_id(show=show)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -98,91 +212,16 @@ def delete_a_show(
|
||||
delete_files_on_disk: bool = False,
|
||||
delete_torrents: bool = False,
|
||||
):
|
||||
"""
|
||||
Delete a show from the library.
|
||||
"""
|
||||
tv_service.delete_show(
|
||||
show_id=show.id,
|
||||
show=show,
|
||||
delete_files_on_disk=delete_files_on_disk,
|
||||
delete_torrents=delete_torrents,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------
|
||||
# GET SHOW INFORMATION
|
||||
# --------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/shows", dependencies=[Depends(current_active_user)], response_model=list[Show]
|
||||
)
|
||||
def get_all_shows(tv_service: tv_service_dep):
|
||||
return tv_service.get_all_shows()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/importable",
|
||||
status_code=status.HTTP_200_OK,
|
||||
dependencies=[Depends(current_superuser)],
|
||||
response_model=list[MediaImportSuggestion],
|
||||
)
|
||||
def get_all_importable_shows(
|
||||
tv_service: tv_service_dep, metadata_provider: metadata_provider_dep
|
||||
):
|
||||
"""
|
||||
get a list of unknown shows that were detected in the tv directory and are importable
|
||||
"""
|
||||
return tv_service.get_importable_tv_shows(metadata_provider=metadata_provider)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/importable/{show_id}",
|
||||
dependencies=[Depends(current_superuser)],
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def import_detected_show(tv_service: tv_service_dep, tv_show: show_dep, directory: str):
|
||||
"""
|
||||
Import a detected show from the specified directory into the library.
|
||||
"""
|
||||
source_directory = Path(directory)
|
||||
if source_directory not in get_importable_media_directories(
|
||||
AllEncompassingConfig().misc.tv_directory
|
||||
):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No such directory")
|
||||
tv_service.import_existing_tv_show(
|
||||
tv_show=tv_show, source_directory=source_directory
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/shows/torrents",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[RichShowTorrent],
|
||||
)
|
||||
def get_shows_with_torrents(tv_service: tv_service_dep):
|
||||
"""
|
||||
get all shows that are associated with torrents
|
||||
:return: A list of shows with all their torrents
|
||||
"""
|
||||
result = tv_service.get_all_shows_with_torrents()
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/shows/libraries",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[LibraryItem],
|
||||
)
|
||||
def get_available_libraries():
|
||||
return AllEncompassingConfig().misc.tv_libraries
|
||||
|
||||
|
||||
@router.get(
|
||||
"/shows/{show_id}",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=PublicShow,
|
||||
)
|
||||
def get_a_show(show: show_dep, tv_service: tv_service_dep) -> PublicShow:
|
||||
return tv_service.get_public_show_by_id(show_id=show.id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/shows/{show_id}/metadata",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
@@ -192,10 +231,10 @@ def update_shows_metadata(
|
||||
show: show_dep, tv_service: tv_service_dep, metadata_provider: metadata_provider_dep
|
||||
) -> PublicShow:
|
||||
"""
|
||||
Updates a shows metadata.
|
||||
Update a show's metadata from the provider.
|
||||
"""
|
||||
tv_service.update_show_metadata(db_show=show, metadata_provider=metadata_provider)
|
||||
return tv_service.get_public_show_by_id(show_id=show.id)
|
||||
return tv_service.get_public_show_by_id(show=show)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -207,26 +246,12 @@ def set_continuous_download(
|
||||
show: show_dep, tv_service: tv_service_dep, continuous_download: bool
|
||||
) -> PublicShow:
|
||||
"""
|
||||
Toggles whether future seasons of a show will be downloaded.
|
||||
Toggle whether future seasons of a show will be automatically downloaded.
|
||||
"""
|
||||
tv_service.set_show_continuous_download(
|
||||
show_id=show.id, continuous_download=continuous_download
|
||||
show=show, continuous_download=continuous_download
|
||||
)
|
||||
return tv_service.get_public_show_by_id(show_id=show.id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/shows/{show_id}/torrents",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=RichShowTorrent,
|
||||
)
|
||||
def get_a_shows_torrents(show: show_dep, tv_service: tv_service_dep):
|
||||
return tv_service.get_torrents_for_show(show=show)
|
||||
|
||||
|
||||
# --------------------------------
|
||||
# SET/GET LIBRARY OF A SHOW
|
||||
# --------------------------------
|
||||
return tv_service.get_public_show_by_id(show=show)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -241,15 +266,40 @@ def set_library(
|
||||
library: str,
|
||||
) -> None:
|
||||
"""
|
||||
Sets the library of a Show.
|
||||
Set the library path for a Show.
|
||||
"""
|
||||
tv_service.set_show_library(show_id=show.id, library=library)
|
||||
tv_service.set_show_library(show=show, library=library)
|
||||
return
|
||||
|
||||
|
||||
# --------------------------------
|
||||
# MANAGE REQUESTS
|
||||
# --------------------------------
|
||||
@router.get(
|
||||
"/shows/{show_id}/torrents",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=RichShowTorrent,
|
||||
)
|
||||
def get_a_shows_torrents(show: show_dep, tv_service: tv_service_dep):
|
||||
"""
|
||||
Get torrents associated with a specific show.
|
||||
"""
|
||||
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)],
|
||||
response_model=list[RichSeasonRequest],
|
||||
)
|
||||
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)
|
||||
@@ -259,7 +309,7 @@ def request_a_season(
|
||||
tv_service: tv_service_dep,
|
||||
):
|
||||
"""
|
||||
adds request flag to a season
|
||||
Create a new season request.
|
||||
"""
|
||||
request: SeasonRequest = SeasonRequest.model_validate(season_request)
|
||||
request.requested_by = UserRead.model_validate(user)
|
||||
@@ -270,14 +320,46 @@ def request_a_season(
|
||||
return
|
||||
|
||||
|
||||
@router.get(
|
||||
"/seasons/requests",
|
||||
status_code=status.HTTP_200_OK,
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[RichSeasonRequest],
|
||||
@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,
|
||||
):
|
||||
"""
|
||||
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 get_season_requests(tv_service: tv_service_dep) -> list[RichSeasonRequest]:
|
||||
return tv_service.get_all_season_requests()
|
||||
def authorize_request(
|
||||
tv_service: tv_service_dep,
|
||||
user: Annotated[User, Depends(current_superuser)],
|
||||
season_request_id: SeasonRequestId,
|
||||
authorized_status: bool = False,
|
||||
):
|
||||
"""
|
||||
Authorize or de-authorize a season request.
|
||||
"""
|
||||
season_request = tv_service.get_season_request_by_id(
|
||||
season_request_id=season_request_id
|
||||
)
|
||||
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)
|
||||
return
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -289,6 +371,9 @@ def delete_season_request(
|
||||
user: Annotated[User, Depends(current_active_user)],
|
||||
request_id: SeasonRequestId,
|
||||
):
|
||||
"""
|
||||
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)
|
||||
@@ -304,44 +389,9 @@ def delete_season_request(
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
):
|
||||
"""
|
||||
updates the request flag to true
|
||||
"""
|
||||
season_request = tv_service.get_season_request_by_id(
|
||||
season_request_id=season_request_id
|
||||
)
|
||||
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)
|
||||
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,
|
||||
):
|
||||
# NOTE: wtf is this code
|
||||
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
|
||||
# -----------------------------------------------------------------------------
|
||||
# SEASONS
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -350,6 +400,9 @@ def update_request(
|
||||
response_model=Season,
|
||||
)
|
||||
def get_season(season: season_dep) -> Season:
|
||||
"""
|
||||
Get details for a specific season.
|
||||
"""
|
||||
return season
|
||||
|
||||
|
||||
@@ -361,15 +414,17 @@ def get_season(season: season_dep) -> Season:
|
||||
def get_season_files(
|
||||
season: season_dep, tv_service: tv_service_dep
|
||||
) -> list[PublicSeasonFile]:
|
||||
return tv_service.get_public_season_files_by_season_id(season_id=season.id)
|
||||
"""
|
||||
Get files associated with a specific season.
|
||||
"""
|
||||
return tv_service.get_public_season_files_by_season_id(season=season)
|
||||
|
||||
|
||||
# --------------------------------
|
||||
# MANAGE TORRENTS
|
||||
# --------------------------------
|
||||
# -----------------------------------------------------------------------------
|
||||
# TORRENTS
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# 1 is the default for season_number because it returns multi season torrents
|
||||
@router.get(
|
||||
"/torrents",
|
||||
status_code=status.HTTP_200_OK,
|
||||
@@ -382,6 +437,10 @@ def get_torrents_for_a_season(
|
||||
season_number: int = 1,
|
||||
search_query_override: str = None,
|
||||
):
|
||||
"""
|
||||
Search for torrents for a specific season of a show.
|
||||
Default season_number is 1 because it often returns multi-season torrents.
|
||||
"""
|
||||
return tv_service.get_all_available_torrents_for_a_season(
|
||||
season_number=season_number,
|
||||
show_id=show_id,
|
||||
@@ -389,7 +448,6 @@ def get_torrents_for_a_season(
|
||||
)
|
||||
|
||||
|
||||
# download a torrent
|
||||
@router.post(
|
||||
"/torrents",
|
||||
status_code=status.HTTP_200_OK,
|
||||
@@ -402,6 +460,9 @@ def download_a_torrent(
|
||||
show_id: ShowId,
|
||||
override_file_path_suffix: str = "",
|
||||
):
|
||||
"""
|
||||
Trigger a download for a specific torrent.
|
||||
"""
|
||||
return tv_service.download_torrent(
|
||||
public_indexer_result_id=public_indexer_result_id,
|
||||
show_id=show_id,
|
||||
@@ -409,28 +470,20 @@ def download_a_torrent(
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------
|
||||
# SEARCH SHOWS ON METADATA PROVIDERS
|
||||
# --------------------------------
|
||||
# -----------------------------------------------------------------------------
|
||||
# STATISTICS
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search",
|
||||
"/episodes/count",
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=int,
|
||||
description="Total number of episodes downloaded",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[MetaDataProviderSearchResult],
|
||||
)
|
||||
def search_metadata_providers_for_a_show(
|
||||
tv_service: tv_service_dep, query: str, metadata_provider: metadata_provider_dep
|
||||
):
|
||||
return tv_service.search_for_show(query=query, metadata_provider=metadata_provider)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recommended",
|
||||
dependencies=[Depends(current_active_user)],
|
||||
response_model=list[MetaDataProviderSearchResult],
|
||||
)
|
||||
def get_recommended_shows(
|
||||
tv_service: tv_service_dep, metadata_provider: metadata_provider_dep
|
||||
):
|
||||
return tv_service.get_popular_shows(metadata_provider=metadata_provider)
|
||||
def get_total_count_of_downloaded_episodes(tv_service: tv_service_dep):
|
||||
"""
|
||||
Get the total count of downloaded episodes across all shows.
|
||||
"""
|
||||
return tv_service.get_total_downloaded_episoded_count()
|
||||
|
||||
@@ -3,7 +3,7 @@ import shutil
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from media_manager.config import AllEncompassingConfig
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.database import get_session
|
||||
from media_manager.exceptions import InvalidConfigError
|
||||
from media_manager.indexer.repository import IndexerRepository
|
||||
@@ -127,8 +127,8 @@ class TvService:
|
||||
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_id: ShowId, library: str) -> None:
|
||||
self.tv_repository.set_show_library(show_id=show_id, library=library)
|
||||
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:
|
||||
"""
|
||||
@@ -140,24 +140,21 @@ class TvService:
|
||||
|
||||
def delete_show(
|
||||
self,
|
||||
show_id: ShowId,
|
||||
show: Show,
|
||||
delete_files_on_disk: bool = False,
|
||||
delete_torrents: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Delete a show from the database, optionally deleting files and torrents.
|
||||
|
||||
:param show_id: The ID of the show to delete.
|
||||
:param show: The show to delete.
|
||||
:param delete_files_on_disk: Whether to delete the show's files from disk.
|
||||
:param delete_torrents: Whether to delete associated torrents from the torrent client.
|
||||
"""
|
||||
if delete_files_on_disk or delete_torrents:
|
||||
show = self.tv_repository.get_show_by_id(show_id)
|
||||
|
||||
log.debug(f"Deleting ID: {show.id} - Name: {show.name}")
|
||||
|
||||
if delete_files_on_disk:
|
||||
# Get the show's directory path
|
||||
show_dir = self.get_root_show_directory(show=show)
|
||||
|
||||
log.debug(f"Attempt to delete show directory: {show_dir}")
|
||||
@@ -166,8 +163,7 @@ class TvService:
|
||||
log.info(f"Deleted show directory: {show_dir}")
|
||||
|
||||
if delete_torrents:
|
||||
# Get all torrents associated with this show
|
||||
torrents = self.tv_repository.get_torrents_by_show_id(show_id=show_id)
|
||||
torrents = self.tv_repository.get_torrents_by_show_id(show_id=show.id)
|
||||
for torrent in torrents:
|
||||
try:
|
||||
self.torrent_service.cancel_download(torrent, delete_files=True)
|
||||
@@ -175,20 +171,19 @@ class TvService:
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to delete torrent {torrent.hash}: {e}")
|
||||
|
||||
# Delete from database
|
||||
self.tv_repository.delete_show(show_id=show_id)
|
||||
self.tv_repository.delete_show(show_id=show.id)
|
||||
|
||||
def get_public_season_files_by_season_id(
|
||||
self, season_id: SeasonId
|
||||
self, season: Season
|
||||
) -> list[PublicSeasonFile]:
|
||||
"""
|
||||
Get all public season files for a given season ID.
|
||||
Get all public season files for a given season.
|
||||
|
||||
:param season_id: The ID of the season.
|
||||
:param season: The season object.
|
||||
:return: A list of public season files.
|
||||
"""
|
||||
season_files = self.tv_repository.get_season_files_by_season_id(
|
||||
season_id=season_id
|
||||
season_id=season.id
|
||||
)
|
||||
public_season_files = [PublicSeasonFile.model_validate(x) for x in season_files]
|
||||
result = []
|
||||
@@ -289,7 +284,6 @@ class TvService:
|
||||
):
|
||||
result.added = True
|
||||
|
||||
# Fetch the internal show ID.
|
||||
try:
|
||||
show = self.tv_repository.get_show_by_external_id(
|
||||
external_id=result.external_id,
|
||||
@@ -322,14 +316,13 @@ class TvService:
|
||||
|
||||
return filtered_results
|
||||
|
||||
def get_public_show_by_id(self, show_id: ShowId) -> PublicShow:
|
||||
def get_public_show_by_id(self, show: Show) -> PublicShow:
|
||||
"""
|
||||
Get a public show by its ID.
|
||||
Get a public show from a Show object.
|
||||
|
||||
:param show_id: The ID of the show.
|
||||
:param show: The show object.
|
||||
:return: A public show.
|
||||
"""
|
||||
show = self.tv_repository.get_show_by_id(show_id=show_id)
|
||||
seasons = [PublicSeason.model_validate(season) for season in show.seasons]
|
||||
for season in seasons:
|
||||
season.downloaded = self.is_season_downloaded(season_id=season.id)
|
||||
@@ -377,7 +370,6 @@ class TvService:
|
||||
)
|
||||
|
||||
if torrent_file.imported:
|
||||
print("Servas")
|
||||
return True
|
||||
except RuntimeError as e:
|
||||
log.error(f"Error retrieving torrent, error: {e}")
|
||||
@@ -575,7 +567,7 @@ class TvService:
|
||||
return True
|
||||
|
||||
def get_root_show_directory(self, show: Show):
|
||||
misc_config = AllEncompassingConfig().misc
|
||||
misc_config = MediaManagerConfig().misc
|
||||
show_directory_name = f"{remove_special_characters(show.name)} ({show.year}) [{show.metadata_provider}id-{show.external_id}]"
|
||||
log.debug(
|
||||
f"Show {show.name} without special characters: {remove_special_characters(show.name)}"
|
||||
@@ -618,7 +610,7 @@ class TvService:
|
||||
/ episode_file_name
|
||||
)
|
||||
|
||||
# import subtitles
|
||||
# import subtitle
|
||||
for subtitle_file in subtitle_files:
|
||||
regex_result = re.search(
|
||||
subtitle_pattern, subtitle_file.name, re.IGNORECASE
|
||||
@@ -762,9 +754,7 @@ class TvService:
|
||||
:param db_show: The Show to update
|
||||
:return: The updated Show object, or None if the show is not found or an error occurs.
|
||||
"""
|
||||
# Get the existing show from the database
|
||||
log.debug(f"Found show: {db_show.name} for metadata update.")
|
||||
# old_poster_url = db_show.poster_url # poster_url removed from db_show
|
||||
|
||||
# Use stored original_language preference for metadata fetching
|
||||
fresh_show_data = metadata_provider.get_show_metadata(
|
||||
@@ -777,7 +767,6 @@ class TvService:
|
||||
return db_show
|
||||
log.debug(f"Fetched fresh metadata for show: {fresh_show_data.name}")
|
||||
|
||||
# Update show attributes (poster_url is not part of ShowSchema anymore)
|
||||
self.tv_repository.update_show_attributes(
|
||||
show_id=db_show.id,
|
||||
name=fresh_show_data.name,
|
||||
@@ -874,17 +863,17 @@ class TvService:
|
||||
return updated_show
|
||||
|
||||
def set_show_continuous_download(
|
||||
self, show_id: ShowId, continuous_download: bool
|
||||
self, show: Show, continuous_download: bool
|
||||
) -> Show:
|
||||
"""
|
||||
Set the continuous download flag for a show.
|
||||
|
||||
:param show_id: The ID of the show.
|
||||
:param show: The show object.
|
||||
:param continuous_download: True to enable continuous download, False to disable.
|
||||
:return: The updated Show object.
|
||||
"""
|
||||
return self.tv_repository.update_show_attributes(
|
||||
show_id=show_id, continuous_download=continuous_download
|
||||
show_id=show.id, continuous_download=continuous_download
|
||||
)
|
||||
|
||||
def get_import_candidates(
|
||||
@@ -932,7 +921,7 @@ class TvService:
|
||||
def get_importable_tv_shows(
|
||||
self, metadata_provider: AbstractMetadataProvider
|
||||
) -> list[MediaImportSuggestion]:
|
||||
tv_directory = AllEncompassingConfig().misc.tv_directory
|
||||
tv_directory = MediaManagerConfig().misc.tv_directory
|
||||
import_suggestions: list[MediaImportSuggestion] = []
|
||||
candidate_dirs = get_importable_media_directories(tv_directory)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user