Merge branch 'master' into fork/strangeglyph/master

# Conflicts:
#	media_manager/main.py
This commit is contained in:
maxid
2026-01-02 16:24:18 +01:00
39 changed files with 2416 additions and 1680 deletions

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View 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()

View File

@@ -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

View File

@@ -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}"

View File

@@ -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] = []

View File

@@ -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
View 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)

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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
"""

View File

@@ -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:

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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

View 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

View File

@@ -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,

View File

@@ -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),

View File

@@ -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(

View File

@@ -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:

View File

@@ -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}

View File

@@ -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.

View File

@@ -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()

View File

@@ -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)