Merge branch 'refs/heads/master' into fork/aasmoe/feat/multi-language-metadata

# Conflicts:
#	metadata_relay/app/tmdb.py
#	web/src/lib/api/api.d.ts
This commit is contained in:
maxid
2025-12-20 14:53:41 +01:00
17 changed files with 293 additions and 16 deletions

View File

@@ -7,6 +7,8 @@ Frontend settings are configured through environment variables in your `docker-c
## Configuration File Location
<warning>Note that MediaManager may need to be restarted for changes in the config file to take effect.</warning>
Your `config.toml` file should be in the directory that's mounted to `/app/config/config.toml` inside the container:
```yaml

View File

@@ -36,6 +36,13 @@ DEBUG - media_manager.indexer.utils -
Read timed out. (read timeout=10)
```
- `follow_redirects`
This is necessary for some indexers that use redirect links for torrent downloads. Especially useful if your download
client cannot access Prowlarr directly. This increases the time it takes to fetch torrent details, so only enable it if
you really need it.
Default is `false`.
## Jackett (`[indexers.jackett]`)
- `enabled`

View File

@@ -4,6 +4,8 @@
Always check the container and browser logs for more specific error messages
</tip>
## Authentication Issues
<procedure title="I can't log in with OAuth/OIDC?" id="procedure-i-cannot-log-in-with-oauth">
<step>Verify your OAuth provider's configuration. <a href="authentication-setup.md" anchor="openid-connect-settings-auth-openid-connect">See the OAuth documentation</a></step>
<step>Check if the callback URI you set in your OIDC providers settings is correct. <a href="authentication-setup.md" anchor="redirect-uri">See the callback URI documentation</a> </step>
@@ -20,9 +22,15 @@
</step>
</procedure>
<procedure title="My hardlinks don't work?" id="procedure-my-hardlinks-dont-work">
<step>Make sure you are using only one volumes for TV, Movies and Downloads. <a href="https://raw.githubusercontent.com/maxdorninger/MediaManager/refs/heads/master/docker-compose.yaml"> See the configuration in the example <code>docker-compose.yaml</code> file.</a></step>
</procedure>
## Hard linking Issues
- Make sure you are using only one volumes for TV, Movies and Downloads.
<a href="https://raw.githubusercontent.com/maxdorninger/MediaManager/refs/heads/master/docker-compose.yaml"> See the configuration in the example <code>docker-compose.yaml</code> file.</a>
The reason is that hard linking only works within the same filesystem. If your downloads are on a different volume than
your media library, hard linking will not work.
## Torrent Search Issues
<procedure title="I get no search results for torrents?" id="procedure-i-get-no-search-results">
<step>Try switching to the advanced tab when searching for torrents.</step>
@@ -30,4 +38,9 @@
<step>If you still don't get any search results, check the logs, they will provide more information on what is going wrong.</step>
</procedure>
## Import and download Issues
- If you configured a category with a special save path,
<a href="qBittorrent-Category.md">carefully read this page about MM with qBittorrent save paths.</a>
<note>If it still doesn't work, <a href="https://github.com/maxdorninger/MediaManager/issues">please open an Issue.</a> It is possible that a bug is causing the issue.</note>

View File

@@ -121,6 +121,7 @@ url = "http://localhost:9696"
api_key = ""
reject_torrents_on_url_error = true
timeout_seconds = 60
follow_redirects = false
# Jackett settings
[indexers.jackett]

View File

@@ -121,6 +121,7 @@ url = "http://localhost:9696"
api_key = ""
reject_torrents_on_url_error = true
timeout_seconds = 60
follow_redirects = false
# Jackett settings
[indexers.jackett]

View File

@@ -7,6 +7,7 @@ class ProwlarrConfig(BaseSettings):
url: str = "http://localhost:9696"
reject_torrents_on_url_error: bool = True
timeout_seconds: int = 60
follow_redirects: bool = False
class JackettConfig(BaseSettings):

View File

@@ -27,6 +27,7 @@ class Prowlarr(GenericIndexer):
self.url = config.url
self.reject_torrents_on_url_error = config.reject_torrents_on_url_error
self.timeout_seconds = config.timeout_seconds
self.follow_redirects = config.follow_redirects
def search(self, query: str, is_tv: bool) -> list[IndexerQueryResult]:
log.debug("Searching for " + query)
@@ -94,7 +95,7 @@ class Prowlarr(GenericIndexer):
log.error(f"No valid download URL found for result: {result}")
return None
if not initial_url.startswith("magnet:"):
if not initial_url.startswith("magnet:") and self.follow_redirects:
try:
final_download_url = follow_redirects_to_final_torrent_url(
initial_url=initial_url,

View File

@@ -28,6 +28,7 @@ from media_manager.movies.schemas import (
)
from media_manager.movies.dependencies import (
movie_service_dep,
movie_dep,
)
from media_manager.metadataProvider.dependencies import metadata_provider_dep
from media_manager.movies.schemas import MovieRequestBase
@@ -70,6 +71,24 @@ def add_a_movie(
return movie
@router.delete(
"/{movie_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(current_superuser)],
)
def delete_a_movie(
movie_service: movie_service_dep,
movie: movie_dep,
delete_files_on_disk: bool = False,
delete_torrents: bool = False,
):
movie_service.delete_movie(
movie_id=movie.id,
delete_files_on_disk=delete_files_on_disk,
delete_torrents=delete_torrents,
)
# --------------------------------
# GET MOVIES
# --------------------------------

View File

@@ -1,4 +1,5 @@
import re
import shutil
from pathlib import Path
from sqlalchemy.exc import IntegrityError
@@ -114,6 +115,48 @@ class MovieService:
"""
self.movie_repository.delete_movie_request(movie_request_id=movie_request_id)
def delete_movie(
self,
movie_id: MovieId,
delete_files_on_disk: bool = False,
delete_torrents: bool = False,
) -> None:
"""
Delete a movie from the database, optionally deleting files and torrents.
:param movie_id: The ID of the movie to delete.
:param delete_files_on_disk: Whether to delete the movie's files from disk.
:param delete_torrents: Whether to delete associated torrents from the torrent client.
"""
if delete_files_on_disk or delete_torrents:
movie = self.movie_repository.get_movie_by_id(movie_id=movie_id)
log.debug(f"Deleting ID: {movie.id} - Name: {movie.name}")
if delete_files_on_disk:
# Get the movie's directory path
movie_dir = self.get_movie_root_path(movie=movie)
log.debug(f"Attempt to delete movie directory: {movie_dir}")
if movie_dir.exists() and movie_dir.is_dir():
shutil.rmtree(movie_dir)
log.info(f"Deleted movie directory: {movie_dir}")
if delete_torrents:
# Get all torrents associated with this movie
torrents = self.movie_repository.get_torrents_by_movie_id(
movie_id=movie_id
)
for torrent in torrents:
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}")
# Delete from database
self.movie_repository.delete_movie(movie_id=movie_id)
def get_public_movie_files_by_movie_id(
self, movie_id: MovieId
) -> list[PublicMovieFile]:
@@ -233,11 +276,14 @@ class MovieService:
# Fetch the internal movie ID.
try:
movie = self.movie_repository.get_movie_by_external_id(
external_id=result.external_id, metadata_provider=metadata_provider.name
external_id=result.external_id,
metadata_provider=metadata_provider.name,
)
result.id = movie.id
except Exception:
log.error(f"Unable to find internal movie ID for {result.external_id} on {metadata_provider.name}")
log.error(
f"Unable to find internal movie ID for {result.external_id} on {metadata_provider.name}"
)
return results
def get_popular_movies(

View File

@@ -9,8 +9,11 @@ import bencoder
import patoolib
import requests
import libtorrent
from requests.exceptions import InvalidSchema
from media_manager.config import AllEncompassingConfig
from media_manager.indexer.schemas import IndexerQueryResult
from media_manager.indexer.utils import follow_redirects_to_final_torrent_url
from media_manager.torrent.schemas import Torrent
log = logging.getLogger(__name__)
@@ -141,6 +144,15 @@ 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}")
final_url = follow_redirects_to_final_torrent_url(
initial_url=torrent.download_url,
session=requests.Session(),
timeout=AllEncompassingConfig().indexers.prowlarr.timeout_seconds,
)
torrent_hash = str(libtorrent.parse_magnet_uri(final_url).info_hash)
return torrent_hash
except Exception as e:
log.error(f"Failed to download torrent file: {e}")
raise

View File

@@ -34,7 +34,6 @@ from media_manager.schemas import MediaImportSuggestion
from media_manager.tv.dependencies import (
season_dep,
show_dep,
tv_repository_dep,
tv_service_dep,
)
from media_manager.metadataProvider.dependencies import metadata_provider_dep
@@ -88,10 +87,19 @@ def get_total_count_of_downloaded_episodes(tv_service: tv_service_dep):
@router.delete(
"/shows/{show_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(current_active_user)],
dependencies=[Depends(current_superuser)],
)
def delete_a_show(tv_repository: tv_repository_dep, show: show_dep):
tv_repository.delete_show(show_id=show.id)
def delete_a_show(
tv_service: tv_service_dep,
show: show_dep,
delete_files_on_disk: bool = False,
delete_torrents: bool = False,
):
tv_service.delete_show(
show_id=show.id,
delete_files_on_disk=delete_files_on_disk,
delete_torrents=delete_torrents,
)
# --------------------------------

View File

@@ -1,4 +1,5 @@
import re
import shutil
from sqlalchemy.exc import IntegrityError
@@ -130,6 +131,46 @@ class TvService:
"""
self.tv_repository.delete_season_request(season_request_id=season_request_id)
def delete_show(
self,
show_id: ShowId,
delete_files_on_disk: bool = False,
delete_torrents: bool = False,
) -> None:
"""
Delete a show from the database, optionally deleting files and torrents.
:param show_id: The ID of the show to delete.
:param delete_files_on_disk: Whether to delete the show's files from disk.
:param delete_torrents: Whether to delete associated torrents from the torrent client.
"""
if delete_files_on_disk or delete_torrents:
show = self.tv_repository.get_show_by_id(show_id)
log.debug(f"Deleting ID: {show.id} - Name: {show.name}")
if delete_files_on_disk:
# Get the show's directory path
show_dir = self.get_root_show_directory(show=show)
log.debug(f"Attempt to delete show directory: {show_dir}")
if show_dir.exists() and show_dir.is_dir():
shutil.rmtree(show_dir)
log.info(f"Deleted show directory: {show_dir}")
if delete_torrents:
# Get all torrents associated with this show
torrents = self.tv_repository.get_torrents_by_show_id(show_id=show_id)
for torrent in torrents:
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}")
# Delete from database
self.tv_repository.delete_show(show_id=show_id)
def get_public_season_files_by_season_id(
self, season_id: SeasonId
) -> list[PublicSeasonFile]:
@@ -246,11 +287,14 @@ class TvService:
# Fetch the internal show ID.
try:
show = self.tv_repository.get_show_by_external_id(
external_id=result.external_id, metadata_provider=metadata_provider.name
external_id=result.external_id,
metadata_provider=metadata_provider.name,
)
result.id = show.id
except Exception:
log.error(f"Unable to find internal show ID for {result.external_id} on {metadata_provider.name}")
log.error(
f"Unable to find internal show ID for {result.external_id} on {metadata_provider.name}"
)
return results
def get_popular_shows(

View File

@@ -21,7 +21,7 @@ else:
@router.get("/tv/search")
async def search_tmdb_tv(query: str, page: int = 1, language: str = "en"):
return Search().tv(page=page, query=query, include_adult=True, language=language)
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"):
@@ -37,7 +37,7 @@ else:
@router.get("/movies/search")
async def search_tmdb_movies(query: str, page: int = 1, language: str = "en"):
return Search().movie(page=page, query=query, include_adult=True, language=language)
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"):

View File

@@ -0,0 +1,109 @@
<script lang="ts">
import type { components } from '$lib/api/api.ts';
import { toast } from 'svelte-sonner';
import client from '$lib/api/index.ts';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { getFullyQualifiedMediaName } from '$lib/utils.ts';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { buttonVariants } from '$lib/components/ui/button/index.js';
let {
media,
isShow
}: {
media: components['schemas']['PublicMovie'] | components['schemas']['PublicShow'];
isShow: boolean;
} = $props();
let deleteDialogOpen = $state(false);
let deleteFilesOnDisk = $state(false);
let deleteTorrents = $state(false);
async function delete_movie() {
if (!media.id) {
toast.error('Movie ID is missing');
return;
}
const { response } = await client.DELETE('/api/v1/movies/{movie_id}', {
params: {
path: { movie_id: media.id },
query: { delete_files_on_disk: deleteFilesOnDisk, delete_torrents: deleteTorrents }
}
});
if (!response.ok) {
const errorText = await response.text();
toast.error('Failed to delete movie: ' + errorText);
} else {
toast.success('Movie deleted successfully.');
deleteDialogOpen = false;
await goto(resolve('/dashboard/movies', {}), { invalidateAll: true });
}
}
async function delete_show() {
const { response } = await client.DELETE('/api/v1/tv/shows/{show_id}', {
params: {
path: { show_id: media.id! },
query: { delete_files_on_disk: deleteFilesOnDisk, delete_torrents: deleteTorrents }
}
});
if (!response.ok) {
const errorText = await response.text();
toast.error('Failed to delete show: ' + errorText);
} else {
toast.success('Show deleted successfully.');
deleteDialogOpen = false;
await goto(resolve('/dashboard/tv', {}), { invalidateAll: true });
}
}
</script>
<AlertDialog.Root bind:open={deleteDialogOpen}>
<AlertDialog.Trigger class={buttonVariants({ variant: 'destructive' })}>
Delete {isShow ? ' Show' : ' Movie'}
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete - {getFullyQualifiedMediaName(media)}?</AlertDialog.Title>
<AlertDialog.Description>
This action cannot be undone. This will permanently delete
<strong>{getFullyQualifiedMediaName(media)}</strong>.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="flex flex-col gap-3 py-4">
<div class="flex items-center space-x-2">
<Checkbox bind:checked={deleteFilesOnDisk} id="delete-files" />
<Label
for="delete-files"
class="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Also delete files on disk (this will only remove imported files, not downloads)
</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox bind:checked={deleteTorrents} id="delete-torrents" />
<Label
for="delete-torrents"
class="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Also delete torrents (this will remove torrents from your download clients)
</Label>
</div>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() => {
if (isShow) {
delete_show();
} else delete_movie();
}}
class={buttonVariants({ variant: 'destructive' })}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -22,6 +22,7 @@
let email = $state('');
let password = $state('');
let errorMessage = $state('');
let successMessage = $state('');
let isLoading = $state(false);
async function handleLogin(event: Event) {
@@ -29,6 +30,7 @@
isLoading = true;
errorMessage = '';
successMessage = '';
const { error, response } = await client.POST('/api/v1/auth/cookie/login', {
body: {
@@ -45,8 +47,8 @@
if (!error) {
console.log('Login successful!');
console.log('Received User Data: ', response);
errorMessage = 'Login successful! Redirecting...';
toast.success(errorMessage);
successMessage = 'Login successful! Redirecting...';
toast.success(successMessage);
goto(resolve('/dashboard', {}));
} else {
toast.error('Login failed!');
@@ -100,6 +102,13 @@
</Alert.Root>
{/if}
{#if successMessage}
<Alert.Root variant="default">
<Alert.Title>Success</Alert.Title>
<Alert.Description>{successMessage}</Alert.Description>
</Alert.Root>
{/if}
{#if isLoading}
<LoadingBar />
{/if}

View File

@@ -14,6 +14,7 @@
import LibraryCombobox from '$lib/components/library-combobox.svelte';
import { base } from '$app/paths';
import * as Card from '$lib/components/ui/card/index.js';
import DeleteMediaDialog from '$lib/components/delete-media-dialog.svelte';
let movie: components['schemas']['PublicMovie'] = page.data.movie;
let user: () => components['schemas']['UserRead'] = getContext('user');
@@ -92,6 +93,7 @@
</Card.Header>
<Card.Content class="flex flex-col items-center gap-4">
<LibraryCombobox media={movie} mediaType="movie" />
<DeleteMediaDialog isShow={false} media={movie} />
</Card.Content>
</Card.Root>
{/if}

View File

@@ -19,6 +19,7 @@
import { Label } from '$lib/components/ui/label';
import LibraryCombobox from '$lib/components/library-combobox.svelte';
import * as Card from '$lib/components/ui/card/index.js';
import DeleteMediaDialog from '$lib/components/delete-media-dialog.svelte';
import { resolve } from '$app/paths';
import client from '$lib/api';
@@ -135,6 +136,7 @@
</div>
{/if}
<LibraryCombobox media={show()} mediaType="tv" />
<DeleteMediaDialog isShow={true} media={show()} />
</Card.Content>
</Card.Root>
{/if}