diff --git a/.github/workflows/build-push-backend.yml b/.github/workflows/build-push-backend.yml index 04622f3..cec9d99 100644 --- a/.github/workflows/build-push-backend.yml +++ b/.github/workflows/build-push-backend.yml @@ -32,13 +32,9 @@ jobs: with: images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}/backend tags: | - # set latest tag for default branch - type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} - # tag event 'refs/tags/v1.0.0' will generate 'v1.0.0' + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=ref,event=tag - # branch event 'refs/heads/main' will generate 'main' type=ref,event=branch - # sha type=sha - name: Build and push Docker image diff --git a/.github/workflows/build-push-frontend.yml b/.github/workflows/build-push-frontend.yml index 6057f99..4f482a4 100644 --- a/.github/workflows/build-push-frontend.yml +++ b/.github/workflows/build-push-frontend.yml @@ -35,7 +35,7 @@ jobs: with: images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}/frontend tags: | - type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=ref,event=tag type=ref,event=branch type=sha diff --git a/Dockerfile b/Dockerfile index 7e0d612..a5e5126 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,9 @@ ENV MOVIE_DIRECTORY=/data/movies ENV TORRENT_DIRECTORY=/data/torrents ENV OPENID_ENABLED=FALSE +RUN apt-get update && apt-get install -y ca-certificates + + WORKDIR /app COPY media_manager ./media_manager COPY alembic ./alembic diff --git a/Writerside/mm.tree b/Writerside/mm.tree index 6a06360..076f527 100644 --- a/Writerside/mm.tree +++ b/Writerside/mm.tree @@ -8,12 +8,13 @@ + - - + + diff --git a/Writerside/redirection-rules.xml b/Writerside/redirection-rules.xml index ce11b76..11a9a84 100644 --- a/Writerside/redirection-rules.xml +++ b/Writerside/redirection-rules.xml @@ -22,4 +22,8 @@ Created after removal of "Quick Start Guide" from MediaManager Quick-Start-Guide.html + + Created after removal of "user-guide" from MediaManager + user-guide.html + \ No newline at end of file diff --git a/Writerside/topics/Configuration.md b/Writerside/topics/Configuration.md index 21b1192..5d3f8cc 100644 --- a/Writerside/topics/Configuration.md +++ b/Writerside/topics/Configuration.md @@ -1,6 +1,6 @@ # Configuration The configuration of MediaManager is divided into backend and frontend settings, which can be set in your -`docker-compose.yml` file or in separate `.env` files. +`docker-compose.yaml` file or in separate `.env` files. All settings are set as environment variables, because this makes backing up the configuration easier and allows for easier sharing/transferring of the configuration. \ No newline at end of file diff --git a/Writerside/topics/User-Guide.md b/Writerside/topics/User-Guide.md new file mode 100644 index 0000000..b29d75c --- /dev/null +++ b/Writerside/topics/User-Guide.md @@ -0,0 +1,51 @@ +# Usage + +If you are coming from Radarr or Sonarr you will find that MediaManager does things a bit differently. +Instead of completely automatically downloading and managing your media, MediaManager focuses on providing an +easy-to-use interface to guide you through the process of finding and downloading media. Advanced features like multiple +qualities of a show/movie necessitate such a paradigm shift. __So here is a quick step-by-step guide to get you started: +__ + + + + + Add a show on the "Add Show" page + After adding the show you will be redirected to the show's page. + There you can click the "Request Season" button. + Select one or more seasons that you want to download + Then select the "Min Quality", this will be the minimum resolution of the content to download. + Then select the "Wanted Quality", this will be the maximum resolution of the content to download. + Finally click Submit request, though this is not the last step! + An administrator first has to approve your request for download, only then will the requested content be downloaded. +

Congratulation! You've downloaded a show.

+
+
+ + + Add a show on the "Add Show" page + After adding the show you will be redirected to the show's page. + There you can click the "Request Season" button. + Select one or more seasons that you want to download + Then select the "Min Quality", this will be the minimum resolution of the content to download. + Then select the "Wanted Quality", this will be the maximum resolution of the content to download. + Finally click Submit request, as you are an admin, your request will be automatically approved. +

Congratulation! You've downloaded a show.

+
+ +

You can only directly download a show if you are an admin!

+ Go to a show's page. + There you can click the "Download Season" button. + Enter the season's number that you want to download + Then optionally select the "File Path Suffix", it needs to be unique per season per show! + Then click "Download" on a torrent that you want to download. +

Congratulation! You've downloaded a show.

+
+ +

Users need their requests to be approved by an admin, to do this follow these steps:

+ Go to the "Requests" page. + There you can approve, delete or modify a user's request. +
+
+
+ + diff --git a/Writerside/topics/configuration-backend.md b/Writerside/topics/configuration-backend.md index c925ebd..a315ce1 100644 --- a/Writerside/topics/configuration-backend.md +++ b/Writerside/topics/configuration-backend.md @@ -1,7 +1,12 @@ # Backend -These variables configure the core backend application, database connections, authentication, and integrations. They are -typically set as environment variables for the backend Docker container. +These variables configure the core backend application, database connections, authentication, and integrations. + +## General Settings + +| Variable | Description | Default | +|-----------------|-----------------------------|-----------| +| `API_BASE_PATH` | The url base of the backend | `/api/v1` | ## Database Settings @@ -13,6 +18,18 @@ typically set as environment variables for the backend Docker container. | `DB_PASSWORD` | Password for the PostgreSQL user. | `MediaManager` | `mypassword` | | `DB_DBNAME` | Name of the PostgreSQL database. | `MediaManager` | `mydatabase` | +## Download Client Settings + +Currently, only qBittorrent is supported as a download client. But support for other clients isn't unlikely in the +future. + +| Variable | Description | Default | Example | +|--------------------|-----------------------------|-------------|--------------------| +| `QBITTORRENT_HOST` | Host of the QBittorrent API | `localhost` | `qbit.example.com` | +| `QBITTORRENT_PORT` | Port of the QBittorrent API | `8080` | `443` | +| `QBITTORRENT_USER` | Username for QBittorrent | `admin` | - | +| `QBITTORRENT_PASS` | Password for QBittorrent | `admin` | - | + ## Metadata Provider Settings These settings configure the integrations with external metadata providers like The Movie Database (TMDB) and The TVDB. @@ -47,7 +64,7 @@ generate a free API key in your account settings. ## Directory Settings - Normally you don't need to change these, as the default mountpoints are usually sufficient. In your `docker-compose.yml`, you can just mount `/any/directory` to `/data/torrents`. + Normally you don't need to change these, as the default mountpoints are usually sufficient. In your `docker-compose.yaml`, you can just mount `/any/directory` to `/data/torrents`. | Variable | Description | Default | diff --git a/Writerside/topics/configuration-frontend.md b/Writerside/topics/configuration-frontend.md index 872cc1d..69cf205 100644 --- a/Writerside/topics/configuration-frontend.md +++ b/Writerside/topics/configuration-frontend.md @@ -8,7 +8,13 @@ ## Build Arguments (web/Dockerfile) -| Argument | Description | Example (in build command) | -|-----------|-----------------------------------------------------------------------------------------------------------|--------------------------------------------------------------| -| `VERSION` | Sets the `PUBLIC_VERSION` environment variable at runtime in the frontend container. Passed during build. | `docker build --build-arg VERSION=1.2.3 -f web/Dockerfile .` | +**TODO: expand on this section** + +To configure a url base path for the frontend, you need to build the frontend docker container, this is because +unfortunately SvelteKit needs to know the base path at build time. + +| Argument | Description | Example (in build command) | +|------------|-----------------------------------------------------------------------------------------------------------|----------------------------------------------------------------| +| `VERSION` | Sets the `PUBLIC_VERSION` environment variable at runtime in the frontend container. Passed during build. | `docker build --build-arg VERSION=1.2.3 -f web/Dockerfile .` | +| `BASE_URL` | Sets the base url path, it must begin with a slash and not end | `docker build --build-arg BASE_URL=/media -f web/Dockerfile .` | diff --git a/Writerside/topics/configuration-overview.md b/Writerside/topics/configuration-overview.md index 305b390..81cd87a 100644 --- a/Writerside/topics/configuration-overview.md +++ b/Writerside/topics/configuration-overview.md @@ -2,33 +2,26 @@ The recommended way to install and run Media Manager is using Docker and Docker Compose. -1. **Prerequisites:** - * Ensure Docker and Docker Compose are installed on your system. - * If you plan to use OAuth 2.0 / OpenID Connect for authentication, you will need an account and client credentials - from an OpenID provider (e.g., Authentik, Pocket ID). +## Prerequisites -2. **Setup:** - * Copy the docker-compose.yml from the MediaManager repo. - * Configure the necessary environment variables in your `docker-compose.yml` file. - * (Optional) Create a `.env` file in the root directory for backend environment variables and/or a `web/.env` for - frontend environment variables if you prefer to manage them separately from `docker-compose.yml`. +* Ensure Docker and Docker Compose are installed on your system. +* If you plan to use OAuth 2.0 / OpenID Connect for authentication, you will need an account and client credentials + from an OpenID provider (e.g., Authentik, Pocket ID). -3. **Running the Application:** - * Execute the command `docker-compose up -d` from the root directory. This will build the Docker images (if not - already built) and start all the services (backend, frontend, and potentially a database if configured in your - compose file). - * The backend will typically be available at `http://localhost:8000` and the frontend at `http://localhost:3000` (or - as configured). +## Setup -# Configuration Overview +* Download the docker-compose.yaml from the MediaManager repo with the following command: + ``` + wget -o docker-compose.yaml https://raw.githubusercontent.com/maxdorninger/MediaManager/refs/heads/master/docker-compose.yaml + ``` -Media Manager is configured primarily through environment variables. These can be set in your `docker-compose.yml` file, -a `.env` file. +* Configure the necessary environment variables in your `docker-compose.yaml` file. +* For more information on the available configuration options, see the [Configuration section](Configuration.md) of the + documentation. -Detailed configuration options are split into backend and frontend sections: + + It is good practice to put API keys and other sensitive information in a separate `.env` file and reference them in your + `docker-compose.yaml`. + -* [Backend Configuration](configuration-backend.md) -* [Frontend Configuration](configuration-frontend.md) - -Build arguments are also used during the Docker image build process, primarily for versioning. diff --git a/Writerside/topics/troubleshooting.md b/Writerside/topics/troubleshooting.md index 5e46b75..9193f17 100644 --- a/Writerside/topics/troubleshooting.md +++ b/Writerside/topics/troubleshooting.md @@ -10,21 +10,21 @@ ## Authentication Issues - * Double-check `AUTH_TOKEN_SECRET`. If it changes, existing sessions/tokens will be invalidated. - * For OpenID: - * Verify `OPENID_CLIENT_ID`, `OPENID_CLIENT_SECRET`, and `OPENID_CONFIGURATION_ENDPOINT` are correct. - * Ensure the `FRONTEND_URL` is accurate and that your OpenID provider has the correct redirect URI whitelisted ( - e.g., `http://your-frontend-url/api/v1/auth/cookie/Authentik/callback`). - * Check backend logs for errors from `httpx_oauth` or `fastapi-users`. +* Double-check `AUTH_TOKEN_SECRET`. If it changes, existing sessions/tokens will be invalidated. +* For OpenID: + * Verify `OPENID_CLIENT_ID`, `OPENID_CLIENT_SECRET`, and `OPENID_CONFIGURATION_ENDPOINT` are correct. + * Ensure the `FRONTEND_URL` is accurate and that your OpenID provider has the correct redirect URI whitelisted ( + e.g., `http://your-frontend-url/api/v1/auth/cookie/Authentik/callback`). + * Check backend logs for errors from `httpx_oauth` or `fastapi-users`. ## CORS Errors - * Ensure `FRONTEND_URL` is correctly set. - * Ensure your frontend's url is listed in `CORS_URLS`. +* Ensure `FRONTEND_URL` is correctly set. +* Ensure your frontend's url is listed in `CORS_URLS`. ## Data Not Appearing / File Issues - * Verify that the volume mounts for `IMAGE_DIRECTORY`, `TV_DIRECTORY`, `MOVIE_DIRECTORY`, and `TORRENT_DIRECTORY` in - your `docker-compose.yml` are correctly pointing to your media folders on the host machine. - * Check file and directory permissions for the user running the Docker container (or the `node` user inside the - containers). +* Verify that the volume mounts for `IMAGE_DIRECTORY`, `TV_DIRECTORY`, `MOVIE_DIRECTORY`, and `TORRENT_DIRECTORY` in + your `docker-compose.yaml` are correctly pointing to your media folders on the host machine. +* Check file and directory permissions for the user running the Docker container (or the `node` user inside the + containers). diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..0414354 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,71 @@ +services: + backend: + image: ghcr.io/maxdorninger/mediamanager/backend:latest + container_name: backend + ports: + - "8000:8000" + + environment: + - QBITTORRENT_PASSWORD= + - QBITTORRENT_HOST= + - QBITTORRENT_USERNAME= + - QBITTORRENT_PORT= + + - PROWLARR_URL=http://prowlarr:9696 + - PROWLARR_ENABLED=TRUE + - PROWLARR_API_KEY= + + - TMDB_API_KEY= + - CORS_URLS= + + - DB_HOST=db + #- DB_NAME= + #- DB_PORT= + #- DB_PASSOWORD= + #- DB_DBNAME= + + - AUTH_TOKEN_SECRET= + - AUTH_ADMIN_EMAIL= + - FRONTEND_URL= + #- AUTH_SESSION_LIFETIME= + + #- OPENID_ENABLED=TRUE + #- OPENID_CLIENT_ID= + #- OPENID_CLIENT_SECRET= + #- OPENID_CONFIGURATION_ENDPOINT= + #- OPENID_NAME= + + #- API_BASE_PATH=/api/v1 + #- TVDB_API_KEY= + #- DEVELOPMENT= + + volumes: + - ./data:/data/images + - ./tv:/data/tv + - ./movie:/data/movies + - ./torrent:/data/torrents + frontend: + image: ghcr.io/maxdorninger/mediamanager/frontend:latest + container_name: frontend + ports: + - "3000:3000" + volumes: + - ./cache:/app/cache + environment: + - PUBLIC_API_URL=http://localhost:8000/api/v1 + - PUBLIC_SSR_API_URL=http://backend:8000/api/v1 + # - PUBLIC_WEB_SSR=false + db: + image: postgres:latest + restart: unless-stopped + container_name: postgres + volumes: + - ./postgres:/var/lib/postgresql/data + environment: + POSTGRES_USER: MediaManager + POSTGRES_DB: MediaManager + POSTGRES_PASSWORD: MediaManager + ports: + - "5432:5432" + + diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index ce5c6d8..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,55 +0,0 @@ -services: - db: - image: postgres:latest - restart: unless-stopped - container_name: postgres - volumes: - - .\res\postgres:/var/lib/postgresql/data - environment: - POSTGRES_USER: MediaManager - POSTGRES_DB: MediaManager - POSTGRES_PASSWORD: MediaManager - ports: - - "5432:5432" - prowlarr: - image: lscr.io/linuxserver/prowlarr:latest - container_name: prowlarr - environment: - - PUID=1000 - - PGID=1000 - - TZ=Etc/UTC - volumes: - - .\res\prowlarr:/config - restart: unless-stopped - ports: - - "9696:9696" - qbittorrent: - image: lscr.io/linuxserver/qbittorrent:latest - container_name: qbittorrent - environment: - - PUID=1000 - - PGID=1000 - - TZ=Etc/UTC - - WEBUI_PORT=8080 - - TORRENTING_PORT=6881 - ports: - - 8080:8080 - - 6881:6881 - - 6881:6881/udp - restart: unless-stopped - volumes: - - ./torrent/:/download/:rw - pocket-id: - image: ghcr.io/pocket-id/pocket-id - restart: unless-stopped - env_file: .env - ports: - - 1411:1411 - volumes: - - "./res/pocket-id:/app/data" - healthcheck: - test: "curl -f http://localhost:1411/healthz" - interval: 1m30s - timeout: 5s - retries: 2 - start_period: 10s \ No newline at end of file diff --git a/media_manager/auth/oauth.py b/media_manager/auth/oauth.py index a161039..db66358 100644 --- a/media_manager/auth/oauth.py +++ b/media_manager/auth/oauth.py @@ -1,13 +1,13 @@ from typing import Optional import jwt -from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback from httpx_oauth.oauth2 import BaseOAuth2, OAuth2Token from pydantic import BaseModel -from fastapi_users import models, schemas -from fastapi_users.authentication import AuthenticationBackend, Authenticator, Strategy +from fastapi_users import models +from fastapi_users.authentication import AuthenticationBackend, Strategy from fastapi_users.exceptions import UserAlreadyExists from fastapi_users.jwt import SecretType, decode_jwt, generate_jwt from fastapi_users.manager import BaseUserManager, UserManagerDependency diff --git a/media_manager/auth/users.py b/media_manager/auth/users.py index c925f91..29fdb50 100644 --- a/media_manager/auth/users.py +++ b/media_manager/auth/users.py @@ -2,7 +2,6 @@ import os import uuid from typing import Optional -import httpx from fastapi import Depends, Request from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models from fastapi_users.authentication import ( diff --git a/media_manager/indexer/schemas.py b/media_manager/indexer/schemas.py index 6091998..d9b5ed0 100644 --- a/media_manager/indexer/schemas.py +++ b/media_manager/indexer/schemas.py @@ -30,13 +30,13 @@ class IndexerQueryResult(BaseModel): very_low_quality_pattern = r"\b(480p|480P|360p|360P)\b" if re.search(high_quality_pattern, self.title): - return Quality.high + return Quality.uhd elif re.search(medium_quality_pattern, self.title): - return Quality.medium + return Quality.fullhd elif re.search(low_quality_pattern, self.title): - return Quality.low + return Quality.hd elif re.search(very_low_quality_pattern, self.title): - return Quality.very_low + return Quality.sd return Quality.unknown diff --git a/media_manager/main.py b/media_manager/main.py index 7c790a4..a6e7a02 100644 --- a/media_manager/main.py +++ b/media_manager/main.py @@ -1,9 +1,13 @@ import logging +import os import sys from logging.config import dictConfig from pythonjsonlogger.json import JsonFormatter +import torrent.service +from database import SessionLocal + LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, @@ -46,13 +50,18 @@ log = logging.getLogger(__name__) from media_manager.database import init_db import media_manager.tv.router as tv_router +from media_manager.tv.service import auto_download_all_approved_season_requests import media_manager.torrent.router as torrent_router -init_db() -log.info("Database initialized") - from media_manager.config import BasicConfig from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from datetime import datetime +from contextlib import asynccontextmanager +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +init_db() +log.info("Database initialized") basic_config = BasicConfig() if basic_config.DEVELOPMENT: @@ -64,7 +73,31 @@ if basic_config.DEVELOPMENT: else: log.info("Development Mode not activated!") -app = FastAPI(root_path="/api/v1") + +def hourly_tasks(): + log.info(f"Tasks are running at {datetime.now()}") + auto_download_all_approved_season_requests() + torrent.service.TorrentService(db=SessionLocal()).import_all_torrents() + + +scheduler = BackgroundScheduler() +trigger = CronTrigger(minute=0, hour="*") +scheduler.add_job(hourly_tasks, trigger) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + scheduler.start() + yield + scheduler.shutdown() + + +app = FastAPI(lifespan=lifespan) + + +base_path = os.getenv("API_BASE_PATH") or "/api/v1" +log.info("Base Path for API: %s", base_path) +app = FastAPI(root_path=base_path) if basic_config.DEVELOPMENT: origins = [ @@ -162,5 +195,7 @@ app.mount( name="static-images", ) +log.info("Hello World!") + if __name__ == "__main__": uvicorn.run(app, host="127.0.0.1", port=5049, log_config=LOGGING_CONFIG) diff --git a/media_manager/torrent/schemas.py b/media_manager/torrent/schemas.py index db5a861..73ad3f4 100644 --- a/media_manager/torrent/schemas.py +++ b/media_manager/torrent/schemas.py @@ -8,13 +8,21 @@ TorrentId = typing.NewType("TorrentId", uuid.UUID) class Quality(Enum): - high = 1 - medium = 2 - low = 3 - very_low = 4 + uhd = 1 + fullhd = 2 + hd = 3 + sd = 4 unknown = 5 +class QualityStrings(Enum): + uhd = "4K" + fullhd = "1080p" + hd = "720p" + sd = "400p" + unknown = "unknown" + + class TorrentStatus(Enum): finished = 1 downloading = 2 diff --git a/media_manager/torrent/service.py b/media_manager/torrent/service.py index 522c8b3..327816f 100644 --- a/media_manager/torrent/service.py +++ b/media_manager/torrent/service.py @@ -8,7 +8,6 @@ from pathlib import Path import bencoder import qbittorrentapi import requests -from fastapi_utils.tasks import repeat_every from pydantic_settings import BaseSettings, SettingsConfigDict from sqlalchemy.orm import Session @@ -291,10 +290,10 @@ class TorrentService: for x in media_manager.torrent.repository.get_all_torrents(db=self.db) ] - def get_torrent_by_id(self, id: TorrentId) -> Torrent: + def get_torrent_by_id(self, torrent_id: TorrentId) -> Torrent: return self.get_torrent_status( media_manager.torrent.repository.get_torrent_by_id( - torrent_id=id, db=self.db + torrent_id=torrent_id, db=self.db ) ) @@ -308,10 +307,10 @@ class TorrentService: ) media_manager.torrent.repository.delete_torrent(db=self.db, torrent_id=t.id) - @repeat_every(seconds=3600) def import_all_torrents(self) -> list[Torrent]: log.info("Importing all torrents") torrents = self.get_all_torrents() + log.info("Found %d torrents to import", len(torrents)) imported_torrents = [] for t in torrents: if t.imported == False and t.status == TorrentStatus.finished: diff --git a/media_manager/tv/repository.py b/media_manager/tv/repository.py index 44029c8..245a37d 100644 --- a/media_manager/tv/repository.py +++ b/media_manager/tv/repository.py @@ -289,3 +289,24 @@ def get_season_request( db: Session, season_request_id: SeasonRequestId ) -> SeasonRequestSchema: return SeasonRequestSchema.model_validate(db.get(SeasonRequest, season_request_id)) + + +def get_show_by_season_id(db: Session, season_id: SeasonId) -> ShowSchema | None: + """ + Retrieve a show by one of its season's ID. + + :param db: The database session. + :param season_id: The ID of the season to retrieve the show for. + :return: A ShowSchema object if found, otherwise None. + """ + stmt = ( + select(Show) + .join(Season, Show.id == Season.show_id) + .where(Season.id == season_id) + ) + + result = db.execute(stmt).unique().scalar_one_or_none() + if not result: + return None + + return ShowSchema.model_validate(result) diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index 07e3b4c..9b58287 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -4,6 +4,7 @@ import media_manager.indexer.service import media_manager.metadataProvider import media_manager.torrent.repository import media_manager.tv.repository +from media_manager.database import SessionLocal from media_manager.indexer import IndexerQueryResult from media_manager.indexer.schemas import IndexerQueryResultId from media_manager.metadataProvider.schemas import MetaDataProviderShowSearchResult @@ -29,6 +30,7 @@ from media_manager.tv.schemas import ( SeasonRequestId, RichSeasonRequest, ) +from media_manager.torrent.schemas import QualityStrings def add_show(db: Session, external_id: int, metadata_provider: str) -> Show | None: @@ -284,3 +286,82 @@ def download_torrent( ) add_season_file(db=db, season_file=season_file) return show_torrent + + +def download_approved_season_request( + db: Session, + season_request: SeasonRequest, + show_id: ShowId, +) -> bool: + if not season_request.authorized: + log.error(f"Season request {season_request.id} is not authorized for download") + raise ValueError( + f"Season request {season_request.id} is not authorized for download" + ) + log.info(f"Downloading approved season request {season_request.id}") + + season = get_season(db=db, season_id=season_request.season_id) + torrents = get_all_available_torrents_for_a_season( + db=db, season_number=season.number, show_id=show_id + ) + available_torrents: list[IndexerQueryResult] = [] + for torrent in torrents: + if ( + torrent.quality > season_request.wanted_quality + or torrent.quality < season_request.min_quality + or torrent.seeders < 3 + ): + log.info( + f"Skipping torrent {torrent.title} with quality {torrent.quality} for season {season.id}, because it does not match the requested quality {season_request.wanted_quality}" + ) + elif torrent.season == [ + season.number, + ]: + log.info( + f"Skipping torrent {torrent.title} with quality {torrent.quality} for season {season.id}, because it contains to many/wrong seasons {torrent.season} (wanted: {season.number})" + ) + else: + available_torrents.append(torrent) + log.info( + f"Taking torrent {torrent.title} with quality {torrent.quality} for season {season.id} into consideration" + ) + if len(available_torrents) == 0: + log.warning( + f"No torrents matching criteria were found (wanted quality: {season_request.wanted_quality}, min_quality: {season_request.min_quality} for season {season.id})" + ) + return False + + available_torrents.sort() + + torrent = TorrentService(db=db).download(indexer_result=available_torrents[0]) + season_file = SeasonFile( + season_id=season.id, + quality=torrent.quality, + torrent_id=torrent.id, + file_path_suffix=QualityStrings[torrent.quality.name].value.upper(), + ) + add_season_file(db=db, season_file=season_file) + return True + + +def auto_download_all_approved_season_requests() -> None: + db: Session = SessionLocal() + log.info("Auto downloading all approved season requests") + season_requests = media_manager.tv.repository.get_season_requests(db=db) + log.info(f"Found {len(season_requests)} season requests to process") + count = 0 + for season_request in season_requests: + if season_request.authorized: + log.info(f"Processing season request {season_request.id} for download") + show = media_manager.tv.repository.get_show_by_season_id( + db=db, season_id=season_request.season_id + ) + if download_approved_season_request( + db=db, season_request=season_request, show_id=show.id + ): + count += 1 + else: + log.warning( + f"Failed to download season request {season_request.id} for show {show.name}" + ) + log.info(f"Auto downloaded {count} approved season requests") diff --git a/pyproject.toml b/pyproject.toml index 226cadc..ec1830d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,4 +26,5 @@ dependencies = [ "typing-inspect>=0.9.0", "uvicorn>=0.34.2", "fastapi-utils>=0.8.0", + "apscheduler>=3.11.0", ] diff --git a/uv.lock b/uv.lock index 2a02129..b6ef704 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] +[[package]] +name = "apscheduler" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, +] + [[package]] name = "argon2-cffi" version = "23.1.0" @@ -575,6 +587,7 @@ name = "mediamanager" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "apscheduler" }, { name = "bencoder" }, { name = "cachetools" }, { name = "fastapi", extra = ["standard"] }, @@ -601,6 +614,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "apscheduler", specifier = ">=3.11.0" }, { name = "bencoder", specifier = ">=0.2.0" }, { name = "cachetools", specifier = ">=6.0.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, @@ -1090,6 +1104,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "urllib3" version = "2.4.0" diff --git a/web/Dockerfile b/web/Dockerfile index 5e7a2ff..994f598 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,14 +1,13 @@ ARG VERSION - +ARG BASE_URL="" FROM node:24-alpine AS build -ARG VERSION USER node:node WORKDIR /app COPY --chown=node:node . . RUN npm ci -RUN env PUBLIC_VERSION=${VERSION} npm run build +RUN env PUBLIC_VERSION=${VERSION} BASE_URL=${BASE_URL} npm run build FROM node:24-alpine AS frontend ARG VERSION diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index fe0810a..71d218f 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -9,10 +9,10 @@ import {browser} from "$app/environment"; const apiUrl = browser ? env.PUBLIC_API_URL : env.PUBLIC_SSR_API_URL; export const qualityMap: { [key: number]: string } = { - 1: 'high', - 2: 'medium', - 3: 'low', - 4: 'very low', + 1: '4K/UHD', + 2: '1080p/FullHD', + 3: '720p/HD', + 4: '480p/SD', 5: 'unknown' }; export const torrentStatusMap: { [key: number]: string } = { diff --git a/web/src/routes/dashboard/tv/torrents/+page.svelte b/web/src/routes/dashboard/tv/torrents/+page.svelte index 2a45f15..598f715 100644 --- a/web/src/routes/dashboard/tv/torrents/+page.svelte +++ b/web/src/routes/dashboard/tv/torrents/+page.svelte @@ -60,9 +60,9 @@ {:else} -

- You've not added any torrents yet. -

+
+ No Torrents added yet. +
{/each} {/await} diff --git a/web/svelte.config.js b/web/svelte.config.js index a730c79..c8c96ba 100644 --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -1,6 +1,8 @@ import adapter from '@sveltejs/adapter-node'; import {vitePreprocess} from '@sveltejs/vite-plugin-svelte'; +const base = process.env.BASE_URL || ''; + /** @type {import('@sveltejs/kit').Config} */ const config = { // Consult https://svelte.dev/docs/kit/integrations @@ -11,7 +13,10 @@ const config = { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://svelte.dev/docs/kit/adapters for more information about adapters. - adapter: adapter() + adapter: adapter(), + paths: { + base: base + } } };