Merge pull request #48 from maxdorninger/add-configuration-via-toml-file

Add configuration via toml file and enhance documentation
This commit is contained in:
Maximilian Dorninger
2025-07-11 13:42:51 +02:00
committed by GitHub
49 changed files with 1018 additions and 562 deletions

View File

@@ -31,7 +31,8 @@ other services.
```
wget -O docker-compose.yaml https://raw.githubusercontent.com/maxdorninger/MediaManager/refs/heads/master/docker-compose.yaml
# Edit docker-compose.yaml to set the environment variables!
wget -O docker-compose.yaml https://raw.githubusercontent.com/maxdorninger/MediaManager/refs/heads/master/config.toml
# you probably need to edit the config.toml file, for more help see the documentation
docker compose up -d
```

View File

@@ -10,11 +10,15 @@
<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-backend.md">
</toc-element>
<toc-element topic="Indexer-Settings.md"/>
<toc-element topic="configuration-backend.md"/>
<toc-element topic="configuration-frontend.md"/>
<toc-element topic="authentication-setup.md"/>
<toc-element topic="database-configuration.md"/>
<toc-element topic="download-client-configuration.md"/>
<toc-element topic="Indexer-Settings.md"/>
<toc-element topic="Notifications.md"/>
<toc-element topic="Reverse-Proxy.md"/>
<toc-element topic="metadata-provider-configuration.md"/>
</toc-element>
<toc-element topic="troubleshooting.md"/>
<toc-element topic="developer-guide.md"/>

View File

@@ -26,4 +26,8 @@
<description>Created after removal of "user-guide" from MediaManager</description>
<accepts>user-guide.html</accepts>
</rule>
<rule id="7cb7d801">
<description>Created after removal of "MetadataRelay" from MediaManager</description>
<accepts>MetadataRelay.html</accepts>
</rule>
</rules>

View File

@@ -1,6 +1,67 @@
# Configuration
The configuration of MediaManager is divided into backend and frontend settings, which can be set in your
`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.
MediaManager uses a TOML configuration file (`config.toml`) for all backend settings.
This centralized configuration approach makes it easier to manage, backup, and share your MediaManager setup.
Frontend settings are configured through environment variables in your `docker-compose.yaml` file.
## Configuration File Location
Your `config.toml` file should be mounted to `/data/config.toml` inside the container:
```yaml
volumes:
- ./config.toml:/data/config.toml
```
Though you can change the location of the configuration file by setting the `CONFIG_FILE` env variable to another path,
e.g. `/etc/mm/config.toml`.
## Configuration Sections
The configuration is organized into the following sections:
- `[misc]` - General settings
- `[database]` - Database settings
- `[auth]` - Authentication settings
- `[notifications]` - Notification settings (Email, Gotify, Ntfy, Pushover)
- `[torrents]` - Download client settings (qBittorrent, SABnzbd)
- `[indexers]` - Indexer settings (Prowlarr and Jackett )
- `[metadata]` - TMDB and TVDB settings
## Configuring Secrets
For sensitive information like API keys, passwords, and secrets, you _should_ use environment variables.
You can actually set every configuration value through environment variables.
For example, to set the `token_secret` value for authentication, with a .toml file you would use:
```toml
[auth]
token_secret = "your_super_secret_key_here"
```
But you can also set it through an environment variable:
```
AUTH_TOKEN_SECRET = "your_super_secret_key_here"
```
or another example with the OIDC client secret:
```toml
[auth]
...
[auth.openid_connect]
client_secret = "your_client_secret_from_provider"
```
env variable:
```
AUTH_OPENID_CONNECT_CLIENT_SECRET = "your_client_secret_from_provider"
```
So for every config "level", you basically have to take the name of the value and prepend it with the section names in
uppercase with underscores as delimiters.

View File

@@ -1,37 +1,53 @@
# Indexer Settings
# Indexers
## Prowlarr
Indexer settings are configured in the `[indexers]` section of your `config.toml` file. MediaManager supports both Prowlarr and Jackett as indexer providers.
### `PROWLARR_ENABLED`
## Prowlarr (`[indexers.prowlarr]`)
Set to `True` to enable Prowlarr. Default is `False`. Example: `true`.
- `enabled`
### `PROWLARR_API_KEY`
Set to `true` to enable Prowlarr. Default is `false`.
This is your Prowlarr API key. Example: `prowlarr_api_key`.
- `url`
### `PROWLARR_URL`
Base URL of your Prowlarr instance.
Base URL of your Prowlarr instance. Default is `http://localhost:9696`. Example: `http://prowlarr:9696`.
- `api_key`
## Jackett
API key for Prowlarr. You can find this in Prowlarr's settings under General.
### `JACKETT_ENABLED`
## Jackett (`[indexers.jackett]`)
Set to `True` to enable Jackett. Default is `False`. Example: `true`.
- `enabled`
### `JACKETT_API_KEY`
Set to `true` to enable Jackett. Default is `false`.
This is your Prowlarr API key. Example: `jackett_api_key`.
- `url`
### `JACKETT_URL`
Base URL of your Jackett instance.
Base URL of your Prowlarr instance. Default is `http://localhost:9117`. Example: `http://prowlarr:9117`.
- `api_key`
### `JACKETT_INDEXERS`
API key for Jackett. You can find this in Jackett's dashboard.
List of all indexers for Jackett to search through. Default is `all`. Example: `["1337x","0magnet"]`.
- `indexers`
<note>
<include from="notes.topic" element-id="list-format"/>
</note>
List of indexer names to use with Jackett. You can specify which indexers Jackett should search through.
## Example Configuration
Here's a complete example of the indexers section in your `config.toml`:
```toml
[indexers]
[indexers.prowlarr]
enabled = true
url = "http://prowlarr:9696"
api_key = "your_prowlarr_api_key"
[indexers.jackett]
enabled = false
url = "http://jackett:9117"
api_key = "your_jackett_api_key"
indexers = ["1337x", "rarbg"]
```

View File

@@ -0,0 +1,121 @@
# Notifications
These settings are configured in the `[notifications]` section of your `config.toml` file.
### SMTP Configuration (`[notifications.smtp_config]`)
For sending emails, MediaManager uses the SMTP protocol. You can use any SMTP server, like Gmail or SMTP2GO.
- `smtp_host`
Hostname of the SMTP server.
- `smtp_port`
Port of the SMTP server.
- `smtp_user`
Username for the SMTP server.
- `smtp_password`
Password or app password for the SMTP server.
- `from_email`
Email address from which emails will be sent.
- `use_tls`
Set to `true` to use TLS for the SMTP connection. Default is `true`.
### Email Notifications (`[notifications.email_notifications]`)
- `enabled`
Set to `true` to enable email notifications. Default is `false`.
- `emails`
List of email addresses to send notifications to.
### Gotify Notifications (`[notifications.gotify]`)
- `enabled`
Set to `true` to enable Gotify notifications. Default is `false`.
- `api_key`
API key for Gotify.
- `url`
Base URL of your Gotify instance. Note the lack of a trailing slash.
### Ntfy Notifications (`[notifications.ntfy]`)
- `enabled`
Set to `true` to enable Ntfy notifications. Default is `false`.
- `url`
URL of your ntfy instance plus the topic.
### Pushover Notifications (`[notifications.pushover]`)
- `enabled`
Set to `true` to enable Pushover notifications. Default is `false`.
- `api_key`
API key for Pushover.
- `user`
User key for Pushover.
## Example Configuration
Here's a complete example of the notifications section in your `config.toml`:
```toml
[notifications]
# SMTP settings for email notifications and password resets
[notifications.smtp_config]
smtp_host = "smtp.gmail.com"
smtp_port = 587
smtp_user = "your-email@gmail.com"
smtp_password = "your-app-password"
from_email = "mediamanager@example.com"
use_tls = true
# Email notification settings
[notifications.email_notifications]
enabled = true
emails = ["admin@example.com", "notifications@example.com"]
# Gotify notification settings
[notifications.gotify]
enabled = true
api_key = "your_gotify_api_key"
url = "https://gotify.example.com"
# Ntfy notification settings
[notifications.ntfy]
enabled = false
url = "https://ntfy.sh/your-private-topic"
# Pushover notification settings
[notifications.pushover]
enabled = false
api_key = "your_pushover_api_key"
user = "your_pushover_user_key"
```
<note>
You can enable multiple notification methods simultaneously. For example, you could have both email and Gotify notifications enabled at the same time.
</note>

View File

@@ -0,0 +1,55 @@
# Reverse Proxy
MediaManager is probably unlike any other service that you have deployed before, because it has a separate frontend and backend container.
This means that setting up a reverse proxy is a bit different.
When deploying MediaManager, you have two choices:
- Use two different hostnames, e.g. `mediamanager.example.com` for the frontend and `api-mediamanager.example.com` for the backend.
- Use a single hostname with a base path, e.g. `mediamanager.example.com` for the frontend and `mediamanager.example.com/api/v1` for the backend.
If you choose the first option, you can set up your reverse proxy as usual, forwarding requests to the appropriate containers based on the hostname.
If you choose the second option, you need to ensure that your reverse proxy is configured to handle the base path correctly.
## Example Caddy Configuration
```
mm.my-domain.com {
@api path /api/*
reverse_proxy @api 10.0.0.7:8000
reverse_proxy 10.0.0.7:3000
}
```
## Example Traefik Configuration
This example assumes you use Traefik with Docker labels.
```yaml
services:
backend:
image: ghcr.io/maxdorninger/mediamanager/backend:latest
ports:
- "8000:8000"
labels:
- "traefik.enable=true"
- "traefik.http.routers.mediamanager-backend.rule=Host(`media.example`)&&PathPrefix(`/api/v1`)"
- "traefik.http.routers.mediamanager-backend.tls=true"
- "traefik.http.routers.mediamanager-backend.tls.certresolver=letsencrypt"
- "traefik.http.routers.mediamanager-backend.entrypoints=websecure"
environment:
- MISC_FRONTEND_URL=https://media.example/
frontend:
image: ghcr.io/maxdorninger/mediamanager/frontend:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.mediamanager-frontend.rule=Host(`media.example`)"
- "traefik.http.routers.mediamanager-frontend.tls=true"
- "traefik.http.routers.mediamanager-frontend.tls.certresolver=letsencrypt"
- "traefik.http.routers.mediamanager-frontend.entrypoints=websecure"
ports:
- "3000:3000"
environment:
- PUBLIC_API_URL=https://media.example/api/v1
```

View File

@@ -1,80 +1,70 @@
# Authentication
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.
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.
All authentication settings are configured in the `[auth]` section of your `config.toml` file.
## General Authentication Settings (`[auth]`)
- `token_secret`
Strong secret key for signing JWTs (create with `openssl rand -hex 32`). This is a required field.
- `session_lifetime`
Lifetime of user sessions in seconds. Default is `86400` (1 day).
- `admin_emails`
A list of email addresses for administrator accounts. This is a required field.
- `email_password_resets`
Toggle for enabling password resets via email. If users request a password reset because they forgot their password, they will be sent an email with a link to reset it. Default is `false`.
<note>
Note the lack of a trailing slash in some env vars like OPENID_CONFIGURATION_ENDPOINT. This is important.
To use email password resets, you must also configure SMTP settings in the <code>[notifications.smtp_config]</code> section.
</note>
## General Authentication Settings
## OpenID Connect Settings (`[auth.openid_connect]`)
### `AUTH_TOKEN_SECRET`
OpenID Connect allows you to integrate with external identity providers like Google, Microsoft Azure AD, Keycloak, or any other OIDC-compliant provider.
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`.
- `enabled`
### `AUTH_SESSION_LIFETIME`
Set to `true` to enable OpenID Connect authentication. Default is `false`.
Lifetime of user sessions in seconds. Default is `86400` (1 day). Example: `AUTH_SESSION_LIFETIME=604800` (1 week).
- `client_id`
### `AUTH_ADMIN_EMAIL`
Client ID provided by your OpenID Connect provider.
A list of email addresses for administrator accounts. This is a required field. Example:
`AUTH_ADMIN_EMAIL=admin@example.com`.
- `client_secret`
### `FRONTEND_URL`
Client secret provided by your OpenID Connect provider.
The URL the frontend will be accessed from. This is a required field. Example: `https://mediamanager.example/`.
- `configuration_endpoint`
### `AUTH_EMAIL_PASSWORD_RESETS`
OpenID Connect configuration endpoint URL. Note the lack of a trailing slash - this is important.
Toggle for enabling password resets via email. If users request a password reset in case they forgot their password,
they will be sent an email with a link to reset it. Default is `FALSE`.
- `name`
<note>
On login/registration, every user whose email is in <code>AUTH_ADMIN_EMAIL</code> will be granted admin privileges.
Users whose email is not in <code>AUTH_ADMIN_EMAIL</code> will be regular users and will need to be verified by an administrator,
this can be done in the settings page.
</note>
<tip>
<include from="notes.topic" element-id="list-format"/>
</tip>
Display name for the OpenID Connect provider that will be shown on the login page.
## OpenID Connect (OAuth 2.0)
## Example Configuration
### `OPENID_ENABLED`
Here's a complete example of the authentication section in your `config.toml`:
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
1. Set `OPENID_ENABLED=TRUE`
2. Configure the following environment variables:
* `OPENID_CLIENT_ID`
* `OPENID_CLIENT_SECRET`
* `OPENID_CONFIGURATION_ENDPOINT`
* `OPENID_NAME` (optional)
* `FRONTEND_URL` (it is important that this is set correctly, as it is used for the redirect URIs)
3. Your OpenID server will likely want a redirect URI. This URL will be like:
`{FRONTEND_URL}/api/v1/auth/cookie/{OPENID_NAME}/callback`. The exact path depends on the `OPENID_NAME`.
4. Example URL: `https://mediamanager.example/api/v1/auth/cookie/Authentik/callback`
```toml
[auth]
token_secret = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6"
session_lifetime = 604800 # 1 week
admin_emails = ["admin@example.com", "manager@example.com"]
email_password_resets = true
[auth.openid_connect]
enabled = true
client_id = "mediamanager-client"
client_secret = "your-secret-key-here"
configuration_endpoint = "https://auth.example.com/.well-known/openid-configuration"
name = "Authentik"
```

View File

@@ -1,220 +1,43 @@
# Backend
These settings configure the core backend application through the `config.toml` file. All backend configuration is now centralized in this TOML file instead of environment variables.
These variables configure the core backend application, database connections, authentication, and integrations.
## General Settings (`[misc]`)
- `frontend_url`
The URL the frontend will be accessed from. This is a required field and must include the trailing slash.
- `cors_urls`
A list of origins you are going to access the API from. Note the lack of trailing slashes.
- `api_base_path`
The URL base path of the backend API. Default is `/api/v1`. Note the lack of a trailing slash.
- `development`
Set to `true` to enable development mode. Default is `false`.
## Example Configuration
Here's a complete example of the general settings section in your `config.toml`:
```toml
[misc]
# REQUIRED: Change this to match your actual frontend URL
frontend_url = "http://localhost:3000/"
# REQUIRED: List all origins that will access the API
cors_urls = ["http://localhost:3000", "http://localhost:8000"]
# Optional: API base path (rarely needs to be changed)
api_base_path = "/api/v1"
# Optional: Development mode (set to true for debugging)
development = false
```
<note>
<include from="notes.topic" element-id="list-format"/>
The <code>frontend_url</code> and <code>cors_urls</code> are the most important settings to configure correctly. Make sure they match your actual deployment URLs.
</note>
## 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
### `DB_HOST`
Hostname or IP of the PostgreSQL server. Default is `localhost`.
### `DB_PORT`
Port number of the PostgreSQL server. Default is `5432`.
### `DB_USER`
Username for PostgreSQL connection. Default is `MediaManager`.
### `DB_PASSWORD`
Password for the PostgreSQL user. Default is `MediaManager`.
### `DB_DBNAME`
Name of the PostgreSQL database. Default is `MediaManager`.
## 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_PASSWORD`
Password for QBittorrent. Default is `admin`.
## Metadata Provider Settings
<note>
Note the lack of a trailing slash in some env vars like <code>TMDB_RELAY_URL</code>. This is important.
</note>
These settings configure the integrations with external metadata providers like The Movie Database (TMDB) and The TVDB.
### TMDB (The Movie Database)
TMDB is the primary metadata provider for MediaManager. It provides detailed information about movies and TV shows.
<tip>
Other software like Jellyfin use TMDB as well, so there won't be any metadata discrepancies.
</tip>
#### `TMDB_RELAY_URL`
If you want use your own TMDB relay service, set this to the URL of your own MetadataRelay. Otherwise, don't set it to
use the default relay.
Default: `https://metadata-relay.maxid.me/tmdb`.
### TVDB (The TVDB)
<warning>
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.
</warning>
#### `TVDB_RELAY_URL`
If you want use your own TVDB relay service, set this to the URL of your own MetadataRelay. Otherwise, don't set it to
use the default relay.
Default: `https://metadata-relay.maxid.me/tvdb`.
### MetadataRelay
<note>
To use MediaManager <strong>you don't need to set up your own MetadataRelay</strong>, as the default relay which is hosted by me, the dev of MediaManager, should be sufficient for most purposes.
</note>
The MetadataRelay is a service that provides metadata for MediaManager. It acts as a proxy for TMDB and TVDB, allowing
you to use your own API keys, but not strictly needing your own because only me, the developer, needs to create accounts
for API keys.
You might want to use it if you want to avoid rate limits, to protect your privacy, or other reasons.
If you know Sonarr's Skyhook, this is similar to that.
#### Where to get API keys
Get an API key from [The Movie Database](https://www.themoviedb.org/settings/api). You can create
an account and generate a free API key in your account settings.
Get an API key from [The TVDB](https://thetvdb.com/auth/register). You can create an account and
generate a free API key in your account settings.
<tip>
If you want to use your own MetadataRelay, you can set the <code>TMDB_RELAY_URL</code> and/or <code>TVDB_RELAY_URL</code> to your own relay service.
</tip>
## Directory Settings
<note>
Normally you don't need to change these, as the default mountpoints are usually sufficient. In your <code>docker-compose.yaml</code>, you can just mount <code>/any/directory</code> to <code>/data/torrents</code>.
</note>
### `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`.
## Email Settings
For sending emails, MediaManager uses the SMTP protocol. You can use any SMTP server, like Gmail or SMTP2GO.
### `EMAIL_SMTP_HOST`
Hostname of the SMTP server.
### `EMAIL_SMTP_PORT`
Port of the SMTP server.
### `EMAIL_SMTP_USER`
Username for the SMTP server.
### `EMAIL_SMTP_PASSWORD`
Password for the SMTP server.
### `EMAIL_FROM_EMAIL`
Email address from which emails will be sent.
### `EMAIL_USE_TLS`
Set to `True` to use TLS for the SMTP connection. Default is `False`.
For secure connections, use TLS.
## Notification Settings
MediaManager can send Notifications via email, ntfy.sh, Pushover and Gotify. You can enable any of these.
To enable a notification method, set the corresponding environment variables.
### Email
#### `NOTIFICATION_EMAIL`
If set notifications will be sent via email to this email address.
Example: `notifications@example.com`.
### Gotify
#### `NOTIFICATION_GOTIFY_API_KEY`
API key for Gotify.
#### `NOTIFICATION_GOTIFY_URL`
Base URL of your Gotify instance. Example: `https://gotify.example.com`.
Note the lack of a trailing slash.
### Ntfy
#### `NOTIFICATION_NTFY_URL`
URL of your ntfy instance + the topic. Example `https://ntfy.sh/your-topic`.
### Pushover
#### `NOTIFICATION_PUSHOVER_API_KEY`
API key for Pushover.
#### `NOTIFICATION_PUSHOVER_USER`
Username for Pushover.
## Build Arguments (Dockerfile)
### `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 .`

View File

@@ -11,13 +11,15 @@ You (the browser) must reach the backend from this url. Default is `http://local
**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.
Unfortunately you need to build the frontend docker container, to configure a url base path for the frontend. This is because
SvelteKit needs to know the base path at build time.
### `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 .`
- clone the repo
- cd into the repo's root directory
- `docker build --build-arg BASE_URL=/media -f web/Dockerfile .`
### `VERSION`

View File

@@ -0,0 +1,42 @@
# Database
Database settings are configured in the `[database]` section of your `config.toml` file. MediaManager uses PostgreSQL as its database backend.
## Database Settings (`[database]`)
- `host`
Hostname or IP of the PostgreSQL server. Default is `localhost`.
- `port`
Port number of the PostgreSQL server. Default is `5432`.
- `user`
Username for PostgreSQL connection. Default is `MediaManager`.
- `password`
Password for the PostgreSQL user. Default is `MediaManager`.
- `dbname`
Name of the PostgreSQL database. Default is `MediaManager`.
## Example Configuration
Here's a complete example of the database section in your `config.toml`:
```toml
[database]
host = "db"
port = 5432
user = "MediaManager"
password = "your_secure_password"
dbname = "MediaManager"
```
<tip>
In docker-compose deployments the containers name is simultaneously its hostname, so you can use "db" or "postgres" as host.
</tip>

View File

@@ -0,0 +1,109 @@
# Download Clients
Download client settings are configured in the `[torrents]` section of your `config.toml` file. MediaManager supports both qBittorrent and SABnzbd as download clients.
## qBittorrent Settings (`[torrents.qbittorrent]`)
qBittorrent is a popular BitTorrent client that MediaManager can integrate with for downloading torrents.
- `enabled`
Set to `true` to enable qBittorrent integration. Default is `false`.
- `host`
Hostname or IP of the qBittorrent server. Include the protocol (http/https).
- `port`
Port of the qBittorrent Web UI/API. Default is `8080`.
- `username`
Username for qBittorrent Web UI authentication. Default is `admin`.
- `password`
Password for qBittorrent Web UI authentication. Default is `admin`.
## SABnzbd Settings (`[torrents.sabnzbd]`)
SABnzbd is a Usenet newsreader that MediaManager can integrate with for downloading NZB files.
- `enabled`
Set to `true` to enable SABnzbd integration. Default is `false`.
- `host`
Hostname or IP of the SABnzbd server.
- `port`
Port of the SABnzbd API. Default is `8080`.
- `api_key`
API key for SABnzbd. You can find this in SABnzbd's configuration under "General" → "API Key".
## Example Configuration
Here's a complete example of the download clients section in your `config.toml`:
```toml
[torrents]
# qBittorrent configuration
[torrents.qbittorrent]
enabled = true
host = "http://qbittorrent"
port = 8080
username = "admin"
password = "your_secure_password"
# SABnzbd configuration
[torrents.sabnzbd]
enabled = false
host = "sabnzbd"
port = 8080
api_key = "your_sabnzbd_api_key"
```
## Docker Compose Integration
When using Docker Compose, make sure your download clients are accessible from the MediaManager backend:
```yaml
services:
# MediaManager backend
backend:
image: ghcr.io/maxdorninger/mediamanager/backend:latest
# ... other configuration ...
# qBittorrent service
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
ports:
- "8080:8080"
environment:
- WEBUI_PORT=8080
volumes:
- ./data/torrents:/downloads
# ... other configuration ...
# SABnzbd service (optional)
sabnzbd:
image: lscr.io/linuxserver/sabnzbd:latest
ports:
- "8081:8080"
volumes:
- ./data/usenet:/downloads
# ... other configuration ...
```
<note>
You can enable both qBittorrent and SABnzbd simultaneously if you want to support both BitTorrent and Usenet downloads.
</note>
<tip>
Make sure the download directories in your download clients are accessible to MediaManager for proper file management and organization.
</tip>

View File

@@ -0,0 +1,69 @@
# Metadata Provider Configuration
Metadata provider settings are configured in the `[metadata]` section of your `config.toml` file. These settings control how MediaManager retrieves information about movies and TV shows.
## TMDB Settings (`[metadata.tmdb]`)
TMDB (The Movie Database) is the primary metadata provider for MediaManager. It provides detailed information about movies and TV shows.
<tip>
Other software like Jellyfin use TMDB as well, so there won't be any metadata discrepancies.
</tip>
### `tmdb_relay_url`
If you want to use your own TMDB relay service, set this to the URL of your own MetadataRelay. Otherwise, use the default relay.
- **Default:** `https://metadata-relay.maxid.me/tmdb`
- **Example:** `https://your-own-relay.example.com/tmdb`
## TVDB Settings (`[metadata.tvdb]`)
<warning>
The TVDB might provide false metadata and doesn't support some features of MediaManager like showing overviews. Therefore, TMDB is the preferred metadata provider.
</warning>
### `tvdb_relay_url`
If you want to use your own TVDB relay service, set this to the URL of your own MetadataRelay. Otherwise, use the default relay.
- **Default:** `https://metadata-relay.maxid.me/tvdb`
- **Example:** `https://your-own-relay.example.com/tvdb`
## MetadataRelay
<note>
To use MediaManager <strong>you don't need to set up your own MetadataRelay</strong>, as the default relay hosted by the developer should be sufficient for most purposes.
</note>
The MetadataRelay is a service that provides metadata for MediaManager. It acts as a proxy for TMDB and TVDB, allowing you to use your own API keys if needed, but the default relay means you don't need to create accounts for API keys yourself.
You might want to use your own relay if you want to avoid rate limits, protect your privacy, or for other reasons. If you know Sonarr's Skyhook, this is similar to that.
### Where to get API keys
- Get a TMDB API key from [The Movie Database](https://www.themoviedb.org/settings/api)
- Get a TVDB API key from [The TVDB](https://thetvdb.com/auth/register)
<tip>
If you want to use your own MetadataRelay, you can set the <code>tmdb_relay_url</code> and/or <code>tvdb_relay_url</code> to your own relay service.
</tip>
## Example Configuration
Here's a complete example of the metadata section in your `config.toml`:
```toml
[metadata]
# TMDB configuration
[metadata.tmdb]
tmdb_relay_url = "https://metadata-relay.maxid.me/tmdb"
# TVDB configuration
[metadata.tvdb]
tvdb_relay_url = "https://metadata-relay.maxid.me/tvdb"
```
<note>
In most cases, you can simply use the default values and don't need to specify these settings in your config file at all.
</note>

View File

@@ -13,12 +13,12 @@
* 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`.
* Check if your reverse proxy is correctly configured, see [Reverse Proxy Configuration](Reverse-Proxy.md) for examples.
## Data Not Appearing / File Issues
@@ -26,3 +26,4 @@
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).
* For hardlinks to work, you must not use different docker volumes for TV, Torrents, etc.

104
config.toml Normal file
View File

@@ -0,0 +1,104 @@
# MediaManager Complete Configuration File
# This file contains all available configuration options for MediaManager
# Documentation: https://maxdorninger.github.io/MediaManager/introduction.html
[misc]
# it's very likely that you need to change this for MediaManager to work
frontend_url = "http://localhost:3000/" # note the trailing slash
cors_urls = ["http://localhost:3000", "http://localhost:8000"] # note the lack of a trailing slash
# you probaly don't need to change this
api_base_path = "/api/v1"
development = false
[database]
host = "db"
port = 5432
user = "MediaManager"
password = "MediaManager"
dbname = "MediaManager"
[auth]
email_password_resets = false # if true, you also need to set up SMTP (notifications.smtp_config)
token_secret = "" # generate a random string with "openssl rand -hex 32", e.g. here https://www.cryptool.org/en/cto/openssl/
session_lifetime = 86400 # this is how long you will be logged in after loggin in, in seconds
admin_emails = ["admin@example.com", "admin2@example.com"]
# OpenID Connect settings
[auth.openid_connect]
enabled = false
client_id = ""
client_secret = ""
configuration_endpoint = "https://openid.example.com/.well-known/openid-configuration"
name = "OpenID"
[notifications]
# SMTP settings for email notifications and email password resets
[notifications.smtp_config]
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_user = "admin"
smtp_password = "admin"
from_email = "mediamanager@example.com"
use_tls = true
# Email notification settings
[notifications.email_notifications]
enabled = false
emails = ["admin@example.com", "admin2@example.com"] # List of email addresses to send notifications to
# Gotify notification settings
[notifications.gotify]
enabled = false
api_key = ""
url = "https://gotify.example.com"
# Ntfy notification settings
[notifications.ntfy]
enabled = false
url = "https://ntfy.sh/your-topic"
# Pushover notification settings
[notifications.pushover]
enabled = false
api_key = ""
user = ""
[torrents]
# qBittorrent settings
[torrents.qbittorrent]
enabled = false
host = "http://localhost"
port = 8080
username = "admin"
password = "admin"
# SABnzbd settings
[torrents.sabnzbd]
enabled = false
host = "localhost"
port = 8080
api_key = ""
[indexers]
# Prowlarr settings
[indexers.prowlarr]
enabled = false
url = "http://localhost:9696"
api_key = ""
# Jackett settings
[indexers.jackett]
enabled = false
url = "http://localhost:9117"
api_key = ""
indexers = ["1337x"] # List of indexer names to use
# its very unlikely that you need to change this
[metadata]
[metadata.tmdb]
tmdb_relay_url = "https://metadata-relay.maxid.me/tmdb"
[metadata.tvdb]
tvdb_relay_url = "https://metadata-relay.maxid.me/tvdb"

View File

@@ -4,71 +4,10 @@ services:
ports:
- "8000:8000"
environment:
- QBITTORRENT_PASSWORD=
- QBITTORRENT_HOST=
- QBITTORRENT_USERNAME=
- QBITTORRENT_PORT=
- CORS_URLS=["http://localhost:3000","https://mm.example.com"]
- DB_HOST=db
#- DB_NAME=
#- DB_PORT=
#- DB_PASSWORD=
#- DB_DBNAME=
#- PROWLARR_ENABLED=
#- PROWLARR_URL=http://prowlarr:9696
#- PROWLARR_API_KEY=
#- JACKETT_ENABLED=
#- JACKETT_URL=http://jackett:9117
#- JACKETT_API_KEY=
# example indexers, you can also just use ["all"] to use all indexers configured in Jackett
#- JACKETT_INDEXERS=["1337x","Rarbg"]
# generate a random string with "openssl rand -hex 32"
- AUTH_TOKEN_SECRET=
# this should be you email address
- AUTH_ADMIN_EMAIL=["admin1@example.com","admin2@example.com"]
# if you forget your password you can request a link and get it via email, you must have the email settings configured for this to work obviously
#- AUTH_EMAIL_PASSWORD_RESETS=TRUE
# this is the URL of your frontend, e.g. https://mediamanager.example.com
- FRONTEND_URL=
#- EMAIL_SMTP_HOST=
#- EMAIL_SMTP_PORT=
#- EMAIL_SMTP_USER=
#- EMAIL_SMTP_PASSWORD=
#- EMAIL_FROM_EMAIL=
#- EMAIL_USE_TLS=TRUE
#- NOTIFICATION_EMAIL=notify@example.com
#- NOTIFICATION_GOTIFY_API_KEY=
#- NOTIFICATION_GOTIFY_URL=https://gotify.example.com
#- NOTIFICATION_PUSHOVER_API_KEY=
#- NOTIFICATION_PUSHOVER_USER=
#- NOTIFICATION_NTFY_URL=https://ntfy.sh/your-topic
#- OPENID_ENABLED=FALSE
#- OPENID_CLIENT_ID=
#- OPENID_CLIENT_SECRET=
#- OPENID_CONFIGURATION_ENDPOINT=
#- OPENID_NAME=
#- AUTH_SESSION_LIFETIME=
#- API_BASE_PATH=/api/v1
#- DEVELOPMENT=
- CONFIG_FILE=/data/config.toml
volumes:
- ./data/:/data/
- ./config.toml:/data/config.toml
frontend:
image: ghcr.io/maxdorninger/mediamanager/frontend:latest
ports:

View File

@@ -1,25 +1,25 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import BaseSettings
from pydantic import Field
import secrets
class OpenIdConfig(BaseSettings):
client_id: str = ""
client_secret: str = ""
configuration_endpoint: str = ""
name: str = "OpenID"
enabled: bool = False
class AuthConfig(BaseSettings):
# to get a signing key run:
# openssl rand -hex 32
model_config = SettingsConfigDict(env_prefix="AUTH_")
token_secret: str = Field(default_factory=secrets.token_hex)
session_lifetime: int = 60 * 60 * 24
admin_email: list[str] = []
admin_emails: list[str] = []
email_password_resets: bool = False
openid_connect: OpenIdConfig
@property
def jwt_signing_key(self):
return self._jwt_signing_key
class OpenIdConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="OPENID_")
client_id: str
client_secret: str
configuration_endpoint: str
name: str = "OpenID"

View File

@@ -2,18 +2,15 @@ from fastapi import APIRouter, Depends
from fastapi import status
from sqlalchemy import select
from media_manager.auth.config import OpenIdConfig
from media_manager.config import AllEncompassingConfig
from media_manager.auth.db import User
from media_manager.auth.schemas import UserRead
from media_manager.auth.users import current_superuser
from media_manager.database import DbSessionDependency
from media_manager.auth.users import openid_client
users_router = APIRouter()
auth_metadata_router = APIRouter()
openid_enabled = openid_client is not None
if openid_enabled:
oauth_config = OpenIdConfig()
oauth_config = AllEncompassingConfig().auth.openid_connect
@users_router.get(
@@ -29,7 +26,7 @@ def get_all_users(db: DbSessionDependency) -> list[UserRead]:
@auth_metadata_router.get("/auth/metadata", status_code=status.HTTP_200_OK)
def get_auth_metadata() -> dict:
if openid_enabled:
if oauth_config.enabled:
return {
"oauth_name": oauth_config.name,
}

View File

@@ -1,5 +1,4 @@
import logging
import os
import uuid
from typing import Optional
@@ -17,23 +16,18 @@ from fastapi.responses import RedirectResponse, Response
from starlette import status
import media_manager.notification.utils
from media_manager.auth.config import AuthConfig, OpenIdConfig
from media_manager.auth.db import User, get_user_db
from media_manager.auth.schemas import UserUpdate
from media_manager.config import BasicConfig
from media_manager.config import AllEncompassingConfig
log = logging.getLogger(__name__)
config = AuthConfig()
config = AllEncompassingConfig().auth
SECRET = config.token_secret
LIFETIME = config.session_lifetime
if (
os.getenv("OPENID_ENABLED") is not None
and os.getenv("OPENID_ENABLED").upper() == "TRUE"
):
openid_config = OpenIdConfig()
if config.openid_connect.enabled:
openid_config = AllEncompassingConfig().auth.openid_connect
openid_client = OpenID(
base_scopes=["openid", "email", "profile"],
client_id=openid_config.client_id,
@@ -52,14 +46,14 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
async def on_after_register(self, user: User, request: Optional[Request] = None):
log.info(f"User {user.id} has registered.")
if user.email in config.admin_email:
if user.email in config.admin_emails:
updated_user = UserUpdate(is_superuser=True, is_verified=True)
await self.update(user=user, user_update=updated_user)
async def on_after_forgot_password(
self, user: User, token: str, request: Optional[Request] = None
):
link = f"{BasicConfig().FRONTEND_URL}login/reset-password?token={token}"
link = f"{AllEncompassingConfig().misc.frontend_url}login/reset-password?token={token}"
log.info(f"User {user.id} has forgot their password. Reset Link: {link}")
if not config.email_password_resets:
@@ -115,7 +109,7 @@ def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]:
class RedirectingCookieTransport(CookieTransport):
async def get_login_response(self, token: str) -> Response:
response = RedirectResponse(
str(BasicConfig().FRONTEND_URL) + "dashboard",
str(AllEncompassingConfig().misc.frontend_url) + "dashboard",
status_code=status.HTTP_302_FOUND,
)
return self._set_login_cookie(response, token)

View File

@@ -1,7 +1,28 @@
import os
from pathlib import Path
from typing import Type, Tuple
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings
from pydantic_settings import (
BaseSettings,
SettingsConfigDict,
PydanticBaseSettingsSource,
TomlConfigSettingsSource,
)
from media_manager.auth.config import AuthConfig
from media_manager.database.config import DbConfig
from media_manager.indexer.config import IndexerConfig
from media_manager.metadataProvider.config import MetadataProviderConfig
from media_manager.notification.config import NotificationConfig
from media_manager.torrent.config import TorrentConfig
config_path = os.getenv("CONFIG_FILE")
if config_path is None:
config_path = Path(__file__).parent.parent / "config.toml"
else:
config_path = Path(config_path)
class BasicConfig(BaseSettings):
@@ -9,8 +30,42 @@ class BasicConfig(BaseSettings):
tv_directory: Path = Path(__file__).parent.parent / "data" / "tv"
movie_directory: Path = Path(__file__).parent.parent / "data" / "movies"
torrent_directory: Path = Path(__file__).parent.parent / "data" / "torrents"
usenet_directory: Path = Path(__file__).parent.parent / "data" / "usenet"
FRONTEND_URL: AnyHttpUrl = "http://localhost:3000/"
CORS_URLS: list[str] = []
DEVELOPMENT: bool = False
frontend_url: AnyHttpUrl = "http://localhost:3000/"
cors_urls: list[str] = []
development: bool = False
api_base_path: str = "/api/v1"
class AllEncompassingConfig(BaseSettings):
model_config = SettingsConfigDict(
toml_file=config_path, case_sensitive=False, env_nested_delimiter="_"
)
"""
This class is used to load all configurations from the environment variables.
It combines the BasicConfig with any additional configurations needed.
"""
misc: BasicConfig
torrents: TorrentConfig
notifications: NotificationConfig
metadata: MetadataProviderConfig
indexers: IndexerConfig
database: DbConfig
auth: AuthConfig
@classmethod
def settings_customise_sources(
cls,
settings_cls: Type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> Tuple[PydanticBaseSettingsSource, ...]:
return (
init_settings,
env_settings,
dotenv_settings,
TomlConfigSettingsSource(settings_cls),
file_secret_settings,
)

View File

@@ -6,23 +6,23 @@ from fastapi import Depends
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, declarative_base, sessionmaker
from media_manager.database.config import DbConfig
from media_manager.config import AllEncompassingConfig
log = logging.getLogger(__name__)
config = DbConfig()
config = AllEncompassingConfig().database
db_url = (
"postgresql+psycopg"
+ "://"
+ config.USER
+ config.user
+ ":"
+ config.PASSWORD
+ config.password
+ "@"
+ config.HOST
+ config.host
+ ":"
+ str(config.PORT)
+ str(config.port)
+ "/"
+ config.DBNAME
+ config.dbname
)
engine = create_engine(db_url, echo=False)

View File

@@ -1,10 +1,9 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import BaseSettings
class DbConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="DB_")
HOST: str = "localhost"
PORT: int = 5432
USER: str = "MediaManager"
PASSWORD: str = "MediaManager"
DBNAME: str = "MediaManager"
host: str = "localhost"
port: int = 5432
user: str = "MediaManager"
password: str = "MediaManager"
dbname: str = "MediaManager"

View File

@@ -1,16 +1,3 @@
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
from media_manager.indexer.indexers.prowlarr import Prowlarr
log = logging.getLogger(__name__)
indexers: list[GenericIndexer] = []
if ProwlarrConfig().enabled:
indexers.append(Prowlarr())
if JackettConfig().enabled:
indexers.append(Jackett())

View File

@@ -1,16 +1,19 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import BaseSettings
class ProwlarrConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="PROWLARR_")
enabled: bool | None = False
api_key: str | None = None
enabled: bool = False
api_key: str = ""
url: str = "http://localhost:9696"
class JackettConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="JACKETT_")
enabled: bool | None = False
api_key: str | None = None
enabled: bool = False
api_key: str = ""
url: str = "http://localhost:9696"
indexers: list[str] = ["all"]
class IndexerConfig(BaseSettings):
prowlarr: ProwlarrConfig
jackett: JackettConfig

View File

@@ -6,8 +6,8 @@ import requests
from pydantic import HttpUrl
from media_manager.indexer.indexers.generic import GenericIndexer
from media_manager.indexer.config import JackettConfig
from media_manager.indexer.schemas import IndexerQueryResult
from media_manager.config import AllEncompassingConfig
log = logging.getLogger(__name__)
@@ -19,7 +19,7 @@ class Jackett(GenericIndexer):
"""
super().__init__(name="jackett")
config = JackettConfig()
config = AllEncompassingConfig().indexers.jackett
self.api_key = config.api_key
self.url = config.url
self.indexers = config.indexers

View File

@@ -3,7 +3,7 @@ import logging
import requests
from media_manager.indexer.indexers.generic import GenericIndexer
from media_manager.indexer.config import ProwlarrConfig
from media_manager.config import AllEncompassingConfig
from media_manager.indexer.schemas import IndexerQueryResult
log = logging.getLogger(__name__)
@@ -18,7 +18,7 @@ class Prowlarr(GenericIndexer):
:param kwargs: Additional keyword arguments to pass to the superclass constructor.
"""
super().__init__(name="prowlarr")
config = ProwlarrConfig()
config = AllEncompassingConfig().indexers.prowlarr
self.api_key = config.api_key
self.url = config.url
log.debug("Registering Prowlarr as Indexer")

View File

@@ -1,12 +1,26 @@
from media_manager.indexer import log, indexers
import logging
from media_manager.config import AllEncompassingConfig
from media_manager.indexer.indexers.generic import GenericIndexer
from media_manager.indexer.indexers.jackett import Jackett
from media_manager.indexer.indexers.prowlarr import Prowlarr
from media_manager.indexer.schemas import IndexerQueryResultId, IndexerQueryResult
from media_manager.indexer.repository import IndexerRepository
from media_manager.notification.manager import notification_manager
log = logging.getLogger(__name__)
class IndexerService:
def __init__(self, indexer_repository: IndexerRepository):
config = AllEncompassingConfig()
self.repository = indexer_repository
self.indexers: list[GenericIndexer] = []
if config.indexers.prowlarr.enabled:
self.indexers.append(Prowlarr())
if config.indexers.jackett.enabled:
self.indexers.append(Jackett())
def get_result(self, result_id: IndexerQueryResultId) -> IndexerQueryResult:
return self.repository.get_result(result_id=result_id)
@@ -24,7 +38,7 @@ class IndexerService:
results = []
failed_indexers = []
for indexer in indexers:
for indexer in self.indexers:
try:
indexer_results = indexer.search(query, is_tv=is_tv)
results.extend(indexer_results)

View File

@@ -50,7 +50,7 @@ logging.basicConfig(
log = logging.getLogger(__name__)
from media_manager.database import init_db # noqa: E402
from media_manager.config import BasicConfig # noqa: E402
from media_manager.config import AllEncompassingConfig # noqa: E402
import media_manager.torrent.router as torrent_router # noqa: E402
import media_manager.movies.router as movies_router # noqa: E402
import media_manager.tv.router as tv_router # noqa: E402
@@ -99,13 +99,12 @@ from apscheduler.triggers.cron import CronTrigger # noqa: E402
init_db()
log.info("Database initialized")
basic_config = BasicConfig()
if basic_config.DEVELOPMENT:
basic_config.torrent_directory.mkdir(parents=True, exist_ok=True)
basic_config.tv_directory.mkdir(parents=True, exist_ok=True)
basic_config.movie_directory.mkdir(parents=True, exist_ok=True)
basic_config.image_directory.mkdir(parents=True, exist_ok=True)
config = AllEncompassingConfig()
if config.misc.development:
config.misc.torrent_directory.mkdir(parents=True, exist_ok=True)
config.misc.tv_directory.mkdir(parents=True, exist_ok=True)
config.misc.movie_directory.mkdir(parents=True, exist_ok=True)
config.misc.image_directory.mkdir(parents=True, exist_ok=True)
log.warning("Development Mode activated!")
else:
log.info("Development Mode not activated!")
@@ -146,11 +145,11 @@ async def lifespan(app: FastAPI):
scheduler.shutdown()
base_path = os.getenv("API_BASE_PATH") or "/api/v1"
base_path = config.misc.api_base_path
log.info("Base Path for API: %s", base_path)
app = FastAPI(root_path=base_path, lifespan=lifespan)
origins = basic_config.CORS_URLS
origins = config.misc.cors_urls
log.info("CORS URLs activated for following origins:")
for origin in origins:
log.info(f" - {origin}")
@@ -231,7 +230,7 @@ app.include_router(movies_router.router, prefix="/movies", tags=["movie"])
app.include_router(notification_router, prefix="/notification", tags=["notification"])
app.mount(
"/static/image",
StaticFiles(directory=basic_config.image_directory),
StaticFiles(directory=config.misc.image_directory),
name="static-images",
)
@@ -248,26 +247,26 @@ log.info("Hello World!")
# Startup filesystem checks
# ----------------------------
try:
test_dir = basic_config.tv_directory / Path(".media_manager_test_dir")
test_dir = config.misc.tv_directory / Path(".media_manager_test_dir")
test_dir.mkdir(parents=True, exist_ok=True)
test_dir.rmdir()
log.info(f"Successfully created test dir in TV directory at: {test_dir}")
test_dir = basic_config.movie_directory / Path(".media_manager_test_dir")
test_dir = config.misc.movie_directory / Path(".media_manager_test_dir")
test_dir.mkdir(parents=True, exist_ok=True)
test_dir.rmdir()
log.info(f"Successfully created test dir in Movie directory at: {test_dir}")
test_dir = basic_config.image_directory / Path(".media_manager_test_dir")
test_dir = config.misc.image_directory / Path(".media_manager_test_dir")
test_dir.touch()
test_dir.unlink()
log.info(f"Successfully created test file in Image directory at: {test_dir}")
# check if hardlink creation works
test_dir = basic_config.tv_directory / Path(".media_manager_test_dir")
test_dir = config.misc.tv_directory / Path(".media_manager_test_dir")
test_dir.mkdir(parents=True, exist_ok=True)
torrent_dir = basic_config.torrent_directory / Path(".media_manager_test_dir")
torrent_dir = config.misc.torrent_directory / Path(".media_manager_test_dir")
torrent_dir.mkdir(parents=True, exist_ok=True)
test_torrent_file = torrent_dir / Path(".media_manager.test.torrent")

View File

@@ -1,16 +1,16 @@
import logging
from abc import ABC, abstractmethod
import media_manager.config
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
from media_manager.tv.schemas import Show
from media_manager.movies.schemas import Movie
from media_manager.config import AllEncompassingConfig
log = logging.getLogger(__name__)
class AbstractMetadataProvider(ABC):
storage_path = media_manager.config.BasicConfig().image_directory
storage_path = AllEncompassingConfig().misc.image_directory
@property
@abstractmethod

View File

@@ -0,0 +1,14 @@
from pydantic_settings import BaseSettings
class TmdbConfig(BaseSettings):
tmdb_relay_url: str = "https://metadata-relay.maxid.me/tmdb"
class TvdbConfig(BaseSettings):
tvdb_relay_url: str = "https://metadata-relay.maxid.me/tvdb"
class MetadataProviderConfig(BaseSettings):
tvdb: TvdbConfig
tmdb: TmdbConfig

View File

@@ -1,9 +1,9 @@
import logging
import requests
from pydantic_settings import BaseSettings
import media_manager.metadataProvider.utils
from media_manager.config import AllEncompassingConfig
from media_manager.metadataProvider.abstractMetaDataProvider import (
AbstractMetadataProvider,
)
@@ -13,10 +13,6 @@ from media_manager.movies.schemas import Movie
from media_manager.notification.manager import notification_manager
class TmdbConfig(BaseSettings):
TMDB_RELAY_URL: str = "https://metadata-relay.maxid.me/tmdb"
ENDED_STATUS = {"Ended", "Canceled"}
log = logging.getLogger(__name__)
@@ -26,8 +22,8 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
name = "tmdb"
def __init__(self):
config = TmdbConfig()
self.url = config.TMDB_RELAY_URL
config = AllEncompassingConfig().metadata.tmdb
self.url = config.tmdb_relay_url
def __get_show_metadata(self, id: int) -> dict:
try:

View File

@@ -1,9 +1,9 @@
import requests
import logging
from pydantic_settings import BaseSettings
import media_manager.metadataProvider.utils
from media_manager.config import AllEncompassingConfig
from media_manager.metadataProvider.abstractMetaDataProvider import (
AbstractMetadataProvider,
)
@@ -12,10 +12,6 @@ from media_manager.tv.schemas import Episode, Season, Show, SeasonNumber
from media_manager.movies.schemas import Movie
class TvdbConfig(BaseSettings):
TVDB_RELAY_URL: str = "https://metadata-relay.maxid.me/tvdb"
log = logging.getLogger(__name__)
@@ -23,8 +19,8 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
name = "tvdb"
def __init__(self):
config = TvdbConfig()
self.url = config.TVDB_RELAY_URL
config = AllEncompassingConfig().metadata.tvdb
self.url = config.tvdb_relay_url
def __get_show(self, id: int) -> dict:
return requests.get(f"{self.url}/tv/shows/{id}").json()

View File

@@ -3,6 +3,7 @@ import re
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from media_manager.config import AllEncompassingConfig
from media_manager.exceptions import InvalidConfigError
from media_manager.indexer.repository import IndexerRepository
from media_manager.database import SessionLocal
@@ -28,7 +29,6 @@ from media_manager.torrent.schemas import QualityStrings
from media_manager.movies.repository import MovieRepository
from media_manager.exceptions import NotFoundError
import pprint
from media_manager.config import BasicConfig
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.utils import import_file, import_torrent
from media_manager.indexer.service import IndexerService
@@ -464,7 +464,7 @@ class MovieService:
)
movie_file_path = (
BasicConfig().movie_directory
AllEncompassingConfig().misc.movie_directory
/ f"{movie.name} ({movie.year}) [{movie.metadata_provider}id-{movie.external_id}]"
)
movie_files: list[MovieFile] = self.torrent_service.get_movie_files_of_torrent(

View File

@@ -1,29 +1,44 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import BaseSettings
class EmailConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="EMAIL_")
smtp_host: str
smtp_port: int
smtp_user: str
smtp_password: str
from_email: str
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
from_email: str = ""
use_tls: bool = False
class NotificationConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="NOTIFICATION_")
class EmailNotificationsConfig(BaseSettings):
enabled: bool = False
emails: list[str] = [] # the email addresses to send notifications to
email: str | None = None # the email address to send notifications to
ntfy_url: str | None = (
class GotifyConfig(BaseSettings):
enabled: bool = False
api_key: str | None = None
url: str | None = (
None # e.g. https://gotify.example.com (note lack of trailing slash)
)
class NtfyConfig(BaseSettings):
enabled: bool = False
url: str | None = (
None # e.g. https://ntfy.sh/your-topic (note lack of trailing slash)
)
pushover_api_key: str | None = None
pushover_user: str | None = None
gotify_api_key: str | None = None
gotify_url: str | None = (
None # e.g. https://gotify.example.com (note lack of trailing slash)
)
class PushoverConfig(BaseSettings):
enabled: bool = False
api_key: str | None = None
user: str | None = None
class NotificationConfig(BaseSettings):
smtp_config: EmailConfig
email_notifications: EmailNotificationsConfig
gotify: GotifyConfig
ntfy: NtfyConfig
pushover: PushoverConfig

View File

@@ -20,7 +20,7 @@ from media_manager.notification.service_providers.ntfy import (
from media_manager.notification.service_providers.pushover import (
PushoverNotificationServiceProvider,
)
from media_manager.notification.config import NotificationConfig
from media_manager.config import AllEncompassingConfig
logger = logging.getLogger(__name__)
@@ -31,13 +31,13 @@ class NotificationManager:
"""
def __init__(self):
self.config = NotificationConfig()
self.config = AllEncompassingConfig().notifications
self.providers: List[AbstractNotificationServiceProvider] = []
self._initialize_providers()
def _initialize_providers(self) -> None:
# Email provider
if self.config.email:
if self.config.email_notifications.enabled:
try:
self.providers.append(EmailNotificationServiceProvider())
logger.info("Email notification provider initialized")
@@ -45,7 +45,7 @@ class NotificationManager:
logger.error(f"Failed to initialize Email provider: {e}")
# Gotify provider
if self.config.gotify_api_key and self.config.gotify_url:
if self.config.gotify.enabled:
try:
self.providers.append(GotifyNotificationServiceProvider())
logger.info("Gotify notification provider initialized")
@@ -53,7 +53,7 @@ class NotificationManager:
logger.error(f"Failed to initialize Gotify provider: {e}")
# Ntfy provider
if self.config.ntfy_url:
if self.config.ntfy.enabled:
try:
self.providers.append(NtfyNotificationServiceProvider())
logger.info("Ntfy notification provider initialized")
@@ -61,7 +61,7 @@ class NotificationManager:
logger.error(f"Failed to initialize Ntfy provider: {e}")
# Pushover provider
if self.config.pushover_api_key and self.config.pushover_user:
if self.config.pushover.enabled:
try:
self.providers.append(PushoverNotificationServiceProvider())
logger.info("Pushover notification provider initialized")

View File

@@ -3,12 +3,12 @@ from media_manager.notification.schemas import MessageNotification
from media_manager.notification.service_providers.abstractNotificationServiceProvider import (
AbstractNotificationServiceProvider,
)
from media_manager.notification.config import NotificationConfig
from media_manager.config import AllEncompassingConfig
class EmailNotificationServiceProvider(AbstractNotificationServiceProvider):
def __init__(self):
self.config = NotificationConfig()
self.config = AllEncompassingConfig().notifications.email_notifications
def send_notification(self, message: MessageNotification) -> bool:
subject = "MediaManager - " + message.title
@@ -23,7 +23,10 @@ class EmailNotificationServiceProvider(AbstractNotificationServiceProvider):
</body>
</html>
"""
media_manager.notification.utils.send_email(
subject=subject, html=html, addressee=self.config.email
)
for email in self.config.emails:
media_manager.notification.utils.send_email(
subject=subject, html=html, addressee=email
)
return True

View File

@@ -1,6 +1,6 @@
import requests
from media_manager.notification.config import NotificationConfig
from media_manager.config import AllEncompassingConfig
from media_manager.notification.schemas import MessageNotification
from media_manager.notification.service_providers.abstractNotificationServiceProvider import (
AbstractNotificationServiceProvider,
@@ -13,11 +13,11 @@ class GotifyNotificationServiceProvider(AbstractNotificationServiceProvider):
"""
def __init__(self):
self.config = NotificationConfig()
self.config = AllEncompassingConfig().notifications.gotify
def send_notification(self, message: MessageNotification) -> bool:
response = requests.post(
url=f"{self.config.gotify_url}/message?token={self.config.gotify_api_key}",
url=f"{self.config.url}/message?token={self.config.api_key}",
json={
"message": message.message,
"title": message.title,

View File

@@ -1,6 +1,6 @@
import requests
from media_manager.notification.config import NotificationConfig
from media_manager.config import AllEncompassingConfig
from media_manager.notification.schemas import MessageNotification
from media_manager.notification.service_providers.abstractNotificationServiceProvider import (
AbstractNotificationServiceProvider,
@@ -13,11 +13,11 @@ class NtfyNotificationServiceProvider(AbstractNotificationServiceProvider):
"""
def __init__(self):
self.config = NotificationConfig()
self.config = AllEncompassingConfig().notifications.ntfy
def send_notification(self, message: MessageNotification) -> bool:
response = requests.post(
url=self.config.ntfy_url,
url=self.config.url,
data=message.message.encode(encoding="utf-8"),
headers={
"Title": "MediaManager - " + message.title,

View File

@@ -1,6 +1,6 @@
import requests
from media_manager.notification.config import NotificationConfig
from media_manager.config import AllEncompassingConfig
from media_manager.notification.schemas import MessageNotification
from media_manager.notification.service_providers.abstractNotificationServiceProvider import (
AbstractNotificationServiceProvider,
@@ -9,14 +9,14 @@ from media_manager.notification.service_providers.abstractNotificationServicePro
class PushoverNotificationServiceProvider(AbstractNotificationServiceProvider):
def __init__(self):
self.config = NotificationConfig()
self.config = AllEncompassingConfig().notifications.pushover
def send_notification(self, message: MessageNotification) -> bool:
response = requests.post(
url="https://api.pushover.net/1/messages.json",
params={
"token": self.config.pushover_api_key,
"user": self.config.pushover_user,
"token": self.config.api_key,
"user": self.config.user,
"message": message.message,
"title": "MediaManager - " + message.title,
},

View File

@@ -3,13 +3,13 @@ import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from media_manager.notification.config import EmailConfig
from media_manager.config import AllEncompassingConfig
log = logging.getLogger(__name__)
def send_email(html: str, addressee: str, subject: str | list[str]) -> None:
email_conf = EmailConfig()
def send_email(subject: str, html: str, addressee: str) -> None:
email_conf = AllEncompassingConfig().notifications.smtp_config
message = MIMEMultipart()
message["From"] = email_conf.from_email
message["To"] = addressee

View File

@@ -0,0 +1,23 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class QbittorrentConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="QBITTORRENT_")
host: str = "localhost"
port: int = 8080
username: str = "admin"
password: str = "admin"
enabled: bool = False
class SabnzbdConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="SABNZBD_")
host: str = "localhost"
port: int = 8080
api_key: str = ""
enabled: bool = False
class TorrentConfig(BaseSettings):
qbittorrent: QbittorrentConfig
sabnzbd: SabnzbdConfig

View File

@@ -4,9 +4,8 @@ import logging
import bencoder
import qbittorrentapi
import requests
from pydantic_settings import BaseSettings, SettingsConfigDict
from media_manager.config import BasicConfig
from media_manager.config import AllEncompassingConfig
from media_manager.indexer.schemas import IndexerQueryResult
from media_manager.torrent.download_clients.abstractDownloadClient import (
AbstractDownloadClient,
@@ -16,14 +15,6 @@ from media_manager.torrent.schemas import TorrentStatus, Torrent
log = logging.getLogger(__name__)
class QbittorrentConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="QBITTORRENT_")
host: str = "localhost"
port: int = 8080
username: str = "admin"
password: str = "admin"
class QbittorrentDownloadClient(AbstractDownloadClient):
DOWNLOADING_STATE = (
"allocating",
@@ -48,7 +39,7 @@ class QbittorrentDownloadClient(AbstractDownloadClient):
UNKNOWN_STATE = ("unknown",)
def __init__(self):
self.config = QbittorrentConfig()
self.config = AllEncompassingConfig().torrents.qbittorrent
self.api_client = qbittorrentapi.Client(**self.config.model_dump())
try:
self.api_client.auth_log_in()
@@ -69,7 +60,8 @@ class QbittorrentDownloadClient(AbstractDownloadClient):
log.info(f"Attempting to download torrent: {indexer_result.title}")
torrent_filepath = (
BasicConfig().torrent_directory / f"{indexer_result.title}.torrent"
AllEncompassingConfig().misc.torrent_directory
/ f"{indexer_result.title}.torrent"
)
if torrent_filepath.exists():

View File

@@ -1,6 +1,6 @@
import logging
from pydantic_settings import BaseSettings, SettingsConfigDict
from media_manager.config import AllEncompassingConfig
from media_manager.indexer.schemas import IndexerQueryResult
from media_manager.torrent.download_clients.abstractDownloadClient import (
AbstractDownloadClient,
@@ -11,13 +11,6 @@ import sabnzbd_api
log = logging.getLogger(__name__)
class SabnzbdConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="SABNZBD_")
host: str = "localhost"
port: int = 8080
api_key: str = ""
class SabnzbdDownloadClient(AbstractDownloadClient):
DOWNLOADING_STATE = (
"Downloading",
@@ -32,7 +25,7 @@ class SabnzbdDownloadClient(AbstractDownloadClient):
UNKNOWN_STATE = ("Unknown",)
def __init__(self):
self.config = SabnzbdConfig()
self.config = AllEncompassingConfig().torrents.sabnzbd
self.client = sabnzbd_api.SabnzbdClient(
host=self.config.host,
port=str(self.config.port),
@@ -59,9 +52,7 @@ class SabnzbdDownloadClient(AbstractDownloadClient):
try:
# Add NZB to SABnzbd queue
response = self.client.add_uri(
url=str(indexer_result.download_url),
)
response = self.client.add_uri(url=str(indexer_result.download_url))
if not response["status"]:
error_msg = response
log.error(f"Failed to add NZB to SABnzbd: {error_msg}")

View File

@@ -1,7 +1,7 @@
import logging
import os
from enum import Enum
from media_manager.config import AllEncompassingConfig
from media_manager.indexer.schemas import IndexerQueryResult
from media_manager.torrent.download_clients.abstractDownloadClient import (
AbstractDownloadClient,
@@ -30,13 +30,14 @@ class DownloadManager:
def __init__(self):
self._torrent_client: AbstractDownloadClient | None = None
self._usenet_client: AbstractDownloadClient | None = None
self.config = AllEncompassingConfig().torrents
self._initialize_clients()
def _initialize_clients(self) -> None:
"""Initialize and register the default download clients"""
# Initialize qBittorrent client for torrents
if os.getenv("QBITTORRENT_ENABLED", "false").lower() == "true":
if self.config.qbittorrent.enabled:
try:
self._torrent_client = QbittorrentDownloadClient()
log.info(
@@ -46,7 +47,7 @@ class DownloadManager:
log.error(f"Failed to initialize qBittorrent client: {e}")
# Initialize SABnzbd client for usenet
if os.getenv("SABNZBD_ENABLED", "false").lower() == "true":
if self.config.sabnzbd.enabled:
try:
self._usenet_client = SabnzbdDownloadClient()
log.info("SABnzbd client initialized and set as active usenet client")

View File

@@ -4,7 +4,7 @@ from pathlib import Path
import shutil
import patoolib
from media_manager.config import BasicConfig
from media_manager.config import AllEncompassingConfig
from media_manager.torrent.schemas import Torrent
log = logging.getLogger(__name__)
@@ -53,7 +53,7 @@ def extract_archives(files):
def get_torrent_filepath(torrent: Torrent):
return BasicConfig().torrent_directory / torrent.title
return AllEncompassingConfig().misc.torrent_directory / torrent.title
def import_file(target_file: Path, source_file: Path):

View File

@@ -3,6 +3,7 @@ import re
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from media_manager.config import AllEncompassingConfig
from media_manager.exceptions import InvalidConfigError
from media_manager.indexer.repository import IndexerRepository
from media_manager.database import SessionLocal
@@ -35,7 +36,6 @@ from media_manager.tv.repository import TvRepository
from media_manager.exceptions import NotFoundError
import pprint
from pathlib import Path
from media_manager.config import BasicConfig
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.utils import import_file, import_torrent
from media_manager.indexer.service import IndexerService
@@ -502,7 +502,7 @@ class TvService:
)
show_file_path = (
BasicConfig().tv_directory
AllEncompassingConfig().misc.tv_directory
/ f"{show.name} ({show.year}) [{show.metadata_provider}id-{show.external_id}]"
)
season_files = self.torrent_service.get_season_files_of_torrent(torrent=torrent)

View File

@@ -15,7 +15,7 @@ dependencies = [
"patool>=4.0.1",
"psycopg[binary]>=3.2.9",
"pydantic>=2.11.5",
"pydantic-settings>=2.9.1",
"pydantic-settings[toml]>=2.9.1",
"python-json-logger>=3.3.0",
"qbittorrent-api>=2025.5.0",
"requests>=2.32.3",

View File

@@ -1,19 +1,24 @@
import uuid
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
from pydantic import HttpUrl
from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId
from media_manager.indexer.repository import IndexerRepository
from media_manager.indexer.service import IndexerService
from media_manager.indexer.indexers.generic import GenericIndexer
class DummyIndexer:
class DummyIndexer(GenericIndexer):
def __init__(self):
super().__init__(name="DummyIndexer")
def search(self, query, is_tv=True):
return [
IndexerQueryResult(
id=IndexerQueryResultId(uuid.uuid4()),
title=f"{query} S01 1080p",
download_url="https://example.com/torrent1",
download_url=HttpUrl("https://example.com/torrent1"),
seeders=10,
flags=["test"],
size=123456,
@@ -31,10 +36,17 @@ def mock_indexer_repository():
@pytest.fixture
def indexer_service(monkeypatch, mock_indexer_repository):
# Patch the global indexers list
monkeypatch.setattr("media_manager.indexer.service.indexers", [DummyIndexer()])
return IndexerService(indexer_repository=mock_indexer_repository)
def indexer_service(mock_indexer_repository):
# Mock the config to disable real indexers
with patch("media_manager.indexer.service.AllEncompassingConfig") as mock_config:
# Configure the mock to disable all real indexers
mock_config.return_value.indexers.prowlarr.enabled = False
mock_config.return_value.indexers.jackett.enabled = False
service = IndexerService(indexer_repository=mock_indexer_repository)
# Manually set the dummy indexer
service.indexers = [DummyIndexer()]
return service
def test_search_returns_results(indexer_service, mock_indexer_repository):
@@ -50,7 +62,7 @@ def test_get_result_returns_result(mock_indexer_repository):
expected_result = IndexerQueryResult(
id=result_id,
title="Test S01 1080p",
download_url="https://example.com/torrent2",
download_url=HttpUrl("https://example.com/torrent2"),
seeders=10,
flags=["test"],
size=123456,

28
uv.lock generated
View File

@@ -637,7 +637,7 @@ dependencies = [
{ name = "pillow-avif-plugin" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pydantic-settings", extra = ["toml"] },
{ name = "pytest" },
{ name = "python-json-logger" },
{ name = "qbittorrent-api" },
@@ -669,7 +669,7 @@ requires-dist = [
{ name = "pillow-avif-plugin", specifier = ">=1.5.2" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" },
{ name = "pydantic", specifier = ">=2.11.5" },
{ name = "pydantic-settings", specifier = ">=2.9.1" },
{ name = "pydantic-settings", extras = ["toml"], specifier = ">=2.9.1" },
{ name = "pytest", specifier = ">=8.4.0" },
{ name = "python-json-logger", specifier = ">=3.3.0" },
{ name = "qbittorrent-api", specifier = ">=2025.5.0" },
@@ -907,6 +907,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -1175,6 +1180,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/dd/ade05d202db728b23e54aa0959622d090776023917e7308c1b2469a07b76/tmdbsimple-2.9.1-py3-none-any.whl", hash = "sha256:b52387c667bad1dccf5f776a576a971622a111fc08b7f731e360fabcc9860616", size = 38954, upload-time = "2022-01-25T21:48:34.653Z" },
]
[[package]]
name = "tomli"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" },
{ url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" },
{ url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" },
{ url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" },
{ url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" },
{ url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" },
{ url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" },
{ url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" },
{ url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
]
[[package]]
name = "tvdb-v4-official"
version = "1.1.0"