mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-26 02:35:57 +02:00
Merge pull request #8 from maxdorninger/add-automatic-downloads
Add automatic downloads
This commit is contained in:
6
.github/workflows/build-push-backend.yml
vendored
6
.github/workflows/build-push-backend.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/build-push-frontend.yml
vendored
2
.github/workflows/build-push-frontend.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
|
||||
<toc-element topic="introduction.md"/>
|
||||
<toc-element topic="configuration-overview.md"/>
|
||||
<toc-element topic="User-Guide.md"/>
|
||||
<toc-element topic="Configuration.md">
|
||||
<toc-element topic="authentication-setup.md"/>
|
||||
<toc-element topic="configuration-frontend.md"/>
|
||||
<toc-element topic="configuration-backend.md">
|
||||
<toc-element topic="Indexer-Settings.md"/>
|
||||
</toc-element>
|
||||
<toc-element topic="Indexer-Settings.md"/>
|
||||
<toc-element topic="configuration-frontend.md"/>
|
||||
</toc-element>
|
||||
<toc-element topic="troubleshooting.md"/>
|
||||
<toc-element topic="developer-guide.md"/>
|
||||
|
||||
@@ -22,4 +22,8 @@
|
||||
<description>Created after removal of "Quick Start Guide" from MediaManager</description>
|
||||
<accepts>Quick-Start-Guide.html</accepts>
|
||||
</rule>
|
||||
<rule id="570cb9d1">
|
||||
<description>Created after removal of "user-guide" from MediaManager</description>
|
||||
<accepts>user-guide.html</accepts>
|
||||
</rule>
|
||||
</rules>
|
||||
@@ -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.
|
||||
51
Writerside/topics/User-Guide.md
Normal file
51
Writerside/topics/User-Guide.md
Normal file
@@ -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:
|
||||
__
|
||||
|
||||
<tabs>
|
||||
<tab id="as-a-user" title="as a user">
|
||||
<procedure title="Downloading/Requesting a show" id="request-show-user">
|
||||
<step>Add a show on the "Add Show" page</step>
|
||||
<step>After adding the show you will be redirected to the show's page.</step>
|
||||
<step>There you can click the "Request Season" button.</step>
|
||||
<step>Select one or more seasons that you want to download</step>
|
||||
<step>Then select the "Min Quality", this will be the minimum resolution of the content to download.</step>
|
||||
<step>Then select the "Wanted Quality", this will be the <strong>maximum</strong> resolution of the content to download.</step>
|
||||
<step>Finally click Submit request, though this is not the last step!</step>
|
||||
<step>An administrator first has to approve your request for download, only then will the requested content be downloaded.</step>
|
||||
<p>Congratulation! You've downloaded a show.</p>
|
||||
</procedure>
|
||||
</tab>
|
||||
<tab id="as-an-admin" title="as an admin">
|
||||
<procedure title="Requesting a show" id="request-show-admin">
|
||||
<step>Add a show on the "Add Show" page</step>
|
||||
<step>After adding the show you will be redirected to the show's page.</step>
|
||||
<step>There you can click the "Request Season" button.</step>
|
||||
<step>Select one or more seasons that you want to download</step>
|
||||
<step>Then select the "Min Quality", this will be the minimum resolution of the content to download.</step>
|
||||
<step>Then select the "Wanted Quality", this will be the <strong>maximum</strong> resolution of the content to download.</step>
|
||||
<step>Finally click Submit request, as you are an admin, your request will be automatically approved.</step>
|
||||
<p>Congratulation! You've downloaded a show.</p>
|
||||
</procedure>
|
||||
<procedure title="Downloading a show" id="download-show-admin">
|
||||
<p>You can only directly download a show if you are an admin!</p>
|
||||
<step>Go to a show's page.</step>
|
||||
<step>There you can click the "Download Season" button.</step>
|
||||
<step>Enter the season's number that you want to download</step>
|
||||
<step>Then optionally select the "File Path Suffix", <strong>it needs to be unique per season per show!</strong> </step>
|
||||
<step>Then click "Download" on a torrent that you want to download.</step>
|
||||
<p>Congratulation! You've downloaded a show.</p>
|
||||
</procedure>
|
||||
<procedure title="Managing requests" id="approving-request-admin">
|
||||
<p>Users need their requests to be approved by an admin, to do this follow these steps:</p>
|
||||
<step>Go to the "Requests" page.</step>
|
||||
<step>There you can approve, delete or modify a user's request.</step>
|
||||
</procedure>
|
||||
</tab>
|
||||
</tabs>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
<note>
|
||||
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`.
|
||||
</note>
|
||||
|
||||
| Variable | Description | Default |
|
||||
|
||||
@@ -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 .` |
|
||||
|
||||
|
||||
@@ -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:
|
||||
<note>
|
||||
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`.
|
||||
</note>
|
||||
|
||||
* [Backend Configuration](configuration-backend.md)
|
||||
* [Frontend Configuration](configuration-frontend.md)
|
||||
|
||||
Build arguments are also used during the Docker image build process, primarily for versioning.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
71
docker-compose.yaml
Normal file
71
docker-compose.yaml
Normal file
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -26,4 +26,5 @@ dependencies = [
|
||||
"typing-inspect>=0.9.0",
|
||||
"uvicorn>=0.34.2",
|
||||
"fastapi-utils>=0.8.0",
|
||||
"apscheduler>=3.11.0",
|
||||
]
|
||||
|
||||
26
uv.lock
generated
26
uv.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } = {
|
||||
|
||||
@@ -60,9 +60,9 @@
|
||||
</Card.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
|
||||
You've not added any torrents yet.
|
||||
</h3>
|
||||
<div class="col-span-full text-center text-muted-foreground">
|
||||
No Torrents added yet.
|
||||
</div>
|
||||
{/each}
|
||||
</Accordion.Root>
|
||||
{/await}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user