Added opensubtitles

This commit is contained in:
Dispatcharr 2025-12-04 19:59:52 -06:00
parent ee73c8d62b
commit 3baf3494c8
16 changed files with 1848 additions and 50 deletions

View file

@ -13,4 +13,5 @@ router.register(r"progress", api_views.WatchProgressViewSet, basename="watchprog
urlpatterns = [
path("", include(router.urls)),
path("stream/<str:token>/", views.stream_media_file, name="stream-file"),
path("subtitles/<uuid:subtitle_id>/", views.subtitle_file, name="media-library-subtitle-file"),
]

View file

@ -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,
}
)

View file

@ -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:

View file

@ -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")},
},
),
]

View file

@ -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."""

View file

@ -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 = {

View file

@ -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 []

View file

@ -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:

View file

@ -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}

View file

@ -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,

View file

@ -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)

View file

@ -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",
]

View file

@ -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');

View file

@ -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();

View file

@ -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) => (
<track
key={`${sub.id}-${selectedSubtitleId === sub.id ? 'on' : 'off'}`}
kind="subtitles"
label={subtitleLabel(sub)}
srcLang={sub.language || 'und'}
src={sub.url || sub.external_url || ''}
default={selectedSubtitleId === sub.id}
/>
))}
</video>
{/* VOD title overlay when not loading - auto-hides after 4 seconds */}
{!isLoading && metadata && contentType !== 'live' && showOverlay && (
@ -1294,6 +1442,60 @@ export default function FloatingVideo() {
/>
)}
</Group>
<Menu withinPortal={false}>
<Menu.Target>
<ActionIcon
variant="subtle"
color={selectedSubtitleId ? 'cyan' : 'gray'}
radius="xl"
size="lg"
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
aria-label="Subtitles"
disabled={subtitlesLoading || contentType !== 'library'}
>
<Text size="xs" c={selectedSubtitleId ? 'cyan' : 'gray.2'} fw={600}>
CC
</Text>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
<Menu.Label>Subtitles</Menu.Label>
<Menu.Item
onClick={() => setSelectedSubtitleId(null)}
disabled={availableSubtitles.length === 0}
>
Off
</Menu.Item>
{availableSubtitles.map((sub) => (
<Menu.Item
key={sub.id}
onClick={() => setSelectedSubtitleId(sub.id)}
rightSection={
selectedSubtitleId === sub.id ? (
<Text size="xs" c="cyan">
On
</Text>
) : null
}
>
{subtitleLabel(sub)}
</Menu.Item>
))}
<Divider />
<Menu.Item
onClick={() => fetchSubtitles()}
disabled={subtitlesLoading || contentType !== 'library'}
>
{subtitlesLoading ? 'Fetching…' : 'Fetch from OpenSubtitles'}
</Menu.Item>
{subtitleError && (
<Menu.Item disabled c="red">
{subtitleError}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
<ActionIcon
variant="subtle"
color="gray"

View file

@ -11,13 +11,18 @@ import useUserAgentsStore from '../store/userAgents';
import useStreamProfilesStore from '../store/streamProfiles';
import {
Accordion,
ActionIcon,
Alert,
Anchor,
Box,
Button,
Center,
Flex,
Group,
FileInput,
Loader,
List,
Modal,
MultiSelect,
Select,
Stack,
@ -25,6 +30,8 @@ import {
Text,
TextInput,
NumberInput,
Badge,
Tooltip,
} from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
@ -40,6 +47,11 @@ import {
} from '../constants';
import ConfirmationDialog from '../components/ConfirmationDialog';
import useWarningsStore from '../store/warnings';
import useLibraryStore from '../store/library';
import LibraryFormModal from '../components/library/LibraryFormModal';
import { Pencil, Plus, RefreshCcw, Trash2 } from 'lucide-react';
import tmdbLogoUrl from '../assets/tmdb-logo-blue.svg?url';
import { IconKey } from '@tabler/icons-react';
const TIMEZONE_FALLBACKS = [
'UTC',
@ -182,6 +194,13 @@ const SettingsPage = () => {
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 && (
<>
<Accordion.Item value="media-libraries">
<Accordion.Control>Media Libraries</Accordion.Control>
<Accordion.Panel>
<Stack spacing="md">
<Group justify="space-between" align="center">
<Text c="dimmed" size="sm">
Configure local media libraries used for scanning and playback.
</Text>
<Tooltip
label={addLibraryTooltipLabel}
disabled={!libraryActionsDisabled}
withArrow
>
<span>
<Button
size="xs"
leftSection={<Plus size={14} />}
disabled={libraryActionsDisabled}
onClick={() => {
setEditingLibrarySettings(null);
setLibraryModalOpen(true);
}}
>
Add Library
</Button>
</span>
</Tooltip>
</Group>
<Stack spacing="xs">
<TextInput
label="TMDB API Key"
placeholder="Enter TMDB API key"
value={tmdbKey}
onChange={(event) => setTmdbKey(event.currentTarget.value)}
rightSection={
tmdbKey && tmdbKey.trim().length > 0 ? (
<Tooltip label="Remove TMDB API key" withArrow>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={handleDeleteTmdbKey}
disabled={savingTmdbKey || tmdbValidating}
aria-label="Remove TMDB API key"
>
<Trash2 size={14} />
</ActionIcon>
</Tooltip>
) : null
}
rightSectionPointerEvents="auto"
description="Used for metadata and artwork lookups."
/>
<Group justify="space-between" align="center">
<Button
variant="subtle"
size="xs"
color="gray"
onClick={() => setTmdbHintOpen(true)}
>
Where do I get this?
</Button>
<Button
size="xs"
variant="light"
onClick={handleSaveTmdbKey}
loading={savingTmdbKey || tmdbValidating}
>
Save Metadata Settings
</Button>
</Group>
{tmdbValidationMessage && (
<Group gap="xs" align="center">
{tmdbValidating && <Loader size="xs" />}
<Text size="xs" c={tmdbMessageColor}>
{tmdbValidationMessage}
</Text>
</Group>
)}
<Divider my="sm" />
<Group gap="xs" align="center">
<IconKey size={16} />
<Text fw={600} size="sm">
OpenSubtitles (optional)
</Text>
</Group>
<Text c="dimmed" size="xs">
Provide your OpenSubtitles credentials to improve subtitle fetching. API key is preferred; username/password can increase limits.
</Text>
<TextInput
label="OpenSubtitles API Key"
placeholder="Enter OpenSubtitles API key"
value={osApiKey}
onChange={(event) => setOsApiKey(event.currentTarget.value)}
description="Used for subtitle searches and downloads."
/>
<Group grow>
<TextInput
label="OpenSubtitles Username"
placeholder="Optional username"
value={osUsername}
onChange={(event) => setOsUsername(event.currentTarget.value)}
/>
<TextInput
label="OpenSubtitles Password"
placeholder="Optional password"
type="password"
value={osPassword}
onChange={(event) => setOsPassword(event.currentTarget.value)}
/>
</Group>
<Group justify="flex-end">
<Button
size="xs"
variant="light"
onClick={handleSaveOsSettings}
loading={savingOsSettings}
>
Save Subtitle Settings
</Button>
</Group>
</Stack>
{!metadataSourcesAvailable && !tmdbValidating && (
<Alert color="yellow" variant="light" radius="md">
All metadata sources are currently unavailable. Configure a working TMDB
key or try again once Movie-DB fallback is reachable.
</Alert>
)}
{librariesLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : mediaLibraries.length === 0 ? (
<Text c="dimmed" size="sm">
No libraries configured yet.
</Text>
) : (
<Stack spacing="sm">
{mediaLibraries.map((library) => (
<Group
key={library.id}
justify="space-between"
align="center"
p="sm"
style={{
border: '1px solid rgba(148, 163, 184, 0.2)',
borderRadius: 8,
}}
>
<Stack spacing={4} style={{ flex: 1 }}>
<Group gap="sm">
<Text fw={600}>{library.name}</Text>
<Badge color="violet" variant="light">
{library.library_type}
</Badge>
<Badge
color={library.auto_scan_enabled ? 'green' : 'gray'}
variant="outline"
>
{library.auto_scan_enabled ? 'Auto-scan' : 'Manual'}
</Badge>
</Group>
<Text size="xs" c="dimmed">
Last scan:{' '}
{library.last_scan_at
? new Date(library.last_scan_at).toLocaleString()
: 'Never'}
</Text>
</Stack>
<Group gap="xs">
<Tooltip label="Trigger scan">
<ActionIcon
variant="light"
onClick={() => handleLibrarySettingsScan(library)}
>
<RefreshCcw size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Edit">
<ActionIcon
variant="light"
onClick={() => {
setEditingLibrarySettings(library);
setLibraryModalOpen(true);
}}
>
<Pencil size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete">
<ActionIcon
variant="light"
color="red"
onClick={() => handleLibrarySettingsDelete(library)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
))}
</Stack>
)}
<Center py="md">
<Anchor
href="https://www.themoviedb.org/"
target="_blank"
rel="noopener noreferrer"
>
<img
src={tmdbLogoUrl}
alt="Powered by TMDB"
style={{
width: 180,
height: 'auto',
display: 'block',
}}
/>
</Anchor>
</Center>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="dvr-settings">
<Accordion.Control>DVR</Accordion.Control>
<Accordion.Panel>
@ -1311,6 +1914,63 @@ const SettingsPage = () => {
</Accordion>
</Box>
<Modal
opened={tmdbHintOpen}
onClose={() => setTmdbHintOpen(false)}
title="How to get a TMDB API key"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 2 }}
>
<Stack spacing="sm">
<Text size="sm">
Dispatcharr uses TMDB (The Movie Database) for artwork and metadata. You can create
a key in just a couple of minutes:
</Text>
<List size="sm" spacing="xs">
<List.Item>
Visit{' '}
<Anchor
href="https://www.themoviedb.org/"
target="_blank"
rel="noopener noreferrer"
>
themoviedb.org
</Anchor>{' '}
and sign in or create a free account.
</List.Item>
<List.Item>
Open your{' '}
<Anchor
href="https://www.themoviedb.org/settings/api"
target="_blank"
rel="noopener noreferrer"
>
TMDB account settings
</Anchor>{' '}
and choose <Text component="span" fw={500}>API</Text> from the sidebar.
</List.Item>
<List.Item>
Complete the short API application and copy the generated v3 API key into the field
above.
</List.Item>
</List>
<Text size="sm" c="dimmed">
TMDB issues separate v3 and v4 keys--Dispatcharr only needs the v3 key for metadata lookups.
</Text>
</Stack>
</Modal>
<LibraryFormModal
opened={libraryModalOpen}
onClose={() => {
setLibraryModalOpen(false);
setEditingLibrarySettings(null);
}}
library={editingLibrarySettings}
onSubmit={handleLibrarySettingsSubmit}
submitting={librarySubmitting}
/>
<ConfirmationDialog
opened={rehashConfirmOpen}
onClose={() => {