Merge branch 'master' into feat/multi-language-metadata

This commit is contained in:
aasmoe
2025-12-21 14:43:17 +01:00
committed by GitHub
12 changed files with 225 additions and 79 deletions

33
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,33 @@
# Contributing to MediaManager
First off, thank you for considering contributing to MediaManager.
## Why
Following this guide helps me merge your PRs faster, prevents unnecessary back-and-forth and wasted effort.
## How to suggest a feature or enhancement
Open an issue which describes the feature you would like to
see, why you need it, and how it should work.
There we can discuss its scope and implementation.
## How to contribute
Generally, if you have any questions or need help on the implementation side of MediaManager,
just ask in the issue, or in a draft PR.
Also, see the contribution guide in the docs for information on how to setup the dev environment:
https://maxdorninger.github.io/MediaManager/developer-guide.html
### For something that is a one or two line fix:
Make the change, and open a PR with a short description of what you changed and why.
### For something that is bigger than a one or two line fix:
Explain why you are making the change.
Be sure to give a rough overview on how your implementation works, and maybe any design decisions you made.
Also include any relevant limitations or trade-offs you made.
It's best to also open an issue first to discuss larger changes before you start working on them.

View File

@@ -1,11 +1,10 @@
# Importing existing media
In order for MediaManager to be able to import existing media (e.g. downloaded by Sonarr or Radarr)
3 conditions have to be met:
2 conditions have to be met:
1. The folder's name must not contain `[tmdbid-xxxxx]` or `[tvdbid-xxxxx]`.
2. The folder's name must not start with a dot.
3. The media must be in the root tv/movie library
1. The folder's name must not start with a dot.
2. The media must be in the root tv/movie library
Here is an example, using these rules:
@@ -14,9 +13,8 @@ Here is an example, using these rules:
└── data/
├── tv/
│ ├── Rick and Morty # WILL be imported
│ ├── Stranger Things (2016) # WILL be imported
│ ├── Breaking Bad (2008) [tmdbid-1396] # WILL NOT be imported
│ ├── Stranger Things (2016) {tvdb_12345} [x265] # WILL be imported
├── Breaking Bad (2008) [tmdbid-1396] # WILL be imported
│ ├── .The Office (2013) # WILL NOT
│ │
│ └── my-custom-library/
@@ -44,16 +42,18 @@ So after importing, the directory would look like this (using the above director
│ ├── .Rick and Morty # RENAMED
│ ├── Rick and Morty (2013) [tmdbid-60625] # IMPORTED
│ │
│ ├── .Stranger Things (2016) # RENAMED
│ ├── .Stranger Things (2016) {tvdb_12345} [x265]
│ ├── Stranger Things (2016) [tmdbid-66732] # IMPORTED
│ │
│ ├── .The Office (2013) # IGNORED
├── Breaking Bad (2008) [tmdbid-1396] # IGNORED
│ ├── .Breaking Bad (2008) [tmdbid-1396]
│ ├── Breaking Bad (2008) [tmdbid-1396] # IMPORTED
│ │
│ └── my-custom-library/
│ └── The Simpsons # IGNORED
└── movie/
├── .Oppenheimer (2023) # RENAMED
├── .Oppenheimer (2023)
└── Oppenheimer (2023) [tmdbid-872585] # IMPORTED
```

View File

@@ -8,6 +8,7 @@ from media_manager.metadataProvider.abstractMetaDataProvider import (
AbstractMetadataProvider,
)
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
from media_manager.torrent.utils import remove_special_chars_and_parentheses
from media_manager.tv.schemas import Episode, Season, Show, SeasonNumber, EpisodeNumber
from media_manager.movies.schemas import Movie
from media_manager.notification.manager import notification_manager
@@ -82,7 +83,11 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
def __search_tv(self, query: str, page: int) -> dict:
try:
response = requests.get(
url=f"{self.url}/tv/search", params={"query": query, "page": page, "language": self.default_language}
url=f"{self.url}/tv/search",
params={
"query": remove_special_chars_and_parentheses(query),
"page": page,
},
)
response.raise_for_status()
return response.json()
@@ -131,7 +136,11 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
def __search_movie(self, query: str, page: int) -> dict:
try:
response = requests.get(
url=f"{self.url}/movies/search", params={"query": query, "page": page, "language": self.default_language}
url=f"{self.url}/movies/search",
params={
"query": remove_special_chars_and_parentheses(query),
"page": page,
},
)
response.raise_for_status()
return response.json()

View File

@@ -8,6 +8,7 @@ from media_manager.metadataProvider.abstractMetaDataProvider import (
AbstractMetadataProvider,
)
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
from media_manager.torrent.utils import remove_special_chars_and_parentheses
from media_manager.tv.schemas import Episode, Season, Show, SeasonNumber
from media_manager.movies.schemas import Movie
@@ -29,7 +30,10 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
return requests.get(f"{self.url}/tv/seasons/{id}").json()
def __search_tv(self, query: str) -> dict:
return requests.get(f"{self.url}/tv/search", params={"query": query}).json()
return requests.get(
f"{self.url}/tv/search",
params={"query": remove_special_chars_and_parentheses(query)},
).json()
def __get_trending_tv(self) -> dict:
return requests.get(f"{self.url}/tv/trending").json()
@@ -38,7 +42,10 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
return requests.get(f"{self.url}/movies/{id}").json()
def __search_movie(self, query: str) -> dict:
return requests.get(f"{self.url}/movies/search", params={"query": query}).json()
return requests.get(
f"{self.url}/movies/search",
params={"query": remove_special_chars_and_parentheses(query)},
).json()
def __get_trending_movies(self) -> dict:
return requests.get(f"{self.url}/movies/trending").json()

View File

@@ -12,7 +12,7 @@ from media_manager.indexer.schemas import (
)
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
from media_manager.schemas import MediaImportSuggestion
from media_manager.torrent.utils import detect_unknown_media
from media_manager.torrent.utils import get_importable_media_directories
from media_manager.torrent.schemas import Torrent
from media_manager.movies import log
from media_manager.movies.schemas import (
@@ -106,15 +106,7 @@ def get_all_importable_movies(
"""
get a list of unknown movies that were detected in the movie directory and are importable
"""
directories = detect_unknown_media(AllEncompassingConfig().misc.movie_directory)
movies = []
for directory in directories:
movies.append(
movie_service.get_import_candidates(
movie=directory, metadata_provider=metadata_provider
)
)
return movies
return movie_service.get_importable_movies(metadata_provider=metadata_provider)
@router.post(
@@ -129,7 +121,7 @@ def import_detected_movie(
get a list of unknown movies that were detected in the movie directory and are importable
"""
source_directory = Path(directory)
if source_directory not in detect_unknown_media(
if source_directory not in get_importable_media_directories(
AllEncompassingConfig().misc.movie_directory
):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No such directory")

View File

@@ -37,7 +37,9 @@ from media_manager.torrent.utils import (
import_file,
get_files_for_import,
remove_special_characters,
strip_trailing_year,
get_importable_media_directories,
extract_external_id_from_string,
remove_special_chars_and_parentheses,
)
from media_manager.indexer.service import IndexerService
from media_manager.metadataProvider.abstractMetaDataProvider import (
@@ -642,7 +644,7 @@ class MovieService:
self, movie: Path, metadata_provider: AbstractMetadataProvider
) -> MediaImportSuggestion:
search_result = self.search_for_movie(
strip_trailing_year(movie.name), metadata_provider
remove_special_chars_and_parentheses(movie.name), metadata_provider
)
import_candidates = MediaImportSuggestion(
directory=movie, candidates=search_result
@@ -653,8 +655,17 @@ class MovieService:
return import_candidates
def import_existing_movie(self, movie: Movie, source_directory: Path) -> bool:
new_source_path = source_directory.parent / ("." + source_directory.name)
try:
source_directory.rename(new_source_path)
except Exception as e:
log.error(
f"Failed to rename directory '{source_directory}' to '{new_source_path}': {e}"
)
raise Exception("Failed to rename directory") from e
video_files, subtitle_files, all_files = get_files_for_import(
directory=source_directory
directory=new_source_path
)
success = self.import_movie(
@@ -673,15 +684,6 @@ class MovieService:
)
)
new_source_path = source_directory.parent / ("." + source_directory.name)
try:
source_directory.rename(new_source_path)
except Exception as e:
log.error(
f"Failed to rename directory '{source_directory}' to '{new_source_path}': {e}"
)
return False
return success
def update_movie_metadata(
@@ -718,6 +720,37 @@ class MovieService:
metadata_provider.download_movie_poster_image(movie=updated_movie)
return updated_movie
def get_importable_movies(
self, metadata_provider: AbstractMetadataProvider
) -> list[MediaImportSuggestion]:
movie_root_path = AllEncompassingConfig().misc.movie_directory
importable_movies: list[MediaImportSuggestion] = []
candidate_dirs = get_importable_media_directories(movie_root_path)
for movie_dir in candidate_dirs:
metadata, external_id = extract_external_id_from_string(movie_dir.name)
if metadata is not None and external_id is not None:
try:
self.movie_repository.get_movie_by_external_id(
external_id=external_id, metadata_provider=metadata
)
log.debug(
f"Movie {movie_dir.name} already exists in the database, skipping."
)
continue
except NotFoundError:
log.debug(
f"Movie {movie_dir.name} not found in database, checking for import candidates."
)
import_candidates = self.get_import_candidates(
movie=movie_dir, metadata_provider=metadata_provider
)
importable_movies.append(import_candidates)
log.debug(f"Found {len(importable_movies)} importable movies.")
return importable_movies
def auto_download_all_approved_movie_requests() -> None:
"""

View File

@@ -1,4 +1,4 @@
from sqlalchemy import select
from sqlalchemy import select, delete
from media_manager.database import DbSessionDependency
from media_manager.torrent.models import Torrent
@@ -55,7 +55,20 @@ class TorrentRepository:
raise NotFoundError(f"Torrent with ID {torrent_id} not found.")
return TorrentSchema.model_validate(result)
def delete_torrent(self, torrent_id: TorrentId):
def delete_torrent(
self, torrent_id: TorrentId, delete_associated_media_files: bool = False
):
if delete_associated_media_files:
movie_files_stmt = delete(MovieFile).where(
MovieFile.torrent_id == torrent_id
)
self.db.execute(movie_files_stmt)
season_files_stmt = delete(SeasonFile).where(
SeasonFile.torrent_id == torrent_id
)
self.db.execute(season_files_stmt)
self.db.delete(self.db.get(Torrent, torrent_id))
def get_movie_of_torrent(self, torrent_id: TorrentId):

View File

@@ -107,7 +107,10 @@ class TorrentService:
def delete_torrent(self, torrent_id: TorrentId):
t = self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
self.torrent_repository.delete_torrent(torrent_id=t.id)
delete_media_files = not t.imported
self.torrent_repository.delete_torrent(
torrent_id=torrent_id, delete_associated_media_files=delete_media_files
)
def get_movie_files_of_torrent(self, torrent: Torrent):
return self.torrent_repository.get_movie_files_of_torrent(torrent_id=torrent.id)

View File

@@ -190,31 +190,60 @@ def remove_special_characters(filename: str) -> str:
return sanitized
def strip_trailing_year(title: str) -> str:
def remove_special_chars_and_parentheses(title: str) -> str:
"""
Removes a trailing space + (4-digit year) at end of string
Removes special characters and bracketed information from the title.
:param title: The original title.
:return: A sanitized version of the title.
"""
return re.sub(r"\s*\(\d{4}\)\s*$", "", title).strip()
# Remove content within brackets
sanitized = re.sub(r"\[.*?\]", "", title)
# Remove content within curly brackets
sanitized = re.sub(r"\{.*?\}", "", sanitized)
# Remove year within parentheses
sanitized = re.sub(r"\(\d{4}\)", "", sanitized)
# Remove special characters
sanitized = remove_special_characters(sanitized)
# Collapse multiple whitespace characters and trim the result
sanitized = re.sub(r"\s+", " ", sanitized).strip()
return sanitized
def detect_unknown_media(path: Path) -> list[Path]:
def get_importable_media_directories(path: Path) -> list[Path]:
libraries = []
libraries.extend(AllEncompassingConfig().misc.movie_libraries)
libraries.extend(AllEncompassingConfig().misc.tv_libraries)
show_dirs = path.glob("*")
log.debug(f"Using Directory {path}")
unknown_tv_shows = []
for media_dir in show_dirs:
# check if directory is one created by MediaManager (contains [tmdbd/tvdbid-0000) or if it is a library
if (
re.search(r"\[(?:tmdbid|tvdbid)-\d+]", media_dir.name, re.IGNORECASE)
or media_dir.absolute()
in [Path(library.path).absolute() for library in libraries]
or media_dir.name.startswith(".")
library_paths = {Path(library.path).absolute() for library in libraries}
unfiltered_dirs = [d for d in path.glob("*") if d.is_dir()]
media_dirs = []
for media_dir in unfiltered_dirs:
if media_dir.absolute() not in library_paths and not media_dir.name.startswith(
"."
):
log.debug(f"MediaManager directory detected: {media_dir.name}")
else:
log.info(f"Detected unknown media directory: {media_dir.name}")
unknown_tv_shows.append(media_dir)
return unknown_tv_shows
media_dirs.append(media_dir)
return media_dirs
def extract_external_id_from_string(input_string: str) -> tuple[str | None, int | None]:
"""
Extracts an external ID (tmdb/tvdb ID) from the given string.
:param input_string: The string to extract the ID from.
:return: The extracted Metadata Provider and ID or None if not found.
"""
match = re.search(
r"\b(tmdb|tvdb)(?:id)?[-_]?([0-9]+)\b", input_string, re.IGNORECASE
)
if match:
return match.group(1).lower(), int(match.group(2))
return None, None

View File

@@ -12,7 +12,7 @@ from media_manager.indexer.schemas import (
IndexerQueryResult,
)
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
from media_manager.torrent.utils import detect_unknown_media
from media_manager.torrent.utils import get_importable_media_directories
from media_manager.torrent.schemas import Torrent
from media_manager.tv import log
from media_manager.exceptions import MediaAlreadyExists
@@ -126,15 +126,7 @@ def get_all_importable_shows(
"""
get a list of unknown shows that were detected in the tv directory and are importable
"""
directories = detect_unknown_media(AllEncompassingConfig().misc.tv_directory)
shows = []
for directory in directories:
shows.append(
tv_service.get_import_candidates(
tv_show=directory, metadata_provider=metadata_provider
)
)
return shows
return tv_service.get_importable_tv_shows(metadata_provider=metadata_provider)
@router.post(
@@ -147,7 +139,7 @@ def import_detected_show(tv_service: tv_service_dep, tv_show: show_dep, director
Import a detected show from the specified directory into the library.
"""
source_directory = Path(directory)
if source_directory not in detect_unknown_media(
if source_directory not in get_importable_media_directories(
AllEncompassingConfig().misc.tv_directory
):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No such directory")

View File

@@ -42,7 +42,9 @@ from media_manager.torrent.utils import (
import_file,
get_files_for_import,
remove_special_characters,
strip_trailing_year,
get_importable_media_directories,
extract_external_id_from_string,
remove_special_chars_and_parentheses,
)
from media_manager.indexer.service import IndexerService
from media_manager.metadataProvider.abstractMetaDataProvider import (
@@ -877,7 +879,7 @@ class TvService:
self, tv_show: Path, metadata_provider: AbstractMetadataProvider
) -> MediaImportSuggestion:
search_result = self.search_for_show(
strip_trailing_year(tv_show.name), metadata_provider
remove_special_chars_and_parentheses(tv_show.name), metadata_provider
)
import_candidates = MediaImportSuggestion(
directory=tv_show, candidates=search_result
@@ -888,8 +890,15 @@ class TvService:
return import_candidates
def import_existing_tv_show(self, tv_show: Show, source_directory: Path) -> None:
new_source_path = source_directory.parent / ("." + source_directory.name)
try:
source_directory.rename(new_source_path)
except Exception as e:
log.error(f"Failed to rename {source_directory} to {new_source_path}: {e}")
raise Exception("Failed to rename source directory") from e
video_files, subtitle_files, all_files = get_files_for_import(
directory=source_directory
directory=new_source_path
)
for season in tv_show.seasons:
success, imported_episode_count = self.import_season(
@@ -908,11 +917,37 @@ class TvService:
if success or imported_episode_count > (len(season.episodes) / 2):
self.tv_repository.add_season_file(season_file=season_file)
new_source_path = source_directory.parent / ("." + source_directory.name)
try:
source_directory.rename(new_source_path)
except Exception as e:
log.error(f"Failed to rename {source_directory} to {new_source_path}: {e}")
def get_importable_tv_shows(
self, metadata_provider: AbstractMetadataProvider
) -> list[MediaImportSuggestion]:
tv_directory = AllEncompassingConfig().misc.tv_directory
import_suggestions: list[MediaImportSuggestion] = []
candidate_dirs = get_importable_media_directories(tv_directory)
for item in candidate_dirs:
metadata, external_id = extract_external_id_from_string(item.name)
if metadata is not None and external_id is not None:
try:
self.tv_repository.get_show_by_external_id(
external_id=external_id,
metadata_provider=metadata,
)
log.debug(
f"Show {item.name} already exists in the database, skipping import suggestion."
)
continue
except NotFoundError:
log.debug(
f"Show {item.name} not found in database, checking for import candidates."
)
import_suggestion = self.get_import_candidates(
tv_show=item, metadata_provider=metadata_provider
)
import_suggestions.append(import_suggestion)
log.debug(f"Detected {len(import_suggestions)} importable TV shows.")
return import_suggestions
def auto_download_all_approved_season_requests() -> None:

View File

@@ -29,7 +29,7 @@
});
client.GET('/api/v1/tv/importable').then(({ data, error }) => {
if (!error) {
importableMovies = data;
importableShows = data;
}
});
}