diff --git a/Writerside/topics/Configuration.md b/Writerside/topics/Configuration.md index fad45ec..c2b6ef5 100644 --- a/Writerside/topics/Configuration.md +++ b/Writerside/topics/Configuration.md @@ -7,6 +7,8 @@ Frontend settings are configured through environment variables in your `docker-c ## Configuration File Location +Note that MediaManager may need to be restarted for changes in the config file to take effect. + Your `config.toml` file should be in the directory that's mounted to `/app/config/config.toml` inside the container: ```yaml diff --git a/Writerside/topics/Indexer-Settings.md b/Writerside/topics/Indexer-Settings.md index 61ecba9..cd53672 100644 --- a/Writerside/topics/Indexer-Settings.md +++ b/Writerside/topics/Indexer-Settings.md @@ -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` diff --git a/Writerside/topics/troubleshooting.md b/Writerside/topics/troubleshooting.md index 55090bb..a529a20 100644 --- a/Writerside/topics/troubleshooting.md +++ b/Writerside/topics/troubleshooting.md @@ -4,6 +4,8 @@ Always check the container and browser logs for more specific error messages +## Authentication Issues + Verify your OAuth provider's configuration. See the OAuth documentation Check if the callback URI you set in your OIDC providers settings is correct. See the callback URI documentation @@ -20,9 +22,15 @@ - - Make sure you are using only one volumes for TV, Movies and Downloads. See the configuration in the example docker-compose.yaml file. - +## Hard linking Issues + +- Make sure you are using only one volumes for TV, Movies and Downloads. + See the configuration in the example docker-compose.yaml file. + +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 Try switching to the advanced tab when searching for torrents. @@ -30,4 +38,9 @@ If you still don't get any search results, check the logs, they will provide more information on what is going wrong. +## Import and download Issues + +- If you configured a category with a special save path, + carefully read this page about MM with qBittorrent save paths. + If it still doesn't work, please open an Issue. It is possible that a bug is causing the issue. \ No newline at end of file diff --git a/config.dev.toml b/config.dev.toml index 3ae7e43..c69f477 100644 --- a/config.dev.toml +++ b/config.dev.toml @@ -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] diff --git a/config.example.toml b/config.example.toml index 3f44315..87a3fd8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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] diff --git a/media_manager/indexer/config.py b/media_manager/indexer/config.py index dafa971..06be397 100644 --- a/media_manager/indexer/config.py +++ b/media_manager/indexer/config.py @@ -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): diff --git a/media_manager/indexer/indexers/prowlarr.py b/media_manager/indexer/indexers/prowlarr.py index 66d90f4..c5918b6 100644 --- a/media_manager/indexer/indexers/prowlarr.py +++ b/media_manager/indexer/indexers/prowlarr.py @@ -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, diff --git a/media_manager/movies/router.py b/media_manager/movies/router.py index 26559af..82072c3 100644 --- a/media_manager/movies/router.py +++ b/media_manager/movies/router.py @@ -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 # -------------------------------- diff --git a/media_manager/movies/service.py b/media_manager/movies/service.py index 3c33745..ce8ce50 100644 --- a/media_manager/movies/service.py +++ b/media_manager/movies/service.py @@ -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( diff --git a/media_manager/torrent/utils.py b/media_manager/torrent/utils.py index d14bb70..25b37f9 100644 --- a/media_manager/torrent/utils.py +++ b/media_manager/torrent/utils.py @@ -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 diff --git a/media_manager/tv/router.py b/media_manager/tv/router.py index 0a2d902..01bad49 100644 --- a/media_manager/tv/router.py +++ b/media_manager/tv/router.py @@ -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, + ) # -------------------------------- diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index 2705bc7..7fcb57f 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -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( diff --git a/metadata_relay/app/tmdb.py b/metadata_relay/app/tmdb.py index c5e24e3..c9a7169 100644 --- a/metadata_relay/app/tmdb.py +++ b/metadata_relay/app/tmdb.py @@ -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"): diff --git a/web/src/lib/components/delete-media-dialog.svelte b/web/src/lib/components/delete-media-dialog.svelte new file mode 100644 index 0000000..997c0bf --- /dev/null +++ b/web/src/lib/components/delete-media-dialog.svelte @@ -0,0 +1,109 @@ + + + + + Delete {isShow ? ' Show' : ' Movie'} + + + + Delete - {getFullyQualifiedMediaName(media)}? + + This action cannot be undone. This will permanently delete + {getFullyQualifiedMediaName(media)}. + + +
+
+ + +
+
+ + +
+
+ + Cancel + { + if (isShow) { + delete_show(); + } else delete_movie(); + }} + class={buttonVariants({ variant: 'destructive' })} + > + Delete + + +
+
diff --git a/web/src/lib/components/login-card.svelte b/web/src/lib/components/login-card.svelte index bafa273..d3f2cbc 100644 --- a/web/src/lib/components/login-card.svelte +++ b/web/src/lib/components/login-card.svelte @@ -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 @@ {/if} + {#if successMessage} + + Success + {successMessage} + + {/if} + {#if isLoading} {/if} diff --git a/web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte b/web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte index 3b11bae..ebe89e8 100644 --- a/web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte +++ b/web/src/routes/dashboard/movies/[movieId=uuid]/+page.svelte @@ -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 @@ + {/if} diff --git a/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte b/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte index f63dc02..301912f 100644 --- a/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte +++ b/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte @@ -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 @@ {/if} + {/if}