mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
Added opensubtitles
This commit is contained in:
parent
ee73c8d62b
commit
3baf3494c8
16 changed files with 1848 additions and 50 deletions
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
45
apps/media_library/migrations/0003_subtitleasset.py
Normal file
45
apps/media_library/migrations/0003_subtitleasset.py
Normal 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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
354
apps/media_library/subtitles.py
Normal file
354
apps/media_library/subtitles.py
Normal 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 []
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue