From 3baf3494c89873f09d2ed59cff48e463172b1b98 Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Thu, 4 Dec 2025 19:59:52 -0600 Subject: [PATCH] Added opensubtitles --- apps/media_library/api_urls.py | 1 + apps/media_library/api_views.py | 35 +- apps/media_library/metadata.py | 203 +++++- .../migrations/0003_subtitleasset.py | 45 ++ apps/media_library/models.py | 35 + apps/media_library/serializers.py | 38 + apps/media_library/subtitles.py | 354 ++++++++++ apps/media_library/tasks.py | 81 ++- apps/media_library/transcode.py | 90 +++ apps/media_library/utils.py | 43 ++ apps/media_library/views.py | 39 +- dispatcharr/settings.py | 2 +- frontend/src/WebSocket.jsx | 35 +- frontend/src/api.js | 33 + frontend/src/components/FloatingVideo.jsx | 204 +++++- frontend/src/pages/Settings.jsx | 660 ++++++++++++++++++ 16 files changed, 1848 insertions(+), 50 deletions(-) create mode 100644 apps/media_library/migrations/0003_subtitleasset.py create mode 100644 apps/media_library/subtitles.py diff --git a/apps/media_library/api_urls.py b/apps/media_library/api_urls.py index b94fa3d3..345b32ba 100644 --- a/apps/media_library/api_urls.py +++ b/apps/media_library/api_urls.py @@ -13,4 +13,5 @@ router.register(r"progress", api_views.WatchProgressViewSet, basename="watchprog urlpatterns = [ path("", include(router.urls)), path("stream//", views.stream_media_file, name="stream-file"), + path("subtitles//", views.subtitle_file, name="media-library-subtitle-file"), ] diff --git a/apps/media_library/api_views.py b/apps/media_library/api_views.py index 6b57aa22..4eadce76 100644 --- a/apps/media_library/api_views.py +++ b/apps/media_library/api_views.py @@ -30,6 +30,7 @@ from apps.media_library.tasks import ( unsync_library_from_vod_task, ) from apps.media_library.vod_sync import unsync_library_from_vod +from apps.media_library.subtitles import download_subtitles_for_media_item logger = logging.getLogger(__name__) @@ -297,7 +298,7 @@ class MediaItemViewSet(viewsets.ModelViewSet): "vod_movie", "vod_series", "vod_episode", - ) + ).prefetch_related("subtitles") if self.action == "list": children_qs = ( @@ -414,6 +415,25 @@ class MediaItemViewSet(viewsets.ModelViewSet): sync_metadata_task.delay(item.id) return Response({"status": "queued"}, status=status.HTTP_202_ACCEPTED) + @action(detail=True, methods=["get"], url_path="subtitles") + def list_subtitles(self, request, pk=None): + item = self.get_object() + subtitles = item.subtitles.all() + serializer = serializers.SubtitleAssetSerializer(subtitles, many=True, context=self.get_serializer_context()) + return Response(serializer.data) + + @action(detail=True, methods=["post"], url_path="fetch-subtitles") + def fetch_subtitles(self, request, pk=None): + item = self.get_object() + language = request.data.get("language") + from apps.media_library.subtitles import ensure_subtitles_for_media_item + + subtitles = ensure_subtitles_for_media_item(item, language=language) + if not subtitles: + raise ValidationError({"detail": "No subtitles could be fetched for this item."}) + serializer = serializers.SubtitleAssetSerializer(subtitles, many=True, context=self.get_serializer_context()) + return Response(serializer.data, status=status.HTTP_201_CREATED) + @action(detail=True, methods=["post"], url_path="set-tmdb") def set_tmdb(self, request, pk=None): item = self.get_object() @@ -462,10 +482,12 @@ class MediaItemViewSet(viewsets.ModelViewSet): start_ms = max(0, int(start_ms_param)) except (TypeError, ValueError): raise ValidationError({"start_ms": "Start offset must be an integer number of milliseconds."}) + force_transcode_raw = request.query_params.get("force_transcode") or request.query_params.get("transcode") + force_transcode = str(force_transcode_raw).lower() in {"1", "true", "yes", "force", "on"} applied_start_ms = 0 should_embed_start = False - if start_ms > 0 and file.requires_transcode: + if start_ms > 0 and (file.requires_transcode or force_transcode): cached_ready = ( file.transcode_status == models.MediaFile.TRANSCODE_STATUS_READY and file.transcoded_path @@ -480,10 +502,15 @@ class MediaItemViewSet(viewsets.ModelViewSet): payload = {"file_id": file.id, "user_id": request.user.id} if should_embed_start: payload["start_ms"] = applied_start_ms + if force_transcode: + payload["force_transcode"] = True token = self._stream_signer.sign_object(payload) stream_url = request.build_absolute_uri(reverse("proxy:library-stream", args=[token])) ttl = getattr(settings, "MEDIA_LIBRARY_STREAM_TOKEN_TTL", 3600) + transcode_status = file.transcode_status + if force_transcode and transcode_status == models.MediaFile.TRANSCODE_STATUS_NOT_REQUIRED: + transcode_status = models.MediaFile.TRANSCODE_STATUS_PENDING return Response( { "url": stream_url, @@ -493,8 +520,8 @@ class MediaItemViewSet(viewsets.ModelViewSet): "duration_ms": duration_ms, "bit_rate": file.bit_rate, "container": file.container, - "requires_transcode": file.requires_transcode, - "transcode_status": file.transcode_status, + "requires_transcode": file.requires_transcode or force_transcode, + "transcode_status": transcode_status, "start_offset_ms": applied_start_ms, } ) diff --git a/apps/media_library/metadata.py b/apps/media_library/metadata.py index 1642f8b5..6eb27ab8 100644 --- a/apps/media_library/metadata.py +++ b/apps/media_library/metadata.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Iterable, Optional, Tuple import requests import tmdbsimple as tmdb @@ -34,6 +34,50 @@ tmdb.REQUESTS_SESSION = _REQUESTS_SESSION _MOVIEDB_SEARCH_CACHE: dict[tuple[str, str], tuple[dict | None, str | None]] = {} +def _get_library_metadata_prefs(media_item: MediaItem) -> dict[str, Any]: + """ + Derive metadata lookup preferences from the item's library and its metadata_options. + """ + prefs: dict[str, Any] = { + "language": None, + "region": None, + "image_languages": None, + "include_adult": False, + "provider_priority": None, + } + library = getattr(media_item, "library", None) + if not library: + return prefs + + prefs["language"] = (library.metadata_language or "").strip() or None + prefs["region"] = (library.metadata_country or "").strip() or None + + options = library.metadata_options or {} + if isinstance(options, dict): + if options.get("language"): + prefs["language"] = str(options["language"]).strip() or prefs["language"] + if options.get("region"): + prefs["region"] = str(options["region"]).strip() or prefs["region"] + if options.get("image_languages"): + languages = options.get("image_languages") + if isinstance(languages, Iterable) and not isinstance(languages, (str, bytes)): + prefs["image_languages"] = [ + str(lang).strip() for lang in languages if str(lang).strip() + ] or None + include_adult = options.get("include_adult") + if include_adult is not None: + prefs["include_adult"] = bool(include_adult) + provider_priority = options.get("provider_priority") + if isinstance(provider_priority, Iterable) and not isinstance(provider_priority, (str, bytes)): + prefs["provider_priority"] = [ + str(provider).strip().lower() + for provider in provider_priority + if str(provider).strip() + ] or None + + return prefs + + def validate_tmdb_api_key(api_key: str, *, use_cache: bool = True) -> tuple[bool, str | None]: """ Validate a TMDB API key by calling the configuration endpoint. @@ -174,11 +218,26 @@ def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: if not _configure_tmdb(api_key): return None + prefs = _get_library_metadata_prefs(media_item) + language = prefs.get("language") + region = prefs.get("region") + image_languages = prefs.get("image_languages") + include_adult = bool(prefs.get("include_adult")) + tmdb_common_kwargs: dict[str, Any] = {} + if language: + tmdb_common_kwargs["language"] = language + if region: + tmdb_common_kwargs["region"] = region + if image_languages: + tmdb_common_kwargs["include_image_language"] = ",".join(image_languages) + normalized = (media_item.normalized_title or "").replace(" ", "_") cache_key_parts = [ str(media_item.item_type), normalized, str(media_item.release_year or ""), + language or "", + region or "", ] if media_item.tmdb_id: cache_key_parts.append(f"id:{media_item.tmdb_id}") @@ -190,6 +249,16 @@ def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: info = None credits = None tmdb_id = media_item.tmdb_id + series_tmdb_id = None + if media_item.item_type == MediaItem.TYPE_EPISODE: + try: + series_tmdb_id = media_item.parent.tmdb_id # type: ignore[attr-defined] + except Exception: + series_tmdb_id = None + if not series_tmdb_id and isinstance(media_item.metadata, dict): + series_tmdb_id = media_item.metadata.get("series_tmdb_id") + if not tmdb_id: + tmdb_id = series_tmdb_id if tmdb_id: try: @@ -200,12 +269,22 @@ def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: try: if media_item.is_movie: movie = tmdb.Movies(lookup_id) - info = movie.info(append_to_response="credits,images") + info = movie.info(append_to_response="credits,images", **tmdb_common_kwargs) credits = info.get("credits", {}) elif media_item.item_type == MediaItem.TYPE_SHOW: tv = tmdb.TV(lookup_id) - info = tv.info(append_to_response="credits,images") + info = tv.info(append_to_response="credits,images", **tmdb_common_kwargs) credits = info.get("credits", {}) + elif media_item.item_type == MediaItem.TYPE_EPISODE and media_item.parent_id: + parent_id = series_tmdb_id or lookup_id + try: + parent_lookup = int(parent_id) if parent_id else None + except (TypeError, ValueError): + parent_lookup = parent_id + if parent_lookup: + episode = tmdb.TV_Episodes(parent_lookup, media_item.season_number, media_item.episode_number) + info = episode.info(append_to_response="credits,images", **tmdb_common_kwargs) + credits = info.get("credits", {}) except Exception: # noqa: BLE001 logger.exception( "Failed to retrieve TMDB info for %s using id %s", media_item, tmdb_id @@ -217,11 +296,28 @@ def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: search = tmdb.Search() try: if media_item.is_movie: - response = search.movie(query=media_item.title, year=media_item.release_year) + response = search.movie( + query=media_item.title, + year=media_item.release_year, + include_adult=include_adult, + **tmdb_common_kwargs, + ) results = response.get("results", []) elif media_item.item_type == MediaItem.TYPE_SHOW: response = search.tv( - query=media_item.title, first_air_date_year=media_item.release_year + query=media_item.title, + first_air_date_year=media_item.release_year, + include_adult=include_adult, + **tmdb_common_kwargs, + ) + results = response.get("results", []) + elif media_item.item_type == MediaItem.TYPE_EPISODE: + # Find parent series first; we'll use this to fetch the episode details. + response = search.tv( + query=media_item.parent.title if media_item.parent else media_item.title, + first_air_date_year=media_item.release_year, + include_adult=include_adult, + **tmdb_common_kwargs, ) results = response.get("results", []) else: @@ -236,18 +332,29 @@ def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: best_match = results[0] tmdb_id = best_match.get("id") + if media_item.item_type == MediaItem.TYPE_EPISODE: + series_tmdb_id = tmdb_id if not tmdb_id: return None try: if media_item.is_movie: movie = tmdb.Movies(tmdb_id) - info = movie.info(append_to_response="credits,images") + info = movie.info(append_to_response="credits,images", **tmdb_common_kwargs) credits = info.get("credits", {}) elif media_item.item_type == MediaItem.TYPE_SHOW: tv = tmdb.TV(tmdb_id) - info = tv.info(append_to_response="credits,images") + info = tv.info(append_to_response="credits,images", **tmdb_common_kwargs) credits = info.get("credits", {}) + elif media_item.item_type == MediaItem.TYPE_EPISODE: + if media_item.parent_id: + episode = tmdb.TV_Episodes( + series_tmdb_id or tmdb_id, media_item.season_number, media_item.episode_number + ) + info = episode.info(append_to_response="credits,images", **tmdb_common_kwargs) + credits = info.get("credits", {}) + else: + return None else: return None except Exception: # noqa: BLE001 @@ -256,6 +363,8 @@ def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: if not tmdb_id and info: tmdb_id = info.get("id") + if media_item.item_type == MediaItem.TYPE_EPISODE and not series_tmdb_id and info: + series_tmdb_id = info.get("show_id") or info.get("show") or info.get("tv_id") or None genres = [g.get("name") for g in info.get("genres", []) if g.get("name")] runtime = None @@ -264,6 +373,8 @@ def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: elif media_item.item_type == MediaItem.TYPE_SHOW: episode_run_time = info.get("episode_run_time") or [] runtime = episode_run_time[0] if episode_run_time else None + elif media_item.item_type == MediaItem.TYPE_EPISODE: + runtime = info.get("runtime") or info.get("episode_run_time") cast = [] for cast_member in credits.get("cast", [])[:12]: @@ -294,7 +405,7 @@ def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: ) release_year = media_item.release_year - release_date = info.get("release_date") or info.get("first_air_date") + release_date = info.get("release_date") or info.get("first_air_date") or info.get("air_date") if release_date: try: release_year = date_parser.parse(release_date).year @@ -305,14 +416,15 @@ def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: { "tmdb_id": str(tmdb_id) if tmdb_id is not None else media_item.tmdb_id, "imdb_id": info.get("imdb_id") or media_item.imdb_id, + "series_tmdb_id": str(series_tmdb_id) if series_tmdb_id else None, "title": info.get("title") or info.get("name") or media_item.title, "synopsis": info.get("overview") or media_item.synopsis, "tagline": info.get("tagline") or media_item.tagline, "release_year": release_year, "genres": genres, "runtime_minutes": runtime, - "poster": build_image_url(info.get("poster_path")), - "backdrop": build_image_url(info.get("backdrop_path")), + "poster": build_image_url(info.get("poster_path") or info.get("still_path")), + "backdrop": build_image_url(info.get("backdrop_path") or info.get("still_path")), "cast": cast, "crew": crew, "source": "tmdb", @@ -668,34 +780,49 @@ def resolve_available_metadata_source() -> Tuple[Optional[str], Optional[str]]: def fetch_metadata_with_fallback(media_item: MediaItem) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: - metadata = None + prefs = _get_library_metadata_prefs(media_item) + provider_priority = prefs.get("provider_priority") or ["tmdb", "movie-db"] tmdb_key = get_tmdb_api_key() - tmdb_error: Optional[str] = None + tmdb_status: tuple[Optional[bool], Optional[str]] = (None, None) - if tmdb_key: - tmdb_valid, tmdb_message = validate_tmdb_api_key(tmdb_key) - if tmdb_valid: - metadata = fetch_tmdb_metadata(media_item) - if metadata: - return metadata, None - tmdb_error = "TMDB returned no metadata for this item." + results: dict[str, dict] = {} + errors: dict[str, str] = {} + + for provider in provider_priority: + provider = provider.lower() + if provider == "tmdb": + if not tmdb_key: + errors["tmdb"] = "TMDB API key not configured." + continue + if tmdb_status[0] is None: + tmdb_status = validate_tmdb_api_key(tmdb_key) + tmdb_valid, tmdb_message = tmdb_status + if not tmdb_valid: + errors["tmdb"] = tmdb_message or "TMDB API key is invalid." + continue + tmdb_metadata = fetch_tmdb_metadata(media_item) + if tmdb_metadata: + results["tmdb"] = tmdb_metadata + break + errors["tmdb"] = "TMDB returned no metadata for this item." + elif provider in {"movie-db", "moviedb", "movie_db"}: + fallback_metadata, fallback_error = fetch_movie_db_metadata(media_item) + if fallback_metadata: + results["movie-db"] = fallback_metadata + break + if fallback_error: + errors["movie-db"] = fallback_error else: - tmdb_error = tmdb_message or "TMDB API key is invalid." - else: - tmdb_error = "TMDB API key not configured." + errors[provider] = "Unknown metadata provider." - fallback_metadata, fallback_error = fetch_movie_db_metadata(media_item) - if fallback_metadata: - return fallback_metadata, None + if results: + provider_name, payload = next(iter(results.items())) + return payload, None - details = [] - if tmdb_error: - details.append(tmdb_error) - if fallback_error: - details.append(fallback_error) message = "All metadata sources are unavailable." + details = " ".join(errors.values()) if details: - message = f"{message} {' '.join(details)}" + message = f"{message} {details}" return None, message @@ -789,6 +916,20 @@ def apply_metadata(media_item: MediaItem, metadata: Dict[str, Any]) -> MediaItem update_fields.append("metadata_source") changed = True + series_tmdb_id = metadata.get("series_tmdb_id") + if ( + series_tmdb_id + and media_item.item_type == MediaItem.TYPE_EPISODE + and media_item.parent_id + ): + try: + parent = media_item.parent # type: ignore[attr-defined] + except Exception: + parent = None + if parent and not parent.tmdb_id: + parent.tmdb_id = str(series_tmdb_id) + parent.save(update_fields=["tmdb_id", "updated_at"]) + if changed or not media_item.metadata_last_synced_at: media_item.metadata_last_synced_at = timezone.now() if "metadata_last_synced_at" not in update_fields: diff --git a/apps/media_library/migrations/0003_subtitleasset.py b/apps/media_library/migrations/0003_subtitleasset.py new file mode 100644 index 00000000..e9ed042f --- /dev/null +++ b/apps/media_library/migrations/0003_subtitleasset.py @@ -0,0 +1,45 @@ +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("media_library", "0002_mediaitem_is_missing"), + ] + + operations = [ + migrations.CreateModel( + name="SubtitleAsset", + fields=[ + ("id", models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, serialize=False)), + ("language", models.CharField(max_length=16, blank=True)), + ("is_forced", models.BooleanField(default=False)), + ("source", models.CharField(max_length=64, blank=True)), + ("file_path", models.CharField(max_length=4096, blank=True)), + ("external_url", models.URLField(blank=True)), + ( + "format", + models.CharField( + max_length=8, + choices=[("srt", "SRT"), ("vtt", "WebVTT")], + default="srt", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "media_item", + models.ForeignKey( + on_delete=models.CASCADE, + related_name="subtitles", + to="media_library.mediaitem", + ), + ), + ], + options={ + "ordering": ["media_item", "language", "created_at"], + "unique_together": {("media_item", "language", "source", "format")}, + }, + ), + ] diff --git a/apps/media_library/models.py b/apps/media_library/models.py index 2e8f7fde..8ff0d962 100644 --- a/apps/media_library/models.py +++ b/apps/media_library/models.py @@ -578,6 +578,41 @@ class ArtworkAsset(models.Model): return f"{self.media_item.title} - {self.asset_type}" +class SubtitleAsset(models.Model): + """Represents a subtitle file linked to a media item.""" + + FORMAT_SRT = "srt" + FORMAT_VTT = "vtt" + + FORMAT_CHOICES = [ + (FORMAT_SRT, "SRT"), + (FORMAT_VTT, "WebVTT"), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + media_item = models.ForeignKey( + MediaItem, + on_delete=models.CASCADE, + related_name="subtitles", + ) + language = models.CharField(max_length=16, blank=True) + is_forced = models.BooleanField(default=False) + source = models.CharField(max_length=64, blank=True) + file_path = models.CharField(max_length=4096, blank=True) + external_url = models.URLField(blank=True) + format = models.CharField(max_length=8, choices=FORMAT_CHOICES, default=FORMAT_SRT) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [("media_item", "language", "source", "format")] + ordering = ["media_item", "language", "created_at"] + + def __str__(self): + return f"{self.media_item.title} - {self.language or 'unknown'} subtitles" + + class MediaFile(models.Model): """Physical file on disk that is associated with a media item.""" diff --git a/apps/media_library/serializers.py b/apps/media_library/serializers.py index 6b862b65..9262750f 100644 --- a/apps/media_library/serializers.py +++ b/apps/media_library/serializers.py @@ -1,4 +1,5 @@ from django.db import transaction +from django.urls import reverse from django.db.models import Prefetch from rest_framework import serializers @@ -204,6 +205,40 @@ class ArtworkAssetSerializer(serializers.ModelSerializer): read_only_fields = ["id", "created_at", "updated_at"] +class SubtitleAssetSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField() + + def get_url(self, obj): + request = self.context.get("request") + if not obj.file_path and obj.external_url: + return obj.external_url + if not obj.file_path: + return None + try: + relative = reverse("media-library-subtitle-file", args=[obj.id]) + except Exception: + relative = None + if request and relative: + return request.build_absolute_uri(relative) + return relative + + class Meta: + model = models.SubtitleAsset + fields = [ + "id", + "language", + "is_forced", + "source", + "format", + "file_path", + "external_url", + "url", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at", "file_path", "external_url"] + + class MediaFileSerializer(serializers.ModelSerializer): class Meta: model = models.MediaFile @@ -415,6 +450,7 @@ class MediaItemListSerializer(MediaItemBaseSerializer): class MediaItemSerializer(MediaItemBaseSerializer): files = MediaFileSerializer(many=True, read_only=True) artwork = ArtworkAssetSerializer(many=True, read_only=True) + subtitles = SubtitleAssetSerializer(many=True, read_only=True) class Meta: model = models.MediaItem @@ -457,6 +493,7 @@ class MediaItemSerializer(MediaItemBaseSerializer): "artwork", "watch_progress", "watch_summary", + "subtitles", ] read_only_fields = [ "id", @@ -473,6 +510,7 @@ class MediaItemSerializer(MediaItemBaseSerializer): "vod_series", "vod_episode", "watch_progress", + "subtitles", "is_missing", ] extra_kwargs = { diff --git a/apps/media_library/subtitles.py b/apps/media_library/subtitles.py new file mode 100644 index 00000000..08bcfab3 --- /dev/null +++ b/apps/media_library/subtitles.py @@ -0,0 +1,354 @@ +import logging +import os +import subprocess +import tempfile +import time +from typing import Iterable, Optional + +import requests +from django.conf import settings +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage + +from apps.media_library.models import MediaItem, SubtitleAsset + +logger = logging.getLogger(__name__) + +OS_API_URL = getattr(settings, "OPENSUBTITLES_API_URL", "https://api.opensubtitles.com/api/v1") +OS_API_KEY = getattr(settings, "OPENSUBTITLES_API_KEY", "") +OS_USERNAME = getattr(settings, "OPENSUBTITLES_USERNAME", "") +OS_PASSWORD = getattr(settings, "OPENSUBTITLES_PASSWORD", "") +OS_USER_AGENT = getattr(settings, "OPENSUBTITLES_USER_AGENT", "Dispatcharr/1.0") + +_OS_SESSION = requests.Session() +_OS_SESSION.headers.update({"Api-Key": OS_API_KEY, "User-Agent": OS_USER_AGENT}) +_OS_TOKEN: Optional[str] = None +_OS_TOKEN_EXPIRES_AT: float = 0.0 + + +def _ensure_token() -> Optional[str]: + global _OS_TOKEN, _OS_TOKEN_EXPIRES_AT + if not OS_API_KEY: + logger.debug("OpenSubtitles API key not configured") + return None + now = time.time() + if _OS_TOKEN and now < _OS_TOKEN_EXPIRES_AT: + return _OS_TOKEN + if not OS_USERNAME or not OS_PASSWORD: + # Tokenless mode is allowed but download limits apply. + return None + try: + response = _OS_SESSION.post( + f"{OS_API_URL}/login", + json={"username": OS_USERNAME, "password": OS_PASSWORD}, + timeout=10, + ) + except requests.RequestException as exc: + logger.warning("OpenSubtitles login failed: %s", exc) + return None + if response.status_code != 200: + logger.warning("OpenSubtitles login returned status %s", response.status_code) + return None + token = (response.json() or {}).get("token") + if not token: + return None + _OS_TOKEN = token + # Token TTL is undocumented; cache for 6 hours. + _OS_TOKEN_EXPIRES_AT = now + 6 * 60 * 60 + return _OS_TOKEN + + +def _headers(with_token: bool = True) -> dict: + headers = {"Api-Key": OS_API_KEY, "User-Agent": OS_USER_AGENT} + if with_token: + token = _ensure_token() + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _language_pref(media_item: MediaItem) -> Optional[str]: + library = getattr(media_item, "library", None) + if library and library.metadata_language: + return library.metadata_language + return None + + +def _store_subtitle(media_item: MediaItem, language: str, fmt: str, content: bytes) -> SubtitleAsset: + safe_language = language or "und" + extension = ".vtt" if fmt == SubtitleAsset.FORMAT_VTT else ".srt" + relative_path = os.path.join("subtitles", str(media_item.id), f"{safe_language}{extension}") + + if default_storage.exists(relative_path): + try: + default_storage.delete(relative_path) + except Exception: + pass + + default_storage.save(relative_path, ContentFile(content)) + + subtitle, _ = SubtitleAsset.objects.update_or_create( + media_item=media_item, + language=safe_language, + format=fmt, + source="opensubtitles", + defaults={ + "file_path": relative_path, + "external_url": "", + "is_forced": False, + }, + ) + return subtitle + + +def download_subtitles_for_media_item(media_item: MediaItem, language: Optional[str] = None) -> list[SubtitleAsset]: + """ + Fetch subtitles from OpenSubtitles and persist them. Returns a list of SubtitleAsset objects. + """ + if not OS_API_KEY: + raise RuntimeError("OpenSubtitles API key is not configured.") + + language = (language or _language_pref(media_item) or "en").lower() + + query_params = {"languages": language, "order_by": "downloads", "order_direction": "desc"} + + imdb_id_raw = media_item.imdb_id or "" + imdb_numeric = "" + if imdb_id_raw: + imdb_numeric = imdb_id_raw.replace("tt", "").strip() + imdb_numeric = "".join(ch for ch in imdb_numeric if ch.isdigit()) + + if imdb_numeric: + query_params["imdb_id"] = imdb_numeric + + if media_item.item_type == MediaItem.TYPE_MOVIE: + query_params["type"] = "movie" + query_params["query"] = media_item.title + if media_item.release_year: + query_params["year"] = media_item.release_year + elif media_item.item_type in (MediaItem.TYPE_SHOW, MediaItem.TYPE_EPISODE): + query_params["type"] = "episode" + series_title = media_item.title + if media_item.item_type == MediaItem.TYPE_EPISODE and media_item.parent: + series_title = media_item.parent.title or series_title + query_params["query"] = series_title + if media_item.season_number is not None: + query_params["season_number"] = media_item.season_number + if media_item.episode_number is not None: + query_params["episode_number"] = media_item.episode_number + if media_item.release_year: + query_params["year"] = media_item.release_year + + try: + response = _OS_SESSION.get( + f"{OS_API_URL}/subtitles", + params=query_params, + headers=_headers(with_token=False), + timeout=12, + ) + except requests.RequestException as exc: + raise RuntimeError(f"OpenSubtitles search failed: {exc}") from exc + + if response.status_code != 200: + raise RuntimeError(f"OpenSubtitles search returned status {response.status_code}.") + + payload = response.json() or {} + data = payload.get("data") or [] + if not isinstance(data, list) or not data: + raise RuntimeError("No subtitles found for this item.") + + best = None + best_downloads = -1 + for entry in data: + attributes = entry.get("attributes", {}) + files = attributes.get("files") or [] + if not files: + continue + file_entry = files[0] or {} + file_id = file_entry.get("file_id") + if not file_id: + continue + downloads = attributes.get("downloads") or 0 + if downloads > best_downloads: + best_downloads = downloads + best = {"file_id": file_id, "attributes": attributes} + + if not best: + raise RuntimeError("No suitable subtitle candidate found.") + + file_id = best["file_id"] + try: + download_resp = _OS_SESSION.post( + f"{OS_API_URL}/download", + json={"file_id": file_id}, + headers=_headers(with_token=True), + timeout=12, + ) + except requests.RequestException as exc: + raise RuntimeError(f"OpenSubtitles download failed: {exc}") from exc + + if download_resp.status_code != 200: + raise RuntimeError(f"OpenSubtitles download returned status {download_resp.status_code}.") + + download_payload = download_resp.json() or {} + link = (download_payload.get("data") or {}).get("link") + subtitle_format = (download_payload.get("data") or {}).get("file_name", "").lower() + fmt = SubtitleAsset.FORMAT_VTT if subtitle_format.endswith(".vtt") else SubtitleAsset.FORMAT_SRT + + if not link: + raise RuntimeError("OpenSubtitles did not return a download link.") + + try: + file_response = _OS_SESSION.get(link, timeout=20) + file_response.raise_for_status() + except requests.RequestException as exc: + raise RuntimeError(f"Unable to fetch subtitle file: {exc}") from exc + + content = file_response.content or b"" + if not content: + raise RuntimeError("Downloaded subtitle is empty.") + + subtitle = _store_subtitle(media_item, language, fmt, content) + return [subtitle] + + +def _extract_embedded_subtitles_for_media_item(media_item: MediaItem, language: Optional[str] = None) -> list[SubtitleAsset]: + """ + Extract the first matching embedded subtitle track from the media item's files. + """ + assets: list[SubtitleAsset] = [] + lang_normalized = (language or _language_pref(media_item) or "").lower() + + for media_file in media_item.files.order_by("-duration_ms", "-size_bytes"): + extra = media_file.extra_streams or {} + streams = extra.get("streams") or [] + candidates = [] + for stream in streams: + if stream.get("codec_type") != "subtitle": + continue + stream_lang = (stream.get("tags", {}) or {}).get("language") or "" + stream_lang = stream_lang.lower() + score = 0 + if lang_normalized and stream_lang == lang_normalized: + score = 2 + elif lang_normalized and stream_lang.startswith(lang_normalized): + score = 1 + candidates.append( + { + "index": stream.get("index"), + "language": stream_lang or "und", + "score": score, + "media_file": media_file, + } + ) + + if not candidates: + continue + + candidates.sort(key=lambda c: c["score"], reverse=True) + chosen = candidates[0] + stream_idx = chosen.get("index") + if stream_idx is None: + continue + + # Extract using ffmpeg to WebVTT for browser compatibility. + with tempfile.NamedTemporaryFile(suffix=".vtt", delete=False) as tmp_file: + tmp_path = tmp_file.name + try: + command = [ + "ffmpeg", + "-y", + "-i", + chosen["media_file"].absolute_path, + "-map", + f"0:s:{stream_idx}", + "-c:s", + "webvtt", + tmp_path, + ] + subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + with open(tmp_path, "rb") as handle: + content = handle.read() + subtitle = _store_subtitle(media_item, chosen["language"], SubtitleAsset.FORMAT_VTT, content) + assets.append(subtitle) + return assets + except Exception as exc: # noqa: BLE001 + logger.debug("Failed to extract embedded subtitle: %s", exc) + continue + finally: + try: + os.remove(tmp_path) + except OSError: + pass + + return assets + + +def _find_sidecar_subtitles(media_item: MediaItem, language: Optional[str] = None) -> list[SubtitleAsset]: + """ + Locate sidecar subtitle files next to the media files. + """ + assets: list[SubtitleAsset] = [] + lang_normalized = (language or _language_pref(media_item) or "").lower() + suffixes = [".vtt", ".srt", ".ass", ".ssa"] + + for media_file in media_item.files.order_by("-duration_ms", "-size_bytes"): + base_path = os.path.splitext(media_file.absolute_path)[0] + directory = os.path.dirname(media_file.absolute_path) + if not os.path.isdir(directory): + continue + + candidate_paths = [] + for suffix in suffixes: + candidate_paths.append(base_path + suffix) + if lang_normalized: + candidate_paths.append(f"{base_path}.{lang_normalized}{suffix}") + + for path in candidate_paths: + if not os.path.exists(path): + continue + ext = os.path.splitext(path)[1].lower() + fmt = SubtitleAsset.FORMAT_VTT if ext == ".vtt" else SubtitleAsset.FORMAT_SRT + language_code = lang_normalized or "und" + subtitle, _ = SubtitleAsset.objects.update_or_create( + media_item=media_item, + language=language_code, + format=fmt, + source="sidecar", + defaults={ + "file_path": path, + "external_url": "", + "is_forced": False, + }, + ) + assets.append(subtitle) + return assets + + return assets + + +def ensure_subtitles_for_media_item(media_item: MediaItem, language: Optional[str] = None) -> list[SubtitleAsset]: + """ + Try to obtain subtitles in priority order: + 1) Use existing SubtitleAssets if present. + 2) Extract embedded subtitles. + 3) Locate sidecar subtitles. + 4) Download from OpenSubtitles. + """ + existing = list(media_item.subtitles.all()) + if existing: + return existing + + embedded = _extract_embedded_subtitles_for_media_item(media_item, language=language) + if embedded: + return embedded + + sidecar = _find_sidecar_subtitles(media_item, language=language) + if sidecar: + return sidecar + + try: + return download_subtitles_for_media_item(media_item, language=language) + except RuntimeError as exc: + logger.debug("Unable to download subtitles: %s", exc) + return [] diff --git a/apps/media_library/tasks.py b/apps/media_library/tasks.py index 75dcbe3a..a2809036 100644 --- a/apps/media_library/tasks.py +++ b/apps/media_library/tasks.py @@ -14,7 +14,7 @@ from django.db.models import Q, Count from django.utils import timezone from apps.media_library.metadata import sync_metadata -from apps.media_library.models import Library, LibraryScan, MediaFile, MediaItem +from apps.media_library.models import Library, LibraryScan, MediaFile, MediaItem, normalize_title from apps.media_library import serializers from apps.media_library.utils import LibraryScanner, apply_probe_metadata, probe_media_file, resolve_media_item from apps.media_library.naming import classify_media_entry @@ -805,7 +805,9 @@ def scan_library_task( ) missing_ids = list( - episode_qs.filter(file_count=0, is_missing=False).values_list("id", flat=True) + episode_qs.filter(file_count=0, is_missing=False) + .exclude(metadata__shared_from_file__isnull=False) + .values_list("id", flat=True) ) if missing_ids: MediaItem.objects.filter(pk__in=missing_ids).update( @@ -815,7 +817,9 @@ def scan_library_task( ) restored_ids = list( - episode_qs.filter(file_count__gt=0, is_missing=True).values_list("id", flat=True) + episode_qs.filter(file_count__gt=0, is_missing=True) + .exclude(metadata__shared_from_file__isnull=False) + .values_list("id", flat=True) ) if restored_ids: MediaItem.objects.filter(pk__in=restored_ids).update( @@ -1023,6 +1027,23 @@ def _identify_media_file( relative_path=relative_dir, file_name=file_record.file_name, ) + extra_episode_numbers: list[int] = [] + if ( + classification.detected_type == MediaItem.TYPE_EPISODE + and isinstance(classification.data, dict) + ): + episode_list = classification.data.get("episode_list") or [] + if isinstance(episode_list, (list, tuple, set)): + normalized_numbers: list[int] = [] + for entry in episode_list: + try: + normalized_numbers.append(int(entry)) + except (TypeError, ValueError): + continue + primary_number = classification.episode + extra_episode_numbers = sorted( + {num for num in normalized_numbers if num is not None and num != primary_number} + ) target_item = None if target_item_id: @@ -1075,8 +1096,55 @@ def _identify_media_file( sync_metadata_task.delay(parent.id) matched = 1 + def _ensure_additional_episodes(shared_from: MediaItem) -> None: + if not extra_episode_numbers or shared_from.item_type != MediaItem.TYPE_EPISODE: + return + parent_series = shared_from.parent + if not parent_series: + return + + for episode_number in extra_episode_numbers: + season_number = shared_from.season_number or 0 + if season_number: + title_to_use = f"S{season_number:02d}E{episode_number:02d}" + else: + title_to_use = f"E{episode_number:02d}" + episode = ( + MediaItem.objects.filter( + library=library, + item_type=MediaItem.TYPE_EPISODE, + parent=parent_series, + season_number=shared_from.season_number, + episode_number=episode_number, + ).first() + ) + if not episode: + episode = MediaItem.objects.create( + library=library, + parent=parent_series, + item_type=MediaItem.TYPE_EPISODE, + status=MediaItem.STATUS_MATCHED, + title=title_to_use, + sort_title=title_to_use, + normalized_title=normalize_title(title_to_use), + release_year=shared_from.release_year, + season_number=shared_from.season_number, + episode_number=episode_number, + metadata={}, + ) + metadata_payload = dict(episode.metadata or {}) + metadata_payload["shared_from_file"] = file_record.id + metadata_payload["shared_from_media_item_id"] = shared_from.id + metadata_payload["shared_from_path"] = file_record.absolute_path + episode.metadata = metadata_payload + episode.is_missing = False + if episode.status != MediaItem.STATUS_MATCHED: + episode.status = MediaItem.STATUS_MATCHED + episode.save(update_fields=["metadata", "is_missing", "status", "updated_at"]) + # Synchronize to VOD catalog when configured. sync_media_item_to_vod(media_item) + _ensure_additional_episodes(media_item) else: unmatched = 1 @@ -1227,6 +1295,13 @@ def prune_stale_scans(max_age_hours: int = 72): logger.info("Pruned %s stale library scan records", deleted) +@shared_task(name="media_library.prune_transcodes") +def prune_transcodes_task(retention_hours: int = 72): + from apps.media_library.transcode import prune_transcode_directory + + return prune_transcode_directory(retention_hours=retention_hours) + + @shared_task(name="media_library.transcode_media_file") def transcode_media_file_task(file_id: int, force: bool = False): try: diff --git a/apps/media_library/transcode.py b/apps/media_library/transcode.py index e619f589..4696c20f 100644 --- a/apps/media_library/transcode.py +++ b/apps/media_library/transcode.py @@ -8,6 +8,7 @@ import subprocess import tempfile import threading from collections import deque +from datetime import datetime, timedelta from pathlib import Path from typing import Iterable, Tuple @@ -47,6 +48,10 @@ PRESET = getattr(settings, "MEDIA_LIBRARY_TRANSCODE_PRESET", "veryfast") TARGET_VIDEO_CODEC = getattr(settings, "MEDIA_LIBRARY_TRANSCODE_VIDEO_CODEC", "libx264") TARGET_AUDIO_CODEC = getattr(settings, "MEDIA_LIBRARY_TRANSCODE_AUDIO_CODEC", "aac") AUDIO_CHANNELS = _int_setting("MEDIA_LIBRARY_TRANSCODE_AUDIO_CHANNELS", 2) +HWACCEL = getattr(settings, "MEDIA_LIBRARY_FFMPEG_HWACCEL", "") +HWACCEL_DEVICE = getattr(settings, "MEDIA_LIBRARY_FFMPEG_HWACCEL_DEVICE", "") +EXTRA_INPUT_ARGS = getattr(settings, "MEDIA_LIBRARY_FFMPEG_EXTRA_INPUT_ARGS", []) +EXTRA_OUTPUT_ARGS = getattr(settings, "MEDIA_LIBRARY_FFMPEG_EXTRA_OUTPUT_ARGS", []) def _build_target_path(media_file: MediaFile) -> Path: @@ -65,9 +70,17 @@ def _build_ffmpeg_command( source_path: Path, *, output: str, fragmented: bool, start_seconds: float = 0.0 ) -> list[str]: command = [FFMPEG_PATH, "-y"] + if HWACCEL: + command.extend(["-hwaccel", str(HWACCEL)]) + if HWACCEL_DEVICE: + command.extend(["-hwaccel_device", str(HWACCEL_DEVICE)]) + if start_seconds and start_seconds > 0: command.extend(["-ss", f"{start_seconds:.3f}"]) + if isinstance(EXTRA_INPUT_ARGS, (list, tuple)): + command.extend([str(arg) for arg in EXTRA_INPUT_ARGS]) + command.extend( [ "-i", @@ -106,6 +119,9 @@ def _build_ffmpeg_command( if VIDEO_BITRATE > 0: command.extend(["-b:v", f"{VIDEO_BITRATE}k"]) + if isinstance(EXTRA_OUTPUT_ARGS, (list, tuple)): + command.extend([str(arg) for arg in EXTRA_OUTPUT_ARGS]) + if fragmented: command.extend(["-movflags", "frag_keyframe+empty_moov+faststart", "-f", "mp4", output]) else: @@ -131,6 +147,38 @@ def ensure_browser_ready_source( return str(target_path), "video/mp4" +def purge_transcoded_artifact(media_file: MediaFile) -> None: + """ + Remove a cached transcode from disk and reset state on the MediaFile. + Useful when the source file disappears or is relocated. + """ + cached_path = media_file.transcoded_path or "" + if cached_path and os.path.exists(cached_path): + try: + os.remove(cached_path) + except OSError: + logger.debug("Unable to remove cached transcode %s", cached_path) + + media_file.transcoded_path = "" + media_file.transcoded_mime_type = "" + media_file.transcode_error = "" + if media_file.requires_transcode: + media_file.transcode_status = MediaFile.TRANSCODE_STATUS_PENDING + else: + media_file.transcode_status = MediaFile.TRANSCODE_STATUS_NOT_REQUIRED + media_file.transcoded_at = None + media_file.save( + update_fields=[ + "transcoded_path", + "transcoded_mime_type", + "transcode_status", + "transcode_error", + "transcoded_at", + "updated_at", + ] + ) + + def _ensure_transcode_to_file(media_file: MediaFile, *, force: bool = False) -> Path: source_path = _as_path(media_file.absolute_path) if not source_path.exists(): @@ -472,3 +520,45 @@ def start_streaming_transcode( """Start a live transcoding session for the given media file.""" session = LiveTranscodeSession(media_file, start_seconds=start_seconds) return session.start() + + +def prune_transcode_directory(retention_hours: int = 72) -> dict[str, int]: + """ + Remove orphaned or stale transcode artifacts from the transcode root. + """ + now = timezone.now() + removed = 0 + skipped = 0 + kept = 0 + + try: + valid_paths = { + Path(path) + for path in MediaFile.objects.exclude(transcoded_path="").values_list("transcoded_path", flat=True) + if path + } + except Exception as exc: # noqa: BLE001 + logger.warning("Unable to fetch transcode path list: %s", exc) + valid_paths = set() + + retention_delta = timedelta(hours=max(retention_hours, 0)) + for child in TRANSCODE_ROOT.glob("*.mp4"): + try: + stat = child.stat() + modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) + expired = bool(retention_hours) and (now - modified > retention_delta) + except OSError: + expired = True + + is_valid = child in valid_paths + if is_valid and not expired: + kept += 1 + continue + + try: + child.unlink() + removed += 1 + except OSError: + skipped += 1 + + return {"removed": removed, "skipped": skipped, "kept": kept} diff --git a/apps/media_library/utils.py b/apps/media_library/utils.py index 14cdac35..ae5c9306 100644 --- a/apps/media_library/utils.py +++ b/apps/media_library/utils.py @@ -1,3 +1,4 @@ +import fnmatch import json import logging import os @@ -9,6 +10,7 @@ from importlib import resources as importlib_resources from pathlib import Path from typing import Dict, Iterable, Iterator, List, Optional +from django.conf import settings from django.db import transaction from django.utils import timezone from guessit import guessit @@ -37,6 +39,7 @@ from apps.media_library.models import ( MEDIA_EXTENSIONS, normalize_title, ) +from apps.media_library.transcode import purge_transcoded_artifact logger = logging.getLogger(__name__) @@ -103,6 +106,18 @@ class LibraryScanner: self.stats: Dict[str, int] = defaultdict(int) self._seen_paths: set[str] = set() self.target_item = None + options = library.metadata_options or {} + ignore_patterns_default = getattr(settings, "MEDIA_LIBRARY_IGNORE_PATTERNS", []) + library_ignore = options.get("ignore_patterns") if isinstance(options, dict) else None + combined_patterns = [] + if isinstance(ignore_patterns_default, (list, tuple, set)): + combined_patterns.extend( + [pat for pat in ignore_patterns_default if isinstance(pat, str) and pat] + ) + if isinstance(library_ignore, (list, tuple, set)): + combined_patterns.extend([pat for pat in library_ignore if isinstance(pat, str) and pat]) + self.ignore_patterns = [pattern.strip() for pattern in combined_patterns if pattern.strip()] + self.skip_hidden = bool(options.get("skip_hidden", True)) if rescan_item_id: self.target_item = ( MediaItem.objects.filter(pk=rescan_item_id, library=library).first() @@ -141,6 +156,8 @@ class LibraryScanner: continue if file_path.suffix.lower() not in MEDIA_EXTENSIONS: continue + if self._should_ignore_path(file_path): + continue absolute_path = str(file_path) self._seen_paths.add(absolute_path) @@ -184,12 +201,20 @@ class LibraryScanner: .exclude(absolute_path__in=self._seen_paths) .exclude(absolute_path="") ) + missing_files = list( + missing_qs.only("id", "transcoded_path", "requires_transcode", "transcode_status") + ) count = missing_qs.update(missing_since=self.now) if count: self.stats["removed"] += count self._log(f"Marked {count} files as missing.") self.scan.removed_files = self.stats["removed"] self.scan.save(update_fields=["removed_files", "updated_at"]) + for file_record in missing_files: + try: + purge_transcoded_artifact(file_record) + except Exception: # noqa: BLE001 + logger.debug("Unable to purge transcode for missing file %s", file_record.id) return count def finalize(self, matched: int, unmatched: int, summary: str | None = None) -> None: @@ -281,6 +306,24 @@ class LibraryScanner: logger.debug("[Library %s] %s", self.library.id, message) self.log_messages.append(message) + def _should_ignore_path(self, file_path: Path) -> bool: + """ + Skip files that match ignore patterns or hidden folders when configured. + """ + if self.skip_hidden: + parts = file_path.parts + if any(segment.startswith(".") for segment in parts): + return True + + if not self.ignore_patterns: + return False + + candidate = str(file_path) + for pattern in self.ignore_patterns: + if fnmatch.fnmatch(candidate, pattern): + return True + return False + def resolve_media_item( library: Library, diff --git a/apps/media_library/views.py b/apps/media_library/views.py index 21ffb60b..8d02c596 100644 --- a/apps/media_library/views.py +++ b/apps/media_library/views.py @@ -14,7 +14,7 @@ from django.http import ( ) from django.views.decorators.http import require_GET -from apps.media_library.models import MediaFile +from apps.media_library.models import MediaFile, SubtitleAsset from apps.media_library.transcode import start_streaming_transcode logger = logging.getLogger(__name__) @@ -58,6 +58,7 @@ def stream_media_file(request, token: str): file_id = payload.get("file_id") user_id = payload.get("user_id") + force_transcode = bool(payload.get("force_transcode")) if request.user.is_authenticated and request.user.id != user_id: return HttpResponseForbidden("Stream token not issued for this user") @@ -96,7 +97,7 @@ def stream_media_file(request, token: str): download_name = f"{base_name or media_file.id}.mp4" else: if cached_path and media_file.transcode_status == MediaFile.TRANSCODE_STATUS_READY: - # Cached entry missing on disk – reset so we regenerate. + # Cached entry missing on disk -- reset so we regenerate. media_file.transcode_status = MediaFile.TRANSCODE_STATUS_PENDING media_file.transcoded_path = "" media_file.transcoded_mime_type = "" @@ -111,7 +112,12 @@ def stream_media_file(request, token: str): ] ) - if original_path and os.path.exists(original_path) and media_file.is_browser_playable(): + if ( + original_path + and os.path.exists(original_path) + and media_file.is_browser_playable() + and not force_transcode + ): playback_path = original_path mime_type = _guess_mime(original_path) download_name = media_file.file_name or os.path.basename(original_path) @@ -208,3 +214,30 @@ def stream_media_file(request, token: str): response["X-Content-Duration"] = formatted_duration response["Content-Duration"] = formatted_duration return response + + +@require_GET +def subtitle_file(request, subtitle_id: str): + if not request.user.is_authenticated: + return HttpResponseForbidden("Authentication required") + + try: + subtitle = SubtitleAsset.objects.get(pk=subtitle_id) + except SubtitleAsset.DoesNotExist: + raise Http404("Subtitle not found") + + file_path = subtitle.file_path or "" + if not file_path: + raise Http404("Subtitle not found") + + if not os.path.isabs(file_path): + media_root = getattr(settings, "MEDIA_ROOT", "") + file_path = os.path.join(media_root, file_path) + + if not os.path.exists(file_path): + raise Http404("Subtitle file missing") + + mime = "text/vtt" if subtitle.format == SubtitleAsset.FORMAT_VTT else "application/x-subrip" + filename = os.path.basename(file_path) or "subtitle.srt" + + return FileResponse(open(file_path, "rb"), content_type=mime, filename=filename) diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index f491a241..faf9e5f3 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -83,6 +83,7 @@ CACHES = { REQUESTS_TIMEOUT = 30 # Seconds for external API requests MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", @@ -90,7 +91,6 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "corsheaders.middleware.CorsMiddleware", ] diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 957e6917..24c96f79 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -26,6 +26,8 @@ export const WebsocketProvider = ({ children }) => { const [val, setVal] = useState(null); const ws = useRef(null); const reconnectTimerRef = useRef(null); + const mediaItemQueueRef = useRef([]); + const mediaItemFlushTimerRef = useRef(null); const [reconnectAttempts, setReconnectAttempts] = useState(0); const [connectionError, setConnectionError] = useState(null); const maxReconnectAttempts = 5; @@ -40,6 +42,19 @@ export const WebsocketProvider = ({ children }) => { const updatePlaylist = usePlaylistsStore((s) => s.updatePlaylist); const applyMediaScanUpdate = useLibraryStore((s) => s.applyScanUpdate); + const enqueueMediaItemUpdate = useCallback((mediaItem) => { + if (!mediaItem) return; + mediaItemQueueRef.current.push(mediaItem); + if (mediaItemFlushTimerRef.current) return; + mediaItemFlushTimerRef.current = setTimeout(() => { + const queuedItems = mediaItemQueueRef.current; + mediaItemQueueRef.current = []; + mediaItemFlushTimerRef.current = null; + if (queuedItems.length > 0) { + useMediaLibraryStore.getState().upsertItems(queuedItems); + } + }, 200); + }, []); // Calculate reconnection delay with exponential backoff const getReconnectDelay = useCallback(() => { @@ -191,7 +206,7 @@ export const WebsocketProvider = ({ children }) => { notifications.update({ id, title: 'Commercials removed', - message: `${title} — kept ${parsedEvent.data.segments_kept} segments`, + message: `${title} -- kept ${parsedEvent.data.segments_kept} segments`, color: 'green.5', loading: false, autoClose: 4000, @@ -229,9 +244,7 @@ export const WebsocketProvider = ({ children }) => { case 'media_scan': { applyMediaScanUpdate(parsedEvent.data); if (parsedEvent.data.media_item) { - useMediaLibraryStore.getState().upsertItems([ - parsedEvent.data.media_item, - ]); + enqueueMediaItemUpdate(parsedEvent.data.media_item); } if (parsedEvent.data.status === 'completed') { @@ -263,9 +276,7 @@ export const WebsocketProvider = ({ children }) => { } case 'media_item_update': { if (parsedEvent.data.media_item) { - useMediaLibraryStore.getState().upsertItems([ - parsedEvent.data.media_item, - ]); + enqueueMediaItemUpdate(parsedEvent.data.media_item); } break; } @@ -917,6 +928,11 @@ export const WebsocketProvider = ({ children }) => { } else if (ws.current) { // Close the connection if the user logs out clearReconnectTimer(); + if (mediaItemFlushTimerRef.current) { + clearTimeout(mediaItemFlushTimerRef.current); + mediaItemFlushTimerRef.current = null; + mediaItemQueueRef.current = []; + } console.log('Closing WebSocket connection due to logout'); ws.current.onclose = null; ws.current.close(); @@ -926,6 +942,11 @@ export const WebsocketProvider = ({ children }) => { return () => { clearReconnectTimer(); // Clear any pending reconnect timers + if (mediaItemFlushTimerRef.current) { + clearTimeout(mediaItemFlushTimerRef.current); + mediaItemFlushTimerRef.current = null; + mediaItemQueueRef.current = []; + } if (ws.current) { console.log('Closing WebSocket connection due to component unmount'); diff --git a/frontend/src/api.js b/frontend/src/api.js index 4427cffa..ff8c2fcf 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2900,6 +2900,9 @@ export default class API { params.append('start_ms', String(normalized)); } } + if (options.forceTranscode || options.force_transcode) { + params.append('force_transcode', '1'); + } const query = params.toString(); const response = await request( `${host}/api/media/items/${id}/stream/${query ? `?${query}` : ''}` @@ -2911,6 +2914,36 @@ export default class API { } } + static async listMediaSubtitles(id) { + try { + const response = await request(`${host}/api/media/items/${id}/subtitles/`); + return response; + } catch (e) { + errorNotification('Failed to load subtitles', e); + throw e; + } + } + + static async fetchMediaSubtitles(id, language = null) { + try { + const body = {}; + if (language) { + body.language = language; + } + const response = await request( + `${host}/api/media/items/${id}/fetch-subtitles/`, + { + method: 'POST', + body, + } + ); + return response; + } catch (e) { + errorNotification('Failed to fetch subtitles', e); + throw e; + } + } + static async getSystemEvents(limit = 100, offset = 0, eventType = null) { try { const params = new URLSearchParams(); diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index d02c6222..ca2ceb75 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -14,6 +14,8 @@ import { Group, Slider, ActionIcon, + Menu, + Divider, } from '@mantine/core'; import { Play, Pause, Volume2, VolumeX, Maximize, Minimize } from 'lucide-react'; import API from '../api'; @@ -76,6 +78,11 @@ export default function FloatingVideo() { const previousVolumeRef = useRef(1); const [showVolumeControl, setShowVolumeControl] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); + const transcodeRetryAttemptedRef = useRef(false); + const [availableSubtitles, setAvailableSubtitles] = useState([]); + const [selectedSubtitleId, setSelectedSubtitleId] = useState(null); + const [subtitlesLoading, setSubtitlesLoading] = useState(false); + const [subtitleError, setSubtitleError] = useState(null); const updateVideoMetadata = useCallback((updates) => { if (!updates || typeof updates !== 'object') { @@ -117,6 +124,128 @@ export default function FloatingVideo() { }, CONTROL_HIDE_DELAY); }; + const retryWithTranscode = useCallback(async () => { + if (transcodeRetryAttemptedRef.current) { + return; + } + if (contentType !== 'library' || !metadata?.mediaItemId) { + return; + } + transcodeRetryAttemptedRef.current = true; + const startOffsetMs = metadata?.startOffsetMs ?? 0; + const relativePosition = Number.isFinite(videoRef.current?.currentTime) + ? videoRef.current.currentTime + : 0; + const startMs = Math.max(0, Math.round(startOffsetMs + relativePosition * 1000)); + setIsLoading(true); + setLoadError('Trying a browser-compatible version...'); + try { + const streamInfo = await API.streamMediaItem(metadata.mediaItemId, { + fileId: metadata.fileId, + startMs, + forceTranscode: true, + }); + const playbackUrl = streamInfo?.url || streamInfo?.stream_url; + if (!playbackUrl) { + throw new Error('No transcoded stream URL was returned.'); + } + const nextStartOffsetMs = streamInfo?.start_offset_ms ?? startOffsetMs; + const resumeHandledByServer = (streamInfo?.start_offset_ms ?? 0) > 0; + useVideoStore.getState().showVideo(playbackUrl, 'library', { + ...metadata, + startOffsetMs: nextStartOffsetMs, + resumePositionMs: resumeHandledByServer ? nextStartOffsetMs : startMs, + resumeHandledByServer, + requiresTranscode: true, + transcodeStatus: streamInfo?.transcode_status ?? 'processing', + durationMs: streamInfo?.duration_ms ?? metadata?.durationMs ?? null, + }); + setIsLoading(false); + setLoadError(null); + } catch (err) { + console.error('Failed to retry playback with transcoding', err); + setIsLoading(false); + setLoadError('Unable to start transcoded playback.'); + } + }, [contentType, metadata]); + + useEffect(() => { + transcodeRetryAttemptedRef.current = false; + }, [metadata?.mediaItemId]); + + const subtitleLabel = useCallback((sub) => { + if (!sub) return 'None'; + const lang = (sub.language || 'und').toUpperCase(); + return `${lang}${sub.is_forced ? ' (forced)' : ''}`; + }, []); + + const applySubtitleSelection = useCallback(() => { + const video = videoRef.current; + if (!video) return; + const targetId = selectedSubtitleId; + const tracks = video.textTracks || []; + for (let i = 0; i < tracks.length; i += 1) { + const track = tracks[i]; + const match = availableSubtitles.find((sub) => subtitleLabel(sub) === track.label); + const shouldShow = targetId && match && match.id === targetId; + track.mode = shouldShow ? 'showing' : 'disabled'; + } + }, [availableSubtitles, selectedSubtitleId, subtitleLabel]); + + const loadSubtitles = useCallback(async () => { + if (contentType !== 'library' || !metadata?.mediaItemId) { + setAvailableSubtitles([]); + setSelectedSubtitleId(null); + return; + } + setSubtitlesLoading(true); + setSubtitleError(null); + try { + const subs = await API.listMediaSubtitles(metadata.mediaItemId); + setAvailableSubtitles(subs || []); + if (subs && subs.length > 0) { + const preferred = + subs.find((s) => (s.language || '').toLowerCase() === 'en') || subs[0]; + setSelectedSubtitleId((prev) => { + if (prev && subs.find((s) => s.id === prev)) { + return prev; + } + return preferred?.id || null; + }); + } else { + setSelectedSubtitleId(null); + } + } catch (err) { + console.error('Failed to load subtitles', err); + setSubtitleError('Unable to load subtitles'); + } finally { + setSubtitlesLoading(false); + } + }, [contentType, metadata?.mediaItemId]); + + const fetchSubtitles = useCallback(async () => { + if (contentType !== 'library' || !metadata?.mediaItemId) return; + setSubtitlesLoading(true); + setSubtitleError(null); + try { + await API.fetchMediaSubtitles(metadata.mediaItemId); + await loadSubtitles(); + } catch (err) { + console.error('Failed to fetch subtitles', err); + setSubtitleError('Subtitle fetch failed'); + } finally { + setSubtitlesLoading(false); + } + }, [contentType, metadata?.mediaItemId, loadSubtitles]); + + useEffect(() => { + loadSubtitles(); + }, [loadSubtitles]); + + useEffect(() => { + applySubtitleSelection(); + }, [applySubtitleSelection, streamUrl]); + useEffect(() => { const video = videoRef.current; if (video) { @@ -603,6 +732,7 @@ export default function FloatingVideo() { fileId, playbackSequence, }); + loadSubtitles(); } catch (error) { console.error('Failed to auto-play next episode', error); setLoadError('Unable to start the next episode automatically.'); @@ -749,6 +879,13 @@ export default function FloatingVideo() { } setLoadError(errorMessage); + if ( + contentType === 'library' && + metadata?.mediaItemId && + !transcodeRetryAttemptedRef.current + ) { + retryWithTranscode(); + } }; // Enhanced progress tracking for VOD @@ -1147,7 +1284,18 @@ export default function FloatingVideo() { {...(contentType !== 'live' && { poster: metadata?.logo?.url, // Use poster if available })} - /> + > + {availableSubtitles.map((sub) => ( + + ))} + {/* VOD title overlay when not loading - auto-hides after 4 seconds */} {!isLoading && metadata && contentType !== 'live' && showOverlay && ( @@ -1294,6 +1442,60 @@ export default function FloatingVideo() { /> )} + + + e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + aria-label="Subtitles" + disabled={subtitlesLoading || contentType !== 'library'} + > + + CC + + + + e.stopPropagation()}> + Subtitles + setSelectedSubtitleId(null)} + disabled={availableSubtitles.length === 0} + > + Off + + {availableSubtitles.map((sub) => ( + setSelectedSubtitleId(sub.id)} + rightSection={ + selectedSubtitleId === sub.id ? ( + + On + + ) : null + } + > + {subtitleLabel(sub)} + + ))} + + fetchSubtitles()} + disabled={subtitlesLoading || contentType !== 'library'} + > + {subtitlesLoading ? 'Fetching…' : 'Fetch from OpenSubtitles'} + + {subtitleError && ( + + {subtitleError} + + )} + + { const authUser = useAuthStore((s) => s.user); const suppressWarning = useWarningsStore((s) => s.suppressWarning); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const mediaLibraries = useLibraryStore((state) => state.libraries); + const librariesLoading = useLibraryStore((state) => state.loading); + const fetchMediaLibraries = useLibraryStore((state) => state.fetchLibraries); + const createMediaLibrary = useLibraryStore((state) => state.createLibrary); + const updateMediaLibrary = useLibraryStore((state) => state.updateLibrary); + const deleteMediaLibrary = useLibraryStore((state) => state.deleteLibrary); + const triggerLibraryScan = useLibraryStore((state) => state.triggerScan); const [accordianValue, setAccordianValue] = useState(null); const [networkAccessSaved, setNetworkAccessSaved] = useState(false); @@ -199,6 +218,37 @@ const SettingsPage = () => { // Add a new state to track the dialog type const [rehashDialogType, setRehashDialogType] = useState(null); // 'save' or 'rehash' + const [libraryModalOpen, setLibraryModalOpen] = useState(false); + const [editingLibrarySettings, setEditingLibrarySettings] = useState(null); + const [librarySubmitting, setLibrarySubmitting] = useState(false); + const tmdbSetting = settings['tmdb-api-key']; + const osUserSetting = settings['opensubtitles-username']; + const osPassSetting = settings['opensubtitles-password']; + const osApiKeySetting = settings['opensubtitles-api-key']; + const TMDB_REQUIREMENT_MESSAGE = + 'Metadata uses TMDB when available and falls back to Movie-DB when necessary.'; + const [tmdbKey, setTmdbKey] = useState(''); + const [metadataSourcesAvailable, setMetadataSourcesAvailable] = + useState(false); + const [tmdbValidating, setTmdbValidating] = useState(false); + const [tmdbValidationState, setTmdbValidationState] = useState('info'); + const [tmdbValidationMessage, setTmdbValidationMessage] = useState( + TMDB_REQUIREMENT_MESSAGE + ); + const [osUsername, setOsUsername] = useState( + osUserSetting && osUserSetting.value !== undefined ? osUserSetting.value : '' + ); + const [osPassword, setOsPassword] = useState( + osPassSetting && osPassSetting.value !== undefined ? osPassSetting.value : '' + ); + const [osApiKey, setOsApiKey] = useState( + osApiKeySetting && osApiKeySetting.value !== undefined ? osApiKeySetting.value : '' + ); + const [savingOsSettings, setSavingOsSettings] = useState(false); + const [activeMetadataSource, setActiveMetadataSource] = + useState('unavailable'); + const [savingTmdbKey, setSavingTmdbKey] = useState(false); + const [tmdbHintOpen, setTmdbHintOpen] = useState(false); // Store pending changed settings when showing the dialog const [pendingChangedSettings, setPendingChangedSettings] = useState(null); @@ -405,6 +455,70 @@ const SettingsPage = () => { loadComskipConfig(); }, []); + useEffect(() => { + if (authUser?.user_level === USER_LEVELS.ADMIN) { + fetchMediaLibraries(); + } + }, [authUser?.user_level, fetchMediaLibraries]); + + const validateTmdbKeyValue = useCallback( + async (value) => { + const trimmed = (value || '').trim(); + setTmdbValidating(true); + setTmdbValidationState('info'); + setTmdbValidationMessage('Checking metadata providers...'); + try { + const result = await API.validateTmdbApiKey(trimmed); + const overallValid = Boolean(result?.overall_valid); + const provider = result?.provider || 'unavailable'; + const message = + result?.message || + (overallValid + ? provider === 'tmdb' + ? 'TMDB key verified successfully. Metadata and artwork will load for your libraries.' + : 'Using Movie-DB fallback for metadata.' + : 'All metadata sources are unavailable.'); + + setMetadataSourcesAvailable(overallValid); + setActiveMetadataSource(provider); + + if (provider === 'tmdb') { + setTmdbValidationState('valid'); + } else if (provider === 'movie-db' && overallValid) { + setTmdbValidationState('fallback'); + } else { + setTmdbValidationState('invalid'); + } + + setTmdbValidationMessage(message); + return result ?? { overall_valid: overallValid, provider, message }; + } catch (error) { + setMetadataSourcesAvailable(false); + setActiveMetadataSource('unavailable'); + setTmdbValidationState('error'); + setTmdbValidationMessage( + 'Unable to reach metadata services right now.' + ); + throw error; + } finally { + setTmdbValidating(false); + } + }, + [TMDB_REQUIREMENT_MESSAGE] + ); + + useEffect(() => { + const currentValue = + tmdbSetting && tmdbSetting.value !== undefined ? tmdbSetting.value : ''; + setTmdbKey(currentValue); + validateTmdbKeyValue(currentValue).catch((error) => { + console.error('Failed to validate TMDB API key', error); + }); + setOsUsername(osUserSetting?.value ?? ''); + setOsPassword(osPassSetting?.value ?? ''); + setOsApiKey(osApiKeySetting?.value ?? ''); + }, [tmdbSetting?.value, osUserSetting?.value, osPassSetting?.value, osApiKeySetting?.value, validateTmdbKeyValue]); + // Clear success states when switching accordion panels useEffect(() => { setGeneralSettingsSaved(false); @@ -618,6 +732,267 @@ const SettingsPage = () => { } }; + const persistTmdbKey = async (rawValue) => { + const trimmedKey = (rawValue || '').trim(); + setTmdbKey(trimmedKey); + setSavingTmdbKey(true); + try { + const validationResult = await validateTmdbKeyValue(trimmedKey); + + if (trimmedKey && !validationResult?.overall_valid) { + notifications.show({ + title: 'Invalid TMDB key', + message: + validationResult?.message || + 'Metadata providers are unavailable. TMDB rejected the API key and Movie-DB is unreachable.', + color: 'red', + }); + return; + } + + if (trimmedKey) { + if (tmdbSetting && tmdbSetting.id) { + await API.updateSetting({ + ...tmdbSetting, + value: trimmedKey, + }); + } else { + await API.createSetting({ + key: 'tmdb-api-key', + name: 'TMDB API Key', + value: trimmedKey, + }); + } + } else if (tmdbSetting && tmdbSetting.id) { + await API.deleteSetting(tmdbSetting); + } + + const provider = validationResult?.provider || activeMetadataSource; + const usingFallback = provider === 'movie-db'; + const title = trimmedKey + ? provider === 'tmdb' + ? 'TMDB key saved' + : 'Saved with fallback' + : usingFallback + ? 'TMDB key removed' + : 'Metadata unavailable'; + const message = trimmedKey + ? provider === 'tmdb' + ? 'TMDB API key saved and verified.' + : usingFallback + ? 'Movie-DB fallback will be used for metadata until TMDB becomes available.' + : 'Metadata providers are currently unavailable. Libraries may fail to scan.' + : usingFallback + ? 'TMDB API key removed. Movie-DB fallback will be used for metadata.' + : 'TMDB API key removed, but no metadata providers are currently available.'; + const color = trimmedKey + ? provider === 'tmdb' + ? 'green' + : usingFallback + ? 'blue' + : 'red' + : usingFallback + ? 'blue' + : 'red'; + + notifications.show({ + title, + message, + color, + }); + } catch (error) { + console.error('Failed to save TMDB key', error); + notifications.show({ + title: 'Error', + message: 'Unable to update TMDB API key.', + color: 'red', + }); + } finally { + setSavingTmdbKey(false); + } + }; + + const handleSaveTmdbKey = () => persistTmdbKey(tmdbKey); + + const handleSaveOsSettings = async () => { + setSavingOsSettings(true); + try { + const tasks = []; + const normalizedUser = osUsername?.trim() || ''; + const normalizedPass = osPassword?.trim() || ''; + const normalizedKey = osApiKey?.trim() || ''; + + // Username + if (normalizedUser) { + if (osUserSetting && osUserSetting.id) { + tasks.push(API.updateSetting({ ...osUserSetting, value: normalizedUser })); + } else { + tasks.push( + API.createSetting({ + key: 'opensubtitles-username', + name: 'OpenSubtitles Username', + value: normalizedUser, + }) + ); + } + } else if (osUserSetting && osUserSetting.id) { + tasks.push(API.deleteSetting(osUserSetting)); + } + + // Password + if (normalizedPass) { + if (osPassSetting && osPassSetting.id) { + tasks.push(API.updateSetting({ ...osPassSetting, value: normalizedPass })); + } else { + tasks.push( + API.createSetting({ + key: 'opensubtitles-password', + name: 'OpenSubtitles Password', + value: normalizedPass, + }) + ); + } + } else if (osPassSetting && osPassSetting.id) { + tasks.push(API.deleteSetting(osPassSetting)); + } + + // API Key (optional but recommended) + if (normalizedKey) { + if (osApiKeySetting && osApiKeySetting.id) { + tasks.push(API.updateSetting({ ...osApiKeySetting, value: normalizedKey })); + } else { + tasks.push( + API.createSetting({ + key: 'opensubtitles-api-key', + name: 'OpenSubtitles API Key', + value: normalizedKey, + }) + ); + } + } else if (osApiKeySetting && osApiKeySetting.id) { + tasks.push(API.deleteSetting(osApiKeySetting)); + } + + await Promise.all(tasks); + notifications.show({ + title: 'Subtitle settings saved', + message: 'OpenSubtitles credentials were updated.', + color: 'green', + }); + } catch (error) { + console.error('Failed to save OpenSubtitles settings', error); + notifications.show({ + title: 'Error', + message: 'Unable to update OpenSubtitles settings.', + color: 'red', + }); + } finally { + setSavingOsSettings(false); + } + }; + + const handleDeleteTmdbKey = async () => { + if (!tmdbKey) return; + const confirmed = + typeof window === 'undefined' + ? true + : window.confirm( + 'Remove the TMDB API key? Metadata will fall back to Movie-DB when possible.' + ); + if (!confirmed) return; + try { + await persistTmdbKey(''); + } catch (error) { + console.error('Failed to remove TMDB key', error); + } + }; + + const handleLibrarySettingsSubmit = async (values) => { + setLibrarySubmitting(true); + try { + if (editingLibrarySettings) { + await updateMediaLibrary(editingLibrarySettings.id, values); + notifications.show({ + title: 'Library updated', + message: 'Changes saved.', + color: 'green', + }); + } else { + await createMediaLibrary(values); + notifications.show({ + title: 'Library created', + message: 'New library added.', + color: 'green', + }); + } + setLibraryModalOpen(false); + setEditingLibrarySettings(null); + fetchMediaLibraries(); + } catch (error) { + console.error('Failed to save library', error); + notifications.show({ + title: 'Library error', + message: error?.body?.detail || 'Unable to save library changes.', + color: 'red', + }); + } finally { + setLibrarySubmitting(false); + } + }; + + const handleLibrarySettingsDelete = async (library) => { + if (!window.confirm(`Delete library "${library.name}"?`)) return; + try { + await deleteMediaLibrary(library.id); + notifications.show({ + title: 'Library deleted', + message: 'Library removed successfully.', + color: 'green', + }); + fetchMediaLibraries(); + } catch (error) { + console.error('Failed to delete library', error); + notifications.show({ + title: 'Library error', + message: 'Unable to delete library.', + color: 'red', + }); + } + }; + + const handleLibrarySettingsScan = async (library) => { + try { + await triggerLibraryScan(library.id, { full: false }); + notifications.show({ + title: 'Scan started', + message: `Library ${library.name} queued for scanning.`, + color: 'blue', + }); + } catch (error) { + console.error('Failed to trigger scan', error); + notifications.show({ + title: 'Scan error', + message: 'Unable to start scan.', + color: 'red', + }); + } + }; + + const libraryActionsDisabled = tmdbValidating || !metadataSourcesAvailable; + const tmdbMessageColor = + tmdbValidationState === 'valid' + ? 'teal.6' + : tmdbValidationState === 'fallback' + ? 'blue.4' + : tmdbValidationState === 'error' || tmdbValidationState === 'invalid' + ? 'red.6' + : 'orange.6'; + const addLibraryTooltipLabel = metadataSourcesAvailable + ? activeMetadataSource === 'tmdb' + ? 'TMDB metadata is available.' + : 'Using Movie-DB fallback for metadata.' + : 'Metadata sources are unavailable. Configure TMDB or try again later.'; + const executeSettingsSaveAndRehash = async () => { setRehashConfirmOpen(false); setGeneralSettingsSaved(false); @@ -775,6 +1150,234 @@ const SettingsPage = () => { {authUser.user_level == USER_LEVELS.ADMIN && ( <> + + Media Libraries + + + + + Configure local media libraries used for scanning and playback. + + + + + + + + + + setTmdbKey(event.currentTarget.value)} + rightSection={ + tmdbKey && tmdbKey.trim().length > 0 ? ( + + + + + + ) : null + } + rightSectionPointerEvents="auto" + description="Used for metadata and artwork lookups." + /> + + + + + {tmdbValidationMessage && ( + + {tmdbValidating && } + + {tmdbValidationMessage} + + + )} + + + + + + OpenSubtitles (optional) + + + + Provide your OpenSubtitles credentials to improve subtitle fetching. API key is preferred; username/password can increase limits. + + setOsApiKey(event.currentTarget.value)} + description="Used for subtitle searches and downloads." + /> + + setOsUsername(event.currentTarget.value)} + /> + setOsPassword(event.currentTarget.value)} + /> + + + + + + + {!metadataSourcesAvailable && !tmdbValidating && ( + + All metadata sources are currently unavailable. Configure a working TMDB + key or try again once Movie-DB fallback is reachable. + + )} + + {librariesLoading ? ( + + + + ) : mediaLibraries.length === 0 ? ( + + No libraries configured yet. + + ) : ( + + {mediaLibraries.map((library) => ( + + + + {library.name} + + {library.library_type} + + + {library.auto_scan_enabled ? 'Auto-scan' : 'Manual'} + + + + Last scan:{' '} + {library.last_scan_at + ? new Date(library.last_scan_at).toLocaleString() + : 'Never'} + + + + + handleLibrarySettingsScan(library)} + > + + + + + { + setEditingLibrarySettings(library); + setLibraryModalOpen(true); + }} + > + + + + + handleLibrarySettingsDelete(library)} + > + + + + + + ))} + + )} +
+ + Powered by TMDB + +
+
+
+
+ DVR @@ -1311,6 +1914,63 @@ const SettingsPage = () => { + setTmdbHintOpen(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 just a couple of minutes: + + + + Visit{' '} + + themoviedb.org + {' '} + and sign in or create a free account. + + + Open your{' '} + + TMDB account settings + {' '} + and choose API from the sidebar. + + + Complete the short API application and copy the generated v3 API key into the field + above. + + + + TMDB issues separate v3 and v4 keys--Dispatcharr only needs the v3 key for metadata lookups. + + + + + { + setLibraryModalOpen(false); + setEditingLibrarySettings(null); + }} + library={editingLibrarySettings} + onSubmit={handleLibrarySettingsSubmit} + submitting={librarySubmitting} + /> + {