diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 52c695d..55c9cd5 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -4,7 +4,7 @@ on: push: branches: [ "master" ] paths: - - 'docs/**' + - 'Writerside/**' - '.github/workflows/build-docs.yml' workflow_dispatch: @@ -104,4 +104,4 @@ jobs: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/build-push-backend.yml b/.github/workflows/build-push-backend.yml index bf25dfa..01d3b72 100644 --- a/.github/workflows/build-push-backend.yml +++ b/.github/workflows/build-push-backend.yml @@ -40,13 +40,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 00b14ad..6a2bf81 100644 --- a/.github/workflows/build-push-frontend.yml +++ b/.github/workflows/build-push-frontend.yml @@ -6,6 +6,7 @@ on: - master tags: - 'v*.*.*' + - 'v*.*' paths: - 'web/**' - '.github/workflows/build-push-frontend.yml' @@ -35,7 +36,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=semver,pattern={{version}} 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/Writerside_libraries.tree b/Writerside/Writerside_libraries.tree new file mode 100644 index 0000000..c396ef3 --- /dev/null +++ b/Writerside/Writerside_libraries.tree @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/Writerside/cfg/buildprofiles.xml b/Writerside/cfg/buildprofiles.xml index 39f7ce5..8299504 100644 --- a/Writerside/cfg/buildprofiles.xml +++ b/Writerside/cfg/buildprofiles.xml @@ -7,12 +7,11 @@ false - 2000 favicon.ico logo.svg - Get MediaManager - https://github.com/maxdorninger/MediaManager/releases + Buy me a coffee ☕ + https://buymeacoffee.com/maxdorninger true https://github.com/maxdorninger/MediaManager 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/Indexer-Settings.md b/Writerside/topics/Indexer-Settings.md index 5a61923..212bfc7 100644 --- a/Writerside/topics/Indexer-Settings.md +++ b/Writerside/topics/Indexer-Settings.md @@ -2,8 +2,36 @@ ## Prowlarr -| Variable | Description | Default | Example | Required (if Prowlarr enabled) | -|--------------------|-------------------------------------|-------------------------|------------------------|--------------------------------| -| `PROWLARR_ENABLED` | Set to `True` to enable Prowlarr. | `True` | `true` | No | -| `PROWLARR_API_KEY` | Your Prowlarr API key. | - | `prowlarr_api_key` | Yes | -| `PROWLARR_URL` | Base URL of your Prowlarr instance. | `http://localhost:9696` | `http://prowlarr:9696` | No | +### `PROWLARR_ENABLED` + +Set to `True` to enable Prowlarr. Default is `False`. Example: `true`. + +### `PROWLARR_API_KEY` + +This is your Prowlarr API key. Example: `prowlarr_api_key`. + +### `PROWLARR_URL` + +Base URL of your Prowlarr instance. Default is `http://localhost:9696`. Example: `http://prowlarr:9696`. + +## Jackett + +### `JACKETT_ENABLED` + +Set to `True` to enable Jackett. Default is `False`. Example: `true`. + +### `JACKETT_API_KEY` + +This is your Prowlarr API key. Example: `jackett_api_key`. + +### `JACKETT_URL` + +Base URL of your Prowlarr instance. Default is `http://localhost:9117`. Example: `http://prowlarr:9117`. + +### `JACKETT_INDEXERS` + +List of all indexers for Jackett to search through. Default is `all`. Example: `["1337x","0magnet"]`. + + + + \ 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..1ed1244 --- /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/authentication-setup.md b/Writerside/topics/authentication-setup.md index 0e40c65..422d5de 100644 --- a/Writerside/topics/authentication-setup.md +++ b/Writerside/topics/authentication-setup.md @@ -3,32 +3,61 @@ MediaManager supports multiple authentication methods. Email/password authentication is the default, but you can also enable OpenID Connect (OAuth 2.0) for integration with external identity providers. - Note the lack of a trailing slash in some env vars like FRONTEND_URL. This is important. -| Variable | Description | Default | Example | Required | -|-------------------------|--------------------------------------------------------------------------|-----------------|-------------------------------------------|----------| -| `AUTH_TOKEN_SECRET` | Strong secret key for signing JWTs (create with `openssl rand -hex 32`). | - | `AUTH_TOKEN_SECRET=your_super_secret_key` | Yes | -| `AUTH_SESSION_LIFETIME` | Lifetime of user sessions in seconds. | `86400` (1 day) | `AUTH_SESSION_LIFETIME=604800` (1 week) | No | -| `AUTH_ADMIN_EMAIL` | Email address of the administrator accounts. | - | `AUTH_ADMIN_EMAIL=admin@example.com` | Yes | -| `FRONTEND_URL` | The url the frontend will be accessed from. | - | `https://mediamanager.example` | Yes | +## General Authentication Settings + +### `AUTH_TOKEN_SECRET` + +Strong secret key for signing JWTs (create with `openssl rand -hex 32`). This is a required field. Example: +`AUTH_TOKEN_SECRET=your_super_secret_key`. + +### `AUTH_SESSION_LIFETIME` + +Lifetime of user sessions in seconds. Default is `86400` (1 day). Example: `AUTH_SESSION_LIFETIME=604800` (1 week). + +### `AUTH_ADMIN_EMAIL` + +A list of email addresses for administrator accounts. This is a required field. Example: +`AUTH_ADMIN_EMAIL=admin@example.com`. + +### `FRONTEND_URL` + +The URL the frontend will be accessed from. This is a required field. Example: `https://mediamanager.example`. -On login/registration, every user whose email is in `AUTH_ADMIN_EMAIL` will be granted admin privileges. -Users whose email is not in `AUTH_ADMIN_EMAIL` will be regular users and will need to be verified by an administrator, +On login/registration, every user whose email is in AUTH_ADMIN_EMAIL will be granted admin privileges. +Users whose email is not in AUTH_ADMIN_EMAIL will be regular users and will need to be verified by an administrator, this can be done in the settings page. + + + + ## OpenID Connect (OAuth 2.0) -| Variable | Description | Default | Example | -|---------------------------------|--------------------------------------------------------------------------------------------------|----------|---------------------------------------------------------------------------------------------| -| `OPENID_ENABLED` | Enables OpenID authentication | `FALSE` | `TRUE` | -| `OPENID_CLIENT_ID` | Client ID from your OpenID provider. | - | - | -| `OPENID_CLIENT_SECRET` | Client Secret from your OpenID provider. | - | - | -| `OPENID_CONFIGURATION_ENDPOINT` | URL of your OpenID provider's discovery document (e.g., `.../.well-known/openid-configuration`). | - | `https://authentik.example.com/application/o/mediamanager/.well-known/openid-configuration` | -| `OPENID_NAME` | Display name for this OpenID provider. | `OpenID` | `Authentik` | +### `OPENID_ENABLED` + +Enables OpenID authentication. Default is `FALSE`. Example: `TRUE`. + +### `OPENID_CLIENT_ID` + +Client ID from your OpenID provider. + +### `OPENID_CLIENT_SECRET` + +Client Secret from your OpenID provider. + +### `OPENID_CONFIGURATION_ENDPOINT` + +URL of your OpenID provider's discovery document (e.g., `.../.well-known/openid-configuration`). Example: +`https://authentik.example.com/application/o/mediamanager/.well-known/openid-configuration`. + +### `OPENID_NAME` + +Display name for this OpenID provider. Default is `OpenID`. Example: `Authentik`. ### Configuring OpenID Connect diff --git a/Writerside/topics/configuration-backend.md b/Writerside/topics/configuration-backend.md index c925ebd..25c03b8 100644 --- a/Writerside/topics/configuration-backend.md +++ b/Writerside/topics/configuration-backend.md @@ -1,17 +1,63 @@ # 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 + +### `API_BASE_PATH` + +The url base of the backend. Default is `/api/v1`. + +### `CORS_URLS` + +Enter a list of origins you are going to access the api from. Example: `https://mm.example`. ## Database Settings -| Variable | Description | Default | Example | -|---------------|------------------------------------------|----------------|--------------| -| `DB_HOST` | Hostname or IP of the PostgreSQL server. | `localhost` | `postgres` | -| `DB_PORT` | Port number of the PostgreSQL server. | `5432` | `5432` | -| `DB_USER` | Username for PostgreSQL connection. | `MediaManager` | `myuser` | -| `DB_PASSWORD` | Password for the PostgreSQL user. | `MediaManager` | `mypassword` | -| `DB_DBNAME` | Name of the PostgreSQL database. | `MediaManager` | `mydatabase` | +### `DB_HOST` + +Hostname or IP of the PostgreSQL server. Default is `localhost`. Example: `postgres`. + +### `DB_PORT` + +Port number of the PostgreSQL server. Default is `5432`. Example: `5432`. + +### `DB_USER` + +Username for PostgreSQL connection. Default is `MediaManager`. Example: `myuser`. + +### `DB_PASSWORD` + +Password for the PostgreSQL user. Default is `MediaManager`. Example: `mypassword`. + +### `DB_DBNAME` + +Name of the PostgreSQL database. Default is `MediaManager`. Example: `mydatabase`. + +## Download Client Settings + +Currently, only qBittorrent is supported as a download client. But support for other clients isn't unlikely in the +future. + +### `QBITTORRENT_HOST` + +Host of the QBittorrent API. Default is `localhost`. Example: `qbit.example.com`. + +### `QBITTORRENT_PORT` + +Port of the QBittorrent API. Default is `8080`. Example: `443`. + +### `QBITTORRENT_USER` + +Username for QBittorrent. Default is `admin`. + +### `QBITTORRENT_PASS` + +Password for QBittorrent. Default is `admin`. ## Metadata Provider Settings @@ -27,39 +73,48 @@ an account and generate a free API key in your account settings. Other software like Jellyfin use TMDB as well, so there won't be any metadata discrepancies. -| Variable | Default | Example | -|----------------|---------|---------------------------------------| -| `TMDB_API_KEY` | None | `TMDB_API_KEY=your_tmdb_api_key_here` | +#### `TMDB_API_KEY` + +Your TMDB API key. Example: `your_tmdb_api_key_here`. ### TVDB (The TVDB) - The TVDB might provide false metadata, also it doesn't support some features of MediaManager like to show overviews, therfore TMDB is the preferred metadata provider. + The TVDB might provide false metadata, also it doesn't support some features of MediaManager like to show overviews, therfore TMDB is the preferred metadata provider. Get an API key from [The TVDB](https://thetvdb.com/auth/register) to use this provider. You can create an account and generate a free API key in your account settings. -| Variable | Default | Example | -|----------------|---------|---------------------------------------| -| `TVDB_API_KEY` | None | `TVDB_API_KEY=your_tvdb_api_key_here` | +#### `TVDB_API_KEY` + +Your TVDB API key. Example: `your_tvdb_api_key_here`. ## 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 | -|---------------------|---------------------------------------------------|------------------| -| `IMAGE_DIRECTORY` | media images (posters, backdrops) will be stored. | `/data/images` | -| `TV_DIRECTORY` | location of TV show files | `/data/tv` | -| `MOVIE_DIRECTORY` | location of movie files | `/data/movies` | -| `TORRENT_DIRECTORY` | location of torrent files and downloads | `/data/torrents` | +### `IMAGE_DIRECTORY` + +Media images (posters, backdrops) will be stored here. Default is `/data/images`. + +### `TV_DIRECTORY` + +Location of TV show files. Default is `/data/tv`. + +### `MOVIE_DIRECTORY` + +Location of movie files. Default is `/data/movies`. + +### `TORRENT_DIRECTORY` + +Location of torrent files and downloads. Default is `/data/torrents`. ## Build Arguments (Dockerfile) -| Argument | Description | Example (in build command) | -|-----------|--------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------| -| `VERSION` | Labels the Docker image with a version. Passed during build (e.g., by GitHub Actions). Frontend uses this as `PUBLIC_VERSION`. | `docker build --build-arg VERSION=1.2.3 .` | +### `VERSION` +Labels the Docker image with a version. Passed during build (e.g., by GitHub Actions). Frontend uses this as +`PUBLIC_VERSION`. Example (in build command): `docker build --build-arg VERSION=1.2.3 .` diff --git a/Writerside/topics/configuration-frontend.md b/Writerside/topics/configuration-frontend.md index 872cc1d..27edabc 100644 --- a/Writerside/topics/configuration-frontend.md +++ b/Writerside/topics/configuration-frontend.md @@ -1,14 +1,34 @@ # Frontend -| Variable | Description | Default | Example | -|----------------------|----------------------------------------------------------------|--------------------------------|-------------------------------------------| -| `PUBLIC_WEB_SSR` | Enables/disables Server-Side Rendering. (this is experimental) | `false` | `true` | -| `PUBLIC_API_URL` | You (the browser) mut reach the backend from this url. | `http://localhost:8000/api/v1` | `https://mediamanager.example.com/api/v1` | -| `PUBLIC_SSR_API_URL` | The frontent container must reach the backend from this url. | `http://localhost:8000/api/v1` | `http://backend:8000/api/v1` | +## Environment Variables + +### `PUBLIC_WEB_SSR` + +Enables/disables Server-Side Rendering. (this is experimental). Default is `false`. Example: `true`. + +### `PUBLIC_API_URL` + +You (the browser) must reach the backend from this url. Default is `http://localhost:8000/api/v1`. Example: +`https://mediamanager.example.com/api/v1`. + +### `PUBLIC_SSR_API_URL` + +The frontend container must reach the backend from this url. Default is `http://localhost:8000/api/v1`. Example: +`http://backend:8000/api/v1`. ## 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. + +### `VERSION` + +Sets the `PUBLIC_VERSION` environment variable at runtime in the frontend container. Passed during build. Example (in +build command): `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 with one. Example (in build command): +`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/developer-guide.md b/Writerside/topics/developer-guide.md index 698fd7f..11a562f 100644 --- a/Writerside/topics/developer-guide.md +++ b/Writerside/topics/developer-guide.md @@ -27,19 +27,16 @@ This section is for those who want to contribute to Media Manager or understand ### Backend -- **Framework:** Python with FastAPI -- **Database ORM:** SQLAlchemy -- **Database Migrations:** Alembic -- **Dependency Management:** uv +- Python with FastAPI +- SQLAlchemy +- Pydantic and Pydantic-Settings ### Frontend -- **Framework:** SvelteKit -- **Language:** TypeScript -- **Styling:** Tailwind CSS -- **Components:** shadcn-svelte for UI components +- TypeScript with SvelteKit +- Tailwind CSS +- shadcn-svelte -### Deployment & CI/CD +### CI/CD -- Docker & Docker Compose - GitHub Actions \ No newline at end of file diff --git a/Writerside/topics/notes.topic b/Writerside/topics/notes.topic new file mode 100644 index 0000000..b289760 --- /dev/null +++ b/Writerside/topics/notes.topic @@ -0,0 +1,8 @@ + + + + + Lists have to be formatted like this: ["item1", "item2", "item3"]. Note the double quotes. + + \ No newline at end of file diff --git a/Writerside/topics/troubleshooting.md b/Writerside/topics/troubleshooting.md index 5e46b75..e6df201 100644 --- a/Writerside/topics/troubleshooting.md +++ b/Writerside/topics/troubleshooting.md @@ -5,26 +5,24 @@ - Always check the container logs for more specific error messages + Always check the container and browser logs for more specific error messages -## Authentication Issues +## Authentication Issues (OIDC) - * 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`. +* 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/Writerside/writerside.cfg b/Writerside/writerside.cfg index baed983..2e8cca9 100644 --- a/Writerside/writerside.cfg +++ b/Writerside/writerside.cfg @@ -5,4 +5,5 @@ + \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..facb639 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,69 @@ +services: + backend: + image: ghcr.io/maxdorninger/mediamanager/backend:latest + container_name: backend + ports: + - "8000:8000" + # In your reverse proxy you will probably need to set rule that only requests with a path prefix + # of /api/v1 will be forwarded to this container + # if you are using traefik the rule is going to look something like this: + # "traefik.http.routers.mm-api.rule=Host(`media.example`)&&PathPrefix(`/api/v1`)" + environment: + - QBITTORRENT_PASSWORD= + - QBITTORRENT_HOST= + - QBITTORRENT_USERNAME= + - QBITTORRENT_PORT= + + - 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 + 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/config.py b/media_manager/auth/config.py index 8e49eb7..1765c2e 100644 --- a/media_manager/auth/config.py +++ b/media_manager/auth/config.py @@ -7,7 +7,7 @@ class AuthConfig(BaseSettings): model_config = SettingsConfigDict(env_prefix="AUTH_") token_secret: str session_lifetime: int = 60 * 60 * 24 - admin_email: str + admin_email: list[str] = [] @property def jwt_signing_key(self): 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/__init__.py b/media_manager/indexer/__init__.py index db2d00b..9f59e42 100644 --- a/media_manager/indexer/__init__.py +++ b/media_manager/indexer/__init__.py @@ -1,7 +1,9 @@ import logging +from media_manager.indexer.config import JackettConfig +from media_manager.indexer.indexers.jackett import Jackett from media_manager.indexer.config import ProwlarrConfig -from media_manager.indexer.indexers.generic import GenericIndexer, IndexerQueryResult +from media_manager.indexer.indexers.generic import GenericIndexer from media_manager.indexer.indexers.prowlarr import Prowlarr log = logging.getLogger(__name__) @@ -10,3 +12,5 @@ indexers: list[GenericIndexer] = [] if ProwlarrConfig().enabled: indexers.append(Prowlarr()) +if JackettConfig().enabled: + indexers.append(Jackett()) diff --git a/media_manager/indexer/config.py b/media_manager/indexer/config.py index c878ee2..861e21e 100644 --- a/media_manager/indexer/config.py +++ b/media_manager/indexer/config.py @@ -3,6 +3,16 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class ProwlarrConfig(BaseSettings): model_config = SettingsConfigDict(env_prefix="PROWLARR_") - enabled: bool = True - api_key: str + enabled: bool | None = False + api_key: str | None url: str = "http://localhost:9696" + + +class JackettConfig(BaseSettings): + model_config = SettingsConfigDict(env_prefix="JACKETT_") + enabled: bool | None = False + api_key: str | None + url: str = "http://localhost:9696" + indexers: list[str] = [ + "all" + ] diff --git a/media_manager/indexer/indexers/generic.py b/media_manager/indexer/indexers/generic.py index d008873..e0b1fa6 100644 --- a/media_manager/indexer/indexers/generic.py +++ b/media_manager/indexer/indexers/generic.py @@ -1,6 +1,3 @@ -from media_manager.indexer.schemas import IndexerQueryResult - - class GenericIndexer(object): name: str @@ -10,7 +7,7 @@ class GenericIndexer(object): else: raise ValueError("indexer name must not be None") - def get_search_results(self, query: str) -> list[IndexerQueryResult]: + def search(self, query: str) -> list["IndexerQueryResult"]: """ Sends a search request to the Indexer and returns the results. diff --git a/media_manager/indexer/indexers/jackett.py b/media_manager/indexer/indexers/jackett.py new file mode 100644 index 0000000..f416768 --- /dev/null +++ b/media_manager/indexer/indexers/jackett.py @@ -0,0 +1,76 @@ +import logging +import xml.etree.ElementTree as ET +from xml.etree.ElementTree import Element + +import requests + +from media_manager.indexer.indexers.generic import GenericIndexer +from media_manager.indexer.config import JackettConfig +from media_manager.indexer.schemas import IndexerQueryResult + +log = logging.getLogger(__name__) + + +class Jackett(GenericIndexer): + def __init__(self, **kwargs): + """ + A subclass of GenericIndexer for interacting with the Jacket API. + + """ + super().__init__(name="jackett") + config = JackettConfig() + self.api_key = config.api_key + self.url = config.url + self.indexers = config.indexers + log.debug("Registering Jacket as Indexer") + + # TODO: change architecture to build query string in the torrent module, instead of tv module + # NOTE: this could be done in parallel, but if there aren't more than a dozen indexers, it shouldn't matter + def search(self, query: str) -> list[IndexerQueryResult]: + log.debug("Searching for " + query) + + responses = [] + for indexer in self.indexers: + log.debug(f"Searching in indexer: {indexer}") + url = ( + self.url + + f"/api/v2.0/indexers/{indexer}/results/torznab/api?apikey={self.api_key}&t=tvsearch&q={query}" + ) + response = requests.get(url) + responses.append(response) + + xmlns = { + "torznab": "http://torznab.com/schemas/2015/feed", + "atom": "http://www.w3.org/2005/Atom", + } + result_list: list[IndexerQueryResult] = [] + for response in responses: + if response.status_code == 200: + xml_tree = ET.fromstring(response.content) + for item in xml_tree.findall("channel/item"): + attributes: list[Element] = [ + x for x in item.findall("torznab:attr", xmlns) + ] + for attribute in attributes: + if attribute.attrib["name"] == "seeders": + seeders = int(attribute.attrib["value"]) + break + else: + log.warning( + f"Seeders not found in torrent: {item.find('title').text}, skipping this torrent" + ) + continue + + result = IndexerQueryResult( + title=item.find("title").text, + download_url=item.find("link").text, + seeders=seeders, + flags=[], + size=int(item.find("size").text), + ) + result_list.append(result) + log.debug(f"Raw result: {result.model_dump()}") + else: + log.error(f"Jacket Error: {response.status_code}") + return [] + return result_list diff --git a/media_manager/indexer/indexers/prowlarr.py b/media_manager/indexer/indexers/prowlarr.py index 1e3dd0a..a4f1268 100644 --- a/media_manager/indexer/indexers/prowlarr.py +++ b/media_manager/indexer/indexers/prowlarr.py @@ -2,7 +2,7 @@ import logging import requests -from media_manager.indexer import GenericIndexer +from media_manager.indexer.indexers.generic import GenericIndexer from media_manager.indexer.config import ProwlarrConfig from media_manager.indexer.schemas import IndexerQueryResult @@ -23,7 +23,7 @@ class Prowlarr(GenericIndexer): self.url = config.url log.debug("Registering Prowlarr as Indexer") - def get_search_results(self, query: str) -> list[IndexerQueryResult]: + def search(self, query: str) -> list[IndexerQueryResult]: log.debug("Searching for " + query) url = self.url + "/api/v1/search" headers = {"accept": "application/json", "X-Api-Key": self.api_key} diff --git a/media_manager/indexer/schemas.py b/media_manager/indexer/schemas.py index 6091998..dc685be 100644 --- a/media_manager/indexer/schemas.py +++ b/media_manager/indexer/schemas.py @@ -10,7 +10,6 @@ from media_manager.torrent.models import Quality IndexerQueryResultId = typing.NewType("IndexerQueryResultId", UUID) -# TODO: use something like strategy pattern to make sorting more user customizable class IndexerQueryResult(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -30,13 +29,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/indexer/service.py b/media_manager/indexer/service.py index 0a6b67d..ba69438 100644 --- a/media_manager/indexer/service.py +++ b/media_manager/indexer/service.py @@ -1,9 +1,9 @@ from sqlalchemy.orm import Session import media_manager.indexer.repository -from media_manager.indexer import IndexerQueryResult, log, indexers +from media_manager.indexer import log, indexers from media_manager.indexer.repository import save_result -from media_manager.indexer.schemas import IndexerQueryResultId +from media_manager.indexer.schemas import IndexerQueryResultId, IndexerQueryResult def search(query: str, db: Session) -> list[IndexerQueryResult]: @@ -12,7 +12,7 @@ def search(query: str, db: Session) -> list[IndexerQueryResult]: log.debug(f"Searching for Torrent: {query}") for i in indexers: - results.extend(i.get_search_results(query)) + results.extend(i.search(query)) for result in results: save_result(result=result, db=db) log.debug(f"Found Torrents: {results}") diff --git a/media_manager/main.py b/media_manager/main.py index 7c790a4..e5447e6 100644 --- a/media_manager/main.py +++ b/media_manager/main.py @@ -1,9 +1,11 @@ import logging +import os import sys from logging.config import dictConfig from pythonjsonlogger.json import JsonFormatter + LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, @@ -46,13 +48,20 @@ 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 +import media_manager.torrent.service +from media_manager.database import SessionLocal + +init_db() +log.info("Database initialized") basic_config = BasicConfig() if basic_config.DEVELOPMENT: @@ -64,17 +73,35 @@ if basic_config.DEVELOPMENT: else: log.info("Development Mode not activated!") -app = FastAPI(root_path="/api/v1") -if basic_config.DEVELOPMENT: - origins = [ - "*", - ] -else: - origins = basic_config.CORS_URLS.split(",") - log.info("CORS URLs activated for following origins:") - for origin in origins: - log.info(f" - {origin}") +def hourly_tasks(): + log.info(f"Tasks are running at {datetime.now()}") + auto_download_all_approved_season_requests() + media_manager.torrent.service.TorrentService( + db=SessionLocal() + ).import_all_torrents() + + +scheduler = BackgroundScheduler() +trigger = CronTrigger(minute=0, hour="*") +scheduler.add_job(hourly_tasks, trigger) +scheduler.start() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + scheduler.shutdown() + + +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, lifespan=lifespan) + +origins = basic_config.CORS_URLS.split(",") +log.info("CORS URLs activated for following origins:") +for origin in origins: + log.info(f" - {origin}") app.add_middleware( CORSMiddleware, @@ -162,5 +189,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..84a00c7 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 @@ -16,7 +15,7 @@ import media_manager.torrent.repository import media_manager.tv.repository import media_manager.tv.service from media_manager.config import BasicConfig -from media_manager.indexer import IndexerQueryResult +from media_manager.indexer.schemas import IndexerQueryResult from media_manager.torrent.repository import ( get_seasons_files_of_torrent, get_show_of_torrent, @@ -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..7681758 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -4,7 +4,8 @@ import media_manager.indexer.service import media_manager.metadataProvider import media_manager.torrent.repository import media_manager.tv.repository -from media_manager.indexer import IndexerQueryResult +from media_manager.database import SessionLocal +from media_manager.indexer.schemas import IndexerQueryResult from media_manager.indexer.schemas import IndexerQueryResultId from media_manager.metadataProvider.schemas import MetaDataProviderShowSearchResult from media_manager.torrent.repository import get_seasons_files_of_torrent @@ -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/components/app-sidebar.svelte b/web/src/lib/components/app-sidebar.svelte index a62de73..778890b 100644 --- a/web/src/lib/components/app-sidebar.svelte +++ b/web/src/lib/components/app-sidebar.svelte @@ -40,12 +40,12 @@ navSecondary: [ { title: 'Support', - url: '#', + url: 'https://github.com/maxdorninger/MediaManager/issues', icon: LifeBuoy }, { title: 'Feedback', - url: '#', + url: 'https://github.com/maxdorninger/MediaManager/issues', icon: Send }, { diff --git a/web/src/lib/components/season-requests-table.svelte b/web/src/lib/components/season-requests-table.svelte index 87ecc69..58d02f2 100644 --- a/web/src/lib/components/season-requests-table.svelte +++ b/web/src/lib/components/season-requests-table.svelte @@ -124,6 +124,7 @@ {request.authorized_by?.email ?? 'N/A'} + {#if user().is_superuser}