diff --git a/apps/media_library/api_urls.py b/apps/media_library/api_urls.py index 4b371f8c..e3a3ab80 100644 --- a/apps/media_library/api_urls.py +++ b/apps/media_library/api_urls.py @@ -7,6 +7,7 @@ from apps.media_library.api_views import ( MediaItemViewSet, browse_library_path, ) +from apps.media_library.artwork import artwork_backdrop, artwork_poster app_name = "media_library" @@ -16,6 +17,16 @@ router.register(r"scans", LibraryScanViewSet, basename="library-scan") router.register(r"items", MediaItemViewSet, basename="media-item") urlpatterns = [ + path( + "items//artwork/poster/", + artwork_poster, + name="media-item-artwork-poster", + ), + path( + "items//artwork/backdrop/", + artwork_backdrop, + name="media-item-artwork-backdrop", + ), path("browse/", browse_library_path, name="browse"), ] diff --git a/apps/media_library/api_views.py b/apps/media_library/api_views.py index 1c39bde9..abb2b542 100644 --- a/apps/media_library/api_views.py +++ b/apps/media_library/api_views.py @@ -1,10 +1,17 @@ +import logging +import mimetypes import os +from django.conf import settings +from django.http import FileResponse +from django.shortcuts import get_object_or_404 from django.utils import timezone from django.urls import reverse +from django.db.models import Count, Q from rest_framework import status, viewsets from rest_framework.decorators import action, api_view, permission_classes from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import AllowAny from rest_framework.response import Response from apps.accounts.permissions import Authenticated, IsAdmin, permission_classes_by_action @@ -16,9 +23,59 @@ from apps.media_library.serializers import ( MediaItemSerializer, MediaItemUpdateSerializer, ) +from apps.media_library.metadata import find_local_artwork_path from apps.media_library.tasks import refresh_media_item_metadata, scan_library from apps.media_library.vod import sync_library_vod_account_state, sync_vod_for_media_item +logger = logging.getLogger(__name__) + + +def _serve_artwork_response(request, item: MediaItem, asset_type: str): + logger.debug( + "Artwork request path=%s item_id=%s asset_type=%s", + request.path, + item.id, + asset_type, + ) + path = find_local_artwork_path(item, asset_type) + if not path: + file = item.files.filter(is_primary=True).first() or item.files.first() + logger.debug( + "Artwork not found item_id=%s asset_type=%s library_id=%s file_path=%s", + item.id, + asset_type, + item.library_id, + file.path if file else None, + ) + response = Response( + {"detail": "Artwork not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + if settings.DEBUG: + response["X-Dispatcharr-Artwork-Status"] = "not-found" + return response + try: + content_type = mimetypes.guess_type(path)[0] or "application/octet-stream" + response = FileResponse(open(path, "rb"), content_type=content_type) + except OSError: + logger.debug( + "Artwork file unavailable item_id=%s asset_type=%s path=%s", + item.id, + asset_type, + path, + ) + response = Response( + {"detail": "Artwork not available."}, + status=status.HTTP_404_NOT_FOUND, + ) + if settings.DEBUG: + response["X-Dispatcharr-Artwork-Status"] = "unavailable" + return response + response["Cache-Control"] = "private, max-age=3600" + if settings.DEBUG: + response["X-Dispatcharr-Artwork-Path"] = path + return response + class MediaLibraryPagination(PageNumberPagination): page_size = 200 @@ -32,9 +89,25 @@ class MediaLibraryPagination(PageNumberPagination): class LibraryViewSet(viewsets.ModelViewSet): - queryset = Library.objects.prefetch_related("locations") serializer_class = LibrarySerializer + def get_queryset(self): + return ( + Library.objects.prefetch_related("locations") + .annotate( + movie_count=Count( + "items", + filter=Q(items__item_type=MediaItem.TYPE_MOVIE), + distinct=True, + ), + show_count=Count( + "items", + filter=Q(items__item_type=MediaItem.TYPE_SHOW), + distinct=True, + ), + ) + ) + def get_permissions(self): try: return [perm() for perm in permission_classes_by_action[self.action]] @@ -153,6 +226,9 @@ class MediaItemViewSet(viewsets.ModelViewSet): try: return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: + action = getattr(self, self.action, None) + if action and hasattr(action, "permission_classes"): + return [perm() for perm in action.permission_classes] return [Authenticated()] def get_queryset(self): @@ -270,6 +346,28 @@ class MediaItemViewSet(viewsets.ModelViewSet): } ) + def _serve_artwork(self, request, asset_type: str): + item = self.get_object() + return _serve_artwork_response(request, item, asset_type) + + @action( + detail=True, + methods=["get"], + url_path="artwork/poster", + permission_classes=[AllowAny], + ) + def artwork_poster(self, request, pk=None): + return self._serve_artwork(request, "poster") + + @action( + detail=True, + methods=["get"], + url_path="artwork/backdrop", + permission_classes=[AllowAny], + ) + def artwork_backdrop(self, request, pk=None): + return self._serve_artwork(request, "backdrop") + def _get_duration_ms(self, media_item: MediaItem) -> int: if media_item.runtime_ms: return media_item.runtime_ms @@ -437,6 +535,20 @@ class MediaItemViewSet(viewsets.ModelViewSet): return Response({"item": serializer.data}) +@api_view(["GET"]) +@permission_classes([AllowAny]) +def artwork_poster(request, pk: int): + item = get_object_or_404(MediaItem, pk=pk) + return _serve_artwork_response(request, item, "poster") + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def artwork_backdrop(request, pk: int): + item = get_object_or_404(MediaItem, pk=pk) + return _serve_artwork_response(request, item, "backdrop") + + @api_view(["GET"]) @permission_classes([IsAdmin]) def browse_library_path(request): diff --git a/apps/media_library/artwork.py b/apps/media_library/artwork.py new file mode 100644 index 00000000..0fe0dd03 --- /dev/null +++ b/apps/media_library/artwork.py @@ -0,0 +1,76 @@ +import logging +import mimetypes + +from django.conf import settings +from django.http import FileResponse +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from apps.media_library.metadata import find_local_artwork_path +from apps.media_library.models import MediaItem + +logger = logging.getLogger(__name__) + + +def serve_artwork_response(request, item: MediaItem, asset_type: str): + logger.debug( + "Artwork request path=%s item_id=%s asset_type=%s", + request.path, + item.id, + asset_type, + ) + path = find_local_artwork_path(item, asset_type) + if not path: + file = item.files.filter(is_primary=True).first() or item.files.first() + logger.debug( + "Artwork not found item_id=%s asset_type=%s library_id=%s file_path=%s", + item.id, + asset_type, + item.library_id, + file.path if file else None, + ) + response = Response( + {"detail": "Artwork not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + if settings.DEBUG: + response["X-Dispatcharr-Artwork-Status"] = "not-found" + return response + try: + content_type = mimetypes.guess_type(path)[0] or "application/octet-stream" + response = FileResponse(open(path, "rb"), content_type=content_type) + except OSError: + logger.debug( + "Artwork file unavailable item_id=%s asset_type=%s path=%s", + item.id, + asset_type, + path, + ) + response = Response( + {"detail": "Artwork not available."}, + status=status.HTTP_404_NOT_FOUND, + ) + if settings.DEBUG: + response["X-Dispatcharr-Artwork-Status"] = "unavailable" + return response + response["Cache-Control"] = "private, max-age=3600" + if settings.DEBUG: + response["X-Dispatcharr-Artwork-Path"] = path + return response + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def artwork_poster(request, pk: int): + item = get_object_or_404(MediaItem, pk=pk) + return serve_artwork_response(request, item, "poster") + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def artwork_backdrop(request, pk: int): + item = get_object_or_404(MediaItem, pk=pk) + return serve_artwork_response(request, item, "backdrop") diff --git a/apps/media_library/metadata.py b/apps/media_library/metadata.py index 83da08c4..2ac99251 100644 --- a/apps/media_library/metadata.py +++ b/apps/media_library/metadata.py @@ -1,4 +1,6 @@ import logging +import os +import xml.etree.ElementTree as ET from typing import Any, Dict, Optional, Tuple import requests @@ -6,12 +8,14 @@ from dateutil import parser as date_parser from django.core.cache import cache from django.utils import timezone +from core.models import CoreSettings from apps.media_library.models import ArtworkAsset, MediaItem from apps.media_library.utils import normalize_title logger = logging.getLogger(__name__) IMAGE_BASE_URL = "https://image.tmdb.org/t/p/original" +TMDB_API_BASE_URL = "https://api.themoviedb.org/3" MOVIEDB_SEARCH_URL = "https://movie-db.org/api.php" MOVIEDB_EPISODE_URL = "https://movie-db.org/episode.php" MOVIEDB_CREDITS_URL = "https://movie-db.org/credits.php" @@ -20,6 +24,7 @@ MOVIEDB_HEALTH_CACHE_KEY = "movie-db:health" MOVIEDB_HEALTH_SUCCESS_TTL = 60 * 15 MOVIEDB_HEALTH_FAILURE_TTL = 60 * 2 +# Shared HTTP session + in-memory caches to reduce duplicate lookups. _REQUESTS_SESSION = requests.Session() _REQUESTS_SESSION.mount( "https://", @@ -28,7 +33,10 @@ _REQUESTS_SESSION.mount( _MOVIEDB_SEARCH_CACHE: dict[tuple[str, ...], tuple[dict | None, str | None]] = {} _MOVIEDB_CREDITS_CACHE: dict[tuple[str, str], tuple[dict | None, str | None]] = {} +_TMDB_SEARCH_CACHE: dict[tuple[str, ...], tuple[dict | None, str | None]] = {} +_TMDB_DETAIL_CACHE: dict[tuple[str, str], tuple[dict | None, str | None]] = {} +# TMDB/Movie-DB genre IDs mapped to display values. GENRE_ID_MAP: dict[int, str] = { 12: "Adventure", 14: "Fantasy", @@ -61,6 +69,7 @@ GENRE_ID_MAP: dict[int, str] = { def _get_library_metadata_prefs(media_item: MediaItem) -> dict[str, Any]: + # Merge library language/region fields with optional metadata overrides. prefs: dict[str, Any] = {"language": None, "region": None} library = getattr(media_item, "library", None) if not library: @@ -79,10 +88,16 @@ def _get_library_metadata_prefs(media_item: MediaItem) -> dict[str, Any]: return prefs -def _metadata_cache_key(media_item: MediaItem, prefs: dict[str, Any] | None = None) -> str: +def _metadata_cache_key( + media_item: MediaItem, + prefs: dict[str, Any] | None = None, + *, + provider: str = "movie-db", +) -> str: + # Include provider + identifiers to avoid collisions across sources. normalized = normalize_title(media_item.title) or media_item.normalized_title or "" key_parts = [ - "movie-db", + provider, str(media_item.item_type or ""), normalized, str(media_item.release_year or ""), @@ -114,6 +129,7 @@ def build_image_url(path: Optional[str]) -> Optional[str]: def _to_serializable(obj): + # Force JSON-safe values so metadata payloads can be persisted. if isinstance(obj, dict): return {key: _to_serializable(value) for key, value in obj.items()} if isinstance(obj, (list, tuple, set)): @@ -123,7 +139,63 @@ def _to_serializable(obj): return str(obj) +def _get_tmdb_api_key() -> Optional[str]: + # Prefer stored setting, but allow env var for deployments. + key = CoreSettings.get_tmdb_api_key() or os.environ.get("TMDB_API_KEY") + if not key: + return None + key = str(key).strip() + return key or None + + +def _is_http_url(value: str | None) -> bool: + if not value: + return False + return value.startswith("http://") or value.startswith("https://") + + +def _safe_xml_text(node: ET.Element | None) -> Optional[str]: + if node is None: + return None + text = node.text or "" + text = text.strip() + return text or None + + +def _parse_xml_int(text: str | None) -> Optional[int]: + if not text: + return None + try: + return int(float(text)) + except (TypeError, ValueError): + return None + + +def _parse_xml_float(text: str | None) -> Optional[float]: + if not text: + return None + try: + return float(text) + except (TypeError, ValueError): + return None + + +def _normalize_numeric_rating(text: str | None) -> Optional[str]: + # MediaItem.rating expects numeric strings; skip non-numeric ratings. + if not text: + return None + value = str(text).strip() + if not value: + return None + try: + float(value) + except (TypeError, ValueError): + return None + return value + + def check_movie_db_health(*, use_cache: bool = True) -> Tuple[bool, str | None]: + # Lightweight probe to decide whether Movie-DB is usable. if use_cache: cached = cache.get(MOVIEDB_HEALTH_CACHE_KEY) if cached is not None: @@ -176,6 +248,449 @@ def _parse_release_year(value: Optional[str]) -> Optional[int]: return None +def _nfo_rating(root: ET.Element) -> Optional[str]: + rating = _normalize_numeric_rating(_safe_xml_text(root.find("rating"))) + if rating: + return rating + ratings = root.find("ratings") + if ratings is None: + return None + entries = ratings.findall("rating") + if not entries: + return None + default_entry = next( + ( + entry + for entry in entries + if str(entry.get("default", "")).lower() in {"true", "1", "yes"} + ), + None, + ) + entry = default_entry or entries[0] + value = _normalize_numeric_rating( + _safe_xml_text(entry.find("value")) or _safe_xml_text(entry) + ) + return value + + +def _nfo_list(root: ET.Element, tag: str) -> list[str]: + values: list[str] = [] + for node in root.findall(tag): + value = _safe_xml_text(node) + if value: + values.append(value) + return values + + +def _nfo_cast(root: ET.Element) -> list[dict[str, Any]]: + cast: list[dict[str, Any]] = [] + for actor in root.findall("actor"): + name = _safe_xml_text(actor.find("name")) or _safe_xml_text(actor) + if not name: + continue + cast.append( + { + "name": name, + "character": _safe_xml_text(actor.find("role")), + "profile_url": _safe_xml_text(actor.find("thumb")), + } + ) + return cast + + +def _nfo_crew(root: ET.Element) -> list[dict[str, Any]]: + crew: list[dict[str, Any]] = [] + for director in root.findall("director"): + name = _safe_xml_text(director) + if name: + crew.append({"name": name, "job": "Director", "department": "Directing"}) + for writer in root.findall("credits"): + name = _safe_xml_text(writer) + if name: + crew.append({"name": name, "job": "Writer", "department": "Writing"}) + return crew + + +def _nfo_art(root: ET.Element) -> tuple[Optional[str], Optional[str]]: + poster_url = None + backdrop_url = None + + for thumb in root.findall("thumb"): + url = _safe_xml_text(thumb) + if not url: + continue + aspect = (thumb.get("aspect") or thumb.get("type") or "").lower() + if not poster_url and aspect in {"poster", "thumb"}: + poster_url = url + if not backdrop_url and aspect in {"fanart", "backdrop", "landscape"}: + backdrop_url = url + + fanart = root.find("fanart") + if fanart is not None: + for thumb in fanart.findall("thumb"): + url = _safe_xml_text(thumb) + if url: + backdrop_url = backdrop_url or url + break + + art = root.find("art") + if art is not None: + if not poster_url: + poster_url = _safe_xml_text(art.find("poster")) + if not backdrop_url: + backdrop_url = _safe_xml_text(art.find("fanart")) or _safe_xml_text( + art.find("backdrop") + ) + + if poster_url and not _is_http_url(poster_url): + poster_url = None + if backdrop_url and not _is_http_url(backdrop_url): + backdrop_url = None + + return poster_url, backdrop_url + + +def _nfo_unique_ids(root: ET.Element) -> tuple[Optional[str], Optional[str]]: + imdb_id = None + tmdb_id = None + for node in root.findall("uniqueid"): + value = _safe_xml_text(node) + if not value: + continue + id_type = str(node.get("type", "")).lower() + if id_type == "imdb": + imdb_id = value + elif id_type in {"tmdb", "themoviedb"}: + tmdb_id = value + if not tmdb_id: + raw_id = _safe_xml_text(root.find("id")) + if raw_id and raw_id.isdigit(): + tmdb_id = raw_id + return imdb_id, tmdb_id + + +def _nfo_runtime_minutes(root: ET.Element) -> Optional[int]: + runtime = _parse_xml_float(_safe_xml_text(root.find("runtime"))) + if runtime: + return int(round(runtime)) + duration = ( + _safe_xml_text(root.find("./fileinfo/streamdetails/video/durationinseconds")) + or _safe_xml_text(root.find("./fileinfo/streamdetails/video/duration")) + ) + seconds = _parse_xml_float(duration) + if seconds: + return int(round(seconds / 60)) + return None + + +def _find_library_base_path(file_path: str, library) -> Optional[str]: + # Identify which library location contains the file path. + if not file_path: + return None + normalized_file_path = os.path.normcase( + os.path.abspath(os.path.expanduser(file_path)) + ) + for location in library.locations.all(): + base_path = os.path.normcase( + os.path.abspath(os.path.expanduser(location.path)) + ) + try: + if os.path.commonpath([base_path, normalized_file_path]) == base_path: + return base_path + except ValueError: + continue + return None + + +def _find_nfo_in_directory( + directory: str, + *, + preferred_names: list[str], + allow_single_fallback: bool = True, +) -> Optional[str]: + # Prefer named NFOs; optionally fallback to the only NFO when unambiguous. + try: + entries = [ + entry + for entry in os.listdir(directory) + if entry.lower().endswith(".nfo") + ] + except OSError: + return None + + entries_by_lower = {entry.lower(): entry for entry in entries} + + for name in preferred_names: + matched = entries_by_lower.get(name.lower()) + if matched: + return os.path.join(directory, matched) + + if allow_single_fallback and len(entries) == 1: + return os.path.join(directory, entries[0]) + return None + + +def _iter_parent_dirs(start_dir: str, *, stop_dir: Optional[str] = None): + if not start_dir: + return + current = os.path.abspath(os.path.expanduser(start_dir)) + stop = os.path.abspath(os.path.expanduser(stop_dir)) if stop_dir else None + stop_norm = os.path.normcase(stop) if stop else None + + while True: + yield current + if stop_norm and os.path.normcase(current) == stop_norm: + break + parent = os.path.dirname(current) + if parent == current: + break + if stop: + try: + if os.path.commonpath([stop, parent]) != stop: + break + except ValueError: + break + current = parent + + +def _find_local_artwork_files( + directory: str | None, +) -> tuple[Optional[str], Optional[str]]: + # Prefer poster.jpg and fanart.jpg when present alongside NFO. + if not directory: + return None, None + try: + entries = [entry for entry in os.listdir(directory) if entry] + except OSError: + return None, None + + entries_by_lower = {entry.lower(): entry for entry in entries} + poster_name = entries_by_lower.get("poster.jpg") + fanart_name = entries_by_lower.get("fanart.jpg") + + poster_path = os.path.join(directory, poster_name) if poster_name else None + fanart_path = os.path.join(directory, fanart_name) if fanart_name else None + logger.debug( + "Local artwork files directory=%s poster=%s fanart=%s", + directory, + poster_path, + fanart_path, + ) + return poster_path, fanart_path + + +def _local_artwork_url(media_item: MediaItem, asset_type: str) -> Optional[str]: + if not media_item.id: + return None + return f"/api/media-library/items/{media_item.id}/artwork/{asset_type}/" + + +def find_local_artwork_path(media_item: MediaItem, asset_type: str) -> Optional[str]: + # Resolve the local artwork path for API responses. + logger.debug( + "Artwork lookup start item_id=%s asset_type=%s", + media_item.id, + asset_type, + ) + if asset_type not in {"poster", "backdrop"}: + logger.debug("Artwork lookup skipped: unsupported asset_type=%s", asset_type) + return None + + nfo_path = _find_local_nfo_path(media_item) + directory = os.path.dirname(nfo_path) if nfo_path else None + if not directory: + file = media_item.files.filter(is_primary=True).first() or media_item.files.first() + if file and file.path: + directory = os.path.dirname(file.path) + logger.debug( + "Artwork lookup using media file directory=%s file_path=%s", + directory, + file.path, + ) + else: + logger.debug("Artwork lookup failed: no media file path available") + if not directory: + return None + + poster_path, fanart_path = _find_local_artwork_files(directory) + target_path = poster_path if asset_type == "poster" else fanart_path + if not target_path: + logger.debug( + "Artwork lookup missing target asset_type=%s directory=%s", + asset_type, + directory, + ) + return None + + base_path = _find_library_base_path(target_path, media_item.library) + if not base_path: + logger.debug( + "Artwork lookup failed: path not under library base path=%s library_id=%s", + target_path, + media_item.library_id, + ) + return None + logger.debug( + "Artwork lookup success item_id=%s asset_type=%s path=%s base_path=%s", + media_item.id, + asset_type, + target_path, + base_path, + ) + return target_path + + +def _find_local_nfo_path(media_item: MediaItem) -> Optional[str]: + # Locate the most likely NFO for movies, shows, or episodes. + files = list(media_item.files.all()) + if not files and media_item.item_type == MediaItem.TYPE_SHOW: + episodes = list( + MediaItem.objects.filter(parent=media_item, item_type=MediaItem.TYPE_EPISODE) + .exclude(files__isnull=True) + .prefetch_related("files")[:5] + ) + files = [] + for episode in episodes: + files.extend(list(episode.files.all())) + + for media_file in files: + file_path = media_file.path + if not file_path: + continue + directory = os.path.dirname(file_path) + base_name = os.path.splitext(os.path.basename(file_path))[0] + + if media_item.item_type in {MediaItem.TYPE_MOVIE, MediaItem.TYPE_EPISODE}: + preferred = [f"{base_name}.nfo"] + if media_item.item_type == MediaItem.TYPE_MOVIE: + preferred.append("movie.nfo") + if media_item.item_type == MediaItem.TYPE_EPISODE: + preferred.append("episode.nfo") + candidate = _find_nfo_in_directory( + directory, + preferred_names=preferred, + allow_single_fallback=media_item.item_type == MediaItem.TYPE_MOVIE, + ) + if candidate: + return candidate + continue + + if media_item.item_type == MediaItem.TYPE_SHOW: + base_path = _find_library_base_path(file_path, media_item.library) + for root_dir in _iter_parent_dirs(directory, stop_dir=base_path): + candidate = _find_nfo_in_directory( + root_dir, + preferred_names=["tvshow.nfo"], + allow_single_fallback=False, + ) + if candidate: + return candidate + return None + + +def fetch_local_nfo_metadata( + media_item: MediaItem, +) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + # Parse NFO XML and map to MediaItem metadata fields. + nfo_path = _find_local_nfo_path(media_item) + if not nfo_path: + return None, "No NFO file found." + + try: + tree = ET.parse(nfo_path) + except (ET.ParseError, OSError) as exc: + return None, f"Failed to parse NFO: {exc}" + + root = tree.getroot() + if root is None: + return None, "NFO did not contain metadata." + + imdb_id, tmdb_id = _nfo_unique_ids(root) + title = _safe_xml_text(root.find("title")) or _safe_xml_text( + root.find("originaltitle") + ) + sort_title = _safe_xml_text(root.find("sorttitle")) + synopsis = _safe_xml_text(root.find("plot")) or _safe_xml_text(root.find("outline")) + tagline = _safe_xml_text(root.find("tagline")) + premiered = _safe_xml_text(root.find("premiered")) or _safe_xml_text(root.find("aired")) + release_year = _parse_xml_int(_safe_xml_text(root.find("year"))) or _parse_release_year(premiered) + runtime_minutes = _nfo_runtime_minutes(root) + genres = _nfo_list(root, "genre") + tags = _nfo_list(root, "tag") + studios = _nfo_list(root, "studio") + rating = _nfo_rating(root) + poster_url, backdrop_url = _nfo_art(root) + local_poster_path, local_backdrop_path = _find_local_artwork_files( + os.path.dirname(nfo_path) + ) + if local_poster_path: + poster_url = _local_artwork_url(media_item, "poster") or poster_url + if local_backdrop_path: + backdrop_url = _local_artwork_url(media_item, "backdrop") or backdrop_url + cast = _nfo_cast(root) + crew = _nfo_crew(root) + + if not any( + [ + title, + synopsis, + tagline, + release_year, + runtime_minutes, + genres, + tags, + studios, + rating, + poster_url, + backdrop_url, + imdb_id, + tmdb_id, + cast, + crew, + ] + ): + return None, "NFO did not contain usable metadata." + + payload = _to_serializable( + { + "source": "nfo", + "title": title, + "sort_title": sort_title, + "synopsis": synopsis, + "tagline": tagline, + "release_year": release_year, + "runtime_minutes": runtime_minutes, + "genres": genres, + "tags": tags, + "studios": studios, + "rating": rating, + "poster": poster_url, + "backdrop": backdrop_url, + "imdb_id": imdb_id, + "tmdb_id": tmdb_id, + "raw": { + "path": nfo_path, + "title": title, + "sort_title": sort_title, + "synopsis": synopsis, + "tagline": tagline, + "release_year": release_year, + "runtime_minutes": runtime_minutes, + "genres": genres, + "tags": tags, + "studios": studios, + "rating": rating, + "poster": poster_url, + "backdrop": backdrop_url, + "imdb_id": imdb_id, + "tmdb_id": tmdb_id, + }, + } + ) + + return payload, None + + def _php_value_from_string(value: str): lowered = value.lower() if lowered == "true": @@ -481,6 +996,7 @@ def _movie_db_fetch_credits( def _normalize_credits(payload: dict | None) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + # Normalize cast/crew into the shared MediaItem schema. if not payload: return [], [] cast_entries = payload.get("cast") or [] @@ -520,6 +1036,431 @@ def _normalize_credits(payload: dict | None) -> tuple[list[dict[str, Any]], list return cast, crew +def _tmdb_title(payload: dict) -> str: + return payload.get("title") or payload.get("name") or "" + + +def _tmdb_candidate_year(payload: dict) -> Optional[int]: + return _parse_release_year( + payload.get("release_date") or payload.get("first_air_date") + ) + + +def _select_tmdb_candidate( + results: list[dict], + title: str, + *, + year: Optional[int] = None, + prefs: dict[str, Any] | None = None, +) -> Optional[dict]: + # Score candidates by title, year, region, and artwork availability. + if not results: + return None + normalized_query = normalize_title(title) + region = (prefs or {}).get("region") + best_score = None + best_result = None + for idx, result in enumerate(results): + if not isinstance(result, dict): + continue + normalized_title = normalize_title(_tmdb_title(result)) + score = 0 + if normalized_query and normalized_title == normalized_query: + score += 6 + elif normalized_query and ( + normalized_query in normalized_title or normalized_title in normalized_query + ): + score += 3 + if year: + candidate_year = _tmdb_candidate_year(result) + if candidate_year and candidate_year == year: + score += 2 + if region: + origin_country = result.get("origin_country") or [] + if isinstance(origin_country, str): + origin_country = [origin_country] + if region in origin_country: + score += 2 + if result.get("poster_path"): + score += 2 + elif result.get("backdrop_path"): + score += 1 + score = (score, -idx) + if best_score is None or score > best_score: + best_score = score + best_result = result + return best_result or (results[0] if results else None) + + +def _tmdb_request( + path: str, + *, + params: dict[str, Any], + timeout: int = 6, +) -> tuple[Optional[dict], Optional[str]]: + # TMDB API request wrapper with consistent error handling. + try: + response = _REQUESTS_SESSION.get( + f"{TMDB_API_BASE_URL}{path}", + params=params, + timeout=timeout, + ) + except requests.RequestException as exc: + return None, f"TMDB request failed: {exc}" + if response.status_code != 200: + return None, f"TMDB returned status {response.status_code}." + try: + payload = response.json() + except ValueError: + return None, "TMDB returned an unexpected response format." + if not isinstance(payload, dict): + return None, "TMDB returned an unexpected payload." + return payload, None + + +def _tmdb_search( + media_type: str, + title: str, + *, + year: Optional[int] = None, + prefs: dict[str, Any] | None = None, +) -> tuple[Optional[dict], Optional[str]]: + api_key = _get_tmdb_api_key() + if not api_key: + return None, "TMDB API key is missing." + language = (prefs or {}).get("language") or "" + region = (prefs or {}).get("region") or "" + normalized_key = ( + media_type, + (title or "").strip().lower(), + str(year or ""), + str(language), + str(region), + ) + if normalized_key in _TMDB_SEARCH_CACHE: + return _TMDB_SEARCH_CACHE[normalized_key] + + params = {"api_key": api_key, "query": title} + if language: + params["language"] = language + if region: + params["region"] = region + if media_type == "movie" and year: + params["year"] = year + if media_type == "tv" and year: + params["first_air_date_year"] = year + + payload, error = _tmdb_request( + f"/search/{media_type}", + params=params, + ) + if error: + _TMDB_SEARCH_CACHE[normalized_key] = (None, error) + return None, error + + results = payload.get("results") or [] + if not results: + message = "TMDB search returned no matches." + _TMDB_SEARCH_CACHE[normalized_key] = (None, message) + return None, message + + candidate = _select_tmdb_candidate(results, title, year=year, prefs=prefs) + _TMDB_SEARCH_CACHE[normalized_key] = (candidate, None) + return candidate, None + + +def _tmdb_fetch_details( + media_type: str, + content_id: str, + *, + prefs: dict[str, Any] | None = None, +) -> tuple[Optional[dict], Optional[str]]: + api_key = _get_tmdb_api_key() + if not api_key: + return None, "TMDB API key is missing." + normalized_key = (media_type, str(content_id)) + if normalized_key in _TMDB_DETAIL_CACHE: + return _TMDB_DETAIL_CACHE[normalized_key] + + params = {"api_key": api_key, "append_to_response": "credits"} + if prefs and prefs.get("language"): + params["language"] = prefs["language"] + + payload, error = _tmdb_request( + f"/{media_type}/{content_id}", + params=params, + ) + if error: + _TMDB_DETAIL_CACHE[normalized_key] = (None, error) + return None, error + + _TMDB_DETAIL_CACHE[normalized_key] = (payload, None) + return payload, None + + +def _tmdb_fetch_episode_details( + tv_id: str, + season: int, + episode: int, + *, + prefs: dict[str, Any] | None = None, +) -> tuple[Optional[dict], Optional[str]]: + api_key = _get_tmdb_api_key() + if not api_key: + return None, "TMDB API key is missing." + params = {"api_key": api_key} + if prefs and prefs.get("language"): + params["language"] = prefs["language"] + payload, error = _tmdb_request( + f"/tv/{tv_id}/season/{season}/episode/{episode}", + params=params, + ) + return payload, error + + +def fetch_tmdb_metadata( + media_item: MediaItem, + *, + use_cache: bool = True, +) -> tuple[Optional[Dict[str, Any]], Optional[str]]: + # TMDB metadata lookup for movies, shows, and episodes. + if media_item.item_type not in { + MediaItem.TYPE_MOVIE, + MediaItem.TYPE_SHOW, + MediaItem.TYPE_EPISODE, + }: + return None, "TMDB does not support this media type." + + prefs = _get_library_metadata_prefs(media_item) + cache_key = _metadata_cache_key(media_item, prefs, provider="tmdb") + if use_cache: + cached = cache.get(cache_key) + if cached: + return cached, None + + if media_item.item_type == MediaItem.TYPE_MOVIE: + direct_id = None + if media_item.movie_db_id and media_item.metadata_source in {"tmdb", "nfo"}: + direct_id = str(media_item.movie_db_id) + + if direct_id: + details, message = _tmdb_fetch_details( + "movie", + direct_id, + prefs=prefs, + ) + else: + candidate, message = _tmdb_search( + "movie", + media_item.title, + year=media_item.release_year, + prefs=prefs, + ) + if not candidate: + return None, message + details, message = _tmdb_fetch_details( + "movie", + str(candidate.get("id")), + prefs=prefs, + ) + if not details: + return None, message + + credits_payload = details.get("credits") if isinstance(details, dict) else None + cast, crew = _normalize_credits(credits_payload) + genres = [entry.get("name") for entry in details.get("genres", []) if entry.get("name")] + runtime = details.get("runtime") + release_year = _parse_release_year(details.get("release_date")) or media_item.release_year + metadata = _to_serializable( + { + "tmdb_id": str(details.get("id") or ""), + "imdb_id": details.get("imdb_id"), + "title": details.get("title") or media_item.title, + "synopsis": details.get("overview") or media_item.synopsis, + "tagline": details.get("tagline") or media_item.tagline, + "release_year": release_year, + "poster": build_image_url(details.get("poster_path")), + "backdrop": build_image_url(details.get("backdrop_path")), + "runtime_minutes": runtime, + "genres": genres, + "studios": [ + entry.get("name") + for entry in details.get("production_companies", []) + if entry.get("name") + ], + "rating": str(details.get("vote_average")) + if details.get("vote_average") is not None + else None, + "cast": cast, + "crew": crew, + "source": "tmdb", + "raw": details, + } + ) + if use_cache: + cache.set(cache_key, metadata, METADATA_CACHE_TIMEOUT) + return metadata, None + + if media_item.item_type == MediaItem.TYPE_SHOW: + direct_id = None + if media_item.movie_db_id and media_item.metadata_source in {"tmdb", "nfo"}: + direct_id = str(media_item.movie_db_id) + + if direct_id: + details, message = _tmdb_fetch_details( + "tv", + direct_id, + prefs=prefs, + ) + else: + candidate, message = _tmdb_search( + "tv", + media_item.title, + year=media_item.release_year, + prefs=prefs, + ) + if not candidate: + return None, message + details, message = _tmdb_fetch_details( + "tv", + str(candidate.get("id")), + prefs=prefs, + ) + if not details: + return None, message + + credits_payload = details.get("credits") if isinstance(details, dict) else None + cast, crew = _normalize_credits(credits_payload) + genres = [entry.get("name") for entry in details.get("genres", []) if entry.get("name")] + runtime_list = details.get("episode_run_time") or [] + runtime = runtime_list[0] if runtime_list else None + release_year = _parse_release_year(details.get("first_air_date")) or media_item.release_year + metadata = _to_serializable( + { + "tmdb_id": str(details.get("id") or ""), + "title": details.get("name") or media_item.title, + "synopsis": details.get("overview") or media_item.synopsis, + "tagline": details.get("tagline") or media_item.tagline, + "release_year": release_year, + "poster": build_image_url(details.get("poster_path")), + "backdrop": build_image_url(details.get("backdrop_path")), + "runtime_minutes": runtime, + "genres": genres, + "studios": [ + entry.get("name") + for entry in details.get("production_companies", []) + if entry.get("name") + ], + "rating": str(details.get("vote_average")) + if details.get("vote_average") is not None + else None, + "cast": cast, + "crew": crew, + "source": "tmdb", + "raw": details, + } + ) + if use_cache: + cache.set(cache_key, metadata, METADATA_CACHE_TIMEOUT) + return metadata, None + + parent_series = media_item.parent or MediaItem.objects.filter( + pk=media_item.parent_id + ).first() + if not parent_series: + return None, "Unable to determine parent series for episode." + + tv_id = None + if parent_series.metadata_source in {"tmdb", "nfo"} and parent_series.movie_db_id: + tv_id = parent_series.movie_db_id + if not tv_id and isinstance(parent_series.metadata, dict): + tv_id = parent_series.metadata.get("id") or parent_series.metadata.get("tmdb_id") + + if not tv_id: + series_candidate, message = _tmdb_search( + "tv", + parent_series.title, + year=parent_series.release_year or media_item.release_year, + prefs=prefs, + ) + if not series_candidate: + return None, message + tv_id = series_candidate.get("id") + + if not tv_id: + return None, "TMDB did not return a tvId for this series." + + if media_item.season_number is None or media_item.episode_number is None: + return None, "Episode numbers are required for TMDB lookup." + + try: + season_number = int(media_item.season_number) + episode_number = int(media_item.episode_number) + except (TypeError, ValueError): + return None, "Episode numbers are required for TMDB lookup." + + details, message = _tmdb_fetch_episode_details( + str(tv_id), + season_number, + episode_number, + prefs=prefs, + ) + if not details: + return None, message + + release_year = _parse_release_year(details.get("air_date")) or media_item.release_year + cast = [] + for star in details.get("guest_stars", [])[:12]: + if not isinstance(star, dict): + continue + name = star.get("name") or star.get("original_name") + if not name: + continue + cast.append( + { + "name": name, + "character": star.get("character"), + "profile_url": build_image_url(star.get("profile_path")), + } + ) + crew = [] + for member in details.get("crew", [])[:15]: + if not isinstance(member, dict): + continue + name = member.get("name") or member.get("original_name") + if not name: + continue + crew.append( + { + "name": name, + "job": member.get("job"), + "department": member.get("department"), + "profile_url": build_image_url(member.get("profile_path")), + } + ) + + metadata = _to_serializable( + { + "tmdb_id": str(details.get("id") or ""), + "title": details.get("name") or media_item.title, + "synopsis": details.get("overview") or media_item.synopsis, + "release_year": release_year, + "poster": build_image_url(details.get("still_path")), + "backdrop": build_image_url(details.get("still_path")), + "runtime_minutes": details.get("runtime"), + "genres": [], + "cast": cast, + "crew": crew, + "source": "tmdb", + "raw": details, + } + ) + + if use_cache: + cache.set(cache_key, metadata, METADATA_CACHE_TIMEOUT) + return metadata, None + + def _movie_db_fetch_episode_details( tv_id: Any, season: int, episode: int ) -> tuple[Optional[dict], Optional[str]]: @@ -553,6 +1494,7 @@ def fetch_movie_db_metadata( *, use_cache: bool = True, ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + # Movie-DB fallback metadata lookup when TMDB is unavailable. if media_item.item_type not in { MediaItem.TYPE_MOVIE, MediaItem.TYPE_SHOW, @@ -561,7 +1503,7 @@ def fetch_movie_db_metadata( return None, "Movie-DB does not support this media type." prefs = _get_library_metadata_prefs(media_item) - cache_key = _metadata_cache_key(media_item, prefs) + cache_key = _metadata_cache_key(media_item, prefs, provider="movie-db") if use_cache: cached = cache.get(cache_key) if cached: @@ -655,7 +1597,9 @@ def fetch_movie_db_metadata( if not parent_series: return None, "Unable to determine parent series for episode." - tv_id = parent_series.movie_db_id or None + tv_id = None + if parent_series.metadata_source not in {"tmdb", "nfo"}: + tv_id = parent_series.movie_db_id or None if not tv_id and isinstance(parent_series.metadata, dict): tv_id = parent_series.metadata.get("movie_db_tv_id") if tv_id and not parent_series.movie_db_id: @@ -767,42 +1711,78 @@ def fetch_movie_db_metadata( return metadata, None -def apply_metadata(media_item: MediaItem, metadata: Dict[str, Any]) -> MediaItem: +def _is_empty_value(value: Any) -> bool: + if value is None: + return True + if isinstance(value, str): + return value.strip() == "" + if isinstance(value, (list, tuple, set, dict)): + return len(value) == 0 + if isinstance(value, (int, float)) and value == 0: + return True + return False + + +def apply_metadata( + media_item: MediaItem, + metadata: Dict[str, Any], + *, + fill_missing_only: bool = False, + update_source: bool = True, +) -> MediaItem: + # Optionally fill only missing fields, preserving existing metadata. changed = False update_fields: list[str] = [] - movie_db_id = metadata.get("movie_db_id") - if movie_db_id and movie_db_id != media_item.movie_db_id: + def can_update(current_value: Any) -> bool: + if not fill_missing_only: + return True + return _is_empty_value(current_value) + + movie_db_id = metadata.get("movie_db_id") or metadata.get("tmdb_id") + if movie_db_id and movie_db_id != media_item.movie_db_id and can_update(media_item.movie_db_id): media_item.movie_db_id = movie_db_id update_fields.append("movie_db_id") changed = True imdb_id = metadata.get("imdb_id") - if imdb_id and imdb_id != media_item.imdb_id: + if imdb_id and imdb_id != media_item.imdb_id and can_update(media_item.imdb_id): media_item.imdb_id = imdb_id update_fields.append("imdb_id") changed = True title = metadata.get("title") - if title and title != media_item.title: + if title and title != media_item.title and can_update(media_item.title): media_item.title = title update_fields.append("title") changed = True + sort_title = metadata.get("sort_title") + if sort_title and sort_title != media_item.sort_title and can_update(media_item.sort_title): + media_item.sort_title = sort_title + update_fields.append("sort_title") + changed = True + synopsis = metadata.get("synopsis") - if synopsis and synopsis != media_item.synopsis: + if synopsis and synopsis != media_item.synopsis and can_update(media_item.synopsis): media_item.synopsis = synopsis update_fields.append("synopsis") changed = True tagline = metadata.get("tagline") - if tagline and tagline != media_item.tagline: + if tagline and tagline != media_item.tagline and can_update(media_item.tagline): media_item.tagline = tagline update_fields.append("tagline") changed = True + rating = metadata.get("rating") + if rating and rating != media_item.rating and can_update(media_item.rating): + media_item.rating = rating + update_fields.append("rating") + changed = True + release_year = metadata.get("release_year") - if release_year and release_year != media_item.release_year: + if release_year and release_year != media_item.release_year and can_update(media_item.release_year): try: media_item.release_year = int(release_year) except (TypeError, ValueError): @@ -812,7 +1792,7 @@ def apply_metadata(media_item: MediaItem, metadata: Dict[str, Any]) -> MediaItem update_fields.append("release_year") runtime_minutes = metadata.get("runtime_minutes") - if runtime_minutes: + if runtime_minutes and can_update(media_item.runtime_ms): new_runtime_ms = int(float(runtime_minutes) * 60 * 1000) if media_item.runtime_ms != new_runtime_ms: media_item.runtime_ms = new_runtime_ms @@ -820,43 +1800,60 @@ def apply_metadata(media_item: MediaItem, metadata: Dict[str, Any]) -> MediaItem changed = True genres = metadata.get("genres") - if genres and genres != media_item.genres: + if genres and genres != media_item.genres and can_update(media_item.genres): media_item.genres = genres update_fields.append("genres") changed = True + tags = metadata.get("tags") + if tags and tags != media_item.tags and can_update(media_item.tags): + media_item.tags = tags + update_fields.append("tags") + changed = True + + studios = metadata.get("studios") + if studios and studios != media_item.studios and can_update(media_item.studios): + media_item.studios = studios + update_fields.append("studios") + changed = True + cast = metadata.get("cast") - if cast and cast != media_item.cast: + if cast and cast != media_item.cast and can_update(media_item.cast): media_item.cast = cast update_fields.append("cast") changed = True crew = metadata.get("crew") - if crew and crew != media_item.crew: + if crew and crew != media_item.crew and can_update(media_item.crew): media_item.crew = crew update_fields.append("crew") changed = True poster = metadata.get("poster") - if poster and poster != media_item.poster_url: + if poster and poster != media_item.poster_url and can_update(media_item.poster_url): media_item.poster_url = poster update_fields.append("poster_url") changed = True backdrop = metadata.get("backdrop") - if backdrop and backdrop != media_item.backdrop_url: + if backdrop and backdrop != media_item.backdrop_url and can_update(media_item.backdrop_url): media_item.backdrop_url = backdrop update_fields.append("backdrop_url") changed = True raw_payload = metadata.get("raw") - if raw_payload and raw_payload != media_item.metadata: + if raw_payload and raw_payload != media_item.metadata and can_update(media_item.metadata): media_item.metadata = raw_payload update_fields.append("metadata") changed = True new_source = metadata.get("source", media_item.metadata_source or "unknown") - if new_source and new_source != media_item.metadata_source: + if ( + update_source + and new_source + and new_source != media_item.metadata_source + and can_update(media_item.metadata_source) + ): media_item.metadata_source = new_source update_fields.append("metadata_source") changed = True @@ -888,16 +1885,68 @@ def apply_metadata(media_item: MediaItem, metadata: Dict[str, Any]) -> MediaItem return media_item +def _needs_remote_metadata(media_item: MediaItem) -> bool: + # Determine if enough metadata is missing to justify remote lookups. + required_fields = [ + media_item.synopsis, + media_item.release_year, + media_item.runtime_ms, + media_item.genres, + media_item.poster_url, + media_item.backdrop_url, + media_item.cast, + media_item.crew, + media_item.rating, + ] + return any(_is_empty_value(value) for value in required_fields) + + def sync_metadata(media_item: MediaItem, *, force: bool = False) -> Optional[MediaItem]: + # Orchestrate metadata sources: NFO -> TMDB -> Movie-DB. + prefer_local = CoreSettings.get_prefer_local_metadata() + has_local_metadata = False + + if prefer_local: + local_metadata, local_error = fetch_local_nfo_metadata(media_item) + if local_metadata: + apply_metadata(media_item, local_metadata) + has_local_metadata = True + elif local_error: + logger.debug("Local metadata skipped for %s: %s", media_item, local_error) + + if has_local_metadata and not _needs_remote_metadata(media_item): + return media_item + + tmdb_metadata = None + tmdb_error = None + if _get_tmdb_api_key(): + tmdb_metadata, tmdb_error = fetch_tmdb_metadata( + media_item, use_cache=not force + ) + if tmdb_metadata: + return apply_metadata( + media_item, + tmdb_metadata, + fill_missing_only=prefer_local and has_local_metadata, + update_source=not (prefer_local and has_local_metadata), + ) + if tmdb_error: + logger.debug("TMDB metadata skipped for %s: %s", media_item, tmdb_error) + movie_db_ok, movie_db_message = check_movie_db_health() if not movie_db_ok: if movie_db_message: - logger.debug("Metadata sync skipped for %s: %s", media_item, movie_db_message) - return None + logger.debug("Movie-DB metadata skipped for %s: %s", media_item, movie_db_message) + return media_item if has_local_metadata else None metadata, error = fetch_movie_db_metadata(media_item, use_cache=not force) if not metadata: if error: - logger.debug("Metadata sync skipped for %s: %s", media_item, error) - return None - return apply_metadata(media_item, metadata) + logger.debug("Movie-DB metadata skipped for %s: %s", media_item, error) + return media_item if has_local_metadata else None + return apply_metadata( + media_item, + metadata, + fill_missing_only=prefer_local and has_local_metadata, + update_source=not (prefer_local and has_local_metadata), + ) diff --git a/apps/media_library/serializers.py b/apps/media_library/serializers.py index b61d2cc1..4853dbe7 100644 --- a/apps/media_library/serializers.py +++ b/apps/media_library/serializers.py @@ -22,6 +22,8 @@ class LibraryLocationSerializer(serializers.ModelSerializer): class LibrarySerializer(serializers.ModelSerializer): locations = LibraryLocationSerializer(many=True) + movie_count = serializers.IntegerField(read_only=True) + show_count = serializers.IntegerField(read_only=True) class Meta: model = Library @@ -38,6 +40,8 @@ class LibrarySerializer(serializers.ModelSerializer): "add_to_vod", "last_scan_at", "last_successful_scan_at", + "movie_count", + "show_count", "locations", "created_at", "updated_at", diff --git a/core/models.py b/core/models.py index b9166f66..70a4be11 100644 --- a/core/models.py +++ b/core/models.py @@ -162,6 +162,8 @@ DVR_COMSKIP_ENABLED_KEY = slugify("DVR Comskip Enabled") DVR_COMSKIP_CUSTOM_PATH_KEY = slugify("DVR Comskip Custom Path") DVR_PRE_OFFSET_MINUTES_KEY = slugify("DVR Pre-Offset Minutes") DVR_POST_OFFSET_MINUTES_KEY = slugify("DVR Post-Offset Minutes") +TMDB_API_KEY = slugify("TMDB API Key") +PREFER_LOCAL_METADATA_KEY = slugify("Prefer Local Metadata") SYSTEM_TIME_ZONE_KEY = slugify("System Time Zone") @@ -350,6 +352,27 @@ class CoreSettings(models.Model): obj.save(update_fields=["value"]) return value + @classmethod + def get_tmdb_api_key(cls): + """Return configured TMDB API key or None when unset.""" + try: + value = cls.objects.get(key=TMDB_API_KEY).value + except cls.DoesNotExist: + return None + if value is None: + return None + value = str(value).strip() + return value or None + + @classmethod + def get_prefer_local_metadata(cls): + """Return True when local NFO metadata is preferred.""" + try: + value = cls.objects.get(key=PREFER_LOCAL_METADATA_KEY).value + except cls.DoesNotExist: + return False + return str(value).lower() in ("1", "true", "yes", "on") + @classmethod def get_dvr_series_rules(cls): """Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}""" diff --git a/frontend/src/assets/tmdb-logo-blue.svg b/frontend/src/assets/tmdb-logo-blue.svg new file mode 100644 index 00000000..8149241e --- /dev/null +++ b/frontend/src/assets/tmdb-logo-blue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/library/AlphabetSidebar.jsx b/frontend/src/components/library/AlphabetSidebar.jsx index 4ef8901a..c2eac4d0 100644 --- a/frontend/src/components/library/AlphabetSidebar.jsx +++ b/frontend/src/components/library/AlphabetSidebar.jsx @@ -1,10 +1,10 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { Box, Stack, UnstyledButton, Text } from '@mantine/core'; const letters = ['#', 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z']; const HOTZONE_WIDTH = 20; // px: invisible strip you hover to reveal -const IDLE_FADE_MS = 1500; // ms of no mouse movement before fade out +const HOTZONE_BOTTOM_OFFSET = 24; // px: keep a little margin from the bottom const LETTER_GAP = 6; // must match Stack spacing const BASE_FONT_PX = 12; // Mantine "xs" ~ 12px by default const MAX_SCALE_BOOST = 0.35; // how large letters grow at the cursor @@ -17,32 +17,20 @@ export default function AlphabetSidebar({ right = 16, }) { const [isHot, setIsHot] = useState(false); // pointer inside hot zone or sidebar - const [isIdle, setIsIdle] = useState(false); // idle while still hovered const [mouseY, setMouseY] = useState(null); // y relative to sidebar + const [hoveredLetter, setHoveredLetter] = useState(null); const sidebarRef = useRef(null); - const idleTimerRef = useRef(null); - - // Reset idle timer whenever mouse moves inside the sidebar - const poke = () => { - setIsIdle(false); - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); - idleTimerRef.current = setTimeout(() => setIsIdle(true), IDLE_FADE_MS); - }; - - useEffect(() => () => idleTimerRef.current && clearTimeout(idleTimerRef.current), []); + const hotZoneHeight = `calc(100vh - ${top}px - ${HOTZONE_BOTTOM_OFFSET}px)`; const handleMouseMove = (e) => { if (!sidebarRef.current) return; const rect = sidebarRef.current.getBoundingClientRect(); setMouseY(e.clientY - rect.top); - poke(); }; const handleMouseLeave = () => { setIsHot(false); - setIsIdle(false); setMouseY(null); - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); }; // Precompute the center Y for each letter button (approx.) @@ -53,7 +41,7 @@ export default function AlphabetSidebar({ }, []); // Visible when hovering the hot zone or the sidebar, unless idling. - const visible = isHot && !isIdle; + const visible = isHot; return ( <> @@ -66,7 +54,7 @@ export default function AlphabetSidebar({ top, right: 0, width: HOTZONE_WIDTH, - height: '70vh', + height: hotZoneHeight, zIndex: 2, }} /> @@ -104,11 +92,18 @@ export default function AlphabetSidebar({ const boost = Math.exp(-(d * d) / (2 * SIGMA_PX * SIGMA_PX)); // 0..1 scale = 1 + MAX_SCALE_BOOST * boost; } + if (hoveredLetter === letter) { + scale = Math.max(scale, 1.5); + } return ( isEnabled && onSelect?.(letter)} + onMouseEnter={() => setHoveredLetter(letter)} + onMouseLeave={() => setHoveredLetter(null)} + onFocus={() => setHoveredLetter(letter)} + onBlur={() => setHoveredLetter(null)} style={{ opacity: isEnabled ? 1 : 0.3, cursor: isEnabled ? 'pointer' : 'default', diff --git a/frontend/src/components/library/LibraryCard.jsx b/frontend/src/components/library/LibraryCard.jsx index 29d5eda1..1dec2f55 100644 --- a/frontend/src/components/library/LibraryCard.jsx +++ b/frontend/src/components/library/LibraryCard.jsx @@ -36,6 +36,11 @@ const LibraryCard = ({ onScan, loadingScan = false, }) => { + const isShowLibrary = library.library_type === 'shows'; + const countValue = isShowLibrary ? library.show_count : library.movie_count; + const count = Number.isFinite(Number(countValue)) ? Number(countValue) : 0; + const countLabel = isShowLibrary ? 'Series' : 'Movies'; + return ( )} + + {count} {countLabel} + + diff --git a/frontend/src/components/library/MediaCard.jsx b/frontend/src/components/library/MediaCard.jsx index 8187a403..96902570 100644 --- a/frontend/src/components/library/MediaCard.jsx +++ b/frontend/src/components/library/MediaCard.jsx @@ -9,6 +9,7 @@ import { Text, } from '@mantine/core'; import { Film, Library as LibraryIcon, Tv2 } from 'lucide-react'; +import useSettingsStore from '../../store/settings'; const typeIcon = { movie: , @@ -63,6 +64,14 @@ const formatRuntime = (runtimeMs) => { return `${hours}h ${minutes}m`; }; +const resolveArtworkUrl = (url, envMode) => { + if (!url) return url; + if (envMode === 'dev' && url.startsWith('/')) { + return `${window.location.protocol}//${window.location.hostname}:5656${url}`; + } + return url; +}; + const MediaCard = ({ item, onClick, @@ -71,6 +80,7 @@ const MediaCard = ({ showTypeBadge = true, style = {}, }) => { + const envMode = useSettingsStore((s) => s.environment.env_mode); const [isTouch, setIsTouch] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [isHovered, setIsHovered] = useState(false); @@ -136,8 +146,12 @@ const MediaCard = ({ const watchSummary = item.watch_summary; const status = watchSummary?.status; const runtimeText = formatRuntime(item.runtime_ms); + const posterUrl = useMemo( + () => resolveArtworkUrl(item.poster_url, envMode), + [item.poster_url, envMode] + ); const hasGenres = Array.isArray(item.genres) && item.genres.length > 0; - const hasPoster = Boolean(item.poster_url); + const hasPoster = Boolean(posterUrl); const showEpisodeBadge = item.item_type === 'show' && watchSummary?.total_episodes; const isActive = isExpanded || (!isTouch && isHovered) || isFocused; @@ -226,11 +240,13 @@ const MediaCard = ({ /> {hasPoster ? ( {item.title} ) : ( diff --git a/frontend/src/components/library/MediaDetailModal.jsx b/frontend/src/components/library/MediaDetailModal.jsx index a6fc416f..3d496eb1 100644 --- a/frontend/src/components/library/MediaDetailModal.jsx +++ b/frontend/src/components/library/MediaDetailModal.jsx @@ -37,6 +37,7 @@ import API from '../../api'; import useMediaLibraryStore from '../../store/mediaLibrary'; import useVideoStore from '../../store/useVideoStore'; import useAuthStore from '../../store/auth'; +import useSettingsStore from '../../store/settings'; import { USER_LEVELS } from '../../constants'; import MediaEditModal from './MediaEditModal'; @@ -49,7 +50,6 @@ const CAST_TILE_SPACING = 1; const STACK_TIGHT = 4; // was 6 const SECTION_STACK = 12; // was larger in some places const DETAIL_SCROLL_HEIGHT = '82vh'; -const DETAIL_PANEL_MIN_HEIGHT = '72vh'; const DETAIL_POSTER_MAX_HEIGHT = '72vh'; // ---------------------------- @@ -62,6 +62,14 @@ const runtimeLabel = (runtimeMs) => { return `${minutes}m`; }; +const resolveArtworkUrl = (url, envMode) => { + if (!url) return url; + if (envMode === 'dev' && url.startsWith('/')) { + return `${window.location.protocol}//${window.location.hostname}:5656${url}`; + } + return url; +}; + const MediaDetailModal = ({ opened, onClose }) => { const activeItem = useMediaLibraryStore((s) => s.activeItem); const activeItemLoading = useMediaLibraryStore((s) => s.activeItemLoading); @@ -74,6 +82,7 @@ const MediaDetailModal = ({ opened, onClose }) => { const pollItem = useMediaLibraryStore((s) => s.pollItem); const showVideo = useVideoStore((s) => s.showVideo); const userLevel = useAuthStore((s) => s.user?.user_level ?? 0); + const env_mode = useSettingsStore((s) => s.environment.env_mode); const canEditMetadata = userLevel >= USER_LEVELS.ADMIN; const [startingPlayback, setStartingPlayback] = useState(false); @@ -332,8 +341,6 @@ const MediaDetailModal = ({ opened, onClose }) => { .filter(Boolean); }, [activeItem]); const hasCredits = castPeople.length > 0 || crewPeople.length > 0; - const detailPanelMinHeight = - activeItem?.item_type === 'show' ? DETAIL_PANEL_MIN_HEIGHT : 'auto'; const handleStartPlayback = async (mode = 'start') => { if (!activeItem) return; @@ -379,7 +386,7 @@ const MediaDetailModal = ({ opened, onClose }) => { mediaTitle: activeItem.title, name: activeItem.title, year: activeItem.release_year, - logo: activeItem.poster_url ? { url: activeItem.poster_url } : undefined, + logo: posterUrl ? { url: posterUrl } : undefined, progressId: activeProgress?.id, resumePositionMs, resumeHandledByServer, @@ -532,6 +539,7 @@ const MediaDetailModal = ({ opened, onClose }) => { episodeDetail.runtime_ms ?? episodeDetail.files?.[0]?.duration_ms ?? null; + const episodePosterUrl = resolveArtworkUrl(episodeDetail.poster_url, env_mode); showVideo(playbackUrl, 'library', { mediaItemId: episodeDetail.id, @@ -541,12 +549,12 @@ const MediaDetailModal = ({ opened, onClose }) => { name: episodeDetail.title, year: episodeDetail.release_year, logo: - episodeDetail.poster_url - ? { url: episodeDetail.poster_url } - : activeItem?.poster_url - ? { url: activeItem.poster_url } + episodePosterUrl + ? { url: episodePosterUrl } + : posterUrl + ? { url: posterUrl } : undefined, - showPoster: activeItem?.poster_url, + showPoster: posterUrl, progressId: episodeProgress?.id, resumePositionMs, resumeHandledByServer, @@ -686,6 +694,27 @@ const MediaDetailModal = ({ opened, onClose }) => { ); const files = activeItem?.files || []; + const posterUrl = useMemo( + () => resolveArtworkUrl(activeItem?.poster_url, env_mode), + [activeItem?.poster_url, env_mode] + ); + const backdropUrl = useMemo(() => { + const fallback = activeItem?.id + ? `/api/media-library/items/${activeItem.id}/artwork/backdrop/` + : ''; + return resolveArtworkUrl(activeItem?.backdrop_url || fallback, env_mode); + }, [activeItem?.backdrop_url, activeItem?.id, env_mode]); + const modalBackgroundStyle = useMemo(() => { + if (!backdropUrl) { + return { backgroundColor: '#1f1f1f' }; + } + return { + backgroundImage: `linear-gradient(180deg, rgba(8, 10, 12, 0.92), rgba(8, 10, 12, 0.86) 45%, rgba(8, 10, 12, 0.92)), url('${backdropUrl}')`, + backgroundSize: 'cover', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + }; + }, [backdropUrl]); const seasonOptions = useMemo( () => @@ -742,6 +771,17 @@ const MediaDetailModal = ({ opened, onClose }) => { size="xl" overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} padding="md" + styles={{ + content: { + ...modalBackgroundStyle, + }, + header: { + background: 'transparent', + }, + body: { + background: 'transparent', + }, + }} title={ @@ -770,7 +810,7 @@ const MediaDetailModal = ({ opened, onClose }) => { - {activeItem.poster_url ? ( + {posterUrl ? ( { }} > {activeItem.title} { {metadataPending && ( { )} + + - {activeItem.item_type === 'show' && ( - <> - - {episodesLoading ? ( - - + {activeItem.item_type === 'show' && ( + + + {episodesLoading ? ( + + + + ) : sortedSeasons.length === 0 ? ( + + No episodes discovered yet. + + ) : ( + + + { - setSeasonManuallySelected(true); - setSelectedSeason(value ? Number(value) : null); - }} - placeholder="Select season" - allowDeselect={false} - w={220} - /> - - {visibleEpisodes.length} episode - {visibleEpisodes.length === 1 ? '' : 's'} - - - {visibleEpisodes.length === 0 ? ( - - No episodes available for this season. - - ) : ( - - {visibleEpisodes.map((episode) => { - const episodeProgress = episode.watch_progress; - const episodeStatus = episode.watch_summary?.status; - const progressPercent = episodeProgress?.percentage - ? Math.round(episodeProgress.percentage * 100) - : null; - const isWatched = episodeStatus === 'watched'; - const isInProgress = episodeStatus === 'in_progress'; - const episodeLoading = episodeActionLoading[episode.id]; - const isExpanded = expandedEpisodeIds.has(episode.id); - const synopsisText = episode.synopsis?.trim(); - return ( - handleEpisodeCardClick(episode.id, event)} - onKeyDown={(event) => handleEpisodeCardKeyDown(episode.id, event)} - style={{ - border: '1px solid rgba(148, 163, 184, 0.15)', - borderRadius: 12, - padding: '10px 12px', - cursor: 'pointer', - }} - > + {visibleEpisodes.length === 0 ? ( + + No episodes available for this season. + + ) : ( + + {visibleEpisodes.map((episode) => { + const episodeProgress = episode.watch_progress; + const episodeStatus = episode.watch_summary?.status; + const progressPercent = episodeProgress?.percentage + ? Math.round(episodeProgress.percentage * 100) + : null; + const isWatched = episodeStatus === 'watched'; + const isInProgress = episodeStatus === 'in_progress'; + const episodeLoading = episodeActionLoading[episode.id]; + const isExpanded = expandedEpisodeIds.has(episode.id); + const synopsisText = episode.synopsis?.trim(); + return ( + handleEpisodeCardClick(episode.id, event)} + onKeyDown={(event) => handleEpisodeCardKeyDown(episode.id, event)} + style={{ + border: '1px solid rgba(148, 163, 184, 0.15)', + borderRadius: 12, + padding: '10px 12px', + cursor: 'pointer', + }} + > + @@ -1016,7 +1057,7 @@ const MediaDetailModal = ({ opened, onClose }) => { {isWatched && ( - Watched + Watched )} {isInProgress && ( @@ -1045,86 +1086,66 @@ const MediaDetailModal = ({ opened, onClose }) => { )} - {synopsisText ? ( - - {synopsisText} - - ) : null} - - - - - - { - event.stopPropagation(); - handleEpisodeDelete(episode); - }} - loading={episodeLoading === 'delete'} - title="Delete episode" - > - - - + + + + { + event.stopPropagation(); + handleEpisodeDelete(episode); + }} + loading={episodeLoading === 'delete'} + title="Delete episode" + > + + + - ); - })} - - )} - - )} - - )} - - - - - - {activeItem.imdb_id && ( - } - > - IMDB {activeItem.imdb_id} - - )} - + {synopsisText ? ( + + {synopsisText} + + ) : null} + + ); + })} + + )} + + )} - - + )} {hasCredits ? ( diff --git a/frontend/src/pages/Library.jsx b/frontend/src/pages/Library.jsx index f9d78484..812d6b9e 100644 --- a/frontend/src/pages/Library.jsx +++ b/frontend/src/pages/Library.jsx @@ -87,6 +87,7 @@ const LibraryPage = () => { const itemsBackgroundLoading = useMediaLibraryStore((s) => s.backgroundLoading); const setItemFilters = useMediaLibraryStore((s) => s.setFilters); const fetchItems = useMediaLibraryStore((s) => s.fetchItems); + const clearItems = useMediaLibraryStore((s) => s.clearItems); const setSelectedMediaLibrary = useMediaLibraryStore((s) => s.setSelectedLibraryId); const openItem = useMediaLibraryStore((s) => s.openItem); const closeItem = useMediaLibraryStore((s) => s.closeItem); @@ -124,14 +125,14 @@ const LibraryPage = () => { if (!libraries || libraries.length === 0) { setSelectedMediaLibrary(null); - fetchItems([]); + clearItems(); return; } setSelectedMediaLibrary(ids.length === 1 ? ids[0] : null); if (ids.length === 0) { - fetchItems([]); + clearItems(); return; } @@ -153,7 +154,15 @@ const LibraryPage = () => { return () => { cancelled = true; }; - }, [libraries, relevantLibraryIds, fetchItems, setSelectedMediaLibrary, debouncedSearch, itemTypeFilter]); + }, [ + libraries, + relevantLibraryIds, + fetchItems, + clearItems, + setSelectedMediaLibrary, + debouncedSearch, + itemTypeFilter, + ]); const filteredItems = useMemo(() => { const typeFiltered = items.filter((item) => item.item_type === itemTypeFilter); diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 12a31385..6bb7b9eb 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -12,13 +12,17 @@ import useUserAgentsStore from '../store/userAgents'; import useStreamProfilesStore from '../store/streamProfiles'; import { Accordion, + Anchor, Alert, Box, Button, Center, + Divider, Flex, Group, FileInput, + List, + Modal, MultiSelect, SimpleGrid, Select, @@ -49,6 +53,7 @@ import useWarningsStore from '../store/warnings'; import LibraryCard from '../components/library/LibraryCard'; import LibraryFormModal from '../components/library/LibraryFormModal'; import LibraryScanDrawer from '../components/library/LibraryScanDrawer'; +import tmdbLogoUrl from '../assets/tmdb-logo-blue.svg?url'; const TIMEZONE_FALLBACKS = [ 'UTC', @@ -202,6 +207,8 @@ const SettingsPage = () => { const removeScan = useLibraryStore((s) => s.removeScan); const cancelLibraryScan = useLibraryStore((s) => s.cancelLibraryScan); const deleteLibraryScan = useLibraryStore((s) => s.deleteLibraryScan); + const tmdbSetting = settings['tmdb-api-key']; + const preferLocalSetting = settings['prefer-local-metadata']; const [accordianValue, setAccordianValue] = useState(null); const [networkAccessSaved, setNetworkAccessSaved] = useState(false); @@ -234,6 +241,10 @@ const SettingsPage = () => { const [librarySubmitting, setLibrarySubmitting] = useState(false); const [scanDrawerOpen, setScanDrawerOpen] = useState(false); const [scanLoadingId, setScanLoadingId] = useState(null); + const [tmdbKey, setTmdbKey] = useState(''); + const [preferLocalMetadata, setPreferLocalMetadata] = useState(false); + const [savingMetadataSettings, setSavingMetadataSettings] = useState(false); + const [tmdbHelpOpen, setTmdbHelpOpen] = useState(false); // UI / local storage settings const [tableSize, setTableSize] = useLocalStorage('table-size', 'default'); @@ -431,6 +442,16 @@ const SettingsPage = () => { loadComskipConfig(); }, []); + useEffect(() => { + const currentKey = tmdbSetting?.value ?? ''; + setTmdbKey(currentKey); + const preferValue = preferLocalSetting?.value; + const normalized = String(preferValue ?? '').toLowerCase(); + setPreferLocalMetadata( + ['1', 'true', 'yes', 'on'].includes(normalized) + ); + }, [tmdbSetting?.value, preferLocalSetting?.value]); + useEffect(() => { if (authUser?.user_level == USER_LEVELS.ADMIN) { fetchLibraries(); @@ -650,6 +671,56 @@ const SettingsPage = () => { } }; + const handleSaveMetadataSettings = async () => { + setSavingMetadataSettings(true); + try { + const tasks = []; + const preferValue = preferLocalMetadata ? 'true' : 'false'; + if (preferLocalSetting?.id) { + tasks.push( + API.updateSetting({ ...preferLocalSetting, value: preferValue }) + ); + } else { + tasks.push( + API.createSetting({ + key: 'prefer-local-metadata', + name: 'Prefer Local Metadata', + value: preferValue, + }) + ); + } + + const trimmedKey = (tmdbKey || '').trim(); + if (tmdbSetting?.id) { + tasks.push(API.updateSetting({ ...tmdbSetting, value: trimmedKey })); + } else if (trimmedKey) { + tasks.push( + API.createSetting({ + key: 'tmdb-api-key', + name: 'TMDB API Key', + value: trimmedKey, + }) + ); + } + + await Promise.all(tasks); + notifications.show({ + title: 'Metadata settings saved', + message: 'Metadata preferences updated successfully.', + color: 'green', + }); + } catch (error) { + console.error('Failed to save metadata settings', error); + notifications.show({ + title: 'Error', + message: 'Unable to save metadata settings.', + color: 'red', + }); + } finally { + setSavingMetadataSettings(false); + } + }; + const executeSettingsSaveAndRehash = async () => { setRehashConfirmOpen(false); setGeneralSettingsSaved(false); @@ -921,6 +992,71 @@ const SettingsPage = () => { Media Library + + + + Metadata Sources + + Prefer local NFO metadata, then fill missing fields + from TMDB. + + + + + setPreferLocalMetadata(event.currentTarget.checked) + } + /> + + setTmdbKey(event.currentTarget.value) + } + description="Used for metadata and artwork lookups." + /> + + + + +
+ + TMDB logo + +
+
+ + + Libraries @@ -1527,6 +1663,53 @@ const SettingsPage = () => { + setTmdbHelpOpen(false)} + title="How to get a TMDB API key" + size="lg" + overlayProps={{ backgroundOpacity: 0.55, blur: 2 }} + > + + + Dispatcharr uses TMDB (The Movie Database) for artwork and + metadata. You can create a key in a few minutes: + + + + Visit{' '} + + themoviedb.org + {' '} + and sign in or create a free account. + + + Open your{' '} + + TMDB account settings + {' '} + and choose API. + + + Complete the short API application and copy the v3 API key into + the field above. + + + + TMDB issues separate v3 and v4 keys. Dispatcharr only needs the v3 + API key for metadata lookups. + + + + { diff --git a/frontend/src/store/library.jsx b/frontend/src/store/library.jsx index 2d5db16e..8f5a8649 100644 --- a/frontend/src/store/library.jsx +++ b/frontend/src/store/library.jsx @@ -1,6 +1,8 @@ import { create } from 'zustand'; import API from '../api'; +let librariesInFlight = null; + const useLibraryStore = create((set, get) => ({ libraries: [], loading: false, @@ -9,13 +11,23 @@ const useLibraryStore = create((set, get) => ({ scansLoading: false, fetchLibraries: async () => { - set({ loading: true, error: null }); - try { - const libraries = await API.getLibraries(); - set({ libraries: Array.isArray(libraries) ? libraries : [], loading: false }); - } catch (error) { - set({ error: error.message || 'Failed to load libraries.', loading: false }); + if (librariesInFlight) { + return librariesInFlight; } + set({ loading: true, error: null }); + librariesInFlight = (async () => { + try { + const libraries = await API.getLibraries(); + set({ libraries: Array.isArray(libraries) ? libraries : [], loading: false }); + return libraries; + } catch (error) { + set({ error: error.message || 'Failed to load libraries.', loading: false }); + return null; + } + })(); + return librariesInFlight.finally(() => { + librariesInFlight = null; + }); }, createLibrary: async (payload) => { diff --git a/frontend/src/store/mediaLibrary.jsx b/frontend/src/store/mediaLibrary.jsx index edf08989..67804cfe 100644 --- a/frontend/src/store/mediaLibrary.jsx +++ b/frontend/src/store/mediaLibrary.jsx @@ -7,6 +7,7 @@ const defaultFilters = { }; const pollHandles = new Map(); +const inFlightRequests = new Map(); const schedulePoll = (itemId, callback, delayMs) => { const handle = setTimeout(callback, delayMs); @@ -41,6 +42,23 @@ const useMediaLibraryStore = create((set, get) => ({ setSelectedLibraryId: (id) => set({ selectedLibraryId: id }), fetchItems: async (libraryIds = [], { background = false, limit, ordering } = {}) => { + const resolvedLibraryIds = + libraryIds === undefined ? get().activeLibraryIds || [] : libraryIds; + const explicitEmpty = Array.isArray(libraryIds) && libraryIds.length === 0; + + if (!resolvedLibraryIds || resolvedLibraryIds.length === 0) { + if (explicitEmpty) { + set({ + items: [], + itemsById: {}, + loading: false, + backgroundLoading: false, + activeLibraryIds: [], + }); + } + return []; + } + if (background) { set({ backgroundLoading: true }); } else { @@ -49,7 +67,7 @@ const useMediaLibraryStore = create((set, get) => ({ try { const params = new URLSearchParams(); - libraryIds.forEach((id) => params.append('library', id)); + resolvedLibraryIds.forEach((id) => params.append('library', id)); if (get().filters.type) { params.append('type', get().filters.type); } @@ -63,28 +81,49 @@ const useMediaLibraryStore = create((set, get) => ({ params.append('limit', limit); } - const response = await API.getMediaItems(params); - const items = Array.isArray(response) ? response : response?.results || []; - const itemsById = items.reduce((acc, item) => { - acc[item.id] = item; - return acc; - }, {}); + const requestKey = `${params.toString()}|bg:${background ? '1' : '0'}`; + if (inFlightRequests.has(requestKey)) { + return await inFlightRequests.get(requestKey); + } - set({ - items, - itemsById, - loading: false, - backgroundLoading: false, - activeLibraryIds: libraryIds, + const requestPromise = (async () => { + const response = await API.getMediaItems(params); + const items = Array.isArray(response) ? response : response?.results || []; + const itemsById = items.reduce((acc, item) => { + acc[item.id] = item; + return acc; + }, {}); + + set({ + items, + itemsById, + loading: false, + backgroundLoading: false, + activeLibraryIds: resolvedLibraryIds, + }); + + return items; + })(); + + inFlightRequests.set(requestKey, requestPromise); + return await requestPromise.finally(() => { + inFlightRequests.delete(requestKey); }); - - return items; } catch (error) { set({ loading: false, backgroundLoading: false }); return []; } }, + clearItems: () => + set({ + items: [], + itemsById: {}, + loading: false, + backgroundLoading: false, + activeLibraryIds: [], + }), + upsertItems: (items) => { if (!Array.isArray(items)) return; set((state) => {