mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-19 08:33:27 +02:00
Compare commits
24 Commits
ruff-enabl
...
make-conta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5761bc661 | ||
|
|
c45c9e5873 | ||
|
|
24fcba6bee | ||
|
|
d5994a9037 | ||
|
|
9e0d0c03c0 | ||
|
|
70ff8f6ace | ||
|
|
e347219721 | ||
|
|
72a626cb1a | ||
|
|
a1f3f92c10 | ||
|
|
caaa08fbf4 | ||
|
|
5db60141bb | ||
|
|
96b84d45db | ||
|
|
311e625eee | ||
|
|
e22e0394bd | ||
|
|
6377aa8b83 | ||
|
|
8855204930 | ||
|
|
7a13326d87 | ||
|
|
15e9cd001f | ||
|
|
e52b84c3c7 | ||
|
|
84a430651f | ||
|
|
463e6914e3 | ||
|
|
e5e85077ae | ||
|
|
a39e0d204a | ||
|
|
dd0b439bbe |
@@ -13,7 +13,7 @@ RUN env PUBLIC_VERSION=${VERSION} PUBLIC_API_URL=${BASE_PATH} BASE_PATH=${BASE_P
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim AS base
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y ca-certificates bash libtorrent21 gcc bc locales postgresql media-types mailcap curl gzip unzip tar 7zip bzip2 unar && \
|
||||
apt-get install -y ca-certificates bash libtorrent21 gcc bc locales postgresql media-types mailcap curl gzip unzip tar 7zip bzip2 unar gosu && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -33,7 +33,6 @@ RUN chown -R mediamanager:mediamanager /app
|
||||
|
||||
USER mediamanager
|
||||
|
||||
# Set uv cache to a writable home directory and use copy mode for volume compatibility
|
||||
ENV UV_CACHE_DIR=/home/mediamanager/.cache/uv \
|
||||
UV_LINK_MODE=copy
|
||||
|
||||
@@ -47,6 +46,7 @@ ARG BASE_PATH=""
|
||||
LABEL author="github.com/maxdorninger"
|
||||
LABEL version=${VERSION}
|
||||
LABEL description="Docker image for MediaManager"
|
||||
USER root
|
||||
|
||||
ENV PUBLIC_VERSION=${VERSION} \
|
||||
CONFIG_DIR="/app/config" \
|
||||
|
||||
@@ -93,5 +93,9 @@ Distributed under the AGPL 3.0. See `LICENSE.txt` for more information.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Thanks to DigitalOcean for sponsoring the project!
|
||||
|
||||
[](https://www.digitalocean.com/?refcode=4edf05429dca&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge)
|
||||
|
||||
* [Thanks to Pawel Czerwinski for the image on the login screen](https://unsplash.com/@pawel_czerwinski)
|
||||
|
||||
|
||||
@@ -106,7 +106,13 @@ def run_migrations_online() -> None:
|
||||
|
||||
"""
|
||||
|
||||
def include_object(_object, name, type_, _reflected, _compare_to):
|
||||
def include_object(
|
||||
_object: object | None,
|
||||
name: str | None,
|
||||
type_: str | None,
|
||||
_reflected: bool | None,
|
||||
_compare_to: object | None,
|
||||
) -> bool:
|
||||
if type_ == "table" and name == "apscheduler_jobs":
|
||||
return False
|
||||
return True
|
||||
|
||||
BIN
docs/.gitbook/assets/image.png
Normal file
BIN
docs/.gitbook/assets/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
layout:
|
||||
width: wide
|
||||
width: default
|
||||
title:
|
||||
visible: true
|
||||
description:
|
||||
@@ -23,16 +23,12 @@ _Replaces Sonarr, Radarr, Seerr, and more._
|
||||
|
||||
### Quick Links
|
||||
|
||||
<table data-view="cards" data-full-width="true"><thead><tr><th align="center"></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td align="center">Installation Guide</td><td><a href="installation-guide.md">installation-guide.md</a></td></tr><tr><td align="center">Configuration</td><td><a href="configuration/">configuration</a></td></tr><tr><td align="center">Developer Guide</td><td><a href="developer-guide.md">developer-guide.md</a></td></tr><tr><td align="center">Troubleshooting</td><td><a href="troubleshooting.md">troubleshooting.md</a></td></tr><tr><td align="center">Advanced Features</td><td><a href="advanced-features/">advanced-features</a></td></tr><tr><td align="center">Import Existing Media</td><td><a href="importing-existing-media.md">importing-existing-media.md</a></td></tr></tbody></table>
|
||||
<table data-view="cards" data-full-width="false"><thead><tr><th align="center"></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td align="center">Installation Guide</td><td><a href="installation/">installation</a></td></tr><tr><td align="center">Configuration</td><td><a href="configuration/">configuration</a></td></tr><tr><td align="center">Developer Guide</td><td><a href="contributing-to-mediamanager/developer-guide.md">developer-guide.md</a></td></tr><tr><td align="center">Troubleshooting</td><td><a href="troubleshooting.md">troubleshooting.md</a></td></tr><tr><td align="center">Advanced Features</td><td><a href="advanced-features/">advanced-features</a></td></tr><tr><td align="center">Import Existing Media</td><td><a href="importing-existing-media.md">importing-existing-media.md</a></td></tr></tbody></table>
|
||||
|
||||
## Support MediaManager & Maximilian Dorninger
|
||||
|
||||
<table data-card-size="large" data-view="cards" data-full-width="true"><thead><tr><th></th><th data-hidden data-card-target data-type="content-ref"></th><th data-hidden data-card-cover data-type="image">Cover image</th></tr></thead><tbody><tr><td>Sponsor me on GitHub Sponsors :)</td><td><a href="https://github.com/sponsors/maxdorninger">https://github.com/sponsors/maxdorninger</a></td><td></td></tr><tr><td>Buy me a coffee :)</td><td><a href="https://buymeacoffee.com/maxdorninger">https://buymeacoffee.com/maxdorninger</a></td><td></td></tr></tbody></table>
|
||||
<table data-card-size="large" data-view="cards" data-full-width="false"><thead><tr><th></th><th data-hidden data-card-target data-type="content-ref"></th><th data-hidden data-card-cover data-type="image">Cover image</th></tr></thead><tbody><tr><td>Sponsor me on GitHub Sponsors :)</td><td><a href="https://github.com/sponsors/maxdorninger">https://github.com/sponsors/maxdorninger</a></td><td></td></tr><tr><td>Buy me a coffee :)</td><td><a href="https://buymeacoffee.com/maxdorninger">https://buymeacoffee.com/maxdorninger</a></td><td></td></tr></tbody></table>
|
||||
|
||||
### MediaManager Sponsors
|
||||
|
||||
<table data-view="cards" data-full-width="true"><thead><tr><th>Sponsor</th><th data-hidden data-card-target data-type="content-ref"></th><th data-hidden data-card-cover data-type="image">Cover image</th></tr></thead><tbody><tr><td>Aljaž Mur Eržen</td><td><a href="https://fosstodon.org/@aljazmerzen">https://fosstodon.org/@aljazmerzen</a></td><td><a href="https://github.com/aljazerzen.png">https://github.com/aljazerzen.png</a></td></tr><tr><td>Luis Rodriguez</td><td><a href="https://github.com/ldrrp">https://github.com/ldrrp</a></td><td><a href="https://github.com/ldrrp.png">https://github.com/ldrrp.png</a></td></tr><tr><td>Brandon P.</td><td><a href="https://github.com/brandon-dacrib">https://github.com/brandon-dacrib</a></td><td><a href="https://github.com/brandon-dacrib.png">https://github.com/brandon-dacrib.png</a></td></tr><tr><td>SeimusS</td><td><a href="https://github.com/SeimusS">https://github.com/SeimusS</a></td><td><a href="https://github.com/SeimusS.png">https://github.com/SeimusS.png</a></td></tr><tr><td>HadrienKerlero</td><td><a href="https://github.com/HadrienKerlero">https://github.com/HadrienKerlero</a></td><td><a href="https://github.com/HadrienKerlero.png">https://github.com/HadrienKerlero.png</a></td></tr><tr><td>keyxmakerx</td><td><a href="https://github.com/keyxmakerx">https://github.com/keyxmakerx</a></td><td><a href="https://github.com/keyxmakerx.png">https://github.com/keyxmakerx.png</a></td></tr><tr><td>LITUATUI</td><td><a href="https://github.com/LITUATUI">https://github.com/LITUATUI</a></td><td><a href="https://github.com/LITUATUI.png">https://github.com/LITUATUI.png</a></td></tr><tr><td>Nicolas</td><td><a href="https://buymeacoffee.com/maxdorninger">https://buymeacoffee.com/maxdorninger</a></td><td><a href="https://cdn.buymeacoffee.com/uploads/profile_pictures/default/v2/B6CDBD/NI.png">https://cdn.buymeacoffee.com/uploads/profile_pictures/default/v2/B6CDBD/NI.png</a></td></tr><tr><td>Josh</td><td><a href="https://buymeacoffee.com/maxdorninger">https://buymeacoffee.com/maxdorninger</a></td><td><a href="https://cdn.buymeacoffee.com/uploads/profile_pictures/default/v2/DEBBB9/JO.png">https://cdn.buymeacoffee.com/uploads/profile_pictures/default/v2/DEBBB9/JO.png</a></td></tr><tr><td>PuppiestDoggo</td><td><a href="https://buymeacoffee.com/maxdorninger">https://buymeacoffee.com/maxdorninger</a></td><td><a href="https://cdn.buymeacoffee.com/uploads/profile_pictures/2025/11/2VeQ8sTGPhj4tiLy.jpg">https://cdn.buymeacoffee.com/uploads/profile_pictures/2025/11/2VeQ8sTGPhj4tiLy.jpg</a></td></tr><tr><td>Seferino</td><td><a href="https://github.com/seferino-fernandez">https://github.com/seferino-fernandez</a></td><td><a href="https://avatars.githubusercontent.com/u/5546622">https://avatars.githubusercontent.com/u/5546622</a></td></tr></tbody></table>
|
||||
|
||||
### MediaManager Repository
|
||||
|
||||
https://github.com/maxdorninger/MediaManager
|
||||
<table data-view="cards" data-full-width="false"><thead><tr><th>Sponsor</th><th data-hidden data-card-target data-type="content-ref"></th><th data-hidden data-card-cover data-type="image">Cover image</th></tr></thead><tbody><tr><td>Aljaž Mur Eržen</td><td><a href="https://fosstodon.org/@aljazmerzen">https://fosstodon.org/@aljazmerzen</a></td><td><a href="https://github.com/aljazerzen.png">https://github.com/aljazerzen.png</a></td></tr><tr><td>Luis Rodriguez</td><td><a href="https://github.com/ldrrp">https://github.com/ldrrp</a></td><td><a href="https://github.com/ldrrp.png">https://github.com/ldrrp.png</a></td></tr><tr><td>Brandon P.</td><td><a href="https://github.com/brandon-dacrib">https://github.com/brandon-dacrib</a></td><td><a href="https://github.com/brandon-dacrib.png">https://github.com/brandon-dacrib.png</a></td></tr><tr><td>SeimusS</td><td><a href="https://github.com/SeimusS">https://github.com/SeimusS</a></td><td><a href="https://github.com/SeimusS.png">https://github.com/SeimusS.png</a></td></tr><tr><td>HadrienKerlero</td><td><a href="https://github.com/HadrienKerlero">https://github.com/HadrienKerlero</a></td><td><a href="https://github.com/HadrienKerlero.png">https://github.com/HadrienKerlero.png</a></td></tr><tr><td>keyxmakerx</td><td><a href="https://github.com/keyxmakerx">https://github.com/keyxmakerx</a></td><td><a href="https://github.com/keyxmakerx.png">https://github.com/keyxmakerx.png</a></td></tr><tr><td>LITUATUI</td><td><a href="https://github.com/LITUATUI">https://github.com/LITUATUI</a></td><td><a href="https://github.com/LITUATUI.png">https://github.com/LITUATUI.png</a></td></tr><tr><td>Nicolas</td><td><a href="https://buymeacoffee.com/maxdorninger">https://buymeacoffee.com/maxdorninger</a></td><td><a href="https://cdn.buymeacoffee.com/uploads/profile_pictures/default/v2/B6CDBD/NI.png">https://cdn.buymeacoffee.com/uploads/profile_pictures/default/v2/B6CDBD/NI.png</a></td></tr><tr><td>Josh</td><td><a href="https://buymeacoffee.com/maxdorninger">https://buymeacoffee.com/maxdorninger</a></td><td><a href="https://cdn.buymeacoffee.com/uploads/profile_pictures/default/v2/DEBBB9/JO.png">https://cdn.buymeacoffee.com/uploads/profile_pictures/default/v2/DEBBB9/JO.png</a></td></tr><tr><td>PuppiestDoggo</td><td><a href="https://buymeacoffee.com/maxdorninger">https://buymeacoffee.com/maxdorninger</a></td><td><a href="https://cdn.buymeacoffee.com/uploads/profile_pictures/2025/11/2VeQ8sTGPhj4tiLy.jpg">https://cdn.buymeacoffee.com/uploads/profile_pictures/2025/11/2VeQ8sTGPhj4tiLy.jpg</a></td></tr><tr><td>Seferino</td><td><a href="https://github.com/seferino-fernandez">https://github.com/seferino-fernandez</a></td><td><a href="https://avatars.githubusercontent.com/u/5546622">https://avatars.githubusercontent.com/u/5546622</a></td></tr><tr><td>Powered by DigitalOcean</td><td><a href="https://m.do.co/c/4edf05429dca">https://m.do.co/c/4edf05429dca</a></td><td data-object-fit="contain"><a href="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_vertical_blue.svg">https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_vertical_blue.svg</a></td></tr></tbody></table>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Table of contents
|
||||
|
||||
* [MediaManager](README.md)
|
||||
* [Installation Guide](installation-guide.md)
|
||||
* [Installation Guide](installation/README.md)
|
||||
* [Docker Compose](installation/docker.md)
|
||||
* [Nix Flakes \[Community\]](installation/flakes.md)
|
||||
* [Importing existing media](importing-existing-media.md)
|
||||
* [Usage](usage.md)
|
||||
* [Configuration](configuration/README.md)
|
||||
@@ -20,7 +22,12 @@
|
||||
* [Metadata Provider Configuration](advanced-features/metadata-provider-configuration.md)
|
||||
* [Custom port](advanced-features/custom-port.md)
|
||||
* [Follow symlinks in frontend files](advanced-features/follow-symlinks-in-frontend-files.md)
|
||||
* [Disable startup ascii art](advanced-features/disable-startup-ascii-art.md)
|
||||
* [Troubleshooting](troubleshooting.md)
|
||||
* [Developer Guide](developer-guide.md)
|
||||
* [API Reference](api-reference.md)
|
||||
* [Screenshots](screenshots.md)
|
||||
|
||||
## Contributing to MediaManager
|
||||
|
||||
* [Developer Guide](contributing-to-mediamanager/developer-guide.md)
|
||||
* [Documentation](contributing-to-mediamanager/documentation.md)
|
||||
|
||||
4
docs/advanced-features/disable-startup-ascii-art.md
Normal file
4
docs/advanced-features/disable-startup-ascii-art.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Disable Startup Ascii Art
|
||||
|
||||
* `MEDIAMANAGER_NO_STARTUP_ART`: Set this environment variable (to any value) \
|
||||
to disable the colorized startup splash screen. Unset to reenable.
|
||||
@@ -1,12 +1,16 @@
|
||||
# Developer Guide
|
||||
---
|
||||
description: >-
|
||||
This section is for those who want to contribute to Media Manager or
|
||||
understand its internals.
|
||||
---
|
||||
|
||||
This section is for those who want to contribute to Media Manager or understand its internals.
|
||||
# Developer Guide
|
||||
|
||||
## Source Code directory structure
|
||||
|
||||
* `media_manager/`: Backend FastAPI application
|
||||
* `web/`: Frontend SvelteKit application
|
||||
* `Writerside/`: Documentation
|
||||
* `docs/`: Documentation (GitBook)
|
||||
* `metadata_relay/`: Metadata relay service, also FastAPI
|
||||
|
||||
## Special Dev Configuration
|
||||
@@ -69,7 +73,6 @@ I use IntellijIdea with the Pycharm and Webstorm plugins to develop this, but th
|
||||
* Pydantic
|
||||
* Ruff
|
||||
* VirtualKit
|
||||
* Writerside (for writing documentation)
|
||||
|
||||
### Recommended Development Workflow
|
||||
|
||||
@@ -203,13 +206,13 @@ uv run fastapi run media_manager/main.py --reload --port 8000
|
||||
* Format code:
|
||||
|
||||
```bash
|
||||
uv run ruff format .
|
||||
ruff format .
|
||||
```
|
||||
|
||||
* Lint code:
|
||||
|
||||
```bash
|
||||
uv run ruff check .
|
||||
ruff check .
|
||||
```
|
||||
|
||||
## Setting up the frontend development environment (Local, Optional)
|
||||
11
docs/contributing-to-mediamanager/documentation.md
Normal file
11
docs/contributing-to-mediamanager/documentation.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Documentation
|
||||
|
||||
MediaManager currently uses GitBook for documentation.
|
||||
|
||||
The files for the documentation are in the \`/docs\` directory. They are \_mostly\_ standard markdown.
|
||||
|
||||
Unfortunately GitBook doesn't provide a way to locally preview the documentation. Instead you can submit a PR with your proposed changes and a GitBook workflow will run which will provide a link to the preview.
|
||||
|
||||
To access the preview just open the \`Details\` link.
|
||||
|
||||
<figure><img src="../.gitbook/assets/image.png" alt=""><figcaption></figcaption></figure>
|
||||
5
docs/installation/README.md
Normal file
5
docs/installation/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Installation Guide
|
||||
|
||||
The recommended way to install and run Media Manager is using Docker and Docker Compose. Other installation methods are not officially supported, but listed here for convenience.
|
||||
|
||||
<table data-view="cards" data-full-width="false"><thead><tr><th align="center"></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td align="center">Docker Compose (recommended)</td><td><a href="docker.md">docker.md</a></td></tr><tr><td align="center">Nix Flakes [Community]</td><td><a href="flakes.md">flakes.md</a></td></tr></tbody></table>
|
||||
@@ -1,6 +1,4 @@
|
||||
# Installation Guide
|
||||
|
||||
The recommended way to install and run Media Manager is using Docker and Docker Compose.
|
||||
# Docker Compose
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -13,7 +11,7 @@ Follow these steps to get MediaManager running with Docker Compose:
|
||||
|
||||
{% stepper %}
|
||||
{% step %}
|
||||
### Get the docker-compose file
|
||||
#### Get the docker-compose file
|
||||
|
||||
Download the `docker-compose.yaml` from the MediaManager repo:
|
||||
|
||||
@@ -23,7 +21,7 @@ wget -O docker-compose.yaml https://github.com/maxdorninger/MediaManager/release
|
||||
{% endstep %}
|
||||
|
||||
{% step %}
|
||||
### Prepare configuration directory and example config
|
||||
#### Prepare configuration directory and example config
|
||||
|
||||
Create a config directory and download the example configuration:
|
||||
|
||||
@@ -34,13 +32,13 @@ wget -O ./config/config.toml https://github.com/maxdorninger/MediaManager/releas
|
||||
{% endstep %}
|
||||
|
||||
{% step %}
|
||||
### Edit configuration
|
||||
#### Edit configuration
|
||||
|
||||
You probably need to edit the `config.toml` file in the `./config` directory to suit your environment and preferences. [How to configure MediaManager.](configuration/)
|
||||
{% endstep %}
|
||||
|
||||
{% step %}
|
||||
### Start MediaManager
|
||||
#### Start MediaManager
|
||||
|
||||
Bring up the stack:
|
||||
|
||||
@@ -58,7 +56,7 @@ docker compose up -d
|
||||
When setting up MediaManager for the first time, you should add your email to `admin_emails` in the `[auth]` config section. MediaManager will then use this email instead of the default admin email. Your account will automatically be created as an admin account, allowing you to manage other users, media, and settings.
|
||||
{% endhint %}
|
||||
|
||||
## MediaManager and MetadataRelay Docker Images
|
||||
## Docker Images
|
||||
|
||||
MediaManager is available as a Docker image on both Red Hat Quay.io and GitHub Container Registry (GHCR):
|
||||
|
||||
@@ -70,7 +68,7 @@ MetadataRelay images are also available on both registries:
|
||||
* quay.io/maxdorninger/metadata\_relay
|
||||
* ghcr.io/maxdorninger/mediamanager/metadata\_relay
|
||||
|
||||
From v1.12.1 onwards, both MediaManager and MetadataRelay images are available on both Quay.io and GHCR. The reason for the switch to Quay.io as the primary image registry is due to GHCR's continued slow performance: https://github.com/orgs/community/discussions/173607
|
||||
From v1.12.1 onwards, both MediaManager and MetadataRelay images are available on both Quay.io and GHCR. The reason for the switch to Quay.io as the primary image registry is due to [GHCR's continued slow performance.](https://github.com/orgs/community/discussions/173607)
|
||||
|
||||
{% hint style="info" %}
|
||||
You can use either the Quay.io or GHCR images interchangeably, as they are built from the same source and the tags are the same across both registries.
|
||||
@@ -85,4 +83,4 @@ Both registries support the following tags:
|
||||
* X.Y.Z: Specific version tags (e.g., `1.12.0`).
|
||||
* X.Y: Points to the latest release in the X.Y series (e.g., `1.12`).
|
||||
* X: Points to the latest release in the X series (e.g., `1`).
|
||||
* pr-: Points to the latest commit in the specified pull request (e.g., `pr-67`).
|
||||
* pr-\<number>: Points to the latest commit in the specified pull request (e.g., `pr-67`).
|
||||
127
docs/installation/flakes.md
Normal file
127
docs/installation/flakes.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Nix Flakes
|
||||
|
||||
{% hint style="note" %}
|
||||
This is a community contribution and not officially supported by the MediaManager team, but included here for convenience.
|
||||
{% endhint %}
|
||||
|
||||
*Please report issues with this method at the [corresponding GitHub repository](https://github.com/strangeglyph/mediamanager-nix).*
|
||||
</note>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This guide assumes that your system is a flakes-based NixOS installation. Hosting MediaManager on a subpath (e.g. `yourdomain.com/mediamanager`) is currently not supported, though contributions to add support are welcome.
|
||||
|
||||
## Importing the community flake
|
||||
|
||||
To use the community-provided flake and module, first import it in your own flake, for example:
|
||||
|
||||
```nix
|
||||
{
|
||||
description = "An example NixOS configuration";
|
||||
|
||||
inputs = {
|
||||
nixpkgs = { url = "github:nixos/nixpkgs/nixos-unstable"; };
|
||||
|
||||
mediamanager-nix = {
|
||||
url = "github:strangeglyph/mediamanager-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = inputs@{
|
||||
nixpkgs,
|
||||
mediamanager-nix,
|
||||
...
|
||||
}: {
|
||||
nixosConfigurations.your-system = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
mediamanager-nix.nixosModules.default
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The flake provides a simple module to set up a MediaManager systemd service. To enable it, set
|
||||
|
||||
```nix
|
||||
{
|
||||
config = {
|
||||
services.media-manager = {
|
||||
enable = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
You will either want to set `services.media-manager.dataDir`, which will provide sensible defaults for the settings
|
||||
`misc.{image,movie,tv,torrent}_directory`, or provide specific paths yourself.
|
||||
|
||||
The host and port that MediaManager listens on can be set using `services.media-manager.{host,port}`.
|
||||
|
||||
To configure MediaManager, use `services.media-manager.settings`, which follows the same structure as the MediaManager
|
||||
`config.toml`. To provision secrets, set `services.media-manager.environmentFile` to a protected file, for example one
|
||||
provided by [agenix](https://github.com/ryantm/agenix) or [sops-nix](https://github.com/Mic92/sops-nix).
|
||||
See [Configuration](Configuration.md#configuring-secrets) for guidance on using environment variables.
|
||||
|
||||
|
||||
{% hint style="warning" %}
|
||||
Do not place secrets in the nix store, as it is world-readable.
|
||||
{% endhint %}
|
||||
|
||||
## Automatic Postgres Setup
|
||||
|
||||
As a convenience feature, the module provides a simple Postgres setup that can be enabled with `services.media-manager.postgres.enable`. This sets up a database user named `services.media-manager.postgres.user` and a database with the same name. Provided the user of the systemd service wasn't changed, authentication should work automatically for unix socket connections (the default mediamanager-nix settings).
|
||||
|
||||
For advanced setups, please refer to the NixOS manual.
|
||||
|
||||
## Example Configuration
|
||||
|
||||
Here is a minimal complete flake for a MediaManager setup:
|
||||
|
||||
```nix
|
||||
{
|
||||
description = "An example NixOS configuration";
|
||||
|
||||
inputs = {
|
||||
nixpkgs = { url = "github:nixos/nixpkgs/nixos-unstable"; };
|
||||
mediamanager-nix = {
|
||||
url = "github:strangeglyph/mediamanager-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = inputs@{
|
||||
nixpkgs,
|
||||
mediamanager-nix,
|
||||
...
|
||||
}: {
|
||||
nixosConfigurations.your-system = nixpkgs.lib.nixosSystem {
|
||||
imports = [
|
||||
mediamanager-nix.nixosModules.default
|
||||
];
|
||||
|
||||
config = {
|
||||
services.media-manager = {
|
||||
enable = true;
|
||||
postgres.enable = true;
|
||||
port = 12345;
|
||||
dataDir = "/tmp";
|
||||
settings = {
|
||||
misc.frontend_url = "http://[::1]:12345";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.settings."10-mediamanager" = {
|
||||
"/tmp/movies".d = { user = config.services.media-manager.user; };
|
||||
"/tmp/shows".d = { user = config.services.media-manager.user; };
|
||||
"/tmp/images".d = { user = config.services.media-manager.user; };
|
||||
"/tmp/torrents".d = { user = config.services.media-manager.user; };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -60,7 +60,7 @@ Switch to advanced tabTry switching to the advanced tab when searching for torre
|
||||
#### Possible Fixes:
|
||||
|
||||
* [Unable to pull image from GitHub Container Registry (Stack Overflow)](https://stackoverflow.com/questions/74656167/unable-to-pull-image-from-github-container-registry-ghcr)
|
||||
* [Try pulling the image from Quay.io](installation-guide.md#mediamanager-and-metadatarelay-docker-images)
|
||||
* [Try pulling the image from Quay.io](/broken/pages/09241b2fcda5d337e8878e4052f4634fe2902d10#mediamanager-and-metadatarelay-docker-images)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -20,7 +20,3 @@ class AuthConfig(BaseSettings):
|
||||
admin_emails: list[str] = []
|
||||
email_password_resets: bool = False
|
||||
openid_connect: OpenIdConfig = OpenIdConfig()
|
||||
|
||||
@property
|
||||
def jwt_signing_key(self):
|
||||
return self._jwt_signing_key
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi_users.db import (
|
||||
@@ -17,7 +16,7 @@ from media_manager.database import Base, build_db_url
|
||||
|
||||
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
|
||||
access_token: Mapped[str] = mapped_column(String(length=4096), nullable=False)
|
||||
refresh_token: Mapped[Optional[str]] = mapped_column(
|
||||
refresh_token: Mapped[str | None] = mapped_column(
|
||||
String(length=4096), nullable=True
|
||||
)
|
||||
|
||||
@@ -34,10 +33,12 @@ engine = create_async_engine(
|
||||
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async def get_async_session() -> AsyncGenerator[AsyncSession]:
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_user_db(session: AsyncSession = Depends(get_async_session)):
|
||||
async def get_user_db(
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> AsyncGenerator[SQLAlchemyUserDatabase]:
|
||||
yield SQLAlchemyUserDatabase(session, User, OAuthAccount)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, status
|
||||
from fastapi_users.router import get_oauth_router
|
||||
from httpx_oauth.oauth2 import OAuth2
|
||||
from sqlalchemy import select
|
||||
@@ -7,6 +10,7 @@ from media_manager.auth.db import User
|
||||
from media_manager.auth.schemas import AuthMetadata, UserRead
|
||||
from media_manager.auth.users import (
|
||||
SECRET,
|
||||
create_default_admin_user,
|
||||
current_superuser,
|
||||
fastapi_users,
|
||||
openid_client,
|
||||
@@ -15,11 +19,18 @@ from media_manager.auth.users import (
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.database import DbSessionDependency
|
||||
|
||||
users_router = APIRouter()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncGenerator:
|
||||
await create_default_admin_user()
|
||||
yield
|
||||
|
||||
|
||||
users_router = APIRouter(lifespan=lifespan)
|
||||
auth_metadata_router = APIRouter()
|
||||
|
||||
|
||||
def get_openid_router():
|
||||
def get_openid_router() -> APIRouter:
|
||||
if openid_client:
|
||||
return get_oauth_router(
|
||||
oauth_client=openid_client,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Optional, override
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, override
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
@@ -49,7 +50,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
self,
|
||||
user: models.UP,
|
||||
update_dict: dict[str, Any],
|
||||
request: Optional[Request] = None,
|
||||
request: Request | None = None,
|
||||
) -> None:
|
||||
log.info(f"User {user.id} has been updated.")
|
||||
if update_dict.get("is_superuser"):
|
||||
@@ -59,7 +60,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
await self.update(user=user, user_update=updated_user)
|
||||
|
||||
@override
|
||||
async def on_after_register(self, user: User, request: Optional[Request] = None):
|
||||
async def on_after_register(
|
||||
self, user: User, request: Request | None = None
|
||||
) -> None:
|
||||
log.info(f"User {user.id} has registered.")
|
||||
if user.email in config.admin_emails:
|
||||
updated_user = UserUpdate(is_superuser=True, is_verified=True)
|
||||
@@ -67,8 +70,8 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
|
||||
@override
|
||||
async def on_after_forgot_password(
|
||||
self, user: User, token: str, request: Optional[Request] = None
|
||||
):
|
||||
self, user: User, token: str, request: Request | None = None
|
||||
) -> None:
|
||||
link = f"{MediaManagerConfig().misc.frontend_url}web/login/reset-password?token={token}"
|
||||
log.info(f"User {user.id} has forgot their password. Reset Link: {link}")
|
||||
|
||||
@@ -83,7 +86,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
<p>Hi {user.email},
|
||||
<br>
|
||||
<br>
|
||||
if you forgot your password, <a href="{link}">reset you password here</a>.<br>
|
||||
if you forgot your password, <a href=\"{link}\">reset you password here</a>.<br>
|
||||
If you did not request a password reset, you can ignore this email.</p>
|
||||
<br>
|
||||
<br>
|
||||
@@ -98,24 +101,26 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
|
||||
@override
|
||||
async def on_after_reset_password(
|
||||
self, user: User, request: Optional[Request] = None
|
||||
):
|
||||
self, user: User, request: Request | None = None
|
||||
) -> None:
|
||||
log.info(f"User {user.id} has reset their password.")
|
||||
|
||||
@override
|
||||
async def on_after_request_verify(
|
||||
self, user: User, token: str, request: Optional[Request] = None
|
||||
):
|
||||
self, user: User, token: str, request: Request | None = None
|
||||
) -> None:
|
||||
log.info(
|
||||
f"Verification requested for user {user.id}. Verification token: {token}"
|
||||
)
|
||||
|
||||
@override
|
||||
async def on_after_verify(self, user: User, request: Optional[Request] = None):
|
||||
async def on_after_verify(self, user: User, request: Request | None = None) -> None:
|
||||
log.info(f"User {user.id} has been verified")
|
||||
|
||||
|
||||
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
|
||||
async def get_user_manager(
|
||||
user_db: SQLAlchemyUserDatabase = Depends(get_user_db),
|
||||
) -> AsyncGenerator[UserManager]:
|
||||
yield UserManager(user_db)
|
||||
|
||||
|
||||
@@ -124,7 +129,7 @@ get_user_db_context = contextlib.asynccontextmanager(get_user_db)
|
||||
get_user_manager_context = contextlib.asynccontextmanager(get_user_manager)
|
||||
|
||||
|
||||
async def create_default_admin_user():
|
||||
async def create_default_admin_user() -> None:
|
||||
"""Create a default admin user if no users exist in the database"""
|
||||
try:
|
||||
async with get_async_session_context() as session:
|
||||
@@ -170,17 +175,13 @@ async def create_default_admin_user():
|
||||
log.info(
|
||||
f"Found {user_count} existing users. Skipping default user creation."
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to create default admin user: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to create default admin user")
|
||||
log.info(
|
||||
"You can create an admin user manually by registering with an email from the admin_emails list in your config."
|
||||
)
|
||||
|
||||
|
||||
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
|
||||
yield UserManager(user_db)
|
||||
|
||||
|
||||
def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]:
|
||||
return JWTStrategy(secret=SECRET, lifetime_seconds=LIFETIME)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Type
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
from pydantic_settings import (
|
||||
@@ -41,7 +40,7 @@ class BasicConfig(BaseSettings):
|
||||
movie_directory: Path = Path(__file__).parent.parent / "data" / "movies"
|
||||
torrent_directory: Path = Path(__file__).parent.parent / "data" / "torrents"
|
||||
|
||||
frontend_url: AnyHttpUrl = "http://localhost:8000"
|
||||
frontend_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000")
|
||||
cors_urls: list[str] = []
|
||||
development: bool = False
|
||||
|
||||
@@ -71,12 +70,12 @@ class MediaManagerConfig(BaseSettings):
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
settings_cls: Type[BaseSettings],
|
||||
settings_cls: type[BaseSettings],
|
||||
init_settings: PydanticBaseSettingsSource,
|
||||
env_settings: PydanticBaseSettingsSource,
|
||||
dotenv_settings: PydanticBaseSettingsSource,
|
||||
file_secret_settings: PydanticBaseSettingsSource,
|
||||
) -> Tuple[PydanticBaseSettingsSource, ...]:
|
||||
) -> tuple[PydanticBaseSettingsSource, ...]:
|
||||
return (
|
||||
init_settings,
|
||||
env_settings,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from contextvars import ContextVar
|
||||
from typing import Annotated, Any, Generator, Optional
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import create_engine
|
||||
@@ -9,12 +10,14 @@ from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.engine.url import URL
|
||||
from sqlalchemy.orm import Session, declarative_base, sessionmaker
|
||||
|
||||
from media_manager.database.config import DbConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
engine: Optional[Engine] = None
|
||||
SessionLocal: Optional[sessionmaker] = None
|
||||
engine: Engine | None = None
|
||||
SessionLocal: sessionmaker | None = None
|
||||
|
||||
|
||||
def build_db_url(
|
||||
@@ -35,7 +38,7 @@ def build_db_url(
|
||||
|
||||
|
||||
def init_engine(
|
||||
db_config: Any | None = None,
|
||||
db_config: DbConfig | None = None,
|
||||
url: str | URL | None = None,
|
||||
) -> Engine:
|
||||
"""
|
||||
@@ -81,7 +84,7 @@ def get_engine() -> Engine:
|
||||
return engine
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, Any, None]:
|
||||
def get_session() -> Generator[Session]:
|
||||
if SessionLocal is None:
|
||||
msg = "Session factory not initialized. Call init_engine(...) first."
|
||||
raise RuntimeError(msg)
|
||||
@@ -89,9 +92,9 @@ def get_session() -> Generator[Session, Any, None]:
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.critical(f"error occurred: {e}")
|
||||
log.critical("", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -4,10 +4,17 @@ from psycopg.errors import UniqueViolation
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
|
||||
class RenameError(Exception):
|
||||
"""Error when renaming something"""
|
||||
|
||||
def __init__(self, message: str = "Failed to rename source directory") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MediaManagerError(Exception):
|
||||
"""Base exception for MediaManager errors."""
|
||||
|
||||
def __init__(self, message: str = "An error occurred."):
|
||||
def __init__(self, message: str = "An error occurred.") -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
@@ -17,76 +24,76 @@ class MediaAlreadyExistsError(MediaManagerError):
|
||||
|
||||
def __init__(
|
||||
self, message: str = "Entity with this ID or other identifier already exists"
|
||||
):
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class NotFoundError(MediaManagerError):
|
||||
"""Raised when an entity is not found (HTTP 404)."""
|
||||
|
||||
def __init__(self, message: str = "The requested entity was not found."):
|
||||
def __init__(self, message: str = "The requested entity was not found.") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class InvalidConfigError(MediaManagerError):
|
||||
"""Raised when the server is improperly configured (HTTP 500)."""
|
||||
|
||||
def __init__(self, message: str = "The server is improperly configured."):
|
||||
def __init__(self, message: str = "The server is improperly configured.") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class BadRequestError(MediaManagerError):
|
||||
"""Raised for invalid client requests (HTTP 400)."""
|
||||
|
||||
def __init__(self, message: str = "Bad request."):
|
||||
def __init__(self, message: str = "Bad request.") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class UnauthorizedError(MediaManagerError):
|
||||
"""Raised for authentication failures (HTTP 401)."""
|
||||
|
||||
def __init__(self, message: str = "Unauthorized."):
|
||||
def __init__(self, message: str = "Unauthorized.") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ForbiddenError(MediaManagerError):
|
||||
"""Raised for forbidden actions (HTTP 403)."""
|
||||
|
||||
def __init__(self, message: str = "Forbidden."):
|
||||
def __init__(self, message: str = "Forbidden.") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ConflictError(MediaManagerError):
|
||||
"""Raised for resource conflicts (HTTP 409)."""
|
||||
|
||||
def __init__(self, message: str = "Conflict."):
|
||||
def __init__(self, message: str = "Conflict.") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class UnprocessableEntityError(MediaManagerError):
|
||||
"""Raised for validation errors (HTTP 422)."""
|
||||
|
||||
def __init__(self, message: str = "Unprocessable entity."):
|
||||
def __init__(self, message: str = "Unprocessable entity.") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# Exception handlers
|
||||
async def media_already_exists_exception_handler(
|
||||
_request: Request, exc: MediaAlreadyExistsError
|
||||
_request: Request, _exc: Exception
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(status_code=409, content={"detail": exc.message})
|
||||
return JSONResponse(status_code=409, content={"detail": str(_exc)})
|
||||
|
||||
|
||||
async def not_found_error_exception_handler(
|
||||
_request: Request, exc: NotFoundError
|
||||
_request: Request, _exc: Exception
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(status_code=404, content={"detail": exc.message})
|
||||
return JSONResponse(status_code=404, content={"detail": str(_exc)})
|
||||
|
||||
|
||||
async def invalid_config_error_exception_handler(
|
||||
_request: Request, exc: InvalidConfigError
|
||||
_request: Request, _exc: Exception
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(status_code=500, content={"detail": exc.message})
|
||||
return JSONResponse(status_code=500, content={"detail": str(_exc)})
|
||||
|
||||
|
||||
async def bad_request_error_handler(
|
||||
@@ -107,8 +114,8 @@ async def forbidden_error_handler(
|
||||
return JSONResponse(status_code=403, content={"detail": exc.message})
|
||||
|
||||
|
||||
async def conflict_error_handler(_request: Request, exc: ConflictError) -> JSONResponse:
|
||||
return JSONResponse(status_code=409, content={"detail": exc.message})
|
||||
async def conflict_error_handler(_request: Request, _exc: Exception) -> JSONResponse:
|
||||
return JSONResponse(status_code=409, content={"detail": str(_exc)})
|
||||
|
||||
|
||||
async def unprocessable_entity_error_handler(
|
||||
@@ -128,7 +135,7 @@ async def sqlalchemy_integrity_error_handler(
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
app.add_exception_handler(NotFoundError, not_found_error_exception_handler)
|
||||
app.add_exception_handler(
|
||||
MediaAlreadyExistsError, media_already_exists_exception_handler
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import shutil
|
||||
from logging import Logger
|
||||
from pathlib import Path
|
||||
|
||||
from media_manager.config import MediaManagerConfig
|
||||
|
||||
def run_filesystem_checks(config, log):
|
||||
|
||||
def run_filesystem_checks(config: MediaManagerConfig, log: Logger) -> None:
|
||||
log.info("Creating directories if they don't exist...")
|
||||
config.misc.tv_directory.mkdir(parents=True, exist_ok=True)
|
||||
config.misc.movie_directory.mkdir(parents=True, exist_ok=True)
|
||||
@@ -33,10 +36,8 @@ def run_filesystem_checks(config, log):
|
||||
if not test_hardlink.samefile(test_torrent_file):
|
||||
log.critical("Hardlink creation failed!")
|
||||
log.info("Successfully created test hardlink in TV directory")
|
||||
except OSError as e:
|
||||
log.error(
|
||||
f"Hardlink creation failed, falling back to copying files. Error: {e}"
|
||||
)
|
||||
except OSError:
|
||||
log.exception("Hardlink creation failed, falling back to copying files")
|
||||
shutil.copy(src=test_torrent_file, dst=test_hardlink)
|
||||
finally:
|
||||
test_hardlink.unlink()
|
||||
|
||||
@@ -5,7 +5,6 @@ from fastapi import Depends
|
||||
from media_manager.database import DbSessionDependency
|
||||
from media_manager.indexer.repository import IndexerRepository
|
||||
from media_manager.indexer.service import IndexerService
|
||||
from media_manager.tv.service import TvService
|
||||
|
||||
|
||||
def get_indexer_repository(db_session: DbSessionDependency) -> IndexerRepository:
|
||||
@@ -21,4 +20,4 @@ def get_indexer_service(
|
||||
return IndexerService(indexer_repository)
|
||||
|
||||
|
||||
indexer_service_dep = Annotated[TvService, Depends(get_indexer_service)]
|
||||
indexer_service_dep = Annotated[IndexerService, Depends(get_indexer_service)]
|
||||
|
||||
@@ -8,7 +8,7 @@ from media_manager.tv.schemas import Show
|
||||
class GenericIndexer(ABC):
|
||||
name: str
|
||||
|
||||
def __init__(self, name: str):
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import concurrent
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
|
||||
@@ -15,7 +16,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Jackett(GenericIndexer, TorznabMixin):
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
A subclass of GenericIndexer for interacting with the Jacket API.
|
||||
|
||||
@@ -45,8 +46,8 @@ class Jackett(GenericIndexer, TorznabMixin):
|
||||
result = future.result()
|
||||
if result is not None:
|
||||
responses.extend(result)
|
||||
except Exception as e:
|
||||
log.error(f"search result failed with: {e}")
|
||||
except Exception:
|
||||
log.exception("Searching failed")
|
||||
|
||||
return responses
|
||||
|
||||
@@ -67,13 +68,15 @@ class Jackett(GenericIndexer, TorznabMixin):
|
||||
|
||||
results = self.process_search_result(response.content)
|
||||
|
||||
log.info(f"Indexer {indexer.name} returned {len(results)} results")
|
||||
log.info(f"Indexer {indexer} returned {len(results)} results")
|
||||
return results
|
||||
|
||||
def search_season(
|
||||
self, query: str, show: Show, season_number: int
|
||||
) -> list[IndexerQueryResult]:
|
||||
pass
|
||||
log.debug(f"Searching for season {season_number} of show {show.name}")
|
||||
return self.search(query=query, is_tv=True)
|
||||
|
||||
def search_movie(self, query: str, movie: Movie) -> list[IndexerQueryResult]:
|
||||
pass
|
||||
log.debug(f"Searching for movie {movie.name}")
|
||||
return self.search(query=query, is_tv=False)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from requests import Session
|
||||
from requests import Response, Session
|
||||
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.indexer.indexers.generic import GenericIndexer
|
||||
@@ -31,14 +31,14 @@ class IndexerInfo:
|
||||
|
||||
|
||||
class Prowlarr(GenericIndexer, TorznabMixin):
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
A subclass of GenericIndexer for interacting with the Prowlarr API.
|
||||
"""
|
||||
super().__init__(name="prowlarr")
|
||||
self.config = MediaManagerConfig().indexers.prowlarr
|
||||
|
||||
def _call_prowlarr_api(self, path: str, parameters: dict | None = None):
|
||||
def _call_prowlarr_api(self, path: str, parameters: dict | None = None) -> Response:
|
||||
url = f"{self.config.url}/api/v1{path}"
|
||||
headers = {"X-Api-Key": self.config.api_key}
|
||||
with Session() as session:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
from media_manager.indexer.schemas import IndexerQueryResult
|
||||
@@ -39,7 +39,7 @@ class TorznabMixin:
|
||||
posted_date = parsedate_to_datetime(
|
||||
attribute.attrib["value"]
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(datetime.UTC)
|
||||
age = int((now - posted_date).total_seconds())
|
||||
else:
|
||||
if attribute.attrib["name"] == "seeders":
|
||||
@@ -61,17 +61,32 @@ class TorznabMixin:
|
||||
if upload_volume_factor == 2:
|
||||
flags.append("doubleupload")
|
||||
|
||||
title = item.find("title").text
|
||||
size_str = item.find("size")
|
||||
if size_str is None or size_str.text is None:
|
||||
log.warning(
|
||||
f"Torznab item {title} has no size, skipping."
|
||||
)
|
||||
continue
|
||||
try:
|
||||
size = int(size_str.text or "0")
|
||||
except ValueError:
|
||||
log.warning(
|
||||
f"Torznab item {title} has invalid size, skipping."
|
||||
)
|
||||
continue
|
||||
|
||||
result = IndexerQueryResult(
|
||||
title=item.find("title").text,
|
||||
title=title or "unknown",
|
||||
download_url=str(item.find("enclosure").attrib["url"]),
|
||||
seeders=seeders,
|
||||
flags=flags,
|
||||
size=int(item.find("size").text),
|
||||
size=size,
|
||||
usenet=is_usenet,
|
||||
age=age,
|
||||
indexer=indexer_name,
|
||||
)
|
||||
result_list.append(result)
|
||||
except Exception as e:
|
||||
log.error(f"1 Torznab search result errored with error: {e}")
|
||||
except Exception:
|
||||
log.exception("1 Torznab search result failed")
|
||||
return result_list
|
||||
|
||||
@@ -14,7 +14,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IndexerRepository:
|
||||
def __init__(self, db: Session):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_result(self, result_id: IndexerQueryResultId) -> IndexerQueryResultSchema:
|
||||
|
||||
@@ -13,7 +13,9 @@ IndexerQueryResultId = typing.NewType("IndexerQueryResultId", UUID)
|
||||
class IndexerQueryResult(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: IndexerQueryResultId = pydantic.Field(default_factory=uuid4)
|
||||
id: IndexerQueryResultId = pydantic.Field(
|
||||
default_factory=lambda: IndexerQueryResultId(uuid4())
|
||||
)
|
||||
title: str
|
||||
download_url: str = pydantic.Field(
|
||||
exclude=True,
|
||||
@@ -62,7 +64,7 @@ class IndexerQueryResult(BaseModel):
|
||||
result = []
|
||||
return result
|
||||
|
||||
def __gt__(self, other) -> bool:
|
||||
def __gt__(self, other: "IndexerQueryResult") -> bool:
|
||||
if self.quality.value != other.quality.value:
|
||||
return self.quality.value < other.quality.value
|
||||
if self.score != other.score:
|
||||
@@ -76,7 +78,7 @@ class IndexerQueryResult(BaseModel):
|
||||
|
||||
return self.size < other.size
|
||||
|
||||
def __lt__(self, other) -> bool:
|
||||
def __lt__(self, other: "IndexerQueryResult") -> bool:
|
||||
if self.quality.value != other.quality.value:
|
||||
return self.quality.value > other.quality.value
|
||||
if self.score != other.score:
|
||||
|
||||
@@ -14,7 +14,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IndexerService:
|
||||
def __init__(self, indexer_repository: IndexerRepository):
|
||||
def __init__(self, indexer_repository: IndexerRepository) -> None:
|
||||
config = MediaManagerConfig()
|
||||
self.repository = indexer_repository
|
||||
self.indexers: list[GenericIndexer] = []
|
||||
@@ -45,9 +45,9 @@ class IndexerService:
|
||||
log.debug(
|
||||
f"Indexer {indexer.__class__.__name__} returned {len(indexer_results)} results for query: {query}"
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Indexer {indexer.__class__.__name__} failed for query '{query}': {e}"
|
||||
except Exception:
|
||||
log.exception(
|
||||
f"Indexer {indexer.__class__.__name__} failed for query '{query}'"
|
||||
)
|
||||
|
||||
for result in results:
|
||||
@@ -55,7 +55,7 @@ class IndexerService:
|
||||
|
||||
return results
|
||||
|
||||
def search_movie(self, movie: Movie):
|
||||
def search_movie(self, movie: Movie) -> list[IndexerQueryResult]:
|
||||
query = f"{movie.name} {movie.year}"
|
||||
query = remove_special_chars_and_parentheses(query)
|
||||
|
||||
@@ -65,9 +65,9 @@ class IndexerService:
|
||||
indexer_results = indexer.search_movie(query=query, movie=movie)
|
||||
if indexer_results:
|
||||
results.extend(indexer_results)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Indexer {indexer.__class__.__name__} failed for movie search '{query}': {e}"
|
||||
except Exception:
|
||||
log.exception(
|
||||
f"Indexer {indexer.__class__.__name__} failed for movie search '{query}'"
|
||||
)
|
||||
|
||||
for result in results:
|
||||
@@ -75,7 +75,7 @@ class IndexerService:
|
||||
|
||||
return results
|
||||
|
||||
def search_season(self, show: Show, season_number: int):
|
||||
def search_season(self, show: Show, season_number: int) -> list[IndexerQueryResult]:
|
||||
query = f"{show.name} {show.year} S{season_number:02d}"
|
||||
query = remove_special_chars_and_parentheses(query)
|
||||
|
||||
@@ -87,9 +87,9 @@ class IndexerService:
|
||||
)
|
||||
if indexer_results:
|
||||
results.extend(indexer_results)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Indexer {indexer.__class__.__name__} failed for season search '{query}': {e}"
|
||||
except Exception:
|
||||
log.exception(
|
||||
f"Indexer {indexer.__class__.__name__} failed for season search '{query}'"
|
||||
)
|
||||
|
||||
for result in results:
|
||||
|
||||
@@ -14,7 +14,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
def evaluate_indexer_query_result(
|
||||
query_result: IndexerQueryResult, ruleset: ScoringRuleSet
|
||||
) -> (IndexerQueryResult, bool):
|
||||
) -> tuple[IndexerQueryResult, bool]:
|
||||
title_rules = MediaManagerConfig().indexers.title_scoring_rules
|
||||
indexer_flag_rules = MediaManagerConfig().indexers.indexer_flag_scoring_rules
|
||||
for rule_name in ruleset.rule_names:
|
||||
@@ -149,8 +149,11 @@ def follow_redirects_to_final_torrent_url(
|
||||
raise RuntimeError(msg)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.debug(f"An error occurred during the request for {initial_url}: {e}")
|
||||
msg = f"An error occurred during the request: {e}"
|
||||
log.debug(
|
||||
f"An error occurred during the request for {initial_url}",
|
||||
exc_info=True,
|
||||
)
|
||||
msg = "An error occurred during the request"
|
||||
raise RuntimeError(msg) from e
|
||||
|
||||
return current_url
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from logging.config import dictConfig
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
@@ -11,8 +11,8 @@ from pythonjsonlogger.json import JsonFormatter
|
||||
|
||||
class ISOJsonFormatter(JsonFormatter):
|
||||
@override
|
||||
def formatTime(self, record, datefmt=None):
|
||||
dt = datetime.fromtimestamp(record.created, tz=timezone.utc)
|
||||
def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
|
||||
dt = datetime.fromtimestamp(record.created, tz=UTC)
|
||||
return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
@@ -21,13 +21,20 @@ LOG_FILE = Path(os.getenv("LOG_FILE", "/app/config/media_manager.log"))
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"filters": {
|
||||
"correlation_id": {
|
||||
"()": "asgi_correlation_id.CorrelationIdFilter",
|
||||
"uuid_length": 32,
|
||||
"default_value": "-",
|
||||
},
|
||||
},
|
||||
"formatters": {
|
||||
"default": {
|
||||
"format": "%(asctime)s - %(levelname)s - %(name)s - %(funcName)s(): %(message)s"
|
||||
"format": "%(asctime)s - [%(correlation_id)s] %(levelname)s - %(name)s - %(funcName)s(): %(message)s"
|
||||
},
|
||||
"json": {
|
||||
"()": ISOJsonFormatter,
|
||||
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
"format": "%(asctime)s %(correlation_id)s %(levelname)s %(name)s %(message)s",
|
||||
"rename_fields": {
|
||||
"levelname": "level",
|
||||
"asctime": "timestamp",
|
||||
@@ -39,11 +46,13 @@ LOGGING_CONFIG = {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "default",
|
||||
"filters": ["correlation_id"],
|
||||
"stream": sys.stdout,
|
||||
},
|
||||
"file": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"formatter": "json",
|
||||
"filters": ["correlation_id"],
|
||||
"filename": str(LOG_FILE),
|
||||
"maxBytes": 10485760,
|
||||
"backupCount": 5,
|
||||
@@ -62,7 +71,7 @@ LOGGING_CONFIG = {
|
||||
}
|
||||
|
||||
|
||||
def setup_logging():
|
||||
def setup_logging() -> None:
|
||||
dictConfig(LOGGING_CONFIG)
|
||||
logging.basicConfig(
|
||||
level=LOG_LEVEL,
|
||||
|
||||
@@ -2,12 +2,13 @@ import logging
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from asgi_correlation_id import CorrelationIdMiddleware
|
||||
from fastapi import APIRouter, FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from psycopg.errors import UniqueViolation
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from starlette.responses import FileResponse, RedirectResponse, Response
|
||||
from starlette.responses import FileResponse, RedirectResponse
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
|
||||
import media_manager.movies.router as movies_router
|
||||
@@ -71,6 +72,7 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "PUT", "POST", "DELETE", "PATCH", "HEAD", "OPTIONS"],
|
||||
)
|
||||
app.add_middleware(CorrelationIdMiddleware, header_name="X-Correlation-ID")
|
||||
api_app = APIRouter(prefix="/api/v1")
|
||||
|
||||
|
||||
@@ -143,23 +145,23 @@ else:
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
async def root() -> RedirectResponse:
|
||||
return RedirectResponse(url="/web/")
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
async def dashboard():
|
||||
async def dashboard() -> RedirectResponse:
|
||||
return RedirectResponse(url="/web/")
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
async def login():
|
||||
async def login() -> RedirectResponse:
|
||||
return RedirectResponse(url="/web/")
|
||||
|
||||
|
||||
# this will serve the custom 404 page for frontend routes, so SvelteKit can handle routing
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request, _exc):
|
||||
async def not_found_handler(request: Request, _exc: Exception) -> Response:
|
||||
if not DISABLE_FRONTEND_MOUNT and any(
|
||||
base_path in ["/web", "/dashboard", "/login"] for base_path in request.url.path
|
||||
):
|
||||
|
||||
@@ -18,15 +18,11 @@ class AbstractMetadataProvider(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_show_metadata(
|
||||
self, show_id: int | None = None, language: str | None = None
|
||||
) -> Show:
|
||||
def get_show_metadata(self, show_id: int, language: str | None = None) -> Show:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def get_movie_metadata(
|
||||
self, movie_id: int | None = None, language: str | None = None
|
||||
) -> Movie:
|
||||
def get_movie_metadata(self, movie_id: int, language: str | None = None) -> Movie:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -21,7 +21,7 @@ log = logging.getLogger(__name__)
|
||||
class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
name = "tmdb"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
config = MediaManagerConfig().metadata.tmdb
|
||||
self.url = config.tmdb_relay_url
|
||||
self.primary_languages = config.primary_languages
|
||||
@@ -51,7 +51,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log.error(f"TMDB API error getting show metadata for ID {show_id}: {e}")
|
||||
log.exception(f"TMDB API error getting show metadata for ID {show_id}")
|
||||
if notification_manager.is_configured():
|
||||
notification_manager.send_notification(
|
||||
title="TMDB API Error",
|
||||
@@ -68,7 +68,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log.error(f"TMDB API error getting show external IDs for ID {show_id}: {e}")
|
||||
log.exception(f"TMDB API error getting show external IDs for ID {show_id}")
|
||||
if notification_manager.is_configured():
|
||||
notification_manager.send_notification(
|
||||
title="TMDB API Error",
|
||||
@@ -90,8 +90,8 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log.error(
|
||||
f"TMDB API error getting season {season_number} metadata for show ID {show_id}: {e}"
|
||||
log.exception(
|
||||
f"TMDB API error getting season {season_number} metadata for show ID {show_id}"
|
||||
)
|
||||
if notification_manager.is_configured():
|
||||
notification_manager.send_notification(
|
||||
@@ -113,7 +113,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log.error(f"TMDB API error searching TV shows with query '{query}': {e}")
|
||||
log.exception(f"TMDB API error searching TV shows with query '{query}'")
|
||||
if notification_manager.is_configured():
|
||||
notification_manager.send_notification(
|
||||
title="TMDB API Error",
|
||||
@@ -131,7 +131,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log.error(f"TMDB API error getting trending TV: {e}")
|
||||
log.exception("TMDB API error getting trending TV")
|
||||
if notification_manager.is_configured():
|
||||
notification_manager.send_notification(
|
||||
title="TMDB API Error",
|
||||
@@ -151,7 +151,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log.error(f"TMDB API error getting movie metadata for ID {movie_id}: {e}")
|
||||
log.exception(f"TMDB API error getting movie metadata for ID {movie_id}")
|
||||
if notification_manager.is_configured():
|
||||
notification_manager.send_notification(
|
||||
title="TMDB API Error",
|
||||
@@ -167,8 +167,8 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log.error(
|
||||
f"TMDB API error getting movie external IDs for ID {movie_id}: {e}"
|
||||
log.exception(
|
||||
f"TMDB API error getting movie external IDs for ID {movie_id}"
|
||||
)
|
||||
if notification_manager.is_configured():
|
||||
notification_manager.send_notification(
|
||||
@@ -190,7 +190,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log.error(f"TMDB API error searching movies with query '{query}': {e}")
|
||||
log.exception(f"TMDB API error searching movies with query '{query}'")
|
||||
if notification_manager.is_configured():
|
||||
notification_manager.send_notification(
|
||||
title="TMDB API Error",
|
||||
@@ -208,7 +208,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log.error(f"TMDB API error getting trending movies: {e}")
|
||||
log.exception("TMDB API error getting trending movies")
|
||||
if notification_manager.is_configured():
|
||||
notification_manager.send_notification(
|
||||
title="TMDB API Error",
|
||||
@@ -243,13 +243,11 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
return True
|
||||
|
||||
@override
|
||||
def get_show_metadata(
|
||||
self, show_id: int | None = None, language: str | None = None
|
||||
) -> Show:
|
||||
def get_show_metadata(self, show_id: int, language: str | None = None) -> Show:
|
||||
"""
|
||||
|
||||
:param id: the external id of the show
|
||||
:type id: int
|
||||
:param show_id: the external id of the show
|
||||
:type show_id: int
|
||||
:param language: optional language code (ISO 639-1) to fetch metadata in
|
||||
:type language: str | None
|
||||
:return: returns a Show object
|
||||
@@ -368,19 +366,17 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
original_language=original_language,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Error processing search result: {e}")
|
||||
except Exception:
|
||||
log.warning("Error processing search result", exc_info=True)
|
||||
return formatted_results
|
||||
|
||||
@override
|
||||
def get_movie_metadata(
|
||||
self, movie_id: int | None = None, language: str | None = None
|
||||
) -> Movie:
|
||||
def get_movie_metadata(self, movie_id: int, language: str | None = None) -> Movie:
|
||||
"""
|
||||
Get movie metadata with language-aware fetching.
|
||||
|
||||
:param id: the external id of the movie
|
||||
:type id: int
|
||||
:param movie_id: the external id of the movie
|
||||
:type movie_id: int
|
||||
:param language: optional language code (ISO 639-1) to fetch metadata in
|
||||
:type language: str | None
|
||||
:return: returns a Movie object
|
||||
@@ -470,8 +466,8 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||
original_language=original_language,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Error processing search result: {e}")
|
||||
except Exception:
|
||||
log.warning("Error processing search result", exc_info=True)
|
||||
return formatted_results
|
||||
|
||||
@override
|
||||
|
||||
@@ -18,7 +18,7 @@ log = logging.getLogger(__name__)
|
||||
class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
name = "tvdb"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
config = MediaManagerConfig().metadata.tvdb
|
||||
self.url = config.tvdb_relay_url
|
||||
|
||||
@@ -63,12 +63,10 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
return False
|
||||
|
||||
@override
|
||||
def get_show_metadata(
|
||||
self, show_id: int | None = None, language: str | None = None
|
||||
) -> Show:
|
||||
def get_show_metadata(self, show_id: int, language: str | None = None) -> Show:
|
||||
"""
|
||||
|
||||
:param id: the external id of the show
|
||||
:param show_id: The external id of the show
|
||||
:param language: does nothing, TVDB does not support multiple languages
|
||||
"""
|
||||
series = self.__get_show(show_id)
|
||||
@@ -150,8 +148,8 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
vote_average=None,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Error processing search result: {e}")
|
||||
except Exception:
|
||||
log.warning("Error processing search result", exc_info=True)
|
||||
return formatted_results
|
||||
results = self.__get_trending_tv()
|
||||
formatted_results = []
|
||||
@@ -178,8 +176,8 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
vote_average=None,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Error processing search result: {e}")
|
||||
except Exception:
|
||||
log.warning("Error processing search result", exc_info=True)
|
||||
return formatted_results
|
||||
|
||||
@override
|
||||
@@ -215,8 +213,8 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
vote_average=None,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Error processing search result: {e}")
|
||||
except Exception:
|
||||
log.warning("Error processing search result", exc_info=True)
|
||||
return formatted_results
|
||||
results = self.__get_trending_movies()
|
||||
results = results[0:20]
|
||||
@@ -230,11 +228,16 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
except KeyError:
|
||||
year = None
|
||||
|
||||
if result.get("image"):
|
||||
poster_path = "https://artworks.thetvdb.com" + str(
|
||||
result.get("image")
|
||||
)
|
||||
else:
|
||||
poster_path = None
|
||||
|
||||
formatted_results.append(
|
||||
MetaDataProviderSearchResult(
|
||||
poster_path="https://artworks.thetvdb.com" + result.get("image")
|
||||
if result.get("image")
|
||||
else None,
|
||||
poster_path=poster_path if result.get("image") else None,
|
||||
overview=result.get("overview"),
|
||||
name=result["name"],
|
||||
external_id=result["id"],
|
||||
@@ -244,8 +247,8 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
vote_average=None,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Error processing search result: {e}")
|
||||
except Exception:
|
||||
log.warning("Error processing search result", exc_info=True)
|
||||
return formatted_results
|
||||
|
||||
@override
|
||||
@@ -264,16 +267,14 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||
return False
|
||||
|
||||
@override
|
||||
def get_movie_metadata(
|
||||
self, movie_id: int | None = None, language: str | None = None
|
||||
) -> Movie:
|
||||
def get_movie_metadata(self, movie_id: int, language: str | None = None) -> Movie:
|
||||
"""
|
||||
|
||||
:param movie_id: the external id of the movie
|
||||
:param language: does nothing, TVDB does not support multiple languages
|
||||
:return: returns a Movie object
|
||||
"""
|
||||
movie = self.__get_movie(movie_id)
|
||||
movie = self.__get_movie(movie_id=movie_id)
|
||||
|
||||
# get imdb id from remote ids
|
||||
imdb_id = None
|
||||
|
||||
@@ -40,7 +40,7 @@ class MovieRepository:
|
||||
Provides methods to retrieve, save, and delete movies.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_movie_by_id(self, movie_id: MovieId) -> MovieSchema:
|
||||
@@ -59,8 +59,8 @@ class MovieRepository:
|
||||
msg = f"Movie with id {movie_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
return MovieSchema.model_validate(result)
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error while retrieving movie {movie_id}: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception(f"Database error while retrieving movie {movie_id}")
|
||||
raise
|
||||
|
||||
def get_movie_by_external_id(
|
||||
@@ -86,9 +86,9 @@ class MovieRepository:
|
||||
msg = f"Movie with external_id {external_id} and provider {metadata_provider} not found."
|
||||
raise NotFoundError(msg)
|
||||
return MovieSchema.model_validate(result)
|
||||
except SQLAlchemyError as e:
|
||||
log.error(
|
||||
f"Database error while retrieving movie by external_id {external_id}: {e}"
|
||||
except SQLAlchemyError:
|
||||
log.exception(
|
||||
f"Database error while retrieving movie by external_id {external_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -103,8 +103,8 @@ class MovieRepository:
|
||||
stmt = select(Movie)
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
return [MovieSchema.model_validate(movie) for movie in results]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error while retrieving all movies: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception("Database error while retrieving all movies")
|
||||
raise
|
||||
|
||||
def save_movie(self, movie: MovieSchema) -> MovieSchema:
|
||||
@@ -140,14 +140,14 @@ class MovieRepository:
|
||||
return MovieSchema.model_validate(db_movie)
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
log.error(f"Integrity error while saving movie {movie.name}: {e}")
|
||||
log.exception(f"Integrity error while saving movie {movie.name}")
|
||||
msg = (
|
||||
f"Movie with this primary key or unique constraint violation: {e.orig}"
|
||||
)
|
||||
raise ConflictError(msg) from e
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(f"Database error while saving movie {movie.name}: {e}")
|
||||
log.exception(f"Database error while saving movie {movie.name}")
|
||||
raise
|
||||
|
||||
def delete_movie(self, movie_id: MovieId) -> None:
|
||||
@@ -168,9 +168,9 @@ class MovieRepository:
|
||||
self.db.delete(movie)
|
||||
self.db.commit()
|
||||
log.info(f"Successfully deleted movie with id: {movie_id}")
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(f"Database error while deleting movie {movie_id}: {e}")
|
||||
log.exception(f"Database error while deleting movie {movie_id}")
|
||||
raise
|
||||
|
||||
def add_movie_request(
|
||||
@@ -204,13 +204,13 @@ class MovieRepository:
|
||||
self.db.refresh(db_model)
|
||||
log.info(f"Successfully added movie request with id: {db_model.id}")
|
||||
return MovieRequestSchema.model_validate(db_model)
|
||||
except IntegrityError as e:
|
||||
except IntegrityError:
|
||||
self.db.rollback()
|
||||
log.error(f"Integrity error while adding movie request: {e}")
|
||||
log.exception("Integrity error while adding movie request")
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(f"Database error while adding movie request: {e}")
|
||||
log.exception("Database error while adding movie request")
|
||||
raise
|
||||
|
||||
def set_movie_library(self, movie_id: MovieId, library: str) -> None:
|
||||
@@ -229,9 +229,9 @@ class MovieRepository:
|
||||
raise NotFoundError(msg)
|
||||
movie.library = library
|
||||
self.db.commit()
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(f"Database error setting library for movie {movie_id}: {e}")
|
||||
log.exception(f"Database error setting library for movie {movie_id}")
|
||||
raise
|
||||
|
||||
def delete_movie_request(self, movie_request_id: MovieRequestId) -> None:
|
||||
@@ -251,10 +251,10 @@ class MovieRepository:
|
||||
raise NotFoundError(msg)
|
||||
self.db.commit()
|
||||
# Successfully deleted movie request with id: {movie_request_id}
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(
|
||||
f"Database error while deleting movie request {movie_request_id}: {e}"
|
||||
log.exception(
|
||||
f"Database error while deleting movie request {movie_request_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -273,8 +273,8 @@ class MovieRepository:
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
return [RichMovieRequestSchema.model_validate(x) for x in results]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error while retrieving movie requests: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception("Database error while retrieving movie requests")
|
||||
raise
|
||||
|
||||
def add_movie_file(self, movie_file: MovieFileSchema) -> MovieFileSchema:
|
||||
@@ -292,13 +292,13 @@ class MovieRepository:
|
||||
self.db.commit()
|
||||
self.db.refresh(db_model)
|
||||
return MovieFileSchema.model_validate(db_model)
|
||||
except IntegrityError as e:
|
||||
except IntegrityError:
|
||||
self.db.rollback()
|
||||
log.error(f"Integrity error while adding movie file: {e}")
|
||||
log.exception("Integrity error while adding movie file")
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(f"Database error while adding movie file: {e}")
|
||||
log.exception("Database error while adding movie file")
|
||||
raise
|
||||
|
||||
def remove_movie_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
|
||||
@@ -313,14 +313,15 @@ class MovieRepository:
|
||||
stmt = delete(MovieFile).where(MovieFile.torrent_id == torrent_id)
|
||||
result = self.db.execute(stmt)
|
||||
self.db.commit()
|
||||
return result.rowcount
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(
|
||||
f"Database error removing movie files for torrent_id {torrent_id}: {e}"
|
||||
log.exception(
|
||||
f"Database error removing movie files for torrent_id {torrent_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
return result.rowcount
|
||||
|
||||
def get_movie_files_by_movie_id(self, movie_id: MovieId) -> list[MovieFileSchema]:
|
||||
"""
|
||||
Retrieve all movie files for a given movie ID.
|
||||
@@ -333,9 +334,9 @@ class MovieRepository:
|
||||
stmt = select(MovieFile).where(MovieFile.movie_id == movie_id)
|
||||
results = self.db.execute(stmt).scalars().all()
|
||||
return [MovieFileSchema.model_validate(sf) for sf in results]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(
|
||||
f"Database error retrieving movie files for movie_id {movie_id}: {e}"
|
||||
except SQLAlchemyError:
|
||||
log.exception(
|
||||
f"Database error retrieving movie files for movie_id {movie_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -367,13 +368,13 @@ class MovieRepository:
|
||||
usenet=torrent.usenet,
|
||||
)
|
||||
formatted_results.append(movie_torrent)
|
||||
return formatted_results
|
||||
except SQLAlchemyError as e:
|
||||
log.error(
|
||||
f"Database error retrieving torrents for movie_id {movie_id}: {e}"
|
||||
)
|
||||
|
||||
except SQLAlchemyError:
|
||||
log.exception(f"Database error retrieving torrents for movie_id {movie_id}")
|
||||
raise
|
||||
|
||||
return formatted_results
|
||||
|
||||
def get_all_movies_with_torrents(self) -> list[MovieSchema]:
|
||||
"""
|
||||
Retrieve all movies that are associated with a torrent, ordered alphabetically by movie name.
|
||||
@@ -391,8 +392,8 @@ class MovieRepository:
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
return [MovieSchema.model_validate(movie) for movie in results]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error retrieving all movies with torrents: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception("Database error retrieving all movies with torrents")
|
||||
raise
|
||||
|
||||
def get_movie_request(self, movie_request_id: MovieRequestId) -> MovieRequestSchema:
|
||||
@@ -410,10 +411,8 @@ class MovieRepository:
|
||||
msg = f"Movie request with id {movie_request_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
return MovieRequestSchema.model_validate(request)
|
||||
except SQLAlchemyError as e:
|
||||
log.error(
|
||||
f"Database error retrieving movie request {movie_request_id}: {e}"
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
log.exception(f"Database error retrieving movie request {movie_request_id}")
|
||||
raise
|
||||
|
||||
def get_movie_by_torrent_id(self, torrent_id: TorrentId) -> MovieSchema:
|
||||
@@ -436,10 +435,8 @@ class MovieRepository:
|
||||
msg = f"Movie for torrent_id {torrent_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
return MovieSchema.model_validate(result)
|
||||
except SQLAlchemyError as e:
|
||||
log.error(
|
||||
f"Database error retrieving movie by torrent_id {torrent_id}: {e}"
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
log.exception(f"Database error retrieving movie by torrent_id {torrent_id}")
|
||||
raise
|
||||
|
||||
def update_movie_attributes(
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from media_manager.auth.schemas import UserRead
|
||||
from media_manager.auth.users import current_active_user, current_superuser
|
||||
from media_manager.config import LibraryItem, MediaManagerConfig
|
||||
from media_manager.exceptions import ConflictError
|
||||
from media_manager.exceptions import ConflictError, NotFoundError
|
||||
from media_manager.indexer.schemas import (
|
||||
IndexerQueryResult,
|
||||
IndexerQueryResultId,
|
||||
@@ -97,7 +97,7 @@ def get_all_importable_movies(
|
||||
)
|
||||
def import_detected_movie(
|
||||
movie_service: movie_service_dep, movie: movie_dep, directory: str
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Import a detected movie from the specified directory into the library.
|
||||
"""
|
||||
@@ -145,7 +145,7 @@ def add_a_movie(
|
||||
metadata_provider: metadata_provider_dep,
|
||||
movie_id: int,
|
||||
language: str | None = None,
|
||||
):
|
||||
) -> Movie:
|
||||
"""
|
||||
Add a new movie to the library.
|
||||
"""
|
||||
@@ -159,6 +159,8 @@ def add_a_movie(
|
||||
movie = movie_service.get_movie_by_external_id(
|
||||
external_id=movie_id, metadata_provider=metadata_provider.name
|
||||
)
|
||||
if not movie:
|
||||
raise NotFoundError from ConflictError
|
||||
return movie
|
||||
|
||||
|
||||
@@ -217,7 +219,7 @@ def create_movie_request(
|
||||
log.info(
|
||||
f"User {user.email} is creating a movie request for {movie_request.movie_id}"
|
||||
)
|
||||
movie_request = MovieRequest.model_validate(movie_request)
|
||||
movie_request: MovieRequest = MovieRequest.model_validate(movie_request)
|
||||
movie_request.requested_by = user
|
||||
if user.is_superuser:
|
||||
movie_request.authorized = True
|
||||
@@ -254,7 +256,7 @@ def authorize_request(
|
||||
movie_request_id: MovieRequestId,
|
||||
user: Annotated[UserRead, Depends(current_superuser)],
|
||||
authorized_status: bool = False,
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Authorize or de-authorize a movie request.
|
||||
"""
|
||||
@@ -266,7 +268,7 @@ def authorize_request(
|
||||
movie_request.authorized_by = user
|
||||
else:
|
||||
movie_request.authorized_by = None
|
||||
return movie_service.update_movie_request(movie_request=movie_request)
|
||||
movie_service.update_movie_request(movie_request=movie_request)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -276,7 +278,7 @@ def authorize_request(
|
||||
)
|
||||
def delete_movie_request(
|
||||
movie_service: movie_service_dep, movie_request_id: MovieRequestId
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Delete a movie request.
|
||||
"""
|
||||
@@ -309,7 +311,7 @@ def delete_a_movie(
|
||||
movie: movie_dep,
|
||||
delete_files_on_disk: bool = False,
|
||||
delete_torrents: bool = False,
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Delete a movie from the library.
|
||||
"""
|
||||
|
||||
@@ -15,7 +15,7 @@ MovieRequestId = typing.NewType("MovieRequestId", UUID)
|
||||
class Movie(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: MovieId = Field(default_factory=uuid.uuid4)
|
||||
id: MovieId = Field(default_factory=lambda: MovieId(uuid.uuid4()))
|
||||
name: str
|
||||
overview: str
|
||||
year: int | None
|
||||
@@ -59,7 +59,7 @@ class CreateMovieRequest(MovieRequestBase):
|
||||
class MovieRequest(MovieRequestBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: MovieRequestId = Field(default_factory=uuid.uuid4)
|
||||
id: MovieRequestId = Field(default_factory=lambda: MovieRequestId(uuid.uuid4()))
|
||||
|
||||
movie_id: MovieId
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.database import SessionLocal, get_session
|
||||
from media_manager.exceptions import InvalidConfigError, NotFoundError
|
||||
from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError
|
||||
from media_manager.indexer.repository import IndexerRepository
|
||||
from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId
|
||||
from media_manager.indexer.service import IndexerService
|
||||
@@ -32,6 +32,7 @@ from media_manager.movies.schemas import (
|
||||
RichMovieRequest,
|
||||
RichMovieTorrent,
|
||||
)
|
||||
from media_manager.notification.repository import NotificationRepository
|
||||
from media_manager.notification.service import NotificationService
|
||||
from media_manager.schemas import MediaImportSuggestion
|
||||
from media_manager.torrent.repository import TorrentRepository
|
||||
@@ -58,8 +59,8 @@ class MovieService:
|
||||
movie_repository: MovieRepository,
|
||||
torrent_service: TorrentService,
|
||||
indexer_service: IndexerService,
|
||||
notification_service: NotificationService = None,
|
||||
):
|
||||
notification_service: NotificationService,
|
||||
) -> None:
|
||||
self.movie_repository = movie_repository
|
||||
self.torrent_service = torrent_service
|
||||
self.indexer_service = indexer_service
|
||||
@@ -70,7 +71,7 @@ class MovieService:
|
||||
external_id: int,
|
||||
metadata_provider: AbstractMetadataProvider,
|
||||
language: str | None = None,
|
||||
) -> Movie | None:
|
||||
) -> Movie:
|
||||
"""
|
||||
Add a new movie to the database.
|
||||
|
||||
@@ -82,7 +83,7 @@ class MovieService:
|
||||
movie_id=external_id, language=language
|
||||
)
|
||||
if not movie_with_metadata:
|
||||
return None
|
||||
raise NotFoundError
|
||||
|
||||
saved_movie = self.movie_repository.save_movie(movie=movie_with_metadata)
|
||||
metadata_provider.download_movie_poster_image(movie=saved_movie)
|
||||
@@ -97,9 +98,7 @@ class MovieService:
|
||||
"""
|
||||
return self.movie_repository.add_movie_request(movie_request=movie_request)
|
||||
|
||||
def get_movie_request_by_id(
|
||||
self, movie_request_id: MovieRequestId
|
||||
) -> MovieRequest | None:
|
||||
def get_movie_request_by_id(self, movie_request_id: MovieRequestId) -> MovieRequest:
|
||||
"""
|
||||
Get a movie request by its ID.
|
||||
|
||||
@@ -150,10 +149,8 @@ class MovieService:
|
||||
try:
|
||||
shutil.rmtree(movie_dir)
|
||||
log.info(f"Deleted movie directory: {movie_dir}")
|
||||
except OSError as e:
|
||||
log.error(
|
||||
f"Deleting movie directory: {movie_dir} : {e.strerror}"
|
||||
)
|
||||
except OSError:
|
||||
log.exception(f"Deleting movie directory: {movie_dir}")
|
||||
|
||||
if delete_torrents:
|
||||
# Get all torrents associated with this movie
|
||||
@@ -170,8 +167,10 @@ class MovieService:
|
||||
torrent=torrent, delete_files=True
|
||||
)
|
||||
log.info(f"Deleted torrent: {torrent.torrent_title}")
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to delete torrent {torrent.hash}: {e}")
|
||||
except Exception:
|
||||
log.warning(
|
||||
f"Failed to delete torrent {torrent.hash}", exc_info=True
|
||||
)
|
||||
|
||||
# Delete from database
|
||||
self.movie_repository.delete_movie(movie_id=movie.id)
|
||||
@@ -236,19 +235,19 @@ class MovieService:
|
||||
self.movie_repository.get_movie_by_external_id(
|
||||
external_id=external_id, metadata_provider=metadata_provider
|
||||
)
|
||||
return True
|
||||
except NotFoundError:
|
||||
return False
|
||||
elif movie_id is not None:
|
||||
try:
|
||||
self.movie_repository.get_movie_by_id(movie_id=movie_id)
|
||||
return True
|
||||
except NotFoundError:
|
||||
return False
|
||||
else:
|
||||
msg = "Use one of the provided overloads for this function!"
|
||||
raise ValueError(msg)
|
||||
|
||||
return True
|
||||
|
||||
def get_all_available_torrents_for_movie(
|
||||
self, movie: Movie, search_query_override: str | None = None
|
||||
) -> list[IndexerQueryResult]:
|
||||
@@ -569,8 +568,8 @@ class MovieService:
|
||||
|
||||
try:
|
||||
movie_root_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to create directory {movie_root_path}: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to create directory {movie_root_path}")
|
||||
return False
|
||||
|
||||
# import movie video
|
||||
@@ -681,9 +680,8 @@ class MovieService:
|
||||
try:
|
||||
source_directory.rename(new_source_path)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to rename {source_directory} to {new_source_path}: {e}")
|
||||
msg = "Failed to rename directory"
|
||||
raise Exception(msg) from e
|
||||
log.exception(f"Failed to rename {source_directory} to {new_source_path}")
|
||||
raise RenameError from e
|
||||
|
||||
video_files, subtitle_files, _all_files = get_files_for_import(
|
||||
directory=new_source_path
|
||||
@@ -781,14 +779,18 @@ def auto_download_all_approved_movie_requests() -> None:
|
||||
Auto download all approved movie requests.
|
||||
This is a standalone function as it creates its own DB session.
|
||||
"""
|
||||
db: Session = SessionLocal()
|
||||
db: Session = SessionLocal() if SessionLocal else next(get_session())
|
||||
movie_repository = MovieRepository(db=db)
|
||||
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
|
||||
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
|
||||
notification_service = NotificationService(
|
||||
notification_repository=NotificationRepository(db=db)
|
||||
)
|
||||
movie_service = MovieService(
|
||||
movie_repository=movie_repository,
|
||||
torrent_service=torrent_service,
|
||||
indexer_service=indexer_service,
|
||||
notification_service=notification_service,
|
||||
)
|
||||
|
||||
log.info("Auto downloading all approved movie requests")
|
||||
@@ -818,10 +820,14 @@ def import_all_movie_torrents() -> None:
|
||||
movie_repository = MovieRepository(db=db)
|
||||
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
|
||||
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
|
||||
notification_service = NotificationService(
|
||||
notification_repository=NotificationRepository(db=db)
|
||||
)
|
||||
movie_service = MovieService(
|
||||
movie_repository=movie_repository,
|
||||
torrent_service=torrent_service,
|
||||
indexer_service=indexer_service,
|
||||
notification_service=notification_service,
|
||||
)
|
||||
log.info("Importing all torrents")
|
||||
torrents = torrent_service.get_all_torrents()
|
||||
@@ -836,11 +842,8 @@ def import_all_movie_torrents() -> None:
|
||||
)
|
||||
continue
|
||||
movie_service.import_torrent_files(torrent=t, movie=movie)
|
||||
except RuntimeError as e:
|
||||
log.error(
|
||||
f"Failed to import torrent {t.title}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
except RuntimeError:
|
||||
log.exception(f"Failed to import torrent {t.title}")
|
||||
log.info("Finished importing all torrents")
|
||||
db.commit()
|
||||
|
||||
@@ -855,6 +858,9 @@ def update_all_movies_metadata() -> None:
|
||||
movie_repository=movie_repository,
|
||||
torrent_service=TorrentService(torrent_repository=TorrentRepository(db=db)),
|
||||
indexer_service=IndexerService(indexer_repository=IndexerRepository(db=db)),
|
||||
notification_service=NotificationService(
|
||||
notification_repository=NotificationRepository(db=db)
|
||||
),
|
||||
)
|
||||
|
||||
log.info("Updating metadata for all movies")
|
||||
@@ -874,9 +880,9 @@ def update_all_movies_metadata() -> None:
|
||||
f"Unsupported metadata provider {movie.metadata_provider} for movie {movie.name}, skipping update."
|
||||
)
|
||||
continue
|
||||
except InvalidConfigError as e:
|
||||
log.error(
|
||||
f"Error initializing metadata provider {movie.metadata_provider} for movie {movie.name}: {e}"
|
||||
except InvalidConfigError:
|
||||
log.exception(
|
||||
f"Error initializing metadata provider {movie.metadata_provider} for movie {movie.name}",
|
||||
)
|
||||
continue
|
||||
movie_service.update_movie_metadata(
|
||||
|
||||
@@ -3,7 +3,6 @@ Notification Manager - Orchestrates sending notifications through all configured
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.notification.schemas import MessageNotification
|
||||
@@ -31,9 +30,9 @@ class NotificationManager:
|
||||
Manages and orchestrates notifications across all configured service providers.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.config = MediaManagerConfig().notifications
|
||||
self.providers: List[AbstractNotificationServiceProvider] = []
|
||||
self.providers: list[AbstractNotificationServiceProvider] = []
|
||||
self._initialize_providers()
|
||||
|
||||
def _initialize_providers(self) -> None:
|
||||
@@ -42,32 +41,32 @@ class NotificationManager:
|
||||
try:
|
||||
self.providers.append(EmailNotificationServiceProvider())
|
||||
logger.info("Email notification provider initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Email provider: {e}")
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize Email provider")
|
||||
|
||||
# Gotify provider
|
||||
if self.config.gotify.enabled:
|
||||
try:
|
||||
self.providers.append(GotifyNotificationServiceProvider())
|
||||
logger.info("Gotify notification provider initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Gotify provider: {e}")
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize Gotify provider")
|
||||
|
||||
# Ntfy provider
|
||||
if self.config.ntfy.enabled:
|
||||
try:
|
||||
self.providers.append(NtfyNotificationServiceProvider())
|
||||
logger.info("Ntfy notification provider initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Ntfy provider: {e}")
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize Ntfy provider")
|
||||
|
||||
# Pushover provider
|
||||
if self.config.pushover.enabled:
|
||||
try:
|
||||
self.providers.append(PushoverNotificationServiceProvider())
|
||||
logger.info("Pushover notification provider initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Pushover provider: {e}")
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize Pushover provider")
|
||||
|
||||
logger.info(f"Initialized {len(self.providers)} notification providers")
|
||||
|
||||
@@ -86,10 +85,10 @@ class NotificationManager:
|
||||
else:
|
||||
logger.warning(f"Failed to send notification via {provider_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending notification via {provider_name}: {e}")
|
||||
except Exception:
|
||||
logger.exception(f"Error sending notification via {provider_name}")
|
||||
|
||||
def get_configured_providers(self) -> List[str]:
|
||||
def get_configured_providers(self) -> list[str]:
|
||||
return [provider.__class__.__name__ for provider in self.providers]
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.exc import (
|
||||
SQLAlchemyError,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql.expression import false
|
||||
|
||||
from media_manager.exceptions import ConflictError, NotFoundError
|
||||
from media_manager.notification.models import Notification
|
||||
@@ -20,7 +21,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationRepository:
|
||||
def __init__(self, db: Session):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_notification(self, nid: NotificationId) -> NotificationSchema:
|
||||
@@ -36,7 +37,7 @@ class NotificationRepository:
|
||||
try:
|
||||
stmt = (
|
||||
select(Notification)
|
||||
.where(Notification.read == False) # noqa: E712
|
||||
.where(Notification.read == false())
|
||||
.order_by(Notification.timestamp.desc())
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().all()
|
||||
@@ -44,8 +45,8 @@ class NotificationRepository:
|
||||
NotificationSchema.model_validate(notification)
|
||||
for notification in results
|
||||
]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error while retrieving unread notifications: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception("Database error while retrieving unread notifications")
|
||||
raise
|
||||
|
||||
def get_all_notifications(self) -> list[NotificationSchema]:
|
||||
@@ -56,11 +57,11 @@ class NotificationRepository:
|
||||
NotificationSchema.model_validate(notification)
|
||||
for notification in results
|
||||
]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error while retrieving notifications: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception("Database error while retrieving notifications")
|
||||
raise
|
||||
|
||||
def save_notification(self, notification: NotificationSchema):
|
||||
def save_notification(self, notification: NotificationSchema) -> None:
|
||||
try:
|
||||
self.db.add(
|
||||
Notification(
|
||||
@@ -71,8 +72,8 @@ class NotificationRepository:
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
except IntegrityError as e:
|
||||
log.error(f"Could not save notification, Error: {e}")
|
||||
except IntegrityError:
|
||||
log.exception("Could not save notification")
|
||||
msg = f"Notification with id {notification.id} already exists."
|
||||
raise ConflictError(msg) from None
|
||||
return
|
||||
|
||||
@@ -69,7 +69,7 @@ def get_notification(
|
||||
)
|
||||
def mark_notification_as_read(
|
||||
notification_id: NotificationId, notification_service: notification_service_dep
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Mark a notification as read.
|
||||
"""
|
||||
@@ -86,7 +86,7 @@ def mark_notification_as_read(
|
||||
)
|
||||
def mark_notification_as_unread(
|
||||
notification_id: NotificationId, notification_service: notification_service_dep
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Mark a notification as unread.
|
||||
"""
|
||||
@@ -103,7 +103,7 @@ def mark_notification_as_unread(
|
||||
)
|
||||
def delete_notification(
|
||||
notification_id: NotificationId, notification_service: notification_service_dep
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Delete a notification.
|
||||
"""
|
||||
|
||||
@@ -12,7 +12,8 @@ class Notification(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: NotificationId = Field(
|
||||
default_factory=uuid.uuid4, description="Unique identifier for the notification"
|
||||
default_factory=lambda: NotificationId(uuid.uuid4()),
|
||||
description="Unique identifier for the notification",
|
||||
)
|
||||
read: bool = Field(False, description="Whether the notification has been read")
|
||||
message: str = Field(description="The content of the notification")
|
||||
|
||||
@@ -7,7 +7,7 @@ class NotificationService:
|
||||
def __init__(
|
||||
self,
|
||||
notification_repository: NotificationRepository,
|
||||
):
|
||||
) -> None:
|
||||
self.notification_repository = notification_repository
|
||||
self.notification_manager = notification_manager
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from media_manager.notification.service_providers.abstract_notification_service_
|
||||
|
||||
|
||||
class EmailNotificationServiceProvider(AbstractNotificationServiceProvider):
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.config = MediaManagerConfig().notifications.email_notifications
|
||||
|
||||
def send_notification(self, message: MessageNotification) -> bool:
|
||||
|
||||
@@ -12,7 +12,7 @@ class GotifyNotificationServiceProvider(AbstractNotificationServiceProvider):
|
||||
Gotify Notification Service Provider
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.config = MediaManagerConfig().notifications.gotify
|
||||
|
||||
def send_notification(self, message: MessageNotification) -> bool:
|
||||
|
||||
@@ -12,7 +12,7 @@ class NtfyNotificationServiceProvider(AbstractNotificationServiceProvider):
|
||||
Ntfy Notification Service Provider
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.config = MediaManagerConfig().notifications.ntfy
|
||||
|
||||
def send_notification(self, message: MessageNotification) -> bool:
|
||||
|
||||
@@ -8,7 +8,7 @@ from media_manager.notification.service_providers.abstract_notification_service_
|
||||
|
||||
|
||||
class PushoverNotificationServiceProvider(AbstractNotificationServiceProvider):
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.config = MediaManagerConfig().notifications.pushover
|
||||
|
||||
def send_notification(self, message: MessageNotification) -> bool:
|
||||
|
||||
@@ -3,6 +3,7 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
import media_manager.database
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.movies.service import (
|
||||
auto_download_all_approved_movie_requests,
|
||||
import_all_movie_torrents,
|
||||
@@ -15,7 +16,7 @@ from media_manager.tv.service import (
|
||||
)
|
||||
|
||||
|
||||
def setup_scheduler(config):
|
||||
def setup_scheduler(config: MediaManagerConfig) -> BackgroundScheduler:
|
||||
from media_manager.database import init_engine
|
||||
|
||||
init_engine(config.database)
|
||||
|
||||
@@ -16,11 +16,11 @@ class AbstractDownloadClient(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def download_torrent(self, torrent: IndexerQueryResult) -> Torrent:
|
||||
def download_torrent(self, indexer_result: IndexerQueryResult) -> Torrent:
|
||||
"""
|
||||
Add a torrent to the download client and return the torrent object.
|
||||
|
||||
:param torrent: The indexer query result of the torrent file to download.
|
||||
:param indexer_result: The indexer query result of the torrent file to download.
|
||||
:return: The torrent object with calculated hash and initial status.
|
||||
"""
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class QbittorrentDownloadClient(AbstractDownloadClient):
|
||||
ERROR_STATE = ("missingFiles", "error", "checkingResumeData")
|
||||
UNKNOWN_STATE = ("unknown",)
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.config = MediaManagerConfig().torrents.qbittorrent
|
||||
self.api_client = qbittorrentapi.Client(
|
||||
host=self.config.host,
|
||||
@@ -53,8 +53,8 @@ class QbittorrentDownloadClient(AbstractDownloadClient):
|
||||
)
|
||||
try:
|
||||
self.api_client.auth_log_in()
|
||||
except Exception as e:
|
||||
log.error(f"Failed to log into qbittorrent: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to log into qbittorrent")
|
||||
raise
|
||||
|
||||
try:
|
||||
@@ -72,11 +72,8 @@ class QbittorrentDownloadClient(AbstractDownloadClient):
|
||||
if self.config.category_save_path != ""
|
||||
else None,
|
||||
)
|
||||
except Exception as e:
|
||||
if str(e) != "":
|
||||
log.error(
|
||||
f"Error on updating MediaManager category in qBittorrent, error: {e}"
|
||||
)
|
||||
except Exception:
|
||||
log.exception("Error on updating MediaManager category in qBittorrent")
|
||||
|
||||
def download_torrent(self, indexer_result: IndexerQueryResult) -> Torrent:
|
||||
"""
|
||||
|
||||
@@ -27,7 +27,7 @@ class SabnzbdDownloadClient(AbstractDownloadClient):
|
||||
ERROR_STATE = ("Failed",)
|
||||
UNKNOWN_STATE = ("Unknown",)
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.config = MediaManagerConfig().torrents.sabnzbd
|
||||
self.client = sabnzbd_api.SabnzbdClient(
|
||||
host=self.config.host,
|
||||
@@ -38,8 +38,8 @@ class SabnzbdDownloadClient(AbstractDownloadClient):
|
||||
try:
|
||||
# Test connection
|
||||
self.client.version()
|
||||
except Exception as e:
|
||||
log.error(f"Failed to connect to SABnzbd: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to connect to SABnzbd")
|
||||
raise
|
||||
|
||||
def download_torrent(self, indexer_result: IndexerQueryResult) -> Torrent:
|
||||
@@ -55,10 +55,7 @@ class SabnzbdDownloadClient(AbstractDownloadClient):
|
||||
url=str(indexer_result.download_url), nzbname=indexer_result.title
|
||||
)
|
||||
if not response["status"]:
|
||||
error_msg = response
|
||||
log.error(f"Failed to add NZB to SABnzbd: {error_msg}")
|
||||
msg = f"Failed to add NZB to SABnzbd: {error_msg}"
|
||||
raise RuntimeError(msg)
|
||||
raise RuntimeError(f"Failed to add NZB to SABnzbd: {response}") # noqa: EM102, TRY003, TRY301
|
||||
|
||||
# Generate a hash for the NZB (using title and download URL)
|
||||
nzo_id = response["nzo_ids"][0]
|
||||
@@ -75,13 +72,12 @@ class SabnzbdDownloadClient(AbstractDownloadClient):
|
||||
|
||||
# Get initial status from SABnzbd
|
||||
torrent.status = self.get_torrent_status(torrent)
|
||||
|
||||
return torrent
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to download NZB {indexer_result.title}: {e}")
|
||||
except Exception:
|
||||
log.exception(f"Failed to download NZB {indexer_result.title}")
|
||||
raise
|
||||
|
||||
return torrent
|
||||
|
||||
def remove_torrent(self, torrent: Torrent, delete_data: bool = False) -> None:
|
||||
"""
|
||||
Remove a torrent from SABnzbd.
|
||||
@@ -91,8 +87,8 @@ class SabnzbdDownloadClient(AbstractDownloadClient):
|
||||
"""
|
||||
try:
|
||||
self.client.delete_job(nzo_id=torrent.hash, delete_files=delete_data)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to remove torrent {torrent.title}: {e}")
|
||||
except Exception:
|
||||
log.exception(f"Failed to remove torrent {torrent.title}")
|
||||
raise
|
||||
|
||||
def pause_torrent(self, torrent: Torrent) -> None:
|
||||
@@ -103,8 +99,8 @@ class SabnzbdDownloadClient(AbstractDownloadClient):
|
||||
"""
|
||||
try:
|
||||
self.client.pause_job(nzo_id=torrent.hash)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to pause torrent {torrent.title}: {e}")
|
||||
except Exception:
|
||||
log.exception(f"Failed to pause torrent {torrent.title}")
|
||||
raise
|
||||
|
||||
def resume_torrent(self, torrent: Torrent) -> None:
|
||||
@@ -115,8 +111,8 @@ class SabnzbdDownloadClient(AbstractDownloadClient):
|
||||
"""
|
||||
try:
|
||||
self.client.resume_job(nzo_id=torrent.hash)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to resume torrent {torrent.title}: {e}")
|
||||
except Exception:
|
||||
log.exception(f"Failed to resume torrent {torrent.title}")
|
||||
raise
|
||||
|
||||
def get_torrent_status(self, torrent: Torrent) -> TorrentStatus:
|
||||
|
||||
@@ -30,7 +30,7 @@ class TransmissionDownloadClient(AbstractDownloadClient):
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.config = MediaManagerConfig().torrents.transmission
|
||||
try:
|
||||
self._client = transmission_rpc.Client(
|
||||
@@ -43,8 +43,8 @@ class TransmissionDownloadClient(AbstractDownloadClient):
|
||||
)
|
||||
# Test connection
|
||||
self._client.session_stats()
|
||||
except Exception as e:
|
||||
log.error(f"Failed to connect to Transmission: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to connect to Transmission")
|
||||
raise
|
||||
|
||||
def download_torrent(self, indexer_result: IndexerQueryResult) -> Torrent:
|
||||
@@ -68,8 +68,8 @@ class TransmissionDownloadClient(AbstractDownloadClient):
|
||||
f"Successfully added torrent to Transmission: {indexer_result.title}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to add torrent to Transmission: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to add torrent to Transmission")
|
||||
raise
|
||||
|
||||
torrent = Torrent(
|
||||
@@ -95,8 +95,8 @@ class TransmissionDownloadClient(AbstractDownloadClient):
|
||||
|
||||
try:
|
||||
self._client.remove_torrent(torrent.hash, delete_data=delete_data)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to remove torrent: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to remove torrent")
|
||||
raise
|
||||
|
||||
def get_torrent_status(self, torrent: Torrent) -> TorrentStatus:
|
||||
@@ -123,13 +123,12 @@ class TransmissionDownloadClient(AbstractDownloadClient):
|
||||
log.warning(
|
||||
f"Torrent {torrent.title} has error status: {transmission_torrent.error_string}"
|
||||
)
|
||||
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to get torrent status: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to get torrent status")
|
||||
return TorrentStatus.error
|
||||
|
||||
return status
|
||||
|
||||
def pause_torrent(self, torrent: Torrent) -> None:
|
||||
"""
|
||||
Pause a torrent download.
|
||||
@@ -140,8 +139,8 @@ class TransmissionDownloadClient(AbstractDownloadClient):
|
||||
self._client.stop_torrent(torrent.hash)
|
||||
log.debug(f"Successfully paused torrent: {torrent.title}")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to pause torrent: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to pause torrent")
|
||||
raise
|
||||
|
||||
def resume_torrent(self, torrent: Torrent) -> None:
|
||||
@@ -154,6 +153,6 @@ class TransmissionDownloadClient(AbstractDownloadClient):
|
||||
self._client.start_torrent(torrent.hash)
|
||||
log.debug(f"Successfully resumed torrent: {torrent.title}")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to resume torrent: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to resume torrent")
|
||||
raise
|
||||
|
||||
@@ -30,7 +30,7 @@ class DownloadManager:
|
||||
Only one torrent client and one usenet client are active at a time.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self._torrent_client: AbstractDownloadClient | None = None
|
||||
self._usenet_client: AbstractDownloadClient | None = None
|
||||
self.config = MediaManagerConfig().torrents
|
||||
@@ -43,22 +43,22 @@ class DownloadManager:
|
||||
if self.config.qbittorrent.enabled:
|
||||
try:
|
||||
self._torrent_client = QbittorrentDownloadClient()
|
||||
except Exception as e:
|
||||
log.error(f"Failed to initialize qBittorrent client: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to initialize qBittorrent client")
|
||||
|
||||
# If qBittorrent is not available or failed, try Transmission
|
||||
if self._torrent_client is None and self.config.transmission.enabled:
|
||||
try:
|
||||
self._torrent_client = TransmissionDownloadClient()
|
||||
except Exception as e:
|
||||
log.error(f"Failed to initialize Transmission client: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to initialize Transmission client")
|
||||
|
||||
# Initialize SABnzbd client for usenet
|
||||
if self.config.sabnzbd.enabled:
|
||||
try:
|
||||
self._usenet_client = SabnzbdDownloadClient()
|
||||
except Exception as e:
|
||||
log.error(f"Failed to initialize SABnzbd client: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to initialize SABnzbd client")
|
||||
|
||||
active_clients = []
|
||||
if self._torrent_client:
|
||||
|
||||
@@ -18,7 +18,7 @@ from media_manager.tv.schemas import Show as ShowSchema
|
||||
|
||||
|
||||
class TorrentRepository:
|
||||
def __init__(self, db: DbSessionDependency):
|
||||
def __init__(self, db: DbSessionDependency) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_seasons_files_of_torrent(
|
||||
@@ -62,7 +62,7 @@ class TorrentRepository:
|
||||
|
||||
def delete_torrent(
|
||||
self, torrent_id: TorrentId, delete_associated_media_files: bool = False
|
||||
):
|
||||
) -> None:
|
||||
if delete_associated_media_files:
|
||||
movie_files_stmt = delete(MovieFile).where(
|
||||
MovieFile.torrent_id == torrent_id
|
||||
@@ -76,7 +76,7 @@ class TorrentRepository:
|
||||
|
||||
self.db.delete(self.db.get(Torrent, torrent_id))
|
||||
|
||||
def get_movie_of_torrent(self, torrent_id: TorrentId):
|
||||
def get_movie_of_torrent(self, torrent_id: TorrentId) -> MovieSchema | None:
|
||||
stmt = (
|
||||
select(Movie)
|
||||
.join(MovieFile, Movie.id == MovieFile.movie_id)
|
||||
@@ -87,7 +87,9 @@ class TorrentRepository:
|
||||
return None
|
||||
return MovieSchema.model_validate(result)
|
||||
|
||||
def get_movie_files_of_torrent(self, torrent_id: TorrentId):
|
||||
def get_movie_files_of_torrent(
|
||||
self, torrent_id: TorrentId
|
||||
) -> list[MovieFileSchema]:
|
||||
stmt = select(MovieFile).where(MovieFile.torrent_id == torrent_id)
|
||||
result = self.db.execute(stmt).scalars().all()
|
||||
return [MovieFileSchema.model_validate(movie_file) for movie_file in result]
|
||||
|
||||
@@ -36,7 +36,7 @@ def delete_torrent(
|
||||
service: torrent_service_dep,
|
||||
torrent: torrent_dep,
|
||||
delete_files: bool = False,
|
||||
):
|
||||
) -> None:
|
||||
if delete_files:
|
||||
try:
|
||||
service.cancel_download(torrent=torrent, delete_files=False)
|
||||
@@ -54,7 +54,7 @@ def delete_torrent(
|
||||
def retry_torrent_download(
|
||||
service: torrent_service_dep,
|
||||
torrent: torrent_dep,
|
||||
):
|
||||
) -> None:
|
||||
service.pause_download(torrent=torrent)
|
||||
service.resume_download(torrent=torrent)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class TorrentStatus(Enum):
|
||||
class Torrent(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: TorrentId = Field(default_factory=uuid.uuid4)
|
||||
id: TorrentId = Field(default_factory=lambda: TorrentId(uuid.uuid4()))
|
||||
status: TorrentStatus
|
||||
title: str
|
||||
quality: Quality
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
from media_manager.indexer.schemas import IndexerQueryResult
|
||||
from media_manager.movies.schemas import Movie
|
||||
from media_manager.movies.schemas import Movie, MovieFile
|
||||
from media_manager.torrent.manager import DownloadManager
|
||||
from media_manager.torrent.repository import TorrentRepository
|
||||
from media_manager.torrent.schemas import Torrent, TorrentId
|
||||
@@ -14,8 +14,8 @@ class TorrentService:
|
||||
def __init__(
|
||||
self,
|
||||
torrent_repository: TorrentRepository,
|
||||
download_manager: DownloadManager = None,
|
||||
):
|
||||
download_manager: DownloadManager | None = None,
|
||||
) -> None:
|
||||
self.torrent_repository = torrent_repository
|
||||
self.download_manager = download_manager or DownloadManager()
|
||||
|
||||
@@ -92,8 +92,8 @@ class TorrentService:
|
||||
for x in self.torrent_repository.get_all_torrents():
|
||||
try:
|
||||
torrents.append(self.get_torrent_status(x))
|
||||
except RuntimeError as e:
|
||||
log.error(f"Error fetching status for torrent {x.title}: {e}")
|
||||
except RuntimeError:
|
||||
log.exception(f"Error fetching status for torrent {x.title}")
|
||||
return torrents
|
||||
|
||||
def get_torrent_by_id(self, torrent_id: TorrentId) -> Torrent:
|
||||
@@ -101,7 +101,7 @@ class TorrentService:
|
||||
self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
|
||||
)
|
||||
|
||||
def delete_torrent(self, torrent_id: TorrentId):
|
||||
def delete_torrent(self, torrent_id: TorrentId) -> None:
|
||||
log.info(f"Deleting torrent with ID: {torrent_id}")
|
||||
t = self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
|
||||
delete_media_files = not t.imported
|
||||
@@ -109,5 +109,5 @@ class TorrentService:
|
||||
torrent_id=torrent_id, delete_associated_media_files=delete_media_files
|
||||
)
|
||||
|
||||
def get_movie_files_of_torrent(self, torrent: Torrent):
|
||||
def get_movie_files_of_torrent(self, torrent: Torrent) -> list[MovieFile]:
|
||||
return self.torrent_repository.get_movie_files_of_torrent(torrent_id=torrent.id)
|
||||
|
||||
@@ -9,6 +9,7 @@ import bencoder
|
||||
import libtorrent
|
||||
import patoolib
|
||||
import requests
|
||||
from pathvalidate import sanitize_filename
|
||||
from requests.exceptions import InvalidSchema
|
||||
|
||||
from media_manager.config import MediaManagerConfig
|
||||
@@ -34,7 +35,7 @@ def list_files_recursively(path: Path = Path()) -> list[Path]:
|
||||
return valid_files
|
||||
|
||||
|
||||
def extract_archives(files):
|
||||
def extract_archives(files: list) -> None:
|
||||
archive_types = {
|
||||
"application/zip",
|
||||
"application/x-zip-compressedapplication/x-compressed",
|
||||
@@ -57,25 +58,25 @@ def extract_archives(files):
|
||||
)
|
||||
try:
|
||||
patoolib.extract_archive(str(file), outdir=str(file.parent))
|
||||
except patoolib.util.PatoolError as e:
|
||||
log.error(f"Failed to extract archive {file}. Error: {e}")
|
||||
except patoolib.util.PatoolError:
|
||||
log.exception(f"Failed to extract archive {file}")
|
||||
|
||||
|
||||
def get_torrent_filepath(torrent: Torrent):
|
||||
def get_torrent_filepath(torrent: Torrent) -> Path:
|
||||
return MediaManagerConfig().misc.torrent_directory / torrent.title
|
||||
|
||||
|
||||
def import_file(target_file: Path, source_file: Path):
|
||||
def import_file(target_file: Path, source_file: Path) -> None:
|
||||
if target_file.exists():
|
||||
target_file.unlink()
|
||||
|
||||
try:
|
||||
target_file.hardlink_to(source_file)
|
||||
except FileExistsError:
|
||||
log.error(f"File already exists at {target_file}.")
|
||||
except (OSError, UnsupportedOperation, NotImplementedError) as e:
|
||||
log.error(
|
||||
f"Failed to create hardlink from {source_file} to {target_file}: {e}. Falling back to copying the file."
|
||||
log.exception(f"File already exists at {target_file}.")
|
||||
except (OSError, UnsupportedOperation, NotImplementedError):
|
||||
log.exception(
|
||||
f"Failed to create hardlink from {source_file} to {target_file}. Falling back to copying the file."
|
||||
)
|
||||
shutil.copy(src=source_file, dst=target_file)
|
||||
|
||||
@@ -87,11 +88,15 @@ def get_files_for_import(
|
||||
Extracts all files from the torrent download directory, including extracting archives.
|
||||
Returns a tuple containing: seperated video files, subtitle files, and all files found in the torrent directory.
|
||||
"""
|
||||
search_directory = directory if directory else get_torrent_filepath(torrent=torrent)
|
||||
if torrent:
|
||||
log.info(f"Importing torrent {torrent}")
|
||||
else:
|
||||
search_directory = get_torrent_filepath(torrent=torrent)
|
||||
elif directory:
|
||||
log.info(f"Importing files from directory {directory}")
|
||||
search_directory = directory
|
||||
else:
|
||||
msg = "Either torrent or directory must be provided."
|
||||
raise ValueError(msg)
|
||||
|
||||
all_files: list[Path] = list_files_recursively(path=search_directory)
|
||||
log.debug(f"Found {len(all_files)} files downloaded by the torrent")
|
||||
@@ -128,7 +133,7 @@ def get_torrent_hash(torrent: IndexerQueryResult) -> str:
|
||||
:return: The hash of the torrent.
|
||||
"""
|
||||
torrent_filepath = (
|
||||
MediaManagerConfig().misc.torrent_directory / f"{torrent.title}.torrent"
|
||||
MediaManagerConfig().misc.torrent_directory / f"{sanitize_filename(torrent.title)}.torrent"
|
||||
)
|
||||
if torrent_filepath.exists():
|
||||
log.warning(f"Torrent file already exists at: {torrent_filepath}")
|
||||
@@ -144,16 +149,16 @@ def get_torrent_hash(torrent: IndexerQueryResult) -> str:
|
||||
response = requests.get(str(torrent.download_url), timeout=30)
|
||||
response.raise_for_status()
|
||||
torrent_content = response.content
|
||||
except InvalidSchema as e:
|
||||
log.debug(f"Invalid schema for URL {torrent.download_url}: {e}")
|
||||
except InvalidSchema:
|
||||
log.debug(f"Invalid schema for URL {torrent.download_url}", exc_info=True)
|
||||
final_url = follow_redirects_to_final_torrent_url(
|
||||
initial_url=torrent.download_url,
|
||||
session=requests.Session(),
|
||||
timeout=MediaManagerConfig().indexers.prowlarr.timeout_seconds,
|
||||
)
|
||||
return str(libtorrent.parse_magnet_uri(final_url).info_hash)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to download torrent file: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to download torrent file")
|
||||
raise
|
||||
|
||||
# saving the torrent file
|
||||
@@ -166,9 +171,10 @@ def get_torrent_hash(torrent: IndexerQueryResult) -> str:
|
||||
torrent_hash = hashlib.sha1( # noqa: S324
|
||||
bencoder.encode(decoded_content[b"info"])
|
||||
).hexdigest()
|
||||
except Exception as e:
|
||||
log.error(f"Failed to decode torrent file: {e}")
|
||||
except Exception:
|
||||
log.exception("Failed to decode torrent file")
|
||||
raise
|
||||
|
||||
return torrent_hash
|
||||
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class TvRepository:
|
||||
Provides methods to retrieve, save, and delete shows and seasons.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_show_by_id(self, show_id: ShowId) -> ShowSchema:
|
||||
@@ -67,8 +67,8 @@ class TvRepository:
|
||||
msg = f"Show with id {show_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
return ShowSchema.model_validate(result)
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error while retrieving show {show_id}: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception(f"Database error while retrieving show {show_id}")
|
||||
raise
|
||||
|
||||
def get_show_by_external_id(
|
||||
@@ -95,9 +95,9 @@ class TvRepository:
|
||||
msg = f"Show with external_id {external_id} and provider {metadata_provider} not found."
|
||||
raise NotFoundError(msg)
|
||||
return ShowSchema.model_validate(result)
|
||||
except SQLAlchemyError as e:
|
||||
log.error(
|
||||
f"Database error while retrieving show by external_id {external_id}: {e}"
|
||||
except SQLAlchemyError:
|
||||
log.exception(
|
||||
f"Database error while retrieving show by external_id {external_id}",
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -114,8 +114,8 @@ class TvRepository:
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
return [ShowSchema.model_validate(show) for show in results]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error while retrieving all shows: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception("Database error while retrieving all shows")
|
||||
raise
|
||||
|
||||
def get_total_downloaded_episodes_count(self) -> int:
|
||||
@@ -124,11 +124,9 @@ class TvRepository:
|
||||
select(func.count()).select_from(Episode).join(Season).join(SeasonFile)
|
||||
)
|
||||
return self.db.execute(stmt).scalar_one_or_none()
|
||||
except SQLAlchemyError as e:
|
||||
log.error(
|
||||
f"Database error while calculating downloaded episodes count: {e}"
|
||||
)
|
||||
raise e
|
||||
except SQLAlchemyError:
|
||||
log.exception("Database error while calculating downloaded episodes count")
|
||||
raise
|
||||
|
||||
def save_show(self, show: ShowSchema) -> ShowSchema:
|
||||
"""
|
||||
@@ -192,9 +190,9 @@ class TvRepository:
|
||||
self.db.rollback()
|
||||
msg = f"Show with this primary key or unique constraint violation: {e.orig}"
|
||||
raise ConflictError(msg) from e
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(f"Database error while saving show {show.name}: {e}")
|
||||
log.exception(f"Database error while saving show {show.name}")
|
||||
raise
|
||||
|
||||
def delete_show(self, show_id: ShowId) -> None:
|
||||
@@ -212,9 +210,9 @@ class TvRepository:
|
||||
raise NotFoundError(msg)
|
||||
self.db.delete(show)
|
||||
self.db.commit()
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(f"Database error while deleting show {show_id}: {e}")
|
||||
log.exception(f"Database error while deleting show {show_id}")
|
||||
raise
|
||||
|
||||
def get_season(self, season_id: SeasonId) -> SeasonSchema:
|
||||
@@ -232,8 +230,8 @@ class TvRepository:
|
||||
msg = f"Season with id {season_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
return SeasonSchema.model_validate(season)
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error while retrieving season {season_id}: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception(f"Database error while retrieving season {season_id}")
|
||||
raise
|
||||
|
||||
def add_season_request(
|
||||
@@ -265,13 +263,13 @@ class TvRepository:
|
||||
self.db.commit()
|
||||
self.db.refresh(db_model)
|
||||
return SeasonRequestSchema.model_validate(db_model)
|
||||
except IntegrityError as e:
|
||||
except IntegrityError:
|
||||
self.db.rollback()
|
||||
log.error(f"Integrity error while adding season request: {e}")
|
||||
log.exception("Integrity error while adding season request")
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(f"Database error while adding season request: {e}")
|
||||
log.exception("Database error while adding season request")
|
||||
raise
|
||||
|
||||
def delete_season_request(self, season_request_id: SeasonRequestId) -> None:
|
||||
@@ -290,10 +288,10 @@ class TvRepository:
|
||||
msg = f"SeasonRequest with id {season_request_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
self.db.commit()
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(
|
||||
f"Database error while deleting season request {season_request_id}: {e}"
|
||||
log.exception(
|
||||
f"Database error while deleting season request {season_request_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -319,9 +317,9 @@ class TvRepository:
|
||||
msg = f"Season number {season_number} for show_id {show_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
return SeasonSchema.model_validate(result)
|
||||
except SQLAlchemyError as e:
|
||||
log.error(
|
||||
f"Database error retrieving season {season_number} for show {show_id}: {e}"
|
||||
except SQLAlchemyError:
|
||||
log.exception(
|
||||
f"Database error retrieving season {season_number} for show {show_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -341,10 +339,10 @@ class TvRepository:
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
return [
|
||||
RichSeasonRequestSchema(
|
||||
id=x.id,
|
||||
id=SeasonRequestId(x.id),
|
||||
min_quality=x.min_quality,
|
||||
wanted_quality=x.wanted_quality,
|
||||
season_id=x.season_id,
|
||||
season_id=SeasonId(x.season_id),
|
||||
show=x.season.show,
|
||||
season=x.season,
|
||||
requested_by=x.requested_by,
|
||||
@@ -353,8 +351,8 @@ class TvRepository:
|
||||
)
|
||||
for x in results
|
||||
]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error while retrieving season requests: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception("Database error while retrieving season requests")
|
||||
raise
|
||||
|
||||
def add_season_file(self, season_file: SeasonFileSchema) -> SeasonFileSchema:
|
||||
@@ -372,13 +370,13 @@ class TvRepository:
|
||||
self.db.commit()
|
||||
self.db.refresh(db_model)
|
||||
return SeasonFileSchema.model_validate(db_model)
|
||||
except IntegrityError as e:
|
||||
except IntegrityError:
|
||||
self.db.rollback()
|
||||
log.error(f"Integrity error while adding season file: {e}")
|
||||
log.exception("Integrity error while adding season file")
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(f"Database error while adding season file: {e}")
|
||||
log.exception("Database error while adding season file")
|
||||
raise
|
||||
|
||||
def remove_season_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
|
||||
@@ -393,13 +391,13 @@ class TvRepository:
|
||||
stmt = delete(SeasonFile).where(SeasonFile.torrent_id == torrent_id)
|
||||
result = self.db.execute(stmt)
|
||||
self.db.commit()
|
||||
return result.rowcount
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(
|
||||
f"Database error removing season files for torrent_id {torrent_id}: {e}"
|
||||
log.exception(
|
||||
f"Database error removing season files for torrent_id {torrent_id}"
|
||||
)
|
||||
raise
|
||||
return result.rowcount
|
||||
|
||||
def set_show_library(self, show_id: ShowId, library: str) -> None:
|
||||
"""
|
||||
@@ -417,9 +415,9 @@ class TvRepository:
|
||||
raise NotFoundError(msg)
|
||||
show.library = library
|
||||
self.db.commit()
|
||||
except SQLAlchemyError as e:
|
||||
except SQLAlchemyError:
|
||||
self.db.rollback()
|
||||
log.error(f"Database error setting library for show {show_id}: {e}")
|
||||
log.exception(f"Database error setting library for show {show_id}")
|
||||
raise
|
||||
|
||||
def get_season_files_by_season_id(
|
||||
@@ -436,9 +434,9 @@ class TvRepository:
|
||||
stmt = select(SeasonFile).where(SeasonFile.season_id == season_id)
|
||||
results = self.db.execute(stmt).scalars().all()
|
||||
return [SeasonFileSchema.model_validate(sf) for sf in results]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(
|
||||
f"Database error retrieving season files for season_id {season_id}: {e}"
|
||||
except SQLAlchemyError:
|
||||
log.exception(
|
||||
f"Database error retrieving season files for season_id {season_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -460,8 +458,8 @@ class TvRepository:
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
return [TorrentSchema.model_validate(torrent) for torrent in results]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error retrieving torrents for show_id {show_id}: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception(f"Database error retrieving torrents for show_id {show_id}")
|
||||
raise
|
||||
|
||||
def get_all_shows_with_torrents(self) -> list[ShowSchema]:
|
||||
@@ -483,8 +481,8 @@ class TvRepository:
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
return [ShowSchema.model_validate(show) for show in results]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error retrieving all shows with torrents: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception("Database error retrieving all shows with torrents")
|
||||
raise
|
||||
|
||||
def get_seasons_by_torrent_id(self, torrent_id: TorrentId) -> list[SeasonNumber]:
|
||||
@@ -504,9 +502,9 @@ class TvRepository:
|
||||
)
|
||||
results = self.db.execute(stmt).scalars().unique().all()
|
||||
return [SeasonNumber(x) for x in results]
|
||||
except SQLAlchemyError as e:
|
||||
log.error(
|
||||
f"Database error retrieving season numbers for torrent_id {torrent_id}: {e}"
|
||||
except SQLAlchemyError:
|
||||
log.exception(
|
||||
f"Database error retrieving season numbers for torrent_id {torrent_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -528,9 +526,9 @@ class TvRepository:
|
||||
msg = f"Season request with id {season_request_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
return SeasonRequestSchema.model_validate(request)
|
||||
except SQLAlchemyError as e:
|
||||
log.error(
|
||||
f"Database error retrieving season request {season_request_id}: {e}"
|
||||
except SQLAlchemyError:
|
||||
log.exception(
|
||||
f"Database error retrieving season request {season_request_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -555,8 +553,8 @@ class TvRepository:
|
||||
msg = f"Show for season_id {season_id} not found."
|
||||
raise NotFoundError(msg)
|
||||
return ShowSchema.model_validate(result)
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error retrieving show by season_id {season_id}: {e}")
|
||||
except SQLAlchemyError:
|
||||
log.exception(f"Database error retrieving show by season_id {season_id}")
|
||||
raise
|
||||
|
||||
def add_season_to_show(
|
||||
|
||||
@@ -7,7 +7,7 @@ from media_manager.auth.db import User
|
||||
from media_manager.auth.schemas import UserRead
|
||||
from media_manager.auth.users import current_active_user, current_superuser
|
||||
from media_manager.config import LibraryItem, MediaManagerConfig
|
||||
from media_manager.exceptions import MediaAlreadyExistsError
|
||||
from media_manager.exceptions import MediaAlreadyExistsError, NotFoundError
|
||||
from media_manager.indexer.schemas import (
|
||||
IndexerQueryResult,
|
||||
IndexerQueryResultId,
|
||||
@@ -94,7 +94,9 @@ def get_all_importable_shows(
|
||||
dependencies=[Depends(current_superuser)],
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def import_detected_show(tv_service: tv_service_dep, tv_show: show_dep, directory: str):
|
||||
def import_detected_show(
|
||||
tv_service: tv_service_dep, tv_show: show_dep, directory: str
|
||||
) -> None:
|
||||
"""
|
||||
Import a detected show from the specified directory into the library.
|
||||
"""
|
||||
@@ -140,7 +142,7 @@ def add_a_show(
|
||||
metadata_provider: metadata_provider_dep,
|
||||
show_id: int,
|
||||
language: str | None = None,
|
||||
):
|
||||
) -> Show:
|
||||
"""
|
||||
Add a new show to the library.
|
||||
"""
|
||||
@@ -154,6 +156,8 @@ def add_a_show(
|
||||
show = tv_service.get_show_by_external_id(
|
||||
show_id, metadata_provider=metadata_provider.name
|
||||
)
|
||||
if not show:
|
||||
raise NotFoundError from MediaAlreadyExistsError
|
||||
return show
|
||||
|
||||
|
||||
@@ -205,7 +209,7 @@ def delete_a_show(
|
||||
show: show_dep,
|
||||
delete_files_on_disk: bool = False,
|
||||
delete_torrents: bool = False,
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Delete a show from the library.
|
||||
"""
|
||||
@@ -296,7 +300,7 @@ def request_a_season(
|
||||
user: Annotated[User, Depends(current_active_user)],
|
||||
season_request: CreateSeasonRequest,
|
||||
tv_service: tv_service_dep,
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Create a new season request.
|
||||
"""
|
||||
@@ -314,7 +318,7 @@ def update_request(
|
||||
tv_service: tv_service_dep,
|
||||
user: Annotated[User, Depends(current_active_user)],
|
||||
season_request: UpdateSeasonRequest,
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Update an existing season request.
|
||||
"""
|
||||
@@ -336,19 +340,20 @@ def authorize_request(
|
||||
user: Annotated[User, Depends(current_superuser)],
|
||||
season_request_id: SeasonRequestId,
|
||||
authorized_status: bool = False,
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Authorize or de-authorize a season request.
|
||||
"""
|
||||
season_request = tv_service.get_season_request_by_id(
|
||||
season_request_id=season_request_id
|
||||
)
|
||||
if not season_request:
|
||||
raise NotFoundError
|
||||
season_request.authorized_by = UserRead.model_validate(user)
|
||||
season_request.authorized = authorized_status
|
||||
if not authorized_status:
|
||||
season_request.authorized_by = None
|
||||
tv_service.update_season_request(season_request=season_request)
|
||||
return
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -359,7 +364,7 @@ def delete_season_request(
|
||||
tv_service: tv_service_dep,
|
||||
user: Annotated[User, Depends(current_active_user)],
|
||||
request_id: SeasonRequestId,
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Delete a season request.
|
||||
"""
|
||||
@@ -367,11 +372,11 @@ def delete_season_request(
|
||||
if user.is_superuser or request.requested_by.id == user.id:
|
||||
tv_service.delete_season_request(season_request_id=request_id)
|
||||
log.info(f"User {user.id} deleted season request {request_id}.")
|
||||
return None
|
||||
return
|
||||
log.warning(
|
||||
f"User {user.id} tried to delete season request {request_id} but is not authorized."
|
||||
)
|
||||
return HTTPException(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to delete this request",
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ SeasonRequestId = typing.NewType("SeasonRequestId", UUID)
|
||||
class Episode(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: EpisodeId = Field(default_factory=uuid.uuid4)
|
||||
id: EpisodeId = Field(default_factory=lambda: EpisodeId(uuid.uuid4()))
|
||||
number: EpisodeNumber
|
||||
external_id: int
|
||||
title: str
|
||||
@@ -29,7 +29,7 @@ class Episode(BaseModel):
|
||||
class Season(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: SeasonId = Field(default_factory=uuid.uuid4)
|
||||
id: SeasonId = Field(default_factory=lambda: SeasonId(uuid.uuid4()))
|
||||
number: SeasonNumber
|
||||
|
||||
name: str
|
||||
@@ -43,7 +43,7 @@ class Season(BaseModel):
|
||||
class Show(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: ShowId = Field(default_factory=uuid.uuid4)
|
||||
id: ShowId = Field(default_factory=lambda: ShowId(uuid.uuid4()))
|
||||
|
||||
name: str
|
||||
overview: str
|
||||
@@ -85,7 +85,7 @@ class UpdateSeasonRequest(SeasonRequestBase):
|
||||
class SeasonRequest(SeasonRequestBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: SeasonRequestId = Field(default_factory=uuid.uuid4)
|
||||
id: SeasonRequestId = Field(default_factory=lambda: SeasonRequestId(uuid.uuid4()))
|
||||
|
||||
season_id: SeasonId
|
||||
requested_by: UserRead | None = None
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from media_manager.config import MediaManagerConfig
|
||||
from media_manager.database import get_session
|
||||
from media_manager.exceptions import InvalidConfigError, NotFoundError
|
||||
from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError
|
||||
from media_manager.indexer.repository import IndexerRepository
|
||||
from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId
|
||||
from media_manager.indexer.service import IndexerService
|
||||
@@ -19,6 +19,7 @@ from media_manager.metadataProvider.abstract_metadata_provider import (
|
||||
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
|
||||
from media_manager.metadataProvider.tmdb import TmdbMetadataProvider
|
||||
from media_manager.metadataProvider.tvdb import TvdbMetadataProvider
|
||||
from media_manager.notification.repository import NotificationRepository
|
||||
from media_manager.notification.service import NotificationService
|
||||
from media_manager.schemas import MediaImportSuggestion
|
||||
from media_manager.torrent.repository import TorrentRepository
|
||||
@@ -66,8 +67,8 @@ class TvService:
|
||||
tv_repository: TvRepository,
|
||||
torrent_service: TorrentService,
|
||||
indexer_service: IndexerService,
|
||||
notification_service: NotificationService = None,
|
||||
):
|
||||
notification_service: NotificationService,
|
||||
) -> None:
|
||||
self.tv_repository = tv_repository
|
||||
self.torrent_service = torrent_service
|
||||
self.indexer_service = indexer_service
|
||||
@@ -78,7 +79,7 @@ class TvService:
|
||||
external_id: int,
|
||||
metadata_provider: AbstractMetadataProvider,
|
||||
language: str | None = None,
|
||||
) -> Show | None:
|
||||
) -> Show:
|
||||
"""
|
||||
Add a new show to the database.
|
||||
|
||||
@@ -173,8 +174,10 @@ class TvService:
|
||||
try:
|
||||
self.torrent_service.cancel_download(torrent, delete_files=True)
|
||||
log.info(f"Deleted torrent: {torrent.hash}")
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to delete torrent {torrent.hash}: {e}")
|
||||
except Exception:
|
||||
log.warning(
|
||||
f"Failed to delete torrent {torrent.hash}", exc_info=True
|
||||
)
|
||||
|
||||
self.tv_repository.delete_show(show_id=show.id)
|
||||
|
||||
@@ -225,19 +228,19 @@ class TvService:
|
||||
self.tv_repository.get_show_by_external_id(
|
||||
external_id=external_id, metadata_provider=metadata_provider
|
||||
)
|
||||
return True
|
||||
except NotFoundError:
|
||||
return False
|
||||
elif show_id is not None:
|
||||
try:
|
||||
self.tv_repository.get_show_by_id(show_id=show_id)
|
||||
return True
|
||||
except NotFoundError:
|
||||
return False
|
||||
else:
|
||||
msg = "Use one of the provided overloads for this function!"
|
||||
raise ValueError(msg)
|
||||
|
||||
return True
|
||||
|
||||
def get_all_available_torrents_for_a_season(
|
||||
self,
|
||||
season_number: int,
|
||||
@@ -378,8 +381,9 @@ class TvService:
|
||||
|
||||
if torrent_file.imported:
|
||||
return True
|
||||
except RuntimeError as e:
|
||||
log.error(f"Error retrieving torrent, error: {e}")
|
||||
except RuntimeError:
|
||||
log.exception("Error retrieving torrent")
|
||||
|
||||
return False
|
||||
|
||||
def get_show_by_external_id(
|
||||
@@ -572,7 +576,7 @@ class TvService:
|
||||
self.delete_season_request(season_request.id)
|
||||
return True
|
||||
|
||||
def get_root_show_directory(self, show: Show):
|
||||
def get_root_show_directory(self, show: Show) -> Path:
|
||||
misc_config = MediaManagerConfig().misc
|
||||
show_directory_name = f"{remove_special_characters(show.name)} ({show.year}) [{show.metadata_provider}id-{show.external_id}]"
|
||||
log.debug(
|
||||
@@ -640,7 +644,7 @@ class TvService:
|
||||
return True
|
||||
else:
|
||||
msg = f"Could not find any video file for episode {episode_number} of show {show.name} S{season.number}"
|
||||
raise Exception(msg)
|
||||
raise Exception(msg) # noqa: TRY002 # TODO: resolve this
|
||||
|
||||
def import_season(
|
||||
self,
|
||||
@@ -658,9 +662,9 @@ class TvService:
|
||||
try:
|
||||
season_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
log.warning(f"Could not create path {season_path}: {e}")
|
||||
log.exception(f"Could not create path {season_path}")
|
||||
msg = f"Could not create path {season_path}"
|
||||
raise Exception(msg) from e
|
||||
raise Exception(msg) from e # noqa: TRY002 # TODO: resolve this
|
||||
|
||||
for episode in season.episodes:
|
||||
try:
|
||||
@@ -900,9 +904,8 @@ class TvService:
|
||||
try:
|
||||
source_directory.rename(new_source_path)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to rename {source_directory} to {new_source_path}: {e}")
|
||||
msg = "Failed to rename source directory"
|
||||
raise Exception(msg) from e
|
||||
log.exception(f"Failed to rename {source_directory} to {new_source_path}")
|
||||
raise RenameError from e
|
||||
|
||||
video_files, subtitle_files, _all_files = get_files_for_import(
|
||||
directory=new_source_path
|
||||
@@ -966,10 +969,14 @@ def auto_download_all_approved_season_requests() -> None:
|
||||
tv_repository = TvRepository(db=db)
|
||||
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
|
||||
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
|
||||
notification_service = NotificationService(
|
||||
notification_repository=NotificationRepository(db=db)
|
||||
)
|
||||
tv_service = TvService(
|
||||
tv_repository=tv_repository,
|
||||
torrent_service=torrent_service,
|
||||
indexer_service=indexer_service,
|
||||
notification_service=notification_service,
|
||||
)
|
||||
|
||||
log.info("Auto downloading all approved season requests")
|
||||
@@ -1001,10 +1008,14 @@ def import_all_show_torrents() -> None:
|
||||
tv_repository = TvRepository(db=db)
|
||||
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
|
||||
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
|
||||
notification_service = NotificationService(
|
||||
notification_repository=NotificationRepository(db=db)
|
||||
)
|
||||
tv_service = TvService(
|
||||
tv_repository=tv_repository,
|
||||
torrent_service=torrent_service,
|
||||
indexer_service=indexer_service,
|
||||
notification_service=notification_service,
|
||||
)
|
||||
log.info("Importing all torrents")
|
||||
torrents = torrent_service.get_all_torrents()
|
||||
@@ -1019,10 +1030,8 @@ def import_all_show_torrents() -> None:
|
||||
)
|
||||
continue
|
||||
tv_service.import_torrent_files(torrent=t, show=show)
|
||||
except RuntimeError as e:
|
||||
log.error(
|
||||
f"Error importing torrent {t.title} for show {show.name}: {e}"
|
||||
)
|
||||
except RuntimeError:
|
||||
log.exception(f"Error importing torrent {t.title} for show {show.name}")
|
||||
log.info("Finished importing all torrents")
|
||||
db.commit()
|
||||
|
||||
@@ -1037,6 +1046,9 @@ def update_all_non_ended_shows_metadata() -> None:
|
||||
tv_repository=tv_repository,
|
||||
torrent_service=TorrentService(torrent_repository=TorrentRepository(db=db)),
|
||||
indexer_service=IndexerService(indexer_repository=IndexerRepository(db=db)),
|
||||
notification_service=NotificationService(
|
||||
notification_repository=NotificationRepository(db=db)
|
||||
),
|
||||
)
|
||||
|
||||
log.info("Updating metadata for all non-ended shows")
|
||||
@@ -1056,9 +1068,9 @@ def update_all_non_ended_shows_metadata() -> None:
|
||||
f"Unsupported metadata provider {show.metadata_provider} for show {show.name}, skipping update."
|
||||
)
|
||||
continue
|
||||
except InvalidConfigError as e:
|
||||
log.error(
|
||||
f"Error initializing metadata provider {show.metadata_provider} for show {show.name}: {e}"
|
||||
except InvalidConfigError:
|
||||
log.exception(
|
||||
f"Error initializing metadata provider {show.metadata_provider} for show {show.name}"
|
||||
)
|
||||
continue
|
||||
updated_show = tv_service.update_show_metadata(
|
||||
|
||||
@@ -104,7 +104,16 @@ ASCII_ART='
|
||||
░░░░░░
|
||||
|
||||
'
|
||||
display_cool_text "$ASCII_ART"
|
||||
if [[ -v MEDIAMANAGER_NO_STARTUP_ART ]]; then
|
||||
echo
|
||||
echo " +================+"
|
||||
echo " | MediaManager |"
|
||||
echo " +================+"
|
||||
echo
|
||||
else
|
||||
display_cool_text "$ASCII_ART"
|
||||
fi
|
||||
|
||||
echo "Buy me a coffee at https://buymeacoffee.com/maxdorninger"
|
||||
|
||||
# Initialize config if it doesn't exist
|
||||
@@ -136,8 +145,30 @@ else
|
||||
echo "Config file found at: $CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# check if running as root, if yes, fix permissions
|
||||
if [ "$(id -u)" = '0' ]; then
|
||||
echo "Running as root. Ensuring file permissions for mediamanager user..."
|
||||
chown -R mediamanager:mediamanager "$CONFIG_DIR"
|
||||
|
||||
if [ -d "/data" ]; then
|
||||
if [ "$(stat -c '%U' /data)" != "mediamanager" ]; then
|
||||
echo "Fixing ownership of /data (this may take a while for large media libraries)..."
|
||||
chown -R mediamanager:mediamanager /data
|
||||
else
|
||||
echo "/data ownership is already correct."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Running as non-root user ($(id -u)). Skipping permission fixes."
|
||||
echo "Note: Ensure your host volumes are manually set to the correct permissions."
|
||||
fi
|
||||
|
||||
echo "Running DB migrations..."
|
||||
uv run alembic upgrade head
|
||||
if [ "$(id -u)" = '0' ]; then
|
||||
gosu mediamanager uv run alembic upgrade head
|
||||
else
|
||||
uv run alembic upgrade head
|
||||
fi
|
||||
|
||||
echo "Starting MediaManager backend service..."
|
||||
echo ""
|
||||
@@ -150,9 +181,16 @@ echo ""
|
||||
|
||||
DEVELOPMENT_MODE=${MEDIAMANAGER_MISC__DEVELOPMENT:-FALSE}
|
||||
PORT=${PORT:-8000}
|
||||
|
||||
if [ "$DEVELOPMENT_MODE" == "TRUE" ]; then
|
||||
echo "Development mode is enabled, enabling auto-reload..."
|
||||
uv run fastapi run /app/media_manager/main.py --port "$PORT" --proxy-headers --reload
|
||||
DEV_OPTIONS="--reload"
|
||||
else
|
||||
uv run fastapi run /app/media_manager/main.py --port "$PORT" --proxy-headers
|
||||
DEV_OPTIONS=""
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" = '0' ]; then
|
||||
exec gosu mediamanager uv run fastapi run /app/media_manager/main.py --port "$PORT" --proxy-headers $DEV_OPTIONS
|
||||
else
|
||||
exec uv run fastapi run /app/media_manager/main.py --port "$PORT" --proxy-headers $DEV_OPTIONS
|
||||
fi
|
||||
@@ -8,23 +8,25 @@ RUN apt-get update && apt-get install -y ca-certificates && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user and group
|
||||
RUN groupadd -g 1000 mediamanager && \
|
||||
useradd -m -u 1000 -g mediamanager mediamanager
|
||||
|
||||
WORKDIR /app
|
||||
# Ensure mediamanager owns the app directory
|
||||
RUN chown -R mediamanager:mediamanager /app
|
||||
|
||||
USER mediamanager
|
||||
|
||||
# Set uv cache to a writable home directory and use copy mode for volume compatibility
|
||||
ENV UV_CACHE_DIR=/home/mediamanager/.cache/uv \
|
||||
UV_LINK_MODE=copy
|
||||
UV_LINK_MODE=copy \
|
||||
UV_COMPILE_BYTECODE=1
|
||||
|
||||
COPY --chown=mediamanager:mediamanager pyproject.toml uv.lock ./
|
||||
|
||||
USER mediamanager
|
||||
RUN --mount=type=cache,target=/home/mediamanager/.cache/uv,uid=1000,gid=1000 \
|
||||
uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
COPY --chown=mediamanager:mediamanager . .
|
||||
RUN --mount=type=cache,target=/home/mediamanager/.cache/uv,uid=1000,gid=1000 \
|
||||
uv sync --locked
|
||||
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "fastapi", "run", "/app/main.py"]
|
||||
CMD ["uv", "run", "fastapi", "run", "/app/main.py", "--port", "8000", "--proxy-headers"]
|
||||
@@ -16,39 +16,43 @@ else:
|
||||
tmdbsimple.API_KEY = tmdb_api_key
|
||||
|
||||
@router.get("/tv/trending")
|
||||
async def get_tmdb_trending_tv(language: str = "en"):
|
||||
async def get_tmdb_trending_tv(language: str = "en") -> dict:
|
||||
return Trending(media_type="tv").info(language=language)
|
||||
|
||||
@router.get("/tv/search")
|
||||
async def search_tmdb_tv(query: str, page: int = 1, language: str = "en"):
|
||||
async def search_tmdb_tv(query: str, page: int = 1, language: str = "en") -> dict:
|
||||
return Search().tv(page=page, query=query, language=language)
|
||||
|
||||
@router.get("/tv/shows/{show_id}")
|
||||
async def get_tmdb_show(show_id: int, language: str = "en"):
|
||||
async def get_tmdb_show(show_id: int, language: str = "en") -> dict:
|
||||
return TV(show_id).info(language=language)
|
||||
|
||||
@router.get("/tv/shows/{show_id}/external_ids")
|
||||
async def get_tmdb_show_external_ids(show_id: int):
|
||||
async def get_tmdb_show_external_ids(show_id: int) -> dict:
|
||||
return TV(show_id).external_ids()
|
||||
|
||||
@router.get("/tv/shows/{show_id}/{season_number}")
|
||||
async def get_tmdb_season(season_number: int, show_id: int, language: str = "en"):
|
||||
async def get_tmdb_season(
|
||||
season_number: int, show_id: int, language: str = "en"
|
||||
) -> dict:
|
||||
return TV_Seasons(season_number=season_number, tv_id=show_id).info(
|
||||
language=language
|
||||
)
|
||||
|
||||
@router.get("/movies/trending")
|
||||
async def get_tmdb_trending_movies(language: str = "en"):
|
||||
async def get_tmdb_trending_movies(language: str = "en") -> dict:
|
||||
return Trending(media_type="movie").info(language=language)
|
||||
|
||||
@router.get("/movies/search")
|
||||
async def search_tmdb_movies(query: str, page: int = 1, language: str = "en"):
|
||||
async def search_tmdb_movies(
|
||||
query: str, page: int = 1, language: str = "en"
|
||||
) -> dict:
|
||||
return Search().movie(page=page, query=query, language=language)
|
||||
|
||||
@router.get("/movies/{movie_id}")
|
||||
async def get_tmdb_movie(movie_id: int, language: str = "en"):
|
||||
async def get_tmdb_movie(movie_id: int, language: str = "en") -> dict:
|
||||
return Movies(movie_id).info(language=language)
|
||||
|
||||
@router.get("/movies/{movie_id}/external_ids")
|
||||
async def get_tmdb_movie_external_ids(movie_id: int):
|
||||
async def get_tmdb_movie_external_ids(movie_id: int) -> dict:
|
||||
return Movies(movie_id).external_ids()
|
||||
|
||||
@@ -16,29 +16,29 @@ else:
|
||||
tvdb_client = tvdb_v4_official.TVDB(tvdb_api_key)
|
||||
|
||||
@router.get("/tv/trending")
|
||||
async def get_tvdb_trending_tv():
|
||||
async def get_tvdb_trending_tv() -> list:
|
||||
return tvdb_client.get_all_series()
|
||||
|
||||
@router.get("/tv/search")
|
||||
async def search_tvdb_tv(query: str):
|
||||
async def search_tvdb_tv(query: str) -> list:
|
||||
return tvdb_client.search(query)
|
||||
|
||||
@router.get("/tv/shows/{show_id}")
|
||||
async def get_tvdb_show(show_id: int):
|
||||
async def get_tvdb_show(show_id: int) -> dict:
|
||||
return tvdb_client.get_series_extended(show_id)
|
||||
|
||||
@router.get("/tv/seasons/{season_id}")
|
||||
async def get_tvdb_season(season_id: int):
|
||||
async def get_tvdb_season(season_id: int) -> dict:
|
||||
return tvdb_client.get_season_extended(season_id)
|
||||
|
||||
@router.get("/movies/trending")
|
||||
async def get_tvdb_trending_movies():
|
||||
async def get_tvdb_trending_movies() -> list:
|
||||
return tvdb_client.get_all_movies()
|
||||
|
||||
@router.get("/movies/search")
|
||||
async def search_tvdb_movies(query: str):
|
||||
async def search_tvdb_movies(query: str) -> list:
|
||||
return tvdb_client.search(query)
|
||||
|
||||
@router.get("/movies/{movie_id}")
|
||||
async def get_tvdb_movie(movie_id: int):
|
||||
async def get_tvdb_movie(movie_id: int) -> dict:
|
||||
return tvdb_client.get_movie_extended(movie_id)
|
||||
|
||||
@@ -5,7 +5,7 @@ from app.tvdb import router as tvdb_router
|
||||
from fastapi import FastAPI
|
||||
from starlette_exporter import PrometheusMiddleware, handle_metrics
|
||||
|
||||
app = FastAPI(root_path=os.getenv("BASE_PATH"))
|
||||
app = FastAPI(root_path=os.getenv("BASE_PATH", ""))
|
||||
|
||||
app.add_middleware(PrometheusMiddleware)
|
||||
app.add_route("/metrics", handle_metrics)
|
||||
@@ -15,5 +15,5 @@ app.include_router(tvdb_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
async def root() -> dict:
|
||||
return {"message": "Hello World"}
|
||||
|
||||
@@ -33,10 +33,15 @@ dependencies = [
|
||||
"sabnzbd-api>=0.1.2",
|
||||
"transmission-rpc>=7.0.11",
|
||||
"libtorrent>=2.0.11",
|
||||
"pathvalidate>=3.3.1",
|
||||
"asgi-correlation-id>=4.3.4",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["ruff"]
|
||||
dev = [
|
||||
"ruff",
|
||||
"ty>=0.0.9",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["media_manager*"]
|
||||
|
||||
12
ruff.toml
12
ruff.toml
@@ -1,3 +1,4 @@
|
||||
exclude = ["alembic/versions"]
|
||||
namespace-packages = ["alembic", "metadata_relay"]
|
||||
|
||||
[format]
|
||||
@@ -5,9 +6,9 @@ line-ending = "lf"
|
||||
quote-style = "double"
|
||||
|
||||
[lint]
|
||||
# to be enabled: ANN, BLE, C90, CPY, D, DOC, DTZ, FBT, G, PL, RSE, SLF, SIM, TC, TRY, UP
|
||||
# to be enabled: BLE, C90, CPY, D, DOC, DTZ, FBT, G, PL, RSE, SLF, SIM, TC
|
||||
extend-select = [
|
||||
"A", "ARG", "ASYNC",
|
||||
"A", "ARG", "ASYNC", "ANN",
|
||||
"B",
|
||||
"C4", "COM",
|
||||
"DTZ",
|
||||
@@ -20,7 +21,8 @@ extend-select = [
|
||||
"Q",
|
||||
"RET", "RUF",
|
||||
"S", "SLOT",
|
||||
"T10", "T20", "TD", "TID",
|
||||
"T10", "T20", "TD", "TID", "TRY",
|
||||
"UP",
|
||||
"W",
|
||||
"YTT"
|
||||
]
|
||||
@@ -32,6 +34,10 @@ ignore = [
|
||||
"E501",
|
||||
# currently a bug?! with providers and depends
|
||||
"FAST003",
|
||||
# I'm not sure if we want to lint them
|
||||
"FIX002",
|
||||
# let's decide if we want this
|
||||
"TD002", "TD003",
|
||||
]
|
||||
|
||||
[lint.flake8-bugbear]
|
||||
|
||||
57
uv.lock
generated
57
uv.lock
generated
@@ -105,6 +105,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asgi-correlation-id"
|
||||
version = "4.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/ff/a6538245ac1eaa7733ec6740774e9d5add019e2c63caa29e758c16c0afdd/asgi_correlation_id-4.3.4.tar.gz", hash = "sha256:ea6bc310380373cb9f731dc2e8b2b6fb978a76afe33f7a2384f697b8d6cd811d", size = 20075, upload-time = "2024-10-17T11:44:30.324Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/ab/6936e2663c47a926e0659437b9333ad87d1ff49b1375d239026e0a268eba/asgi_correlation_id-4.3.4-py3-none-any.whl", hash = "sha256:36ce69b06c7d96b4acb89c7556a4c4f01a972463d3d49c675026cbbd08e9a0a2", size = 15262, upload-time = "2024-10-17T11:44:28.739Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.4.0"
|
||||
@@ -854,6 +867,7 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "apscheduler" },
|
||||
{ name = "asgi-correlation-id" },
|
||||
{ name = "bencoder" },
|
||||
{ name = "cachetools" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
@@ -864,6 +878,7 @@ dependencies = [
|
||||
{ name = "httpx-oauth" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "libtorrent" },
|
||||
{ name = "pathvalidate" },
|
||||
{ name = "patool" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
@@ -886,12 +901,14 @@ dependencies = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.16.1" },
|
||||
{ name = "apscheduler", specifier = ">=3.11.0" },
|
||||
{ name = "asgi-correlation-id", specifier = ">=4.3.4" },
|
||||
{ name = "bencoder", specifier = ">=0.2.0" },
|
||||
{ name = "cachetools", specifier = ">=6.0.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" },
|
||||
@@ -902,6 +919,7 @@ requires-dist = [
|
||||
{ name = "httpx-oauth", specifier = ">=0.16.1" },
|
||||
{ name = "jsonschema", specifier = ">=4.24.0" },
|
||||
{ name = "libtorrent", specifier = ">=2.0.11" },
|
||||
{ name = "pathvalidate", specifier = ">=3.3.1" },
|
||||
{ name = "patool", specifier = ">=4.0.1" },
|
||||
{ name = "pillow", specifier = ">=11.3.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" },
|
||||
@@ -922,7 +940,10 @@ requires-dist = [
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff" }]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
{ name = "ty", specifier = ">=0.0.9" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
@@ -942,6 +963,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathvalidate"
|
||||
version = "3.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "patool"
|
||||
version = "4.0.3"
|
||||
@@ -1673,6 +1703,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d2/3f3e03fe96c23701fa24890dcd393034f4d37fb1e4649f573b1a6f3cf994/tvdb_v4_official-1.1.0-py3-none-any.whl", hash = "sha256:1d66f87f7d3d36feb4923b37aefd5a048dd208096bc640d1898acb1956fc0ba1", size = 3801, upload-time = "2022-09-22T19:11:49.819Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/7b/4f677c622d58563c593c32081f8a8572afd90e43dc15b0dedd27b4305038/ty-0.0.9.tar.gz", hash = "sha256:83f980c46df17586953ab3060542915827b43c4748a59eea04190c59162957fe", size = 4858642, upload-time = "2026-01-05T12:24:56.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/3f/c1ee119738b401a8081ff84341781122296b66982e5982e6f162d946a1ff/ty-0.0.9-py3-none-linux_armv6l.whl", hash = "sha256:dd270d4dd6ebeb0abb37aee96cbf9618610723677f500fec1ba58f35bfa8337d", size = 9763596, upload-time = "2026-01-05T12:24:37.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/41/6b0669ef4cd806d4bd5c30263e6b732a362278abac1bc3a363a316cde896/ty-0.0.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:debfb2ba418b00e86ffd5403cb666b3f04e16853f070439517dd1eaaeeff9255", size = 9591514, upload-time = "2026-01-05T12:24:26.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a1/874aa756aee5118e690340a771fb9ded0d0c2168c0b7cc7d9561c2a750b0/ty-0.0.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:107c76ebb05a13cdb669172956421f7ffd289ad98f36d42a44a465588d434d58", size = 9097773, upload-time = "2026-01-05T12:24:14.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/62/cb9a460cf03baab77b3361d13106b93b40c98e274d07c55f333ce3c716f6/ty-0.0.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6868ca5c87ca0caa1b3cb84603c767356242b0659b88307eda69b2fb0bfa416b", size = 9581824, upload-time = "2026-01-05T12:24:35.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/97/633ecb348c75c954f09f8913669de8c440b13b43ea7d214503f3f1c4bb60/ty-0.0.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14a4aa0eb5c1d3591c2adbdda4e44429a6bb5d2e298a704398bb2a7ccdafdfe", size = 9591050, upload-time = "2026-01-05T12:24:08.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/e6/4b0c6a7a8a234e2113f88c80cc7aaa9af5868de7a693859f3c49da981934/ty-0.0.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01bd4466504cefa36b465c6608e9af4504415fa67f6affc01c7d6ce36663c7f4", size = 10018262, upload-time = "2026-01-05T12:24:53.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/97/076d72a028f6b31e0b87287aa27c5b71a2f9927ee525260ea9f2f56828b8/ty-0.0.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:76c8253d1b30bc2c3eaa1b1411a1c34423decde0f4de0277aa6a5ceacfea93d9", size = 10911642, upload-time = "2026-01-05T12:24:48.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/5a/705d6a5ed07ea36b1f23592c3f0dbc8fc7649267bfbb3bf06464cdc9a98a/ty-0.0.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8992fa4a9c6a5434eae4159fdd4842ec8726259bfd860e143ab95d078de6f8e3", size = 10632468, upload-time = "2026-01-05T12:24:24.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/78/4339a254537488d62bf392a936b3ec047702c0cc33d6ce3a5d613f275cd0/ty-0.0.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c79d503d151acb4a145a3d98702d07cb641c47292f63e5ffa0151e4020a5d33", size = 10273422, upload-time = "2026-01-05T12:24:45.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/40/e7f386e87c9abd3670dcee8311674d7e551baa23b2e4754e2405976e6c92/ty-0.0.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a7ebf89ed276b564baa1f0dd9cd708e7b5aa89f19ce1b2f7d7132075abf93e", size = 10120289, upload-time = "2026-01-05T12:24:17.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/46/1027442596e725c50d0d1ab5179e9fa78a398ab412994b3006d0ee0899c7/ty-0.0.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ae3866e50109d2400a886bb11d9ef607f23afc020b226af773615cf82ae61141", size = 9566657, upload-time = "2026-01-05T12:24:51.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/be/df921cf1967226aa01690152002b370a7135c6cced81e86c12b86552cdc4/ty-0.0.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:185244a5eacfcd8f5e2d85b95e4276316772f1e586520a6cb24aa072ec1bac26", size = 9610334, upload-time = "2026-01-05T12:24:20.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/e8/f085268860232cc92ebe95415e5c8640f7f1797ac3a49ddd137c6222924d/ty-0.0.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f834ff27d940edb24b2e86bbb3fb45ab9e07cf59ca8c5ac615095b2542786408", size = 9726701, upload-time = "2026-01-05T12:24:29.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/b4/9394210c66041cd221442e38f68a596945103d9446ece505889ffa9b3da9/ty-0.0.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:773f4b3ba046de952d7c1ad3a2c09b24f3ed4bc8342ae3cbff62ebc14aa6d48c", size = 10227082, upload-time = "2026-01-05T12:24:40.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9f/75951eb573b473d35dd9570546fc1319f7ca2d5b5c50a5825ba6ea6cb33a/ty-0.0.9-py3-none-win32.whl", hash = "sha256:1f20f67e373038ff20f36d5449e787c0430a072b92d5933c5b6e6fc79d3de4c8", size = 9176458, upload-time = "2026-01-05T12:24:32.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/80/b1cdf71ac874e72678161e25e2326a7d30bc3489cd3699561355a168e54f/ty-0.0.9-py3-none-win_amd64.whl", hash = "sha256:2c415f3bbb730f8de2e6e0b3c42eb3a91f1b5fbbcaaead2e113056c3b361c53c", size = 10040479, upload-time = "2026-01-05T12:24:42.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/8f/abc75c4bb774b12698629f02d0d12501b0a7dff9c31dc3bd6b6c6467e90a/ty-0.0.9-py3-none-win_arm64.whl", hash = "sha256:48e339d794542afeed710ea4f846ead865cc38cecc335a9c781804d02eaa2722", size = 9543127, upload-time = "2026-01-05T12:24:11.731Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.21.0"
|
||||
|
||||
92
web/package-lock.json
generated
92
web/package-lock.json
generated
@@ -146,6 +146,7 @@
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -163,6 +164,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -180,6 +182,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -197,6 +200,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -214,6 +218,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -231,6 +236,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -248,6 +254,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -265,6 +272,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -282,6 +290,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -299,6 +308,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -316,6 +326,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -333,6 +344,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -350,6 +362,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -367,6 +380,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -384,6 +398,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -401,6 +416,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -418,6 +434,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -435,6 +452,7 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -452,6 +470,7 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -469,6 +488,7 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -486,6 +506,7 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -503,6 +524,7 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -520,6 +542,7 @@
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -537,6 +560,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -554,6 +578,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -571,6 +596,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1974,7 +2000,6 @@
|
||||
"integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||
@@ -2014,7 +2039,6 @@
|
||||
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
||||
"debug": "^4.4.1",
|
||||
@@ -2485,7 +2509,6 @@
|
||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
@@ -2732,7 +2755,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2973,7 +2995,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3086,7 +3107,6 @@
|
||||
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/validator": "^13.15.3",
|
||||
"libphonenumber-js": "^1.11.1",
|
||||
@@ -3270,8 +3290,7 @@
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/embla-carousel-reactive-utils": {
|
||||
"version": "8.6.0",
|
||||
@@ -3311,6 +3330,50 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.2",
|
||||
"@esbuild/android-arm": "0.27.2",
|
||||
"@esbuild/android-arm64": "0.27.2",
|
||||
"@esbuild/android-x64": "0.27.2",
|
||||
"@esbuild/darwin-arm64": "0.27.2",
|
||||
"@esbuild/darwin-x64": "0.27.2",
|
||||
"@esbuild/freebsd-arm64": "0.27.2",
|
||||
"@esbuild/freebsd-x64": "0.27.2",
|
||||
"@esbuild/linux-arm": "0.27.2",
|
||||
"@esbuild/linux-arm64": "0.27.2",
|
||||
"@esbuild/linux-ia32": "0.27.2",
|
||||
"@esbuild/linux-loong64": "0.27.2",
|
||||
"@esbuild/linux-mips64el": "0.27.2",
|
||||
"@esbuild/linux-ppc64": "0.27.2",
|
||||
"@esbuild/linux-riscv64": "0.27.2",
|
||||
"@esbuild/linux-s390x": "0.27.2",
|
||||
"@esbuild/linux-x64": "0.27.2",
|
||||
"@esbuild/netbsd-arm64": "0.27.2",
|
||||
"@esbuild/netbsd-x64": "0.27.2",
|
||||
"@esbuild/openbsd-arm64": "0.27.2",
|
||||
"@esbuild/openbsd-x64": "0.27.2",
|
||||
"@esbuild/openharmony-arm64": "0.27.2",
|
||||
"@esbuild/sunos-x64": "0.27.2",
|
||||
"@esbuild/win32-arm64": "0.27.2",
|
||||
"@esbuild/win32-ia32": "0.27.2",
|
||||
"@esbuild/win32-x64": "0.27.2"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-runner": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-runner/-/esbuild-runner-2.2.2.tgz",
|
||||
@@ -3366,7 +3429,6 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4815,7 +4877,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -4956,7 +5017,6 @@
|
||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -4973,7 +5033,6 @@
|
||||
"integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"prettier": "^3.0.0",
|
||||
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
|
||||
@@ -5133,7 +5192,6 @@
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -5386,7 +5444,6 @@
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.8.tgz",
|
||||
"integrity": "sha512-1Jh7FwVh/2Uxg0T7SeE1qFKMhwYH45b2v53bcZpW7qHa6O8iU1ByEj56PF0IQ6dU4HE5gRkic6h+vx+tclHeiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -5560,7 +5617,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"devalue": "^5.3.2",
|
||||
"memoize-weak": "^1.0.2",
|
||||
@@ -5767,8 +5823,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
||||
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
@@ -5913,7 +5968,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -6056,7 +6110,6 @@
|
||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -6739,7 +6792,6 @@
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
4
web/src/lib/api/api.d.ts
vendored
4
web/src/lib/api/api.d.ts
vendored
@@ -3269,7 +3269,7 @@ export interface operations {
|
||||
query: {
|
||||
show_id: string;
|
||||
season_number?: number;
|
||||
search_query_override?: string;
|
||||
search_query_override?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -3636,7 +3636,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['PublicMovie'][];
|
||||
'application/json': components['schemas']['Movie'][];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -127,13 +127,27 @@
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
{#if isShow}
|
||||
{getFullyQualifiedMediaName(
|
||||
(request as components['schemas']['RichSeasonRequest']).show
|
||||
)}
|
||||
<a
|
||||
href={resolve('/dashboard/tv/[showId]', {
|
||||
showId: (request as components['schemas']['RichSeasonRequest']).show.id!
|
||||
})}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{getFullyQualifiedMediaName(
|
||||
(request as components['schemas']['RichSeasonRequest']).show
|
||||
)}
|
||||
</a>
|
||||
{:else}
|
||||
{getFullyQualifiedMediaName(
|
||||
(request as components['schemas']['RichMovieRequest']).movie
|
||||
)}
|
||||
<a
|
||||
href={resolve('/dashboard/movies/[movieId]', {
|
||||
movieId: (request as components['schemas']['RichMovieRequest']).movie.id!
|
||||
})}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{getFullyQualifiedMediaName(
|
||||
(request as components['schemas']['RichMovieRequest']).movie
|
||||
)}
|
||||
</a>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{#if isShow}
|
||||
|
||||
@@ -14,14 +14,19 @@
|
||||
import DeleteTorrentDialog from '$lib/components/torrents/delete-torrent-dialog.svelte';
|
||||
import EditTorrentDialog from '$lib/components/torrents/edit-torrent-dialog.svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
let {
|
||||
torrents,
|
||||
isShow = true
|
||||
isShow = true,
|
||||
showId,
|
||||
movieId
|
||||
}: {
|
||||
torrents:
|
||||
| components['schemas']['MovieTorrent'][]
|
||||
| components['schemas']['RichSeasonTorrent'][];
|
||||
isShow: boolean;
|
||||
showId?: string;
|
||||
movieId?: string;
|
||||
} = $props();
|
||||
|
||||
let user: () => components['schemas']['UserRead'] = getContext('user');
|
||||
@@ -68,7 +73,23 @@
|
||||
{#each torrents as torrent (torrent.torrent_id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
{torrent.torrent_title}
|
||||
{#if isShow && showId}
|
||||
<a
|
||||
href={resolve('/dashboard/tv/[showId]', { showId })}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{torrent.torrent_title}
|
||||
</a>
|
||||
{:else if !isShow && movieId}
|
||||
<a
|
||||
href={resolve('/dashboard/movies/[movieId]', { movieId })}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{torrent.torrent_title}
|
||||
</a>
|
||||
{:else}
|
||||
{torrent.torrent_title}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{#if isShow}
|
||||
<Table.Cell>
|
||||
|
||||
@@ -114,6 +114,16 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img
|
||||
src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg"
|
||||
width="201px"
|
||||
alt="Powered by DigitalOcean"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="mt-24 scroll-m-20 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0"
|
||||
>
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
<Card.Description>A list of all torrents associated with this movie.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-4">
|
||||
<TorrentTable isShow={false} torrents={movie.torrents} />
|
||||
<TorrentTable isShow={false} torrents={movie.torrents} movieId={movie.id} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<TorrentTable isShow={false} torrents={movie.torrents} />
|
||||
<TorrentTable isShow={false} torrents={movie.torrents} movieId={movie.movie_id} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="w-full overflow-x-auto">
|
||||
<TorrentTable isShow={true} torrents={torrents.torrents} />
|
||||
<TorrentTable isShow={true} torrents={torrents.torrents} showId={show.id} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<TorrentTable isShow={true} torrents={show.torrents} />
|
||||
<TorrentTable isShow={true} torrents={show.torrents} showId={show.show_id} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user