Media Server Init

This commit is contained in:
Dispatcharr 2025-10-11 13:33:03 -05:00
parent d5f9ba7e5e
commit 58ea3fa9e3
46 changed files with 13380 additions and 119 deletions

View file

@ -27,6 +27,7 @@ urlpatterns = [
path('core/', include(('core.api_urls', 'core'), namespace='core')),
path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')),
path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')),
path('media/', include(('apps.media_library.api_urls', 'media'), namespace='media')),
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
#path('player/', include(('apps.player.api_urls', 'player'), namespace='player')),
#path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')),

View file

@ -9,6 +9,7 @@ from drf_yasg import openapi
from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
import os, json, requests, logging
from django.conf import settings
from apps.accounts.permissions import (
Authenticated,
IsAdmin,
@ -1462,6 +1463,22 @@ class LogoViewSet(viewsets.ModelViewSet):
return super().destroy(request, *args, **kwargs)
def list(self, request, *args, **kwargs):
"""Support optional no_pagination flag for convenience queries."""
queryset = self.filter_queryset(self.get_queryset())
if request.query_params.get("no_pagination", "").lower() == "true":
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["post"])
def upload(self, request):
if "file" not in request.FILES:
@ -1525,21 +1542,38 @@ class LogoViewSet(viewsets.ModelViewSet):
logo = self.get_object()
logo_url = logo.url
if logo_url.startswith("/data"): # Local file
if not os.path.exists(logo_url):
filesystem_path = None
if logo_url.startswith("/data"):
filesystem_path = logo_url
else:
media_root = getattr(settings, "MEDIA_ROOT", "")
media_url = getattr(settings, "MEDIA_URL", "")
candidate_path = None
if media_root:
if media_url and logo_url.startswith(media_url):
rel_path = logo_url[len(media_url.rstrip("/")) + 1 :]
candidate_path = os.path.join(media_root, rel_path)
elif not logo_url.lower().startswith(("http://", "https://")):
candidate_path = os.path.join(media_root, logo_url.lstrip("/"))
if candidate_path and os.path.exists(candidate_path):
filesystem_path = candidate_path
if filesystem_path:
if not os.path.exists(filesystem_path):
raise Http404("Image not found")
# Get proper mime type (first item of the tuple)
content_type, _ = mimetypes.guess_type(logo_url)
content_type, _ = mimetypes.guess_type(filesystem_path)
if not content_type:
content_type = "image/jpeg" # Default to a common image type
content_type = "image/jpeg"
# Use context manager and set Content-Disposition to inline
response = StreamingHttpResponse(
open(logo_url, "rb"), content_type=content_type
open(filesystem_path, "rb"), content_type=content_type
)
response["Content-Disposition"] = 'inline; filename="{}"'.format(
os.path.basename(logo_url)
response["Content-Disposition"] = 'inline; filename=\"{}\"'.format(
os.path.basename(filesystem_path)
)
return response

View file

@ -0,0 +1 @@
default_app_config = "apps.media_library.apps.MediaLibraryConfig"

View file

@ -0,0 +1,16 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from apps.media_library import api_views, views
router = DefaultRouter()
router.register(r"libraries", api_views.LibraryViewSet, basename="library")
router.register(r"scans", api_views.LibraryScanViewSet, basename="libraryscan")
router.register(r"items", api_views.MediaItemViewSet, basename="mediaitem")
router.register(r"files", api_views.MediaFileViewSet, basename="mediafile")
router.register(r"progress", api_views.WatchProgressViewSet, basename="watchprogress")
urlpatterns = [
path("", include(router.urls)),
path("stream/<str:token>/", views.stream_media_file, name="stream-file"),
]

View file

@ -0,0 +1,591 @@
import logging
import os
from pathlib import Path
from django.conf import settings
from django.core.signing import TimestampSigner
from django.db.models import Prefetch, Q
from django.urls import reverse
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import mixins, status, viewsets
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.exceptions import NotFound, ValidationError
from apps.accounts.permissions import Authenticated
from apps.media_library import models, serializers
from apps.media_library.metadata import (
resolve_available_metadata_source,
sync_metadata,
)
from apps.media_library.tasks import (
enqueue_library_scan,
sync_metadata_task,
cancel_library_scan,
revoke_scan_task,
start_next_library_scan,
resume_orphaned_scans,
sync_library_to_vod_task,
unsync_library_from_vod_task,
)
from apps.media_library.vod_sync import unsync_library_from_vod
logger = logging.getLogger(__name__)
class LibraryViewSet(viewsets.ModelViewSet):
queryset = models.Library.objects.all().prefetch_related("locations")
serializer_class = serializers.LibrarySerializer
permission_classes = [Authenticated]
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
filterset_fields = ["library_type", "auto_scan_enabled"]
search_fields = ["name", "description"]
ordering_fields = ["name", "created_at", "updated_at", "last_scan_at"]
ordering = ["name"]
@action(detail=False, methods=["get"], url_path="browse")
def browse(self, request):
raw_path = request.query_params.get("path")
if not raw_path:
if os.name == "nt":
import string
entries = []
for letter in string.ascii_uppercase:
drive = Path(f"{letter}:/")
if drive.exists():
entries.append(
{
"name": f"{letter}:",
"path": str(drive.resolve()),
}
)
return Response({"path": "", "parent": None, "entries": entries})
root = Path("/").resolve()
entries = []
try:
for child in sorted(root.iterdir(), key=lambda p: p.name.lower()):
if child.is_dir():
entries.append(
{
"name": child.name or str(child),
"path": str(child),
}
)
except PermissionError:
entries = []
return Response({"path": str(root), "parent": None, "entries": entries})
try:
target = Path(raw_path).expanduser()
if not target.exists():
raise ValidationError({"detail": "Directory not found."})
if not target.is_dir():
target = target.parent
target = target.resolve()
except (ValueError, OSError, RuntimeError):
raise ValidationError({"detail": "Invalid path."})
entries = []
try:
for child in sorted(target.iterdir(), key=lambda p: p.name.lower()):
if child.is_dir():
entries.append(
{
"name": child.name or str(child),
"path": str(child),
}
)
except PermissionError:
entries = []
parent = str(target.parent) if target != target.parent else None
return Response(
{
"path": str(target),
"parent": parent,
"entries": entries,
}
)
def perform_create(self, serializer):
provider, error = resolve_available_metadata_source()
if not provider:
raise ValidationError(
{
"detail": error
or "All metadata sources are unavailable. Configure a TMDB API key or try again later."
}
)
library = serializer.save()
if library.auto_scan_enabled:
enqueue_library_scan(library_id=library.id, user_id=self.request.user.id)
def perform_update(self, serializer):
previous_use_as_vod = bool(serializer.instance.use_as_vod_source)
library = serializer.save()
if previous_use_as_vod != library.use_as_vod_source:
if library.use_as_vod_source:
sync_library_to_vod_task.delay(library.id)
else:
unsync_library_from_vod_task.delay(library.id)
if library.auto_scan_enabled and self.request.data.get("trigger_scan"):
enqueue_library_scan(library_id=library.id, user_id=self.request.user.id)
def perform_destroy(self, instance):
from django.db import transaction
with transaction.atomic():
unsync_library_from_vod(instance)
models.MediaItem.objects.filter(library=instance).delete()
models.MediaFile.objects.filter(library=instance).delete()
super().perform_destroy(instance)
@action(detail=True, methods=["post"], url_path="scan")
def scan(self, request, pk=None):
library = self.get_object()
user_id = request.user.id if request.user and request.user.is_authenticated else None
scan = enqueue_library_scan(library_id=library.id, user_id=user_id, force_full=request.data.get("full", False))
serializer = serializers.LibraryScanSerializer(scan)
return Response(serializer.data, status=status.HTTP_202_ACCEPTED)
class LibraryScanViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
queryset = models.LibraryScan.objects.select_related("library", "created_by")
serializer_class = serializers.LibraryScanSerializer
permission_classes = [Authenticated]
filter_backends = [DjangoFilterBackend, OrderingFilter]
filterset_fields = ["library", "status"]
ordering_fields = ["created_at", "started_at", "finished_at"]
ordering = ["-created_at"]
http_method_names = ["get", "head", "options", "delete", "post"]
def list(self, request, *args, **kwargs):
library_param = request.query_params.get("library")
library_id = None
if library_param:
try:
library_id = int(library_param)
except (TypeError, ValueError):
library_id = None
try:
resume_orphaned_scans(library_id=library_id)
except Exception: # noqa: BLE001
logger.exception("Failed to resume orphaned scans for library %s", library_param)
return super().list(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
scan = self.get_object()
if scan.status == models.LibraryScan.STATUS_RUNNING:
raise ValidationError({"detail": "Cannot remove a running scan."})
if scan.status == models.LibraryScan.STATUS_PENDING:
revoke_scan_task(scan.task_id, terminate=False)
library = scan.library
response = super().destroy(request, *args, **kwargs)
start_next_library_scan(library)
return response
response = super().destroy(request, *args, **kwargs)
return response
@action(detail=True, methods=["post"], url_path="cancel")
def cancel(self, request, pk=None):
scan = self.get_object()
summary = request.data.get("summary")
try:
cancelled = cancel_library_scan(scan, summary=summary)
except ValueError as exc: # noqa: BLE001
raise ValidationError({"detail": str(exc)})
serializer = self.get_serializer(cancelled)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["delete"], url_path="purge")
def purge_completed(self, request):
valid_statuses = {
models.LibraryScan.STATUS_COMPLETED,
models.LibraryScan.STATUS_FAILED,
models.LibraryScan.STATUS_CANCELLED,
}
requested_statuses = request.query_params.getlist("status")
if requested_statuses:
statuses = [value for value in requested_statuses if value in valid_statuses]
if not statuses:
raise ValidationError({"detail": "No valid statuses provided."})
else:
statuses = list(valid_statuses)
queryset = self.filter_queryset(
self.get_queryset().filter(status__in=statuses)
)
library_id = request.query_params.get("library")
if library_id:
queryset = queryset.filter(library_id=library_id)
deleted, _ = queryset.delete()
return Response({"deleted": deleted}, status=status.HTTP_200_OK)
class MediaItemViewSet(viewsets.ModelViewSet):
serializer_class = serializers.MediaItemSerializer
permission_classes = [Authenticated]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = [
"library",
"item_type",
"status",
"release_year",
"season_number",
"parent",
]
search_fields = ["title", "synopsis", "tags"]
ordering_fields = [
"sort_title",
"release_year",
"first_imported_at",
"updated_at",
"season_number",
"episode_number",
]
ordering = ["sort_title"]
http_method_names = ["get", "head", "options", "patch", "post"]
pagination_class = None
_stream_signer = TimestampSigner(salt="media-library-stream")
def get_serializer_class(self):
if self.action == "list":
return serializers.MediaItemListSerializer
return super().get_serializer_class()
def get_queryset(self):
user = getattr(self.request, "user", None)
if user and user.is_authenticated:
watch_prefetch = Prefetch(
"watch_progress",
queryset=models.WatchProgress.objects.filter(user=user),
to_attr="_user_watch_progress",
)
episode_watch_prefetch = Prefetch(
"watch_progress",
queryset=models.WatchProgress.objects.filter(user=user),
to_attr="_user_watch_progress",
)
else:
watch_prefetch = Prefetch(
"watch_progress",
queryset=models.WatchProgress.objects.none(),
to_attr="_user_watch_progress",
)
episode_watch_prefetch = Prefetch(
"watch_progress",
queryset=models.WatchProgress.objects.none(),
to_attr="_user_watch_progress",
)
base_queryset = models.MediaItem.objects.select_related(
"library",
"parent",
"vod_movie",
"vod_series",
"vod_episode",
)
if self.action == "list":
children_qs = (
models.MediaItem.objects.filter(item_type=models.MediaItem.TYPE_EPISODE)
.select_related("parent")
.prefetch_related(episode_watch_prefetch)
.order_by("season_number", "episode_number", "id")
)
return base_queryset.prefetch_related(
watch_prefetch,
Prefetch(
"children",
queryset=children_qs,
to_attr="_prefetched_children",
),
)
return base_queryset.prefetch_related("files", "artwork", watch_prefetch)
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
search = self.request.query_params.get("search")
if search:
search = search.strip()
queryset = queryset.filter(
Q(title__icontains=search)
| Q(synopsis__icontains=search)
| Q(tags__icontains=search)
)
return queryset
def get_serializer_context(self):
context = super().get_serializer_context()
context["request"] = self.request
return context
def partial_update(self, request, *args, **kwargs):
allowed_fields = {
"title",
"release_year",
"synopsis",
"tagline",
"genres",
"studios",
"cast",
"crew",
"tags",
"poster_url",
"backdrop_url",
"rating",
"tmdb_id",
"imdb_id",
"runtime_ms",
"metadata",
"status",
}
unknown = {key for key in request.data.keys() if key not in allowed_fields}
if unknown:
raise ValidationError(
{"detail": f"Cannot update fields: {', '.join(sorted(unknown))}"}
)
return super().partial_update(request, *args, **kwargs)
@action(detail=True, methods=["post"], url_path="refresh-metadata")
def refresh_metadata(self, request, pk=None):
item = self.get_object()
sync_metadata_task.delay(item.id)
return Response({"status": "queued"}, status=status.HTTP_202_ACCEPTED)
@action(detail=True, methods=["post"], url_path="set-tmdb")
def set_tmdb(self, request, pk=None):
item = self.get_object()
tmdb_id = request.data.get("tmdb_id")
if tmdb_id in (None, ""):
raise ValidationError({"tmdb_id": "TMDB ID is required."})
tmdb_id_str = str(tmdb_id).strip()
if not tmdb_id_str:
raise ValidationError({"tmdb_id": "TMDB ID is required."})
if item.tmdb_id != tmdb_id_str:
item.tmdb_id = tmdb_id_str
item.save(update_fields=["tmdb_id", "updated_at"])
refreshed = sync_metadata(item)
if not refreshed:
raise ValidationError(
{"detail": "Unable to fetch TMDB metadata for this item."}
)
serializer = self.get_serializer(refreshed)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=True, methods=["get"], url_path="stream")
def stream(self, request, pk=None):
item = self.get_object()
file_id = request.query_params.get("file")
files_qs = item.files.all()
if file_id:
file = files_qs.filter(pk=file_id).first()
if not file:
raise NotFound("Requested media file not found")
else:
file = files_qs.order_by("id").first()
if not file:
return Response(
{"detail": "No media files available for this item."},
status=status.HTTP_404_NOT_FOUND,
)
start_ms = 0
start_ms_param = request.query_params.get("start_ms")
if start_ms_param not in (None, "", "0"):
try:
start_ms = max(0, int(start_ms_param))
except (TypeError, ValueError):
raise ValidationError({"start_ms": "Start offset must be an integer number of milliseconds."})
applied_start_ms = 0
should_embed_start = False
if start_ms > 0 and file.requires_transcode:
cached_ready = (
file.transcode_status == models.MediaFile.TRANSCODE_STATUS_READY
and file.transcoded_path
and os.path.exists(file.transcoded_path)
)
if not cached_ready:
applied_start_ms = start_ms
should_embed_start = True
duration_ms = file.effective_duration_ms or item.runtime_ms or 0
payload = {"file_id": file.id, "user_id": request.user.id}
if should_embed_start:
payload["start_ms"] = applied_start_ms
token = self._stream_signer.sign_object(payload)
stream_url = request.build_absolute_uri(
reverse("api:media:stream-file", args=[token])
)
ttl = getattr(settings, "MEDIA_LIBRARY_STREAM_TOKEN_TTL", 3600)
return Response(
{
"url": stream_url,
"file_id": file.id,
"expires_in": ttl,
"type": "direct",
"duration_ms": duration_ms,
"bit_rate": file.bit_rate,
"container": file.container,
"requires_transcode": file.requires_transcode,
"transcode_status": file.transcode_status,
"start_offset_ms": applied_start_ms,
}
)
@action(detail=True, methods=["post"], url_path="mark-watched")
def mark_watched(self, request, pk=None):
item = self.get_object()
duration = item.runtime_ms
if not duration:
primary_file = item.files.order_by("-duration_ms").first()
duration = primary_file.duration_ms if primary_file else 0
if not duration:
duration = 1000 # default to one second to allow completion state
progress, _ = models.WatchProgress.objects.update_or_create(
user=request.user,
media_item=item,
defaults={
"position_ms": duration or 0,
"duration_ms": duration or 0,
"completed": True,
},
)
progress.update_progress(position_ms=duration or 0, duration_ms=duration or 0)
return Response({"status": "ok"})
@action(detail=True, methods=["post"], url_path="mark-series-watched")
def mark_series_watched(self, request, pk=None):
item = self.get_object()
if item.item_type != models.MediaItem.TYPE_SHOW:
return Response(
{"detail": "Series-level actions are only available for shows."},
status=status.HTTP_400_BAD_REQUEST,
)
episodes = item.children.filter(item_type=models.MediaItem.TYPE_EPISODE)
updated = 0
for episode in episodes:
duration = episode.runtime_ms
if not duration:
primary_file = episode.files.order_by("-duration_ms").first()
duration = primary_file.duration_ms if primary_file else 0
if not duration:
duration = 1000
models.WatchProgress.objects.update_or_create(
user=request.user,
media_item=episode,
defaults={
"position_ms": duration,
"duration_ms": duration,
"completed": True,
},
)
updated += 1
models.WatchProgress.objects.update_or_create(
user=request.user,
media_item=item,
defaults={
"position_ms": 0,
"duration_ms": item.runtime_ms or 0,
"completed": True,
},
)
serializer = self.get_serializer(item)
return Response({"updated": updated, "item": serializer.data})
@action(detail=True, methods=["post"], url_path="mark-series-unwatched")
def mark_series_unwatched(self, request, pk=None):
item = self.get_object()
if item.item_type != models.MediaItem.TYPE_SHOW:
return Response(
{"detail": "Series-level actions are only available for shows."},
status=status.HTTP_400_BAD_REQUEST,
)
episodes = item.children.filter(item_type=models.MediaItem.TYPE_EPISODE)
cleared, _ = models.WatchProgress.objects.filter(
user=request.user,
media_item__in=episodes,
).delete()
models.WatchProgress.objects.filter(user=request.user, media_item=item).delete()
serializer = self.get_serializer(item)
return Response({"cleared": cleared, "item": serializer.data})
@action(detail=True, methods=["post"], url_path="clear-progress")
def clear_progress(self, request, pk=None):
item = self.get_object()
models.WatchProgress.objects.filter(user=request.user, media_item=item).delete()
return Response({"status": "cleared"})
class MediaFileViewSet(viewsets.ReadOnlyModelViewSet):
queryset = models.MediaFile.objects.select_related("library", "media_item", "location")
serializer_class = serializers.MediaFileSerializer
permission_classes = [Authenticated]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ["library", "media_item", "location", "has_subtitles"]
search_fields = ["relative_path", "file_name", "absolute_path"]
ordering_fields = ["relative_path", "file_name", "size_bytes", "updated_at", "last_seen_at"]
ordering = ["relative_path"]
class WatchProgressViewSet(mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
serializer_class = serializers.WatchProgressSerializer
permission_classes = [Authenticated]
def get_queryset(self):
queryset = models.WatchProgress.objects.select_related("media_item", "user")
user_only = self.request.query_params.get("mine", "true").lower() != "false"
if user_only:
queryset = queryset.filter(user=self.request.user)
return queryset
@action(detail=False, methods=["post"], url_path="set")
def set_progress(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
progress = serializer.save()
progress.update_progress(
position_ms=serializer.validated_data.get("position_ms", 0),
duration_ms=serializer.validated_data.get("duration_ms"),
)
return Response(self.get_serializer(progress).data)
@action(detail=True, methods=["post"], url_path="resume")
def resume(self, request, pk=None):
progress = self.get_object()
if progress.duration_ms:
percentage = progress.position_ms / progress.duration_ms
else:
percentage = 0
remaining_ms = max(progress.duration_ms - progress.position_ms, 0)
data = {
"position_ms": progress.position_ms,
"duration_ms": progress.duration_ms,
"percentage": percentage,
"remaining_ms": remaining_ms,
"completed": progress.completed,
"resume_allowed": progress.duration_ms * 0.04 < remaining_ms,
}
return Response(data)

View file

@ -0,0 +1,14 @@
from django.apps import AppConfig
class MediaLibraryConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.media_library"
verbose_name = "Media Library"
def ready(self):
# Import signals when needed without causing circular dependencies
try:
import apps.media_library.signals # noqa: F401
except ImportError:
pass

View file

@ -0,0 +1,582 @@
import logging
from typing import Any, Dict, Optional, Tuple
import requests
import tmdbsimple as tmdb
from dateutil import parser as date_parser
from django.conf import settings
from django.core.cache import cache
from django.utils import timezone
from apps.media_library.models import ArtworkAsset, MediaItem
from core.models import CoreSettings
logger = logging.getLogger(__name__)
TMDB_API_KEY_SETTING = "tmdb-api-key"
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original"
METADATA_CACHE_TIMEOUT = 60 * 60 * 6 # 6 hours
TMDB_VALIDATION_CACHE_SUCCESS_TTL = 60 * 30 # 30 minutes
TMDB_VALIDATION_CACHE_FAILURE_TTL = 60 * 5 # 5 minutes
MOVIEDB_SEARCH_URL = "https://movie-db.org/api.php"
MOVIEDB_HEALTH_CACHE_KEY = "movie-db:health"
MOVIEDB_HEALTH_SUCCESS_TTL = 60 * 15 # 15 minutes
MOVIEDB_HEALTH_FAILURE_TTL = 60 * 2 # 2 minutes
_REQUESTS_SESSION = requests.Session()
_REQUESTS_SESSION.mount(
"https://",
requests.adapters.HTTPAdapter(pool_connections=20, pool_maxsize=40, max_retries=3),
)
tmdb.REQUESTS_SESSION = _REQUESTS_SESSION
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.
Returns a tuple of (is_valid, message). On success, message is None.
"""
normalized_key = (api_key or "").strip()
if not normalized_key:
return False, "TMDB API key is required."
cache_key = f"tmdb-key-validation:{normalized_key}"
if use_cache:
cached = cache.get(cache_key)
if cached is not None:
return cached.get("valid", False), cached.get("message")
try:
response = _REQUESTS_SESSION.get(
"https://api.themoviedb.org/3/configuration",
params={"api_key": normalized_key},
timeout=5,
)
except requests.RequestException as exc:
logger.warning("Unable to validate TMDB API key due to network error: %s", exc)
message = "Could not reach TMDB to validate the API key."
return False, message
if response.status_code == 200:
if use_cache:
cache.set(
cache_key,
{"valid": True, "message": None},
TMDB_VALIDATION_CACHE_SUCCESS_TTL,
)
return True, None
if response.status_code == 401:
message = "TMDB rejected the API key (HTTP 401 Unauthorized)."
else:
message = f"TMDB returned status {response.status_code} while validating the API key."
if use_cache:
cache.set(
cache_key,
{"valid": False, "message": message},
TMDB_VALIDATION_CACHE_FAILURE_TTL,
)
return False, message
def check_movie_db_health(*, use_cache: bool = True) -> Tuple[bool, str | None]:
if use_cache:
cached = cache.get(MOVIEDB_HEALTH_CACHE_KEY)
if cached is not None:
return cached
try:
response = _REQUESTS_SESSION.get(
MOVIEDB_SEARCH_URL,
params={"type": "tv", "query": "The Simpsons"},
timeout=5,
)
except requests.RequestException as exc:
message = f"Movie-DB request failed: {exc}"
cache.set(MOVIEDB_HEALTH_CACHE_KEY, (False, message), MOVIEDB_HEALTH_FAILURE_TTL)
return False, message
if response.status_code != 200:
message = f"Movie-DB returned status {response.status_code}."
cache.set(MOVIEDB_HEALTH_CACHE_KEY, (False, message), MOVIEDB_HEALTH_FAILURE_TTL)
return False, message
try:
payload = response.json()
except ValueError:
message = "Movie-DB returned an unexpected response format."
cache.set(MOVIEDB_HEALTH_CACHE_KEY, (False, message), MOVIEDB_HEALTH_FAILURE_TTL)
return False, message
results = []
if isinstance(payload, dict):
results = payload.get("results") or []
elif isinstance(payload, list):
results = payload
if not results:
message = "Movie-DB did not return any results."
cache.set(MOVIEDB_HEALTH_CACHE_KEY, (False, message), MOVIEDB_HEALTH_FAILURE_TTL)
return False, message
cache.set(MOVIEDB_HEALTH_CACHE_KEY, (True, None), MOVIEDB_HEALTH_SUCCESS_TTL)
return True, None
def get_tmdb_api_key() -> Optional[str]:
# Prefer CoreSettings, fallback to environment variable
try:
setting = CoreSettings.objects.get(key=TMDB_API_KEY_SETTING)
if setting.value:
return setting.value.strip()
except CoreSettings.DoesNotExist:
pass
return getattr(settings, "TMDB_API_KEY", None)
def _configure_tmdb(api_key: str | None) -> bool:
if not api_key:
logger.debug("TMDB API key missing; skipping remote metadata fetch")
return False
if tmdb.API_KEY != api_key:
# we only set when changed
tmdb.API_KEY = api_key
return True
def build_image_url(path: Optional[str]) -> Optional[str]:
if not path:
return None
return f"{TMDB_IMAGE_BASE}{path}"
def _to_serializable(obj):
"""Ensure TMDB data is safe to store in JSON fields."""
if isinstance(obj, dict):
return {key: _to_serializable(value) for key, value in obj.items()}
if isinstance(obj, (list, tuple, set)):
return [_to_serializable(item) for item in obj]
# Keep primitive JSON types; convert everything else to string
if isinstance(obj, (str, int, float, bool)) or obj is None:
return obj
return str(obj)
def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]:
api_key = get_tmdb_api_key()
if not _configure_tmdb(api_key):
return None
normalized = (media_item.normalized_title or "").replace(" ", "_")
cache_key_parts = [
str(media_item.item_type),
normalized,
str(media_item.release_year or ""),
]
if media_item.tmdb_id:
cache_key_parts.append(f"id:{media_item.tmdb_id}")
cache_key = f"media-metadata:{':'.join(cache_key_parts)}"
cached = cache.get(cache_key)
if cached:
return cached
info = None
credits = None
tmdb_id = media_item.tmdb_id
if tmdb_id:
try:
lookup_id = int(tmdb_id)
except (TypeError, ValueError):
lookup_id = tmdb_id
try:
if media_item.is_movie:
movie = tmdb.Movies(lookup_id)
info = movie.info(append_to_response="credits,images")
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")
credits = info.get("credits", {})
except Exception: # noqa: BLE001
logger.exception(
"Failed to retrieve TMDB info for %s using id %s", media_item, tmdb_id
)
info = None
credits = None
if info is None or credits is None:
search = tmdb.Search()
try:
if media_item.is_movie:
response = search.movie(query=media_item.title, year=media_item.release_year)
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
)
results = response.get("results", [])
else:
results = []
except Exception: # noqa: BLE001
logger.exception("TMDB lookup failed for %s", media_item)
return None
if not results:
logger.debug("No TMDB matches found for %s", media_item.title)
return None
best_match = results[0]
tmdb_id = best_match.get("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")
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")
credits = info.get("credits", {})
else:
return None
except Exception: # noqa: BLE001
logger.exception("Failed to retrieve TMDB info for %s", media_item)
return None
if not tmdb_id and info:
tmdb_id = info.get("id")
genres = [g.get("name") for g in info.get("genres", []) if g.get("name")]
runtime = None
if media_item.is_movie:
runtime = info.get("runtime")
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
cast = []
for cast_member in credits.get("cast", [])[:12]:
name = cast_member.get("name")
if not name:
continue
cast.append(
{
"name": name,
"character": cast_member.get("character"),
"profile_url": build_image_url(cast_member.get("profile_path")),
}
)
crew = []
for member in credits.get("crew", [])[:15]:
name = member.get("name")
job = member.get("job")
if not name:
continue
crew.append(
{
"name": name,
"job": job,
"department": member.get("department"),
"profile_url": build_image_url(member.get("profile_path")),
}
)
release_year = media_item.release_year
release_date = info.get("release_date") or info.get("first_air_date")
if release_date:
try:
release_year = date_parser.parse(release_date).year
except (ValueError, TypeError): # noqa: PERF203
release_year = media_item.release_year
metadata = _to_serializable(
{
"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,
"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")),
"cast": cast,
"crew": crew,
"source": "tmdb",
"raw": info,
}
)
cache.set(cache_key, metadata, METADATA_CACHE_TIMEOUT)
return metadata
def _parse_release_year(value: Optional[str]) -> Optional[int]:
if not value:
return None
try:
return date_parser.parse(value).year
except (ValueError, TypeError):
return None
def fetch_movie_db_metadata(media_item: MediaItem) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
if media_item.item_type not in {
MediaItem.TYPE_MOVIE,
MediaItem.TYPE_SHOW,
MediaItem.TYPE_EPISODE,
}:
return None, "Movie-DB fallback does not support this media type."
media_type = "movie" if media_item.is_movie else "tv"
try:
response = _REQUESTS_SESSION.get(
MOVIEDB_SEARCH_URL,
params={"type": media_type, "query": media_item.title},
timeout=6,
)
except requests.RequestException as exc:
message = f"Movie-DB search failed: {exc}"
logger.warning(message)
return None, message
if response.status_code != 200:
message = f"Movie-DB search returned status {response.status_code}."
logger.debug(message)
return None, message
try:
payload = response.json()
except ValueError:
message = "Movie-DB search returned an unexpected response format."
logger.debug(message)
return None, message
results = []
if isinstance(payload, dict):
results = payload.get("results") or []
elif isinstance(payload, list):
results = payload
if not results:
message = "Movie-DB search returned no matches."
logger.debug(message)
return None, message
candidate = results[0]
release_date = candidate.get("release_date") or candidate.get("first_air_date")
release_year = _parse_release_year(release_date) or media_item.release_year
metadata = _to_serializable(
{
"tmdb_id": str(candidate.get("id") or media_item.tmdb_id or ""),
"title": candidate.get("title")
or candidate.get("name")
or media_item.title,
"synopsis": candidate.get("overview") or media_item.synopsis,
"release_year": release_year,
"poster": build_image_url(candidate.get("poster_path")),
"backdrop": build_image_url(candidate.get("backdrop_path")),
"runtime_minutes": candidate.get("runtime"),
"genres": [],
"cast": [],
"crew": [],
"source": "movie-db",
"raw": candidate,
}
)
return metadata, None
def resolve_available_metadata_source() -> Tuple[Optional[str], Optional[str]]:
tmdb_key = get_tmdb_api_key()
tmdb_error: Optional[str] = None
if tmdb_key:
tmdb_valid, tmdb_message = validate_tmdb_api_key(tmdb_key)
if tmdb_valid:
return "tmdb", None
tmdb_error = tmdb_message or "TMDB API key is invalid."
else:
tmdb_error = "TMDB API key not configured."
movie_db_available, movie_db_message = check_movie_db_health()
if movie_db_available:
return "movie-db", None
combined = "All metadata sources are unavailable."
details: list[str] = []
if tmdb_error:
details.append(f"TMDB: {tmdb_error}")
if movie_db_message:
details.append(f"Movie-DB: {movie_db_message}")
if details:
combined = f"{combined} {' '.join(details)}"
return None, combined
def fetch_metadata_with_fallback(media_item: MediaItem) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
metadata = None
tmdb_key = get_tmdb_api_key()
tmdb_error: Optional[str] = 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."
else:
tmdb_error = tmdb_message or "TMDB API key is invalid."
else:
tmdb_error = "TMDB API key not configured."
fallback_metadata, fallback_error = fetch_movie_db_metadata(media_item)
if fallback_metadata:
return fallback_metadata, None
details = []
if tmdb_error:
details.append(tmdb_error)
if fallback_error:
details.append(fallback_error)
message = "All metadata sources are unavailable."
if details:
message = f"{message} {' '.join(details)}"
return None, message
def apply_metadata(media_item: MediaItem, metadata: Dict[str, Any]) -> MediaItem:
changed = False
update_fields: list[str] = []
if metadata.get("tmdb_id") and metadata.get("tmdb_id") != media_item.tmdb_id:
media_item.tmdb_id = metadata["tmdb_id"]
update_fields.append("tmdb_id")
changed = True
if metadata.get("imdb_id") and metadata.get("imdb_id") != media_item.imdb_id:
media_item.imdb_id = metadata["imdb_id"]
update_fields.append("imdb_id")
changed = True
title = metadata.get("title")
if title and title != media_item.title:
media_item.title = title
update_fields.append("title")
changed = True
synopsis = metadata.get("synopsis")
if synopsis and synopsis != media_item.synopsis:
media_item.synopsis = synopsis
update_fields.append("synopsis")
changed = True
tagline = metadata.get("tagline")
if tagline and tagline != media_item.tagline:
media_item.tagline = tagline
update_fields.append("tagline")
changed = True
release_year = metadata.get("release_year")
if release_year and release_year != media_item.release_year:
try:
media_item.release_year = int(release_year)
except (TypeError, ValueError): # noqa: PERF203
media_item.release_year = media_item.release_year
else:
changed = True
update_fields.append("release_year")
runtime_minutes = metadata.get("runtime_minutes")
if runtime_minutes:
new_runtime_ms = int(float(runtime_minutes) * 60 * 1000)
if media_item.runtime_ms != new_runtime_ms:
media_item.runtime_ms = new_runtime_ms
update_fields.append("runtime_ms")
changed = True
genres = metadata.get("genres")
if genres and genres != media_item.genres:
media_item.genres = genres
update_fields.append("genres")
changed = True
cast = metadata.get("cast")
if cast and cast != media_item.cast:
media_item.cast = cast
update_fields.append("cast")
changed = True
crew = metadata.get("crew")
if crew and crew != media_item.crew:
media_item.crew = crew
update_fields.append("crew")
changed = True
poster = metadata.get("poster")
if poster and poster != media_item.poster_url:
media_item.poster_url = poster
update_fields.append("poster_url")
changed = True
backdrop = metadata.get("backdrop")
if backdrop and backdrop != media_item.backdrop_url:
media_item.backdrop_url = backdrop
update_fields.append("backdrop_url")
changed = True
raw_payload = metadata.get("raw")
if raw_payload and raw_payload != media_item.metadata:
media_item.metadata = raw_payload
update_fields.append("metadata")
changed = True
new_source = metadata.get("source", media_item.metadata_source or "unknown")
if new_source and new_source != media_item.metadata_source:
media_item.metadata_source = new_source
update_fields.append("metadata_source")
changed = True
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:
update_fields.append("metadata_last_synced_at")
if update_fields:
media_item.save(update_fields=list(dict.fromkeys(update_fields)))
# Update artwork assets so frontend can access gallery
if poster:
ArtworkAsset.objects.update_or_create(
media_item=media_item,
asset_type=ArtworkAsset.TYPE_POSTER,
source=metadata.get("source", "unknown"),
defaults={"external_url": poster},
)
if backdrop:
ArtworkAsset.objects.update_or_create(
media_item=media_item,
asset_type=ArtworkAsset.TYPE_BACKDROP,
source=metadata.get("source", "unknown"),
defaults={"external_url": backdrop},
)
return media_item
def sync_metadata(media_item: MediaItem) -> Optional[MediaItem]:
metadata, error = fetch_metadata_with_fallback(media_item)
if not metadata:
if error:
logger.debug("Metadata sync skipped for %s: %s", media_item, error)
return None
return apply_metadata(media_item, metadata)

View file

@ -0,0 +1,240 @@
# Generated by Django 5.2.4 on 2025-10-11 15:54
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('vod', '0002_add_last_seen_with_default'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Library',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('slug', models.SlugField(editable=False, max_length=255, unique=True)),
('description', models.TextField(blank=True)),
('library_type', models.CharField(choices=[('movies', 'Movies'), ('shows', 'TV Shows'), ('mixed', 'Mixed'), ('other', 'Other')], default='mixed', max_length=16)),
('auto_scan_enabled', models.BooleanField(default=True)),
('scan_interval_minutes', models.PositiveIntegerField(default=1440, help_text='How often to auto-scan library paths when auto-scan is enabled.')),
('metadata_language', models.CharField(default='en', help_text='Primary language for metadata lookups (ISO-639-1).', max_length=8)),
('metadata_country', models.CharField(default='US', help_text='Primary country/region for metadata lookups (ISO-3166-1 alpha-2).', max_length=8)),
('use_as_vod_source', models.BooleanField(default=False, help_text='When enabled, media matched in this library syncs into the VOD catalog.')),
('metadata_options', models.JSONField(blank=True, help_text='Structured configuration for metadata providers and overrides.', null=True)),
('last_scan_at', models.DateTimeField(blank=True, null=True)),
('last_successful_scan_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='LibraryLocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=4096)),
('include_subdirectories', models.BooleanField(default=True)),
('is_primary', models.BooleanField(default=False, help_text='Primary location is used for generating relative paths when multiple roots share structure.')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='media_library.library')),
],
options={
'ordering': ['library__name', 'path'],
'unique_together': {('library', 'path')},
},
),
migrations.CreateModel(
name='LibraryScan',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=16)),
('started_at', models.DateTimeField(blank=True, null=True)),
('finished_at', models.DateTimeField(blank=True, null=True)),
('total_files', models.PositiveIntegerField(default=0)),
('processed_files', models.PositiveIntegerField(default=0)),
('new_files', models.PositiveIntegerField(default=0)),
('updated_files', models.PositiveIntegerField(default=0)),
('removed_files', models.PositiveIntegerField(default=0)),
('matched_items', models.PositiveIntegerField(default=0)),
('unmatched_files', models.PositiveIntegerField(default=0)),
('task_id', models.CharField(blank=True, max_length=255, null=True)),
('summary', models.TextField(blank=True)),
('log', models.TextField(blank=True)),
('extra', models.JSONField(blank=True, null=True)),
('discovery_status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('skipped', 'Skipped')], default='pending', max_length=16)),
('discovery_total', models.PositiveIntegerField(default=0)),
('discovery_processed', models.PositiveIntegerField(default=0)),
('metadata_status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('skipped', 'Skipped')], default='pending', max_length=16)),
('metadata_total', models.PositiveIntegerField(default=0)),
('metadata_processed', models.PositiveIntegerField(default=0)),
('artwork_status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('skipped', 'Skipped')], default='pending', max_length=16)),
('artwork_total', models.PositiveIntegerField(default=0)),
('artwork_processed', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='library_scans', to=settings.AUTH_USER_MODEL)),
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scans', to='media_library.library')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='MediaItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('item_type', models.CharField(choices=[('collection', 'Collection'), ('show', 'Series'), ('season', 'Season'), ('episode', 'Episode'), ('movie', 'Movie'), ('other', 'Other')], default='other', max_length=16)),
('status', models.CharField(choices=[('pending', 'Pending'), ('matched', 'Matched'), ('failed', 'Failed')], default='pending', max_length=16)),
('title', models.CharField(max_length=512)),
('sort_title', models.CharField(blank=True, max_length=512)),
('normalized_title', models.CharField(blank=True, db_index=True, max_length=512)),
('release_year', models.IntegerField(blank=True, null=True)),
('season_number', models.IntegerField(blank=True, null=True)),
('episode_number', models.IntegerField(blank=True, null=True)),
('runtime_ms', models.BigIntegerField(blank=True, null=True)),
('synopsis', models.TextField(blank=True)),
('tagline', models.CharField(blank=True, max_length=512)),
('rating', models.CharField(blank=True, max_length=32)),
('genres', models.JSONField(blank=True, null=True)),
('studios', models.JSONField(blank=True, null=True)),
('cast', models.JSONField(blank=True, null=True)),
('crew', models.JSONField(blank=True, null=True)),
('tags', models.JSONField(blank=True, null=True)),
('poster_url', models.URLField(blank=True)),
('backdrop_url', models.URLField(blank=True)),
('tmdb_id', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
('imdb_id', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
('tvdb_id', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
('metadata', models.JSONField(blank=True, null=True)),
('metadata_last_synced_at', models.DateTimeField(blank=True, null=True)),
('metadata_source', models.CharField(blank=True, max_length=64)),
('first_imported_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='media_library.library')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='media_library.mediaitem')),
('vod_episode', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='library_items', to='vod.episode')),
('vod_movie', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='library_items', to='vod.movie')),
('vod_series', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='library_items', to='vod.series')),
],
options={
'ordering': ['library__name', 'item_type', 'sort_title', 'title'],
},
),
migrations.CreateModel(
name='MediaFile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('absolute_path', models.CharField(max_length=4096)),
('relative_path', models.CharField(blank=True, max_length=4096)),
('file_name', models.CharField(max_length=1024)),
('size_bytes', models.BigIntegerField(default=0)),
('duration_ms', models.BigIntegerField(blank=True, null=True)),
('video_codec', models.CharField(blank=True, max_length=128)),
('audio_codec', models.CharField(blank=True, max_length=128)),
('audio_channels', models.FloatField(blank=True, null=True)),
('width', models.IntegerField(blank=True, null=True)),
('height', models.IntegerField(blank=True, null=True)),
('frame_rate', models.FloatField(blank=True, null=True)),
('bit_rate', models.BigIntegerField(blank=True, null=True)),
('container', models.CharField(blank=True, max_length=64)),
('has_subtitles', models.BooleanField(default=False)),
('subtitle_languages', models.JSONField(blank=True, null=True)),
('extra_streams', models.JSONField(blank=True, null=True)),
('checksum', models.CharField(blank=True, db_index=True, max_length=64)),
('fingerprint', models.CharField(blank=True, db_index=True, max_length=64)),
('last_modified_at', models.DateTimeField(blank=True, null=True)),
('requires_transcode', models.BooleanField(default=False)),
('transcode_status', models.CharField(choices=[('not_required', 'Not Required'), ('pending', 'Pending'), ('processing', 'Processing'), ('ready', 'Ready'), ('failed', 'Failed')], default='not_required', max_length=20)),
('transcoded_path', models.CharField(blank=True, max_length=4096)),
('transcoded_mime_type', models.CharField(blank=True, max_length=128)),
('transcode_error', models.TextField(blank=True)),
('transcoded_at', models.DateTimeField(blank=True, null=True)),
('last_seen_at', models.DateTimeField(blank=True, null=True)),
('missing_since', models.DateTimeField(blank=True, null=True)),
('notes', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='media_library.library')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='files', to='media_library.librarylocation')),
('media_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='files', to='media_library.mediaitem')),
],
options={
'ordering': ['library', 'relative_path'],
},
),
migrations.CreateModel(
name='ArtworkAsset',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('asset_type', models.CharField(choices=[('poster', 'Poster'), ('backdrop', 'Backdrop'), ('banner', 'Banner'), ('thumb', 'Thumbnail')], max_length=16)),
('external_url', models.URLField(blank=True)),
('local_path', models.CharField(blank=True, max_length=4096)),
('width', models.IntegerField(blank=True, null=True)),
('height', models.IntegerField(blank=True, null=True)),
('language', models.CharField(blank=True, max_length=16)),
('source', models.CharField(blank=True, max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('media_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='artwork', to='media_library.mediaitem')),
],
options={
'ordering': ['media_item', 'asset_type'],
},
),
migrations.CreateModel(
name='WatchProgress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('position_ms', models.BigIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
('duration_ms', models.BigIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
('completed', models.BooleanField(default=False)),
('last_watched_at', models.DateTimeField(auto_now=True)),
('media_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='watch_progress', to='media_library.mediaitem')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_progress', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-last_watched_at'],
},
),
migrations.AddIndex(
model_name='mediaitem',
index=models.Index(fields=['library', 'item_type', 'normalized_title'], name='media_libra_library_0676e6_idx'),
),
migrations.AddIndex(
model_name='mediaitem',
index=models.Index(fields=['library', 'item_type', 'release_year'], name='media_libra_library_cf3bef_idx'),
),
migrations.AddIndex(
model_name='mediafile',
index=models.Index(fields=['library', 'relative_path'], name='media_libra_library_aacdfb_idx'),
),
migrations.AddIndex(
model_name='mediafile',
index=models.Index(fields=['media_item', 'last_seen_at'], name='media_libra_media_i_6f32ca_idx'),
),
migrations.AlterUniqueTogether(
name='mediafile',
unique_together={('library', 'absolute_path')},
),
migrations.AlterUniqueTogether(
name='artworkasset',
unique_together={('media_item', 'asset_type', 'language', 'source')},
),
migrations.AlterUniqueTogether(
name='watchprogress',
unique_together={('user', 'media_item')},
),
]

View file

@ -0,0 +1,808 @@
import hashlib
import os
import uuid
from dataclasses import dataclass
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models
from django.utils import timezone
from django.utils.text import slugify
MEDIA_EXTENSIONS = {
".mkv",
".mp4",
".mov",
".avi",
".m4v",
".ts",
".flv",
".wmv",
".mpg",
".mpeg",
}
class Library(models.Model):
"""Represents a logical grouping of media content (movies, shows, etc.)."""
LIBRARY_TYPE_MOVIES = "movies"
LIBRARY_TYPE_SHOWS = "shows"
LIBRARY_TYPE_MIXED = "mixed"
LIBRARY_TYPE_OTHER = "other"
LIBRARY_TYPE_CHOICES = [
(LIBRARY_TYPE_MOVIES, "Movies"),
(LIBRARY_TYPE_SHOWS, "TV Shows"),
(LIBRARY_TYPE_MIXED, "Mixed"),
(LIBRARY_TYPE_OTHER, "Other"),
]
name = models.CharField(max_length=255, unique=True)
slug = models.SlugField(max_length=255, unique=True, editable=False)
description = models.TextField(blank=True)
library_type = models.CharField(
max_length=16,
choices=LIBRARY_TYPE_CHOICES,
default=LIBRARY_TYPE_MIXED,
)
auto_scan_enabled = models.BooleanField(default=True)
scan_interval_minutes = models.PositiveIntegerField(
default=24 * 60,
help_text="How often to auto-scan library paths when auto-scan is enabled.",
)
metadata_language = models.CharField(
max_length=8,
default="en",
help_text="Primary language for metadata lookups (ISO-639-1).",
)
metadata_country = models.CharField(
max_length=8,
default="US",
help_text="Primary country/region for metadata lookups (ISO-3166-1 alpha-2).",
)
use_as_vod_source = models.BooleanField(
default=False,
help_text="When enabled, media matched in this library syncs into the VOD catalog.",
)
metadata_options = models.JSONField(
blank=True,
null=True,
help_text="Structured configuration for metadata providers and overrides.",
)
last_scan_at = models.DateTimeField(blank=True, null=True)
last_successful_scan_at = models.DateTimeField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.name)
slug_candidate = base_slug
counter = 1
while Library.objects.filter(slug=slug_candidate).exclude(pk=self.pk).exists():
counter += 1
slug_candidate = f"{base_slug}-{counter}"
self.slug = slug_candidate
super().save(*args, **kwargs)
class LibraryLocation(models.Model):
"""Physical path backing a library."""
library = models.ForeignKey(
Library,
on_delete=models.CASCADE,
related_name="locations",
)
path = models.CharField(max_length=4096)
include_subdirectories = models.BooleanField(default=True)
is_primary = models.BooleanField(
default=False,
help_text="Primary location is used for generating relative paths when multiple roots share structure.",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [("library", "path")]
ordering = ["library__name", "path"]
def __str__(self):
return f"{self.library.name}: {self.path}"
class LibraryScan(models.Model):
"""Tracks an individual scan execution."""
STATUS_PENDING = "pending"
STATUS_RUNNING = "running"
STATUS_COMPLETED = "completed"
STATUS_FAILED = "failed"
STATUS_CANCELLED = "cancelled"
STATUS_CHOICES = [
(STATUS_PENDING, "Pending"),
(STATUS_RUNNING, "Running"),
(STATUS_COMPLETED, "Completed"),
(STATUS_FAILED, "Failed"),
(STATUS_CANCELLED, "Cancelled"),
]
STAGE_STATUS_PENDING = "pending"
STAGE_STATUS_RUNNING = "running"
STAGE_STATUS_COMPLETED = "completed"
STAGE_STATUS_SKIPPED = "skipped"
STAGE_STATUS_CHOICES = [
(STAGE_STATUS_PENDING, "Pending"),
(STAGE_STATUS_RUNNING, "Running"),
(STAGE_STATUS_COMPLETED, "Completed"),
(STAGE_STATUS_SKIPPED, "Skipped"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
library = models.ForeignKey(
Library,
on_delete=models.CASCADE,
related_name="scans",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="library_scans",
)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
started_at = models.DateTimeField(blank=True, null=True)
finished_at = models.DateTimeField(blank=True, null=True)
total_files = models.PositiveIntegerField(default=0)
processed_files = models.PositiveIntegerField(default=0)
new_files = models.PositiveIntegerField(default=0)
updated_files = models.PositiveIntegerField(default=0)
removed_files = models.PositiveIntegerField(default=0)
matched_items = models.PositiveIntegerField(default=0)
unmatched_files = models.PositiveIntegerField(default=0)
task_id = models.CharField(max_length=255, blank=True, null=True)
summary = models.TextField(blank=True)
log = models.TextField(blank=True)
extra = models.JSONField(blank=True, null=True)
discovery_status = models.CharField(
max_length=16,
choices=STAGE_STATUS_CHOICES,
default=STAGE_STATUS_PENDING,
)
discovery_total = models.PositiveIntegerField(default=0)
discovery_processed = models.PositiveIntegerField(default=0)
metadata_status = models.CharField(
max_length=16,
choices=STAGE_STATUS_CHOICES,
default=STAGE_STATUS_PENDING,
)
metadata_total = models.PositiveIntegerField(default=0)
metadata_processed = models.PositiveIntegerField(default=0)
artwork_status = models.CharField(
max_length=16,
choices=STAGE_STATUS_CHOICES,
default=STAGE_STATUS_PENDING,
)
artwork_total = models.PositiveIntegerField(default=0)
artwork_processed = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def mark_running(self, task_id: str | None = None):
self.status = self.STATUS_RUNNING
self.started_at = timezone.now()
self.processed_files = 0
if task_id:
self.task_id = task_id
self.save(
update_fields=[
"status",
"started_at",
"task_id",
"processed_files",
"updated_at",
]
)
def mark_completed(self, summary: str = ""):
self.status = self.STATUS_COMPLETED
self.finished_at = timezone.now()
self.summary = summary
self.processed_files = self.total_files
self.save(
update_fields=[
"status",
"finished_at",
"summary",
"processed_files",
"updated_at",
]
)
def mark_failed(self, summary: str = ""):
self.status = self.STATUS_FAILED
self.finished_at = timezone.now()
self.summary = summary
self.save(update_fields=["status", "finished_at", "summary", "updated_at"])
def record_progress(
self,
*,
processed: int | None = None,
matched: int | None = None,
unmatched: int | None = None,
total: int | None = None,
summary: str | None = None,
) -> None:
updates: dict[str, object] = {}
if processed is not None:
self.processed_files = processed
updates["processed_files"] = processed
if matched is not None:
self.matched_items = matched
updates["matched_items"] = matched
if unmatched is not None:
self.unmatched_files = unmatched
updates["unmatched_files"] = unmatched
if total is not None:
total_value = max(0, int(total))
self.total_files = total_value
updates["total_files"] = total_value
if summary is not None:
self.summary = summary
updates["summary"] = summary
if not updates:
return
now = timezone.now()
updates["updated_at"] = now
self.__class__.objects.filter(pk=self.pk).update(**updates)
self.updated_at = now
_stage_field_map = {
"discovery": ("discovery_status", "discovery_total", "discovery_processed"),
"metadata": ("metadata_status", "metadata_total", "metadata_processed"),
"artwork": ("artwork_status", "artwork_total", "artwork_processed"),
}
def _ensure_completed(self) -> bool:
"""
Bring the overall scan status in sync with stage completion.
Returns True when the scan transitions to the completed state.
"""
if self.status == self.STATUS_COMPLETED and self.finished_at:
return False
metadata_done = self.metadata_status in (
self.STAGE_STATUS_COMPLETED,
self.STAGE_STATUS_SKIPPED,
)
artwork_done = self.artwork_status in (
self.STAGE_STATUS_COMPLETED,
self.STAGE_STATUS_SKIPPED,
)
discovery_done = self.discovery_status == self.STAGE_STATUS_COMPLETED
if not (metadata_done and artwork_done and discovery_done):
return False
summary = self.summary or ""
self.mark_completed(summary=summary)
if self.library_id:
now = timezone.now()
Library.objects.filter(pk=self.library_id).update(
last_scan_at=now,
last_successful_scan_at=now,
updated_at=now,
)
if getattr(self, "library", None):
self.library.last_scan_at = now
self.library.last_successful_scan_at = now
self.library.updated_at = now
return True
def stage_snapshot(self, *, normalize: bool = False) -> dict[str, dict[str, int | str]]:
"""
Return the current stage progress for the scan.
When `normalize` is True, metadata and artwork counts are scaled so that
their totals align with the overall `total_files` count. This keeps the
progress bars consistent in the UI without affecting the stored values
that drive the underlying workflow.
"""
# Keeping overall status consistent avoids stale "running" badges in the UI.
self._ensure_completed()
snapshot: dict[str, dict[str, int | str]] = {}
total_files = max(0, int(self.total_files or 0))
for stage, (status_field, total_field, processed_field) in self._stage_field_map.items():
status = getattr(self, status_field)
total_value = max(0, int(getattr(self, total_field) or 0))
processed_value = max(0, int(getattr(self, processed_field) or 0))
if (
normalize
and stage != "discovery"
and status != self.STAGE_STATUS_SKIPPED
and total_files > 0
):
normalized_total = total_files
if total_value > 0:
if processed_value >= total_value:
normalized_processed = normalized_total
else:
ratio = processed_value / total_value
normalized_processed = int(ratio * normalized_total)
if processed_value > 0 and normalized_processed == 0:
normalized_processed = 1
normalized_processed = min(normalized_total, normalized_processed)
else:
normalized_processed = 0
total_value = normalized_total
processed_value = normalized_processed
snapshot[stage] = {
"status": status,
"processed": processed_value,
"total": total_value,
}
return snapshot
def record_stage_progress(
self,
stage: str,
*,
status: str | None = None,
total: int | None = None,
processed: int | None = None,
) -> None:
if stage not in self._stage_field_map:
raise ValueError(f"Unknown stage '{stage}'")
status_field, total_field, processed_field = self._stage_field_map[stage]
updates: dict[str, object] = {}
if status is not None:
if status not in dict(self.STAGE_STATUS_CHOICES):
raise ValueError(f"Invalid stage status '{status}' for stage '{stage}'")
setattr(self, status_field, status)
updates[status_field] = status
if total is not None:
total_value = max(0, int(total))
setattr(self, total_field, total_value)
updates[total_field] = total_value
if processed is not None:
processed_value = max(0, int(processed))
setattr(self, processed_field, processed_value)
updates[processed_field] = processed_value
if not updates:
return
now = timezone.now()
updates["updated_at"] = now
self.__class__.objects.filter(pk=self.pk).update(**updates)
self.updated_at = now
class MediaItem(models.Model):
"""Represents a logical piece of media (movie, series, season, episode)."""
TYPE_COLLECTION = "collection"
TYPE_SHOW = "show"
TYPE_SEASON = "season"
TYPE_EPISODE = "episode"
TYPE_MOVIE = "movie"
TYPE_OTHER = "other"
ITEM_TYPE_CHOICES = [
(TYPE_COLLECTION, "Collection"),
(TYPE_SHOW, "Series"),
(TYPE_SEASON, "Season"),
(TYPE_EPISODE, "Episode"),
(TYPE_MOVIE, "Movie"),
(TYPE_OTHER, "Other"),
]
STATUS_PENDING = "pending"
STATUS_MATCHED = "matched"
STATUS_FAILED = "failed"
STATUS_CHOICES = [
(STATUS_PENDING, "Pending"),
(STATUS_MATCHED, "Matched"),
(STATUS_FAILED, "Failed"),
]
library = models.ForeignKey(
Library,
on_delete=models.CASCADE,
related_name="items",
)
parent = models.ForeignKey(
"self",
on_delete=models.CASCADE,
related_name="children",
null=True,
blank=True,
)
item_type = models.CharField(max_length=16, choices=ITEM_TYPE_CHOICES, default=TYPE_OTHER)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
title = models.CharField(max_length=512)
sort_title = models.CharField(max_length=512, blank=True)
normalized_title = models.CharField(max_length=512, blank=True, db_index=True)
release_year = models.IntegerField(blank=True, null=True)
season_number = models.IntegerField(blank=True, null=True)
episode_number = models.IntegerField(blank=True, null=True)
runtime_ms = models.BigIntegerField(blank=True, null=True)
synopsis = models.TextField(blank=True)
tagline = models.CharField(max_length=512, blank=True)
rating = models.CharField(max_length=32, blank=True)
genres = models.JSONField(blank=True, null=True)
studios = models.JSONField(blank=True, null=True)
cast = models.JSONField(blank=True, null=True)
crew = models.JSONField(blank=True, null=True)
tags = models.JSONField(blank=True, null=True)
poster_url = models.URLField(blank=True)
backdrop_url = models.URLField(blank=True)
tmdb_id = models.CharField(max_length=64, blank=True, null=True, db_index=True)
imdb_id = models.CharField(max_length=64, blank=True, null=True, db_index=True)
tvdb_id = models.CharField(max_length=64, blank=True, null=True, db_index=True)
vod_movie = models.ForeignKey(
"vod.Movie",
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="library_items",
)
vod_series = models.ForeignKey(
"vod.Series",
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="library_items",
)
vod_episode = models.ForeignKey(
"vod.Episode",
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="library_items",
)
metadata = models.JSONField(blank=True, null=True)
metadata_last_synced_at = models.DateTimeField(blank=True, null=True)
metadata_source = models.CharField(max_length=64, blank=True)
first_imported_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
models.Index(fields=["library", "item_type", "normalized_title"]),
models.Index(fields=["library", "item_type", "release_year"]),
]
ordering = ["library__name", "item_type", "sort_title", "title"]
def __str__(self):
return self.title
def ensure_sort_title(self):
if not self.sort_title:
self.sort_title = self.title
if not self.normalized_title:
self.normalized_title = normalize_title(self.title)
def save(self, *args, **kwargs):
self.ensure_sort_title()
super().save(*args, **kwargs)
@property
def is_movie(self) -> bool:
return self.item_type == self.TYPE_MOVIE
@property
def is_episode(self) -> bool:
return self.item_type == self.TYPE_EPISODE
class ArtworkAsset(models.Model):
"""Represents an artwork asset linked to a media item."""
TYPE_POSTER = "poster"
TYPE_BACKDROP = "backdrop"
TYPE_BANNER = "banner"
TYPE_THUMB = "thumb"
ASSET_TYPE_CHOICES = [
(TYPE_POSTER, "Poster"),
(TYPE_BACKDROP, "Backdrop"),
(TYPE_BANNER, "Banner"),
(TYPE_THUMB, "Thumbnail"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
media_item = models.ForeignKey(
MediaItem,
on_delete=models.CASCADE,
related_name="artwork",
)
asset_type = models.CharField(max_length=16, choices=ASSET_TYPE_CHOICES)
external_url = models.URLField(blank=True)
local_path = models.CharField(max_length=4096, blank=True)
width = models.IntegerField(blank=True, null=True)
height = models.IntegerField(blank=True, null=True)
language = models.CharField(max_length=16, blank=True)
source = models.CharField(max_length=64, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [("media_item", "asset_type", "language", "source")]
ordering = ["media_item", "asset_type"]
def __str__(self):
return f"{self.media_item.title} - {self.asset_type}"
class MediaFile(models.Model):
"""Physical file on disk that is associated with a media item."""
TRANSCODE_STATUS_NOT_REQUIRED = "not_required"
TRANSCODE_STATUS_PENDING = "pending"
TRANSCODE_STATUS_PROCESSING = "processing"
TRANSCODE_STATUS_READY = "ready"
TRANSCODE_STATUS_FAILED = "failed"
TRANSCODE_STATUS_CHOICES = [
(TRANSCODE_STATUS_NOT_REQUIRED, "Not Required"),
(TRANSCODE_STATUS_PENDING, "Pending"),
(TRANSCODE_STATUS_PROCESSING, "Processing"),
(TRANSCODE_STATUS_READY, "Ready"),
(TRANSCODE_STATUS_FAILED, "Failed"),
]
BROWSER_SAFE_CONTAINERS = {"mp4", "m4v"}
BROWSER_SAFE_VIDEO_CODECS = {"h264", "avc1"}
BROWSER_SAFE_AUDIO_CODECS = {"aac", "mp3", "mp4a", "libmp3lame"}
library = models.ForeignKey(
Library,
on_delete=models.CASCADE,
related_name="files",
)
media_item = models.ForeignKey(
MediaItem,
on_delete=models.SET_NULL,
related_name="files",
blank=True,
null=True,
)
location = models.ForeignKey(
LibraryLocation,
on_delete=models.SET_NULL,
related_name="files",
blank=True,
null=True,
)
absolute_path = models.CharField(max_length=4096)
relative_path = models.CharField(max_length=4096, blank=True)
file_name = models.CharField(max_length=1024)
size_bytes = models.BigIntegerField(default=0)
duration_ms = models.BigIntegerField(blank=True, null=True)
video_codec = models.CharField(max_length=128, blank=True)
audio_codec = models.CharField(max_length=128, blank=True)
audio_channels = models.FloatField(blank=True, null=True)
width = models.IntegerField(blank=True, null=True)
height = models.IntegerField(blank=True, null=True)
frame_rate = models.FloatField(blank=True, null=True)
bit_rate = models.BigIntegerField(blank=True, null=True)
container = models.CharField(max_length=64, blank=True)
has_subtitles = models.BooleanField(default=False)
subtitle_languages = models.JSONField(blank=True, null=True)
extra_streams = models.JSONField(blank=True, null=True)
checksum = models.CharField(max_length=64, blank=True, db_index=True)
fingerprint = models.CharField(max_length=64, blank=True, db_index=True)
last_modified_at = models.DateTimeField(blank=True, null=True)
requires_transcode = models.BooleanField(default=False)
transcode_status = models.CharField(
max_length=20,
choices=TRANSCODE_STATUS_CHOICES,
default=TRANSCODE_STATUS_NOT_REQUIRED,
)
transcoded_path = models.CharField(max_length=4096, blank=True)
transcoded_mime_type = models.CharField(max_length=128, blank=True)
transcode_error = models.TextField(blank=True)
transcoded_at = models.DateTimeField(blank=True, null=True)
last_seen_at = models.DateTimeField(blank=True, null=True)
missing_since = models.DateTimeField(blank=True, null=True)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [("library", "absolute_path")]
indexes = [
models.Index(fields=["library", "relative_path"]),
models.Index(fields=["media_item", "last_seen_at"]),
]
ordering = ["library", "relative_path"]
def __str__(self):
return self.absolute_path
@property
def extension(self):
return os.path.splitext(self.file_name)[1].lower()
def _normalized_container(self) -> str:
container = (self.container or "").split(",")[0].strip().lower()
if container:
return container
ext = self.extension
return ext[1:] if ext.startswith(".") else ext
@staticmethod
def _normalized_codec(codec_value: str) -> str:
return (codec_value or "").split(".")[0].strip().lower()
def is_browser_playable(self) -> bool:
container = self._normalized_container()
if container not in self.BROWSER_SAFE_CONTAINERS:
return False
video_codec = self._normalized_codec(self.video_codec)
if video_codec and video_codec not in self.BROWSER_SAFE_VIDEO_CODECS:
return False
audio_codec = self._normalized_codec(self.audio_codec)
if audio_codec and audio_codec not in self.BROWSER_SAFE_AUDIO_CODECS:
return False
return True
@property
def effective_duration_ms(self) -> int | None:
"""
Return the best-known duration (ms). Falls back to probe metadata and
associated media item runtime when the direct field is missing.
"""
if self.duration_ms:
try:
return int(self.duration_ms)
except (TypeError, ValueError):
pass
extra = self.extra_streams or {}
format_info = extra.get("format") or {}
candidates: list[tuple[object, float]] = []
if "duration_ms" in format_info:
candidates.append((format_info.get("duration_ms"), 1.0))
if "duration" in format_info:
# ffprobe reports seconds in this field.
candidates.append((format_info.get("duration"), 1000.0))
for value, multiplier in candidates:
if value in (None, "", 0):
continue
try:
numeric = float(value)
except (TypeError, ValueError):
continue
if numeric <= 0:
continue
return int(numeric * multiplier)
if self.media_item_id:
runtime_ms = self.media_item.runtime_ms
if runtime_ms:
try:
return int(runtime_ms)
except (TypeError, ValueError):
return None
return None
def calculate_checksum(self, chunk_size: int = 1024 * 1024) -> str:
"""Calculate a SHA1 checksum for the file."""
sha1 = hashlib.sha1()
try:
with open(self.absolute_path, "rb") as handle:
while True:
data = handle.read(chunk_size)
if not data:
break
sha1.update(data)
except FileNotFoundError:
return ""
return sha1.hexdigest()
class WatchProgress(models.Model):
"""Tracks where a user left off while watching a media item."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="media_progress",
)
media_item = models.ForeignKey(
MediaItem,
on_delete=models.CASCADE,
related_name="watch_progress",
)
position_ms = models.BigIntegerField(default=0, validators=[MinValueValidator(0)])
duration_ms = models.BigIntegerField(default=0, validators=[MinValueValidator(0)])
completed = models.BooleanField(default=False)
last_watched_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [("user", "media_item")]
ordering = ["-last_watched_at"]
def __str__(self):
return f"{self.user} - {self.media_item}"
def update_progress(self, position_ms: int, duration_ms: int | None = None, completion_threshold: float = 0.96):
if duration_ms is not None:
self.duration_ms = max(self.duration_ms, duration_ms)
self.position_ms = max(position_ms, 0)
if self.duration_ms > 0:
ratio = self.position_ms / self.duration_ms
if ratio >= completion_threshold:
self.completed = True
self.position_ms = self.duration_ms
self.save(update_fields=["position_ms", "duration_ms", "completed", "last_watched_at"])
def normalize_title(text: str | None) -> str:
if not text:
return ""
return "".join(ch for ch in text.lower() if ch.isalnum() or ch.isspace()).strip()
@dataclass
class ClassificationResult:
"""Represents the outcome of parsing a media file name."""
detected_type: str
title: str
year: int | None = None
season: int | None = None
episode: int | None = None
episode_title: str | None = None
data: dict | None = None

View file

@ -0,0 +1,525 @@
from django.db import transaction
from django.db.models import Prefetch
from rest_framework import serializers
from apps.media_library import models
class LibraryLocationSerializer(serializers.ModelSerializer):
class Meta:
model = models.LibraryLocation
fields = [
"id",
"path",
"include_subdirectories",
"is_primary",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
class LibrarySerializer(serializers.ModelSerializer):
locations = LibraryLocationSerializer(many=True, required=False)
class Meta:
model = models.Library
fields = [
"id",
"name",
"slug",
"description",
"library_type",
"auto_scan_enabled",
"scan_interval_minutes",
"metadata_language",
"metadata_country",
"use_as_vod_source",
"metadata_options",
"last_scan_at",
"last_successful_scan_at",
"created_at",
"updated_at",
"locations",
]
read_only_fields = [
"id",
"slug",
"last_scan_at",
"last_successful_scan_at",
"created_at",
"updated_at",
]
def create(self, validated_data):
locations_data = validated_data.pop("locations", [])
with transaction.atomic():
library = super().create(validated_data)
self._sync_locations(library, locations_data)
return library
def update(self, instance, validated_data):
locations_data = validated_data.pop("locations", None)
with transaction.atomic():
library = super().update(instance, validated_data)
if locations_data is not None:
self._sync_locations(library, locations_data)
return library
def _sync_locations(self, library: models.Library, locations_data):
seen_location_ids = set()
primary_set = False
for entry in locations_data:
location_id = entry.get("id")
entry_data = {
"path": entry["path"],
"include_subdirectories": entry.get("include_subdirectories", True),
"is_primary": entry.get("is_primary", False),
}
if entry_data["is_primary"]:
primary_set = True
if location_id:
updated = models.LibraryLocation.objects.filter(pk=location_id, library=library).update(**entry_data)
if updated:
seen_location_ids.add(location_id)
continue
defaults = {
"include_subdirectories": entry_data["include_subdirectories"],
"is_primary": entry_data["is_primary"],
}
location, _ = models.LibraryLocation.objects.update_or_create(
library=library,
path=entry_data["path"],
defaults=defaults,
)
seen_location_ids.add(location.id)
if location.is_primary:
primary_set = True
# Remove locations not present in payload
models.LibraryLocation.objects.filter(library=library).exclude(id__in=seen_location_ids).delete()
# Ensure a primary location exists
if not primary_set:
first_location = models.LibraryLocation.objects.filter(library=library).order_by("id").first()
if first_location:
first_location.is_primary = True
first_location.save(update_fields=["is_primary", "updated_at"])
class LibraryScanSerializer(serializers.ModelSerializer):
library_name = serializers.CharField(source="library.name", read_only=True)
created_by_name = serializers.CharField(source="created_by.username", read_only=True)
stages = serializers.SerializerMethodField()
class Meta:
model = models.LibraryScan
fields = [
"id",
"library",
"library_name",
"created_by",
"created_by_name",
"status",
"started_at",
"finished_at",
"total_files",
"processed_files",
"new_files",
"updated_files",
"removed_files",
"matched_items",
"unmatched_files",
"task_id",
"summary",
"log",
"extra",
"discovery_status",
"discovery_total",
"discovery_processed",
"metadata_status",
"metadata_total",
"metadata_processed",
"artwork_status",
"artwork_total",
"artwork_processed",
"stages",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"library_name",
"created_by_name",
"status",
"started_at",
"finished_at",
"total_files",
"processed_files",
"new_files",
"updated_files",
"removed_files",
"matched_items",
"unmatched_files",
"task_id",
"summary",
"log",
"extra",
"discovery_status",
"discovery_total",
"discovery_processed",
"metadata_status",
"metadata_total",
"metadata_processed",
"artwork_status",
"artwork_total",
"artwork_processed",
"stages",
"created_at",
"updated_at",
]
def get_stages(self, obj):
return obj.stage_snapshot(normalize=True)
class ArtworkAssetSerializer(serializers.ModelSerializer):
class Meta:
model = models.ArtworkAsset
fields = [
"id",
"asset_type",
"external_url",
"local_path",
"width",
"height",
"language",
"source",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
class MediaFileSerializer(serializers.ModelSerializer):
class Meta:
model = models.MediaFile
fields = [
"id",
"library",
"media_item",
"location",
"absolute_path",
"relative_path",
"file_name",
"size_bytes",
"duration_ms",
"video_codec",
"audio_codec",
"audio_channels",
"width",
"height",
"frame_rate",
"bit_rate",
"container",
"has_subtitles",
"subtitle_languages",
"extra_streams",
"checksum",
"fingerprint",
"last_modified_at",
"requires_transcode",
"transcode_status",
"transcoded_path",
"transcoded_mime_type",
"transcode_error",
"transcoded_at",
"last_seen_at",
"missing_since",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"last_modified_at",
"requires_transcode",
"transcode_status",
"transcoded_path",
"transcoded_mime_type",
"transcode_error",
"transcoded_at",
"last_seen_at",
"missing_since",
"created_at",
"updated_at",
]
class MediaItemBaseSerializer(serializers.ModelSerializer):
parent_id = serializers.PrimaryKeyRelatedField(source="parent", read_only=True)
watch_progress = serializers.SerializerMethodField()
watch_summary = serializers.SerializerMethodField()
def _get_user(self):
request = self.context.get("request")
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return None
return user
def _get_progress(self, obj, user):
prefetched = getattr(obj, "_user_watch_progress", None)
if prefetched is not None:
return prefetched[0] if prefetched else None
return obj.watch_progress.filter(user=user).first()
def get_watch_progress(self, obj):
user = self._get_user()
if not user:
return None
progress = self._get_progress(obj, user)
if not progress:
return None
duration = progress.duration_ms or obj.runtime_ms
percentage = (progress.position_ms / duration) if duration else 0
return {
"id": progress.id,
"position_ms": progress.position_ms,
"duration_ms": duration,
"completed": progress.completed,
"percentage": percentage,
"last_watched_at": progress.last_watched_at,
}
def get_watch_summary(self, obj):
user = self._get_user()
if not user:
return None
if obj.item_type == models.MediaItem.TYPE_SHOW:
episodes = getattr(obj, "_prefetched_children", None)
if episodes is None:
episodes = list(
models.MediaItem.objects.filter(
parent=obj, item_type=models.MediaItem.TYPE_EPISODE
)
.prefetch_related(
Prefetch(
"watch_progress",
queryset=models.WatchProgress.objects.filter(user=user),
to_attr="_user_watch_progress",
)
)
.order_by("season_number", "episode_number", "id")
)
total = len([ep for ep in episodes if ep.item_type == models.MediaItem.TYPE_EPISODE])
completed = 0
resume_episode = None
resume_progress_time = None
next_episode = None
last_completed_episode = None
last_completed_time = None
for episode in episodes:
if episode.item_type != models.MediaItem.TYPE_EPISODE:
continue
progress = self._get_progress(episode, user)
if progress and progress.completed:
completed += 1
if not last_completed_time or progress.last_watched_at > last_completed_time:
last_completed_time = progress.last_watched_at
last_completed_episode = episode
elif progress and progress.position_ms:
if (
resume_progress_time is None
or progress.last_watched_at > resume_progress_time
):
resume_progress_time = progress.last_watched_at
resume_episode = episode
else:
if not next_episode:
next_episode = episode
status = "unwatched"
if total == 0:
status = "unwatched"
elif completed >= total and total > 0:
status = "watched"
next_episode = None
elif resume_episode or completed > 0:
status = "in_progress"
if not resume_episode:
resume_episode = next_episode
return {
"status": status,
"total_episodes": total,
"completed_episodes": completed,
"resume_episode_id": resume_episode.id if resume_episode else None,
"next_episode_id": next_episode.id if next_episode else None,
"last_completed_episode_id": last_completed_episode.id if last_completed_episode else None,
}
progress = self._get_progress(obj, user)
if not progress:
return {"status": "unwatched"}
if progress.completed:
return {
"status": "watched",
"position_ms": progress.position_ms,
"duration_ms": progress.duration_ms,
}
return {
"status": "in_progress",
"position_ms": progress.position_ms,
"duration_ms": progress.duration_ms,
"percentage": (progress.position_ms / progress.duration_ms)
if progress.duration_ms
else 0,
}
class MediaItemListSerializer(MediaItemBaseSerializer):
class Meta:
model = models.MediaItem
fields = [
"id",
"library",
"parent_id",
"item_type",
"status",
"title",
"sort_title",
"poster_url",
"backdrop_url",
"runtime_ms",
"release_year",
"season_number",
"episode_number",
"genres",
"tags",
"tagline",
"metadata_last_synced_at",
"metadata_source",
"watch_progress",
"watch_summary",
]
read_only_fields = fields
class MediaItemSerializer(MediaItemBaseSerializer):
files = MediaFileSerializer(many=True, read_only=True)
artwork = ArtworkAssetSerializer(many=True, read_only=True)
class Meta:
model = models.MediaItem
fields = [
"id",
"library",
"parent_id",
"item_type",
"status",
"title",
"sort_title",
"normalized_title",
"release_year",
"season_number",
"episode_number",
"runtime_ms",
"synopsis",
"tagline",
"rating",
"genres",
"studios",
"cast",
"crew",
"tags",
"poster_url",
"backdrop_url",
"tmdb_id",
"imdb_id",
"tvdb_id",
"vod_movie",
"vod_series",
"vod_episode",
"metadata",
"metadata_last_synced_at",
"metadata_source",
"first_imported_at",
"updated_at",
"files",
"artwork",
"watch_progress",
"watch_summary",
]
read_only_fields = [
"id",
"library",
"parent_id",
"item_type",
"normalized_title",
"metadata_last_synced_at",
"first_imported_at",
"updated_at",
"files",
"artwork",
"vod_movie",
"vod_series",
"vod_episode",
"watch_progress",
]
extra_kwargs = {
"genres": {"required": False, "allow_null": True},
"studios": {"required": False, "allow_null": True},
"cast": {"required": False, "allow_null": True},
"crew": {"required": False, "allow_null": True},
"tags": {"required": False, "allow_null": True},
"metadata": {"required": False, "allow_null": True},
}
class WatchProgressSerializer(serializers.ModelSerializer):
user_display = serializers.CharField(source="user.username", read_only=True)
media_title = serializers.CharField(source="media_item.title", read_only=True)
class Meta:
model = models.WatchProgress
fields = [
"id",
"user",
"user_display",
"media_item",
"media_title",
"position_ms",
"duration_ms",
"completed",
"last_watched_at",
]
read_only_fields = ["id", "user", "user_display", "media_title", "last_watched_at"]
extra_kwargs = {"user": {"required": False}}
validators = [] # DB constraint enforces uniqueness; allow update-or-create writes
def create(self, validated_data):
user = self.context["request"].user
progress, _ = models.WatchProgress.objects.update_or_create(
user=user,
media_item=validated_data["media_item"],
defaults={
"position_ms": validated_data.get("position_ms", 0),
"duration_ms": validated_data.get("duration_ms", 0),
"completed": validated_data.get("completed", False),
},
)
return progress
def update(self, instance, validated_data):
instance.position_ms = validated_data.get("position_ms", instance.position_ms)
instance.duration_ms = max(
instance.duration_ms, validated_data.get("duration_ms", instance.duration_ms)
)
instance.completed = validated_data.get("completed", instance.completed)
instance.save()
return instance

1149
apps/media_library/tasks.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,474 @@
import contextlib
import hashlib
import logging
import mimetypes
import os
import shutil
import subprocess
import tempfile
import threading
from collections import deque
from pathlib import Path
from typing import Iterable, Tuple
from django.conf import settings
from django.utils import timezone
from .models import MediaFile
logger = logging.getLogger(__name__)
CHUNK_SIZE = 128 * 1024 # 128KB chunks for streaming
def _as_path(value) -> Path:
if isinstance(value, Path):
return value
return Path(str(value))
def _int_setting(name: str, default: int) -> int:
value = getattr(settings, name, default)
try:
return int(value)
except (TypeError, ValueError):
return default
TRANSCODE_ROOT = _as_path(
getattr(settings, "MEDIA_LIBRARY_TRANSCODE_DIR", settings.MEDIA_ROOT / "transcoded")
)
TRANSCODE_ROOT.mkdir(parents=True, exist_ok=True)
FFMPEG_PATH = getattr(settings, "MEDIA_LIBRARY_FFMPEG_PATH", "ffmpeg")
VIDEO_BITRATE = _int_setting("MEDIA_LIBRARY_TRANSCODE_VIDEO_BITRATE", 4500)
AUDIO_BITRATE = _int_setting("MEDIA_LIBRARY_TRANSCODE_AUDIO_BITRATE", 192)
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)
def _build_target_path(media_file: MediaFile) -> Path:
identifier = media_file.checksum or f"{media_file.absolute_path}:{media_file.size_bytes}"
digest = hashlib.sha256(identifier.encode("utf-8")).hexdigest()[:16]
filename = f"{media_file.id}_{digest}.mp4"
return TRANSCODE_ROOT / filename
def _normalize_mime(path: Path) -> str:
mime, _ = mimetypes.guess_type(path.name)
return mime or "video/mp4"
def _build_ffmpeg_command(
source_path: Path, *, output: str, fragmented: bool, start_seconds: float = 0.0
) -> list[str]:
command = [FFMPEG_PATH, "-y"]
if start_seconds and start_seconds > 0:
command.extend(["-ss", f"{start_seconds:.3f}"])
command.extend(
[
"-i",
str(source_path),
]
)
command.extend(
[
"-map",
"0:v:0",
"-map",
"0:a:0?",
"-c:v",
TARGET_VIDEO_CODEC,
"-preset",
PRESET,
"-profile:v",
"high",
"-level",
"4.0",
"-pix_fmt",
"yuv420p",
"-max_muxing_queue_size",
"1024",
"-c:a",
TARGET_AUDIO_CODEC,
"-b:a",
f"{AUDIO_BITRATE}k",
"-ac",
str(max(1, AUDIO_CHANNELS)),
"-sn",
]
)
if VIDEO_BITRATE > 0:
command.extend(["-b:v", f"{VIDEO_BITRATE}k"])
if fragmented:
command.extend(["-movflags", "frag_keyframe+empty_moov+faststart", "-f", "mp4", output])
else:
command.extend(["-movflags", "+faststart", output])
return command
def ensure_browser_ready_source(
media_file: MediaFile, *, force: bool = False
) -> Tuple[str, str]:
"""
Ensure the provided media file is playable by major browsers (Chromium, Firefox, Safari).
Returns a tuple of (absolute_path, mime_type) pointing at either the original file (if compatible)
or a transcoded MP4 fallback.
"""
if media_file.is_browser_playable() and not force:
path = _as_path(media_file.absolute_path)
mime_type, _ = mimetypes.guess_type(path.name)
return str(path), mime_type or "video/mp4"
target_path = _ensure_transcode_to_file(media_file, force=force)
return str(target_path), "video/mp4"
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():
raise FileNotFoundError(f"Media source missing at {source_path}")
target_path = _build_target_path(media_file)
# Re-use existing artifact when it is up-to-date unless force=True.
if (
not force
and media_file.transcode_status == MediaFile.TRANSCODE_STATUS_READY
and media_file.transcoded_path
):
cached_path = Path(media_file.transcoded_path)
if cached_path.exists():
source_mtime = source_path.stat().st_mtime
if cached_path.stat().st_mtime >= source_mtime:
return cached_path
# Cached metadata is stale; clear below.
# Remove stale artifact if a new digest produced a different path.
if media_file.transcoded_path and media_file.transcoded_path != str(target_path):
old_path = Path(media_file.transcoded_path)
if old_path.exists():
try:
old_path.unlink()
except OSError:
logger.debug("Unable to remove old transcode %s", old_path)
target_path.parent.mkdir(parents=True, exist_ok=True)
media_file.transcode_status = MediaFile.TRANSCODE_STATUS_PROCESSING
media_file.transcode_error = ""
media_file.requires_transcode = True
media_file.transcoded_path = ""
media_file.transcoded_mime_type = ""
media_file.transcoded_at = None
media_file.save(
update_fields=[
"transcode_status",
"transcode_error",
"requires_transcode",
"transcoded_path",
"transcoded_mime_type",
"transcoded_at",
"updated_at",
]
)
fd, temp_path = tempfile.mkstemp(dir=str(target_path.parent), suffix=".mp4")
os.close(fd)
command = _build_ffmpeg_command(source_path, output=str(temp_path), fragmented=False)
logger.info(
"Transcoding media file %s (%s) to %s for browser playback",
media_file.id,
source_path,
target_path,
)
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
stderr_tail = (result.stderr or "").strip()[-4000:]
raise RuntimeError(
f"ffmpeg exited with status {result.returncode}: {stderr_tail}"
)
shutil.move(temp_path, target_path)
except Exception as exc: # noqa: BLE001
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
media_file.transcode_status = MediaFile.TRANSCODE_STATUS_FAILED
media_file.transcode_error = str(exc)
media_file.save(
update_fields=["transcode_status", "transcode_error", "updated_at"]
)
logger.error("Transcoding failed for %s: %s", media_file.id, exc)
raise
media_file.transcode_status = MediaFile.TRANSCODE_STATUS_READY
media_file.transcoded_path = str(target_path)
media_file.transcoded_mime_type = _normalize_mime(target_path)
media_file.transcoded_at = timezone.now()
media_file.transcode_error = ""
media_file.requires_transcode = True
media_file.save(
update_fields=[
"transcode_status",
"transcoded_path",
"transcoded_mime_type",
"transcoded_at",
"transcode_error",
"requires_transcode",
"updated_at",
]
)
logger.info("Finished transcoding media file %s", media_file.id)
return target_path
class LiveTranscodeSession:
"""Manage a live ffmpeg transcoding process and stream the output while writing to disk."""
mime_type = "video/mp4"
def __init__(self, media_file: MediaFile, *, start_seconds: float = 0.0):
self.media_file = media_file
self.source_path = _as_path(media_file.absolute_path)
if not self.source_path.exists():
raise FileNotFoundError(f"Media source missing at {self.source_path}")
self.target_path = _build_target_path(media_file)
self.target_path.parent.mkdir(parents=True, exist_ok=True)
self.start_seconds = max(0.0, float(start_seconds))
self.cache_enabled = self.start_seconds == 0.0
if self.cache_enabled:
fd, temp_path = tempfile.mkstemp(dir=str(self.target_path.parent), suffix=".mp4")
os.close(fd)
self.temp_path = Path(temp_path)
else:
self.temp_path = None
self.process: subprocess.Popen | None = None
self._stderr_thread: threading.Thread | None = None
self._stderr_lines: deque[str] = deque(maxlen=200)
self._aborted = False
self._finalized = False
# Prepare media file state
media_file.requires_transcode = True
media_file.transcode_status = MediaFile.TRANSCODE_STATUS_PROCESSING
media_file.transcode_error = ""
update_fields = [
"requires_transcode",
"transcode_status",
"transcode_error",
"updated_at",
]
if self.cache_enabled:
media_file.transcoded_path = ""
media_file.transcoded_mime_type = ""
media_file.transcoded_at = None
update_fields.extend(
[
"transcoded_path",
"transcoded_mime_type",
"transcoded_at",
]
)
media_file.save(update_fields=update_fields)
def start(self) -> "LiveTranscodeSession":
command = _build_ffmpeg_command(
self.source_path,
output="pipe:1",
fragmented=True,
start_seconds=self.start_seconds,
)
logger.info(
"Starting live transcode for media file %s (%s) [start=%.3fs]",
self.media_file.id,
self.source_path,
self.start_seconds,
)
self.process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=CHUNK_SIZE,
)
if self.process.stdout is None or self.process.stderr is None:
raise RuntimeError("Failed to capture ffmpeg output streams")
self._stderr_thread = threading.Thread(
target=self._drain_stderr, name=f"ffmpeg-stderr-{self.media_file.id}", daemon=True
)
self._stderr_thread.start()
return self
def stream(self) -> Iterable[bytes]:
if not self.process or not self.process.stdout:
raise RuntimeError("Live transcode session is not started")
try:
cache_ctx = (
open(self.temp_path, "wb")
if self.cache_enabled and self.temp_path
else contextlib.nullcontext()
)
with cache_ctx as cache_fp:
while True:
chunk = self.process.stdout.read(CHUNK_SIZE)
if not chunk:
break
if cache_fp:
cache_fp.write(chunk)
cache_fp.flush()
yield chunk
except GeneratorExit:
self._aborted = True
logger.debug("Live transcode aborted by client for media file %s", self.media_file.id)
self._terminate_process()
raise
except Exception as exc: # noqa: BLE001
self._aborted = True
logger.warning(
"Live transcode streaming error for media file %s: %s",
self.media_file.id,
exc,
)
self._terminate_process()
raise
finally:
self._finalize()
def _terminate_process(self):
if self.process and self.process.poll() is None:
try:
self.process.terminate()
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
except Exception: # noqa: BLE001
self.process.kill()
def _drain_stderr(self):
assert self.process and self.process.stderr
for line in iter(self.process.stderr.readline, b""):
text = line.decode("utf-8", errors="ignore").strip()
if text:
self._stderr_lines.append(text)
logger.debug("ffmpeg[%s]: %s", self.media_file.id, text)
def _stderr_tail(self) -> str:
return "\n".join(self._stderr_lines)
def _finalize(self):
if self._finalized:
return
self._finalized = True
return_code = None
if self.process:
try:
return_code = self.process.poll()
if return_code is None:
return_code = self.process.wait()
except Exception: # noqa: BLE001
return_code = -1
if self._stderr_thread and self._stderr_thread.is_alive():
self._stderr_thread.join(timeout=2)
if (
return_code == 0
and not self._aborted
and self.cache_enabled
and self.temp_path
and os.path.exists(self.temp_path)
):
try:
shutil.move(self.temp_path, self.target_path)
except Exception as exc: # noqa: BLE001
logger.error(
"Failed to finalize live transcode output for media file %s: %s",
self.media_file.id,
exc,
)
return_code = -1
if return_code == 0 and not self._aborted:
if not self.cache_enabled:
self.media_file.transcode_status = MediaFile.TRANSCODE_STATUS_PENDING
self.media_file.save(update_fields=["transcode_status", "updated_at"])
return
logger.info("Finished live transcode for media file %s", self.media_file.id)
self.media_file.transcode_status = MediaFile.TRANSCODE_STATUS_READY
self.media_file.transcoded_path = str(self.target_path)
self.media_file.transcoded_mime_type = self.mime_type
self.media_file.transcoded_at = timezone.now()
self.media_file.transcode_error = ""
self.media_file.requires_transcode = True
self.media_file.save(
update_fields=[
"transcode_status",
"transcoded_path",
"transcoded_mime_type",
"transcoded_at",
"transcode_error",
"requires_transcode",
"updated_at",
]
)
else:
if self.temp_path and os.path.exists(self.temp_path):
try:
os.remove(self.temp_path)
except OSError:
pass
if self._aborted:
# Mark as pending so a future request can retry.
self.media_file.transcode_status = MediaFile.TRANSCODE_STATUS_PENDING
self.media_file.save(update_fields=["transcode_status", "updated_at"])
else:
msg = self._stderr_tail() or "Unknown ffmpeg failure"
self.media_file.transcode_status = MediaFile.TRANSCODE_STATUS_FAILED
self.media_file.transcode_error = msg[-4000:]
self.media_file.save(
update_fields=["transcode_status", "transcode_error", "updated_at"]
)
logger.error(
"Live transcode failed for media file %s: %s",
self.media_file.id,
self.media_file.transcode_error,
)
def start_streaming_transcode(
media_file: MediaFile, *, start_seconds: float = 0.0
) -> LiveTranscodeSession:
"""Start a live transcoding session for the given media file."""
session = LiveTranscodeSession(media_file, start_seconds=start_seconds)
return session.start()

732
apps/media_library/utils.py Normal file
View file

@ -0,0 +1,732 @@
import json
import logging
import os
import subprocess
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timezone as dt_timezone
from importlib import resources as importlib_resources
from pathlib import Path
from typing import Dict, Iterable, Iterator, List, Optional
from django.db import transaction
from django.utils import timezone
from guessit import guessit
try:
from guessit.rules.properties import website as guessit_website
except Exception: # noqa: BLE001
guessit_website = None
else:
# Python 3.13's importlib.resources.files no longer returns a context manager;
# wrap it so guessit can still use `with files(...)`.
def _compatible_files(package: str): # type: ignore[override]
return importlib_resources.as_file(importlib_resources.files(package))
if guessit_website:
guessit_website.files = _compatible_files
from pymediainfo import MediaInfo
from apps.media_library.models import (
ClassificationResult,
Library,
LibraryLocation,
LibraryScan,
MediaFile,
MediaItem,
MEDIA_EXTENSIONS,
normalize_title,
)
logger = logging.getLogger(__name__)
def _json_safe(value):
"""Ensure value can be serialized via json.dumps."""
if isinstance(value, dict):
return {key: _json_safe(val) for key, val in value.items()}
if isinstance(value, (list, tuple, set)):
return [_json_safe(item) for item in value]
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return str(value)
def _first_numeric(value):
"""Extract the first integer-like value from guessit responses."""
if value is None:
return None
if isinstance(value, (list, tuple, set)):
for item in value:
normalized = _first_numeric(item)
if normalized is not None:
return normalized
return None
if isinstance(value, dict):
# Some guessit versions wrap values (e.g. {"season": 1})
for key in ("season", "episode", "number"):
if key in value:
normalized = _first_numeric(value[key])
if normalized is not None:
return normalized
return None
if isinstance(value, (int, float)):
return int(value)
try:
return int(value)
except (TypeError, ValueError):
return None
@dataclass
class DiscoveredFile:
file_id: int
requires_probe: bool
class LibraryScanner:
"""Performs discovery of files for a library scan."""
def __init__(
self,
library: Library,
scan: LibraryScan,
force_full: bool = False,
rescan_item_id: Optional[int] = None,
) -> None:
self.library = library
self.scan = scan
self.force_full = force_full or rescan_item_id is not None
self.rescan_item_id = rescan_item_id
self.now = timezone.now()
self.log_messages: list[str] = []
self.stats: Dict[str, int] = defaultdict(int)
self._seen_paths: set[str] = set()
self.target_item = None
if rescan_item_id:
self.target_item = (
MediaItem.objects.filter(pk=rescan_item_id, library=library).first()
)
if not self.target_item:
self._log(
f"Target media item {rescan_item_id} not found in library {library.id}."
)
def discover_files(self) -> List[DiscoveredFile]:
"""Walk library locations, ensure MediaFile records exist, and return the IDs."""
return list(self.discover_files_iter())
def discover_files_iter(self) -> Iterator[DiscoveredFile]:
"""Yield discovered files one-at-a-time for incremental processing."""
discovered_count = 0
try:
if not self.library.locations.exists():
self._log(f"Library '{self.library.name}' has no configured locations.")
return
for location in self.library.locations.all():
path = Path(location.path).expanduser()
if not path.exists():
self._log(
f"Path '{path}' does not exist for library '{self.library.name}'."
)
continue
if not path.is_dir():
self._log(f"Path '{path}' is not a directory; skipping.")
continue
iterator = path.rglob("*") if location.include_subdirectories else path.iterdir()
for file_path in iterator:
if not file_path.is_file():
continue
if file_path.suffix.lower() not in MEDIA_EXTENSIONS:
continue
absolute_path = str(file_path)
self._seen_paths.add(absolute_path)
try:
record_data = self._ensure_file_record(location, file_path)
except Exception as exc: # noqa: BLE001
logger.exception("Failed to process file %s", absolute_path)
self._log(f"Failed to process '{absolute_path}': {exc}")
continue
if not record_data:
continue
file_record, requires_probe = record_data
if (
self.target_item
and file_record.media_item_id not in {None, self.target_item.id}
):
continue
discovered_count += 1
self.stats["total"] = discovered_count
yield DiscoveredFile(file_id=file_record.id, requires_probe=requires_probe)
finally:
self.stats["total"] = discovered_count
self.scan.total_files = discovered_count
self.scan.new_files = self.stats.get("new", 0)
self.scan.updated_files = self.stats.get("updated", 0)
self.scan.save(
update_fields=[
"total_files",
"new_files",
"updated_files",
"updated_at",
]
)
def mark_missing_files(self) -> int:
missing_qs = (
MediaFile.objects.filter(library=self.library)
.exclude(absolute_path__in=self._seen_paths)
.exclude(absolute_path="")
)
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"])
return count
def finalize(self, matched: int, unmatched: int, summary: str | None = None) -> None:
self.stats["matched"] += matched
self.stats["unmatched"] += unmatched
self.scan.matched_items = self.stats["matched"]
self.scan.unmatched_files = self.stats["unmatched"]
if summary:
self.scan.summary = summary
self.scan.log = "\n".join(self.log_messages)
self.scan.save(
update_fields=[
"matched_items",
"unmatched_files",
"summary",
"log",
"updated_at",
]
)
def _ensure_file_record(
self, location: LibraryLocation, file_path: Path
) -> Optional[tuple[MediaFile, bool]]:
relative_path = os.path.relpath(file_path, location.path)
stat = file_path.stat()
last_modified = datetime.fromtimestamp(stat.st_mtime, tz=dt_timezone.utc)
with transaction.atomic():
file_record, created = MediaFile.objects.select_for_update().get_or_create(
library=self.library,
absolute_path=str(file_path),
defaults={
"location": location,
"relative_path": relative_path,
"file_name": file_path.name,
"size_bytes": stat.st_size,
"last_modified_at": last_modified,
"last_seen_at": self.now,
},
)
requires_probe = False
checksum_reset = False
if created:
self.stats["new"] += 1
requires_probe = True
checksum_reset = True
else:
should_update = (
self.force_full
or file_record.last_modified_at is None
or last_modified > file_record.last_modified_at
)
if should_update:
file_record.size_bytes = stat.st_size
file_record.last_modified_at = last_modified
self.stats["updated"] += 1
requires_probe = True
checksum_reset = True
file_record.last_seen_at = self.now
file_record.location = location
file_record.relative_path = relative_path
file_record.file_name = file_path.name
file_record.missing_since = None
update_fields = [
"location",
"relative_path",
"file_name",
"size_bytes",
"last_modified_at",
"last_seen_at",
"missing_since",
"updated_at",
]
if checksum_reset and file_record.checksum:
file_record.checksum = ""
update_fields.append("checksum")
file_record.save(update_fields=update_fields)
# Always probe on forced scans or if checksum missing
if self.force_full or not file_record.checksum:
requires_probe = True
return file_record, requires_probe
def _log(self, message: str) -> None:
logger.debug("[Library %s] %s", self.library.id, message)
self.log_messages.append(message)
def classify_media_file(file_name: str) -> ClassificationResult:
base_name = Path(file_name).stem
try:
data = guessit(file_name)
except Exception as exc: # noqa: BLE001
logger.debug("guessit failed for %s: %s", file_name, exc)
return ClassificationResult(
detected_type=MediaItem.TYPE_OTHER,
title=base_name,
data={"error": str(exc)},
)
data = _json_safe(data)
guess_type = data.get("type")
detected_type = MediaItem.TYPE_OTHER
if guess_type == "movie":
detected_type = MediaItem.TYPE_MOVIE
elif guess_type == "episode":
detected_type = MediaItem.TYPE_EPISODE
elif guess_type in {"show", "series", "tv"}:
detected_type = MediaItem.TYPE_SHOW
elif guess_type == "season":
detected_type = MediaItem.TYPE_SEASON
title = data.get("title") or base_name
classification = ClassificationResult(
detected_type=detected_type,
title=title,
year=_first_numeric(data.get("year")),
season=_first_numeric(data.get("season")),
episode=_first_numeric(data.get("episode")),
episode_title=data.get("episode_title"),
data=data,
)
if detected_type == MediaItem.TYPE_EPISODE:
classification.data = dict(data)
classification.data["series_title"] = data.get("series") or data.get("title") or base_name
return classification
def resolve_media_item(
library: Library,
classification: ClassificationResult,
target_item: Optional[MediaItem] = None,
) -> Optional[MediaItem]:
if target_item:
return target_item
title = classification.title or "Unknown"
normalized = normalize_title(title)
if classification.detected_type == MediaItem.TYPE_MOVIE:
queryset = MediaItem.objects.filter(
library=library,
item_type=MediaItem.TYPE_MOVIE,
normalized_title=normalized,
)
match = None
if classification.year:
match = queryset.filter(release_year=classification.year).first()
if not match:
match = queryset.first()
if match:
if classification.year and match.release_year != classification.year:
match.release_year = classification.year
match.save(update_fields=["release_year", "updated_at"])
return match
metadata_payload = _json_safe(classification.data or {})
return MediaItem.objects.create(
library=library,
item_type=MediaItem.TYPE_MOVIE,
status=MediaItem.STATUS_PENDING,
title=title,
sort_title=title,
normalized_title=normalized,
release_year=classification.year,
metadata=metadata_payload,
)
if classification.detected_type == MediaItem.TYPE_SHOW:
metadata_payload = _json_safe(classification.data or {})
match = MediaItem.objects.filter(
library=library,
item_type=MediaItem.TYPE_SHOW,
normalized_title=normalized,
).first()
if match:
return match
return MediaItem.objects.create(
library=library,
item_type=MediaItem.TYPE_SHOW,
status=MediaItem.STATUS_PENDING,
title=title,
sort_title=title,
normalized_title=normalized,
metadata=metadata_payload,
)
if classification.detected_type == MediaItem.TYPE_EPISODE:
series_title = classification.data.get("series_title") if classification.data else title
if not series_title:
series_title = title
series_normalized = normalize_title(series_title)
series_item = (
MediaItem.objects.filter(
library=library,
item_type=MediaItem.TYPE_SHOW,
normalized_title=series_normalized,
).first()
)
if not series_item:
series_metadata = _json_safe(classification.data or {})
series_item = MediaItem.objects.create(
library=library,
item_type=MediaItem.TYPE_SHOW,
status=MediaItem.STATUS_PENDING,
title=series_title,
sort_title=series_title,
normalized_title=series_normalized,
metadata=series_metadata,
)
episode_item = (
MediaItem.objects.filter(
library=library,
item_type=MediaItem.TYPE_EPISODE,
parent=series_item,
season_number=classification.season,
episode_number=classification.episode,
).first()
)
if episode_item:
if classification.episode_title and not episode_item.title:
episode_item.title = classification.episode_title
episode_item.save(update_fields=["title", "updated_at"])
return episode_item
title_to_use = (
classification.episode_title
or (
f"S{classification.season:02d}E{classification.episode:02d}"
if classification.season and classification.episode
else title
)
)
metadata_payload = _json_safe(classification.data or {})
return MediaItem.objects.create(
library=library,
parent=series_item,
item_type=MediaItem.TYPE_EPISODE,
status=MediaItem.STATUS_PENDING,
title=title_to_use,
sort_title=title_to_use,
normalized_title=normalize_title(title_to_use),
release_year=classification.year,
season_number=classification.season,
episode_number=classification.episode,
metadata=metadata_payload,
)
metadata_payload = _json_safe(classification.data or {})
match = MediaItem.objects.filter(
library=library,
item_type=MediaItem.TYPE_OTHER,
normalized_title=normalized,
).first()
if match:
return match
return MediaItem.objects.create(
library=library,
item_type=MediaItem.TYPE_OTHER,
status=MediaItem.STATUS_PENDING,
title=title,
sort_title=title,
normalized_title=normalized,
metadata=metadata_payload,
)
def probe_media_file(path: str) -> dict:
command = [
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration,bit_rate,format_name",
"-show_streams",
"-of",
"json",
path,
]
try:
process = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
text=True,
)
except FileNotFoundError:
logger.debug("ffprobe not available; falling back to pymediainfo for %s", path)
return _probe_with_mediainfo(path)
except subprocess.CalledProcessError as exc: # noqa: BLE001
logger.warning("ffprobe failed for %s: %s", path, exc.stderr)
fallback = _probe_with_mediainfo(path)
if fallback:
return fallback
return {}
try:
return json.loads(process.stdout)
except json.JSONDecodeError:
logger.warning("Failed to parse ffprobe output for %s", path)
return {}
def _probe_with_mediainfo(path: str) -> dict:
try:
media_info = MediaInfo.parse(path)
except Exception: # noqa: BLE001
return {}
format_info: dict = {}
streams: list[dict] = []
for track in media_info.tracks:
if track.track_type == "General":
format_info = {
"duration": track.duration / 1000 if track.duration else None,
"bit_rate": track.overall_bit_rate,
"format_name": track.format,
}
elif track.track_type == "Video":
streams.append(
{
"codec_type": "video",
"codec_name": track.format,
"width": track.width,
"height": track.height,
"avg_frame_rate": track.frame_rate,
}
)
elif track.track_type == "Audio":
streams.append(
{
"codec_type": "audio",
"codec_name": track.format,
"channels": track.channel_s,
}
)
elif track.track_type == "Text":
streams.append(
{
"codec_type": "subtitle",
"codec_name": track.format,
"tags": {"language": track.language},
}
)
return {"format": format_info, "streams": streams}
def apply_probe_metadata(file_record: MediaFile, probe_data: dict) -> None:
if not probe_data:
return
format_info = probe_data.get("format", {})
streams = probe_data.get("streams", [])
duration_ms: int | None = None
duration = format_info.get("duration")
if duration not in (None, "", 0):
try:
duration_ms = int(float(duration) * 1000)
file_record.duration_ms = duration_ms
except (TypeError, ValueError): # noqa: PERF203
logger.debug("Unable to parse duration '%s' for %s", duration, file_record)
if duration_ms is None:
raw_duration_ms = format_info.get("duration_ms")
if raw_duration_ms not in (None, "", 0):
try:
duration_ms = int(float(raw_duration_ms))
file_record.duration_ms = duration_ms
except (TypeError, ValueError): # noqa: PERF203
logger.debug(
"Unable to parse duration_ms '%s' for %s", raw_duration_ms, file_record
)
bit_rate = format_info.get("bit_rate")
try:
if bit_rate:
file_record.bit_rate = int(bit_rate)
except (TypeError, ValueError): # noqa: PERF203
pass
if format_info.get("format_name"):
file_record.container = format_info["format_name"].split(",")[0]
video_stream = next((s for s in streams if s.get("codec_type") == "video"), None)
audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), None)
subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"]
if video_stream:
file_record.video_codec = video_stream.get("codec_name", "")
file_record.width = video_stream.get("width")
file_record.height = video_stream.get("height")
file_record.frame_rate = _safe_frame_rate(video_stream)
if audio_stream:
file_record.audio_codec = audio_stream.get("codec_name", "")
channels = audio_stream.get("channels")
if channels is not None:
try:
file_record.audio_channels = float(channels)
except (TypeError, ValueError): # noqa: PERF203
file_record.audio_channels = None
file_record.has_subtitles = bool(subtitle_streams)
if subtitle_streams:
file_record.subtitle_languages = [
stream.get("tags", {}).get("language") for stream in subtitle_streams
]
file_record.extra_streams = {
"format": format_info,
"streams": streams,
}
needs_transcode = not file_record.is_browser_playable()
file_record.requires_transcode = needs_transcode
update_fields = [
"duration_ms",
"bit_rate",
"container",
"video_codec",
"width",
"height",
"frame_rate",
"audio_codec",
"audio_channels",
"has_subtitles",
"subtitle_languages",
"extra_streams",
"requires_transcode",
"updated_at",
]
if not needs_transcode:
file_record.transcode_status = MediaFile.TRANSCODE_STATUS_NOT_REQUIRED
file_record.transcoded_path = ""
file_record.transcoded_mime_type = ""
file_record.transcode_error = ""
file_record.transcoded_at = None
update_fields.extend(
[
"transcode_status",
"transcoded_path",
"transcoded_mime_type",
"transcode_error",
"transcoded_at",
]
)
else:
if file_record.transcode_status in (
MediaFile.TRANSCODE_STATUS_NOT_REQUIRED,
"",
):
file_record.transcode_status = MediaFile.TRANSCODE_STATUS_PENDING
update_fields.append("transcode_status")
if (
file_record.transcode_status == MediaFile.TRANSCODE_STATUS_READY
and file_record.transcoded_path
and not os.path.exists(file_record.transcoded_path)
):
file_record.transcode_status = MediaFile.TRANSCODE_STATUS_PENDING
file_record.transcoded_path = ""
file_record.transcoded_mime_type = ""
file_record.transcoded_at = None
update_fields.extend(
[
"transcode_status",
"transcoded_path",
"transcoded_mime_type",
"transcoded_at",
]
)
file_record.save(update_fields=update_fields)
if file_record.media_item_id:
candidate_duration_ms = duration_ms or file_record.duration_ms
try:
media_item = file_record.media_item
except MediaFile.media_item.RelatedObjectDoesNotExist: # type: ignore[attr-defined]
media_item = None
if not candidate_duration_ms:
extra = file_record.extra_streams or {}
format_info = extra.get("format") or {}
fallback_candidates: list[tuple[object, float]] = []
if "duration_ms" in format_info:
fallback_candidates.append((format_info.get("duration_ms"), 1.0))
if "duration" in format_info:
fallback_candidates.append((format_info.get("duration"), 1000.0))
for value, multiplier in fallback_candidates:
if value in (None, "", 0):
continue
try:
numeric = float(value)
except (TypeError, ValueError): # noqa: PERF203
continue
if numeric <= 0:
continue
candidate_duration_ms = int(numeric * multiplier)
break
if candidate_duration_ms and media_item and (
not media_item.runtime_ms or media_item.runtime_ms < candidate_duration_ms
):
media_item.runtime_ms = int(candidate_duration_ms)
media_item.save(update_fields=["runtime_ms", "updated_at"])
def _safe_frame_rate(stream: dict) -> Optional[float]:
value = stream.get("avg_frame_rate") or stream.get("r_frame_rate")
if not value or value == "0/0":
return None
try:
if "/" in value:
numerator, denominator = value.split("/", 1)
numerator = float(numerator)
denominator = float(denominator)
if denominator == 0:
return None
return round(numerator / denominator, 3)
return float(value)
except (ValueError, ZeroDivisionError): # noqa: PERF203
return None

210
apps/media_library/views.py Normal file
View file

@ -0,0 +1,210 @@
import logging
import mimetypes
import os
import re
from django.conf import settings
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.http import (
FileResponse,
Http404,
HttpResponse,
HttpResponseForbidden,
StreamingHttpResponse,
)
from django.views.decorators.http import require_GET
from apps.media_library.models import MediaFile
from apps.media_library.transcode import start_streaming_transcode
logger = logging.getLogger(__name__)
STREAM_SIGNER = TimestampSigner(salt="media-library-stream")
TOKEN_TTL = getattr(settings, "MEDIA_LIBRARY_STREAM_TOKEN_TTL", 3600)
mimetypes.add_type("video/x-matroska", ".mkv", strict=False)
def _iter_file(file_obj, offset=0, length=None, chunk_size=8192):
file_obj.seek(offset)
remaining = length
while True:
if remaining is not None and remaining <= 0:
break
read_size = chunk_size if remaining is None else min(chunk_size, remaining)
data = file_obj.read(read_size)
if not data:
break
if remaining is not None:
remaining -= len(data)
yield data
def _guess_mime(path: str | None) -> str:
if not path:
return "application/octet-stream"
mime, _ = mimetypes.guess_type(path)
return mime or "application/octet-stream"
@require_GET
def stream_media_file(request, token: str):
try:
payload = STREAM_SIGNER.unsign_object(token, max_age=TOKEN_TTL)
except SignatureExpired:
return HttpResponseForbidden("Stream link expired")
except BadSignature:
raise Http404("Invalid stream token")
file_id = payload.get("file_id")
user_id = payload.get("user_id")
if request.user.is_authenticated and request.user.id != user_id:
return HttpResponseForbidden("Stream token not issued for this user")
start_ms = payload.get("start_ms", 0)
try:
start_ms = int(start_ms)
except (TypeError, ValueError):
start_ms = 0
start_seconds = max(0.0, start_ms / 1000.0)
try:
media_file = MediaFile.objects.get(pk=file_id)
except MediaFile.DoesNotExist:
raise Http404("Media file not found")
duration_seconds: float | None = None
duration_ms = media_file.effective_duration_ms
if duration_ms:
try:
duration_seconds = float(duration_ms) / 1000.0
except (TypeError, ValueError):
duration_seconds = None
original_path = media_file.absolute_path or ""
cached_path = media_file.transcoded_path or ""
playback_path = ""
mime_type = "application/octet-stream"
download_name = media_file.file_name or f"{media_file.id}.mp4"
if cached_path and os.path.exists(cached_path):
playback_path = cached_path
mime_type = media_file.transcoded_mime_type or "video/mp4"
base_name, _ = os.path.splitext(media_file.file_name or "")
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.
media_file.transcode_status = MediaFile.TRANSCODE_STATUS_PENDING
media_file.transcoded_path = ""
media_file.transcoded_mime_type = ""
media_file.transcoded_at = None
media_file.save(
update_fields=[
"transcode_status",
"transcoded_path",
"transcoded_mime_type",
"transcoded_at",
"updated_at",
]
)
if original_path and os.path.exists(original_path) and media_file.is_browser_playable():
playback_path = original_path
mime_type = _guess_mime(original_path)
download_name = media_file.file_name or os.path.basename(original_path)
elif original_path and os.path.exists(original_path):
# Start live transcode and stream output directly.
try:
session = start_streaming_transcode(
media_file,
start_seconds=start_seconds,
)
except FileNotFoundError:
raise Http404("Media file not found")
except Exception as exc: # noqa: BLE001
logger.error(
"Unable to start live transcode for media file %s: %s",
media_file.id,
exc,
exc_info=True,
)
return HttpResponse(
"Unable to prepare video for playback. Please try again later.",
status=500,
)
base_name, _ = os.path.splitext(media_file.file_name or "")
download_name = f"{base_name or media_file.id}.mp4"
response = StreamingHttpResponse(
session.stream(),
content_type=session.mime_type,
)
response["Content-Disposition"] = f'inline; filename="{download_name}"'
response["Cache-Control"] = "no-store"
response["Accept-Ranges"] = "none"
if duration_seconds:
formatted_duration = f"{duration_seconds:.3f}"
response["X-Content-Duration"] = formatted_duration
response["Content-Duration"] = formatted_duration
return response
else:
raise Http404("Media file not found")
if not playback_path or not os.path.exists(playback_path):
raise Http404("Media file not found")
file_size = os.path.getsize(playback_path)
mime_type = mime_type or _guess_mime(playback_path)
range_header = request.headers.get("Range")
if range_header:
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
if range_match:
start = int(range_match.group(1))
end_raw = range_match.group(2)
end = int(end_raw) if end_raw else file_size - 1
if start >= file_size:
response = HttpResponse(status=416)
response["Content-Range"] = f"bytes */{file_size}"
return response
end = min(end, file_size - 1)
length = end - start + 1
file_handle = open(playback_path, "rb")
def closing_iterator():
try:
yield from _iter_file(file_handle, offset=start, length=length)
finally:
file_handle.close()
response = StreamingHttpResponse(
closing_iterator(), status=206, content_type=mime_type
)
response["Content-Length"] = str(length)
response["Content-Range"] = f"bytes {start}-{end}/{file_size}"
response["Accept-Ranges"] = "bytes"
response["Content-Disposition"] = (
f'inline; filename="{download_name}"'
)
if duration_seconds:
formatted_duration = f"{duration_seconds:.3f}"
response["X-Content-Duration"] = formatted_duration
response["Content-Duration"] = formatted_duration
return response
response = FileResponse(open(playback_path, "rb"), content_type=mime_type)
response["Accept-Ranges"] = "bytes"
response["Content-Length"] = str(file_size)
response["Content-Disposition"] = (
f'inline; filename="{download_name}"'
)
if duration_seconds:
formatted_duration = f"{duration_seconds:.3f}"
response["X-Content-Duration"] = formatted_duration
response["Content-Duration"] = formatted_duration
return response

View file

@ -0,0 +1,657 @@
"""
Helpers for synchronizing local media library items with the VOD catalog.
"""
from __future__ import annotations
import mimetypes
import os
from collections.abc import Iterable
from typing import Any
from urllib.parse import urlparse
import requests
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.utils import timezone
from django.utils.text import slugify
from apps.channels.models import Logo
from apps.media_library.models import ArtworkAsset, Library, MediaFile, MediaItem
from apps.vod.models import Episode, Movie, Series
def _select_primary_file(media_item: MediaItem) -> MediaFile | None:
"""Pick the best representative file for metadata (prefer longest duration)."""
return (
media_item.files.order_by("-duration_ms", "-size_bytes", "id").first()
if media_item.pk
else None
)
def _duration_seconds(media_item: MediaItem) -> int | None:
if media_item.runtime_ms:
return int(media_item.runtime_ms / 1000)
file_record = _select_primary_file(media_item)
if file_record and file_record.duration_ms:
return int(file_record.duration_ms / 1000)
return None
def _collect_quality_info(media_item: MediaItem) -> dict[str, Any] | None:
file_record = _select_primary_file(media_item)
if not file_record:
return None
payload: dict[str, Any] = {}
bit_rate_value = getattr(file_record, "bit_rate", None)
if bit_rate_value:
payload["bitrate"] = bit_rate_value
video_payload: dict[str, Any] = {}
if file_record.width and file_record.height:
video_payload["width"] = file_record.width
video_payload["height"] = file_record.height
if file_record.video_codec:
video_payload["codec"] = file_record.video_codec
if file_record.frame_rate:
video_payload["frame_rate"] = file_record.frame_rate
if video_payload:
payload["video"] = video_payload
audio_payload: dict[str, Any] = {}
if file_record.audio_codec:
audio_payload["codec"] = file_record.audio_codec
if file_record.audio_channels:
audio_payload["channels"] = file_record.audio_channels
if audio_payload:
payload["audio"] = audio_payload
if file_record.container:
payload["container"] = file_record.container
if not payload:
return None
return payload
def _infer_image_extension(content_type: str | None, source_url: str | None) -> str:
"""Return a sensible file extension for downloaded artwork."""
if content_type:
guess = mimetypes.guess_extension(content_type.split(";")[0].strip())
if guess:
return guess
if source_url:
path = urlparse(source_url).path
_, ext = os.path.splitext(path)
if ext:
return ext
return ".jpg"
def _build_media_url(stored_path: str) -> str:
media_url = getattr(settings, "MEDIA_URL", "/media/") or "/media/"
return f"{media_url.rstrip('/')}/{stored_path.lstrip('/')}"
def _download_poster(media_item: MediaItem, source_url: str) -> tuple[str, str] | None:
"""Persist poster art to Django storage, returning (path, media_url)."""
content: bytes | None = None
content_type: str | None = None
if source_url.lower().startswith(("http://", "https://")):
try:
response = requests.get(source_url, timeout=10)
response.raise_for_status()
except Exception:
return None
content = response.content
content_type = response.headers.get("Content-Type")
else:
candidate_path = source_url
media_url = getattr(settings, "MEDIA_URL", "/media/") or "/media/"
media_root = getattr(settings, "MEDIA_ROOT", "")
if candidate_path.startswith(media_url) and media_root:
candidate_path = os.path.join(media_root, candidate_path[len(media_url.rstrip("/")) + 1 :])
if os.path.isabs(candidate_path) and os.path.exists(candidate_path):
try:
with open(candidate_path, "rb") as handle:
content = handle.read()
content_type = mimetypes.guess_type(candidate_path)[0]
except Exception:
return None
else:
return None
if not content:
return None
extension = _infer_image_extension(content_type, source_url)
slug = slugify(media_item.title or "library-item") or str(media_item.id)
relative_path = f"vod/posters/{media_item.id}-{slug}{extension}"
if default_storage.exists(relative_path):
try:
default_storage.delete(relative_path)
except Exception:
pass
stored_path = default_storage.save(relative_path, ContentFile(content))
return stored_path, _build_media_url(stored_path)
def _ensure_logo_for_media_item(media_item: MediaItem) -> tuple[Logo | None, dict[str, Any] | None, str | None]:
metadata = dict(media_item.metadata or {})
poster_source = media_item.poster_url or None
if not poster_source:
poster_asset = media_item.artwork.filter(asset_type=ArtworkAsset.TYPE_POSTER).first()
if poster_asset:
poster_source = poster_asset.local_path or poster_asset.external_url
if not poster_source:
return None, None, None
stored_path = metadata.get("vod_poster_path")
stored_url = metadata.get("vod_poster_url")
stored_source = metadata.get("vod_poster_source_url")
needs_refresh = (
not stored_path
or not default_storage.exists(stored_path or "")
or stored_source != poster_source
)
if needs_refresh:
download_result = _download_poster(media_item, poster_source)
if not download_result:
return None, None, None
stored_path, stored_url = download_result
stored_source = poster_source
if not stored_path or not stored_url:
return None, None, None
metadata_updates = metadata.copy()
metadata_updates.update(
{
"vod_poster_path": stored_path,
"vod_poster_url": stored_url,
"vod_poster_source_url": stored_source,
}
)
logo_name = (media_item.title or "Library Asset")[:255]
logo, _ = Logo.objects.get_or_create(url=stored_url, defaults={"name": logo_name})
return logo, metadata_updates, poster_source
def _clean_local_poster(media_item: MediaItem) -> None:
metadata = dict(media_item.metadata or {})
stored_path = metadata.pop("vod_poster_path", None)
metadata.pop("vod_poster_url", None)
metadata.pop("vod_poster_source_url", None)
if stored_path and default_storage.exists(stored_path):
try:
default_storage.delete(stored_path)
except Exception:
pass
media_item.metadata = metadata
media_item.save(update_fields=["metadata", "updated_at"])
def _merge_custom_properties(existing: dict[str, Any] | None, updates: dict[str, Any]) -> dict[str, Any]:
data = dict(existing or {})
data.update({key: value for key, value in updates.items() if value is not None})
return data
def _update_movie_from_media_item(movie: Movie, media_item: MediaItem) -> Movie:
fields_to_update: list[str] = []
def set_field(field: str, value: Any):
nonlocal fields_to_update
if getattr(movie, field) != value and value is not None:
setattr(movie, field, value)
fields_to_update.append(field)
set_field("name", media_item.title or movie.name)
set_field("description", media_item.synopsis or movie.description)
set_field("year", media_item.release_year or movie.year)
set_field("rating", media_item.rating or movie.rating)
genres = ", ".join(media_item.genres) if isinstance(media_item.genres, Iterable) else None
set_field("genre", genres or movie.genre)
duration = _duration_seconds(media_item)
set_field("duration_secs", duration or movie.duration_secs)
if media_item.tmdb_id and movie.tmdb_id != media_item.tmdb_id:
movie.tmdb_id = media_item.tmdb_id
fields_to_update.append("tmdb_id")
if media_item.imdb_id and movie.imdb_id != media_item.imdb_id:
movie.imdb_id = media_item.imdb_id
fields_to_update.append("imdb_id")
quality_info = _collect_quality_info(media_item) or {}
logo, metadata_updates, poster_source = _ensure_logo_for_media_item(media_item)
if metadata_updates is not None:
current_metadata = media_item.metadata or {}
if metadata_updates != current_metadata:
media_item.metadata = metadata_updates
media_item.save(update_fields=["metadata", "updated_at"])
poster_media_url = (
metadata_updates.get("vod_poster_url") if metadata_updates else None
)
poster_source_url = (
metadata_updates.get("vod_poster_source_url") if metadata_updates else poster_source
)
backdrop_entries: list[str] = []
if media_item.backdrop_url:
backdrop_entries.append(media_item.backdrop_url)
custom_updates = {
"source": "library",
"library_id": media_item.library_id,
"library_item_id": media_item.id,
"poster_url": poster_media_url or poster_source_url or media_item.poster_url,
"backdrop_url": media_item.backdrop_url,
"backdrop_path": backdrop_entries,
"quality": quality_info,
}
merged_custom = _merge_custom_properties(movie.custom_properties, custom_updates)
if merged_custom != movie.custom_properties:
movie.custom_properties = merged_custom
fields_to_update.append("custom_properties")
if logo and (movie.logo_id != logo.id):
movie.logo = logo
fields_to_update.append("logo")
if fields_to_update:
movie.save(update_fields=fields_to_update + ["updated_at"])
return movie
def _update_series_from_media_item(series: Series, media_item: MediaItem) -> Series:
fields_to_update: list[str] = []
def set_field(field: str, value: Any):
nonlocal fields_to_update
if getattr(series, field) != value and value is not None:
setattr(series, field, value)
fields_to_update.append(field)
set_field("name", media_item.title or series.name)
set_field("description", media_item.synopsis or series.description)
set_field("year", media_item.release_year or series.year)
set_field("rating", media_item.rating or series.rating)
genres = ", ".join(media_item.genres) if isinstance(media_item.genres, Iterable) else None
set_field("genre", genres or series.genre)
if media_item.tmdb_id and series.tmdb_id != media_item.tmdb_id:
series.tmdb_id = media_item.tmdb_id
fields_to_update.append("tmdb_id")
if media_item.imdb_id and series.imdb_id != media_item.imdb_id:
series.imdb_id = media_item.imdb_id
fields_to_update.append("imdb_id")
logo, metadata_updates, poster_source = _ensure_logo_for_media_item(media_item)
if metadata_updates is not None:
current_metadata = media_item.metadata or {}
if metadata_updates != current_metadata:
media_item.metadata = metadata_updates
media_item.save(update_fields=["metadata", "updated_at"])
poster_media_url = (
metadata_updates.get("vod_poster_url") if metadata_updates else None
)
poster_source_url = (
metadata_updates.get("vod_poster_source_url") if metadata_updates else poster_source
)
backdrop_entries: list[str] = []
if media_item.backdrop_url:
backdrop_entries.append(media_item.backdrop_url)
custom_updates = {
"source": "library",
"library_id": media_item.library_id,
"library_item_id": media_item.id,
"poster_url": poster_media_url or poster_source_url or media_item.poster_url,
"backdrop_url": media_item.backdrop_url,
"backdrop_path": backdrop_entries,
}
merged_custom = _merge_custom_properties(series.custom_properties, custom_updates)
if merged_custom != series.custom_properties:
series.custom_properties = merged_custom
fields_to_update.append("custom_properties")
if logo and (series.logo_id != logo.id):
series.logo = logo
fields_to_update.append("logo")
if fields_to_update:
series.save(update_fields=fields_to_update + ["updated_at"])
return series
def _update_episode_from_media_item(episode: Episode, media_item: MediaItem, series: Series) -> Episode:
fields_to_update: list[str] = []
def set_field(field: str, value: Any):
nonlocal fields_to_update
if getattr(episode, field) != value and value is not None:
setattr(episode, field, value)
fields_to_update.append(field)
set_field("name", media_item.title or episode.name)
set_field("description", media_item.synopsis or episode.description)
set_field("season_number", media_item.season_number or episode.season_number)
set_field("episode_number", media_item.episode_number or episode.episode_number)
duration = _duration_seconds(media_item)
set_field("duration_secs", duration or episode.duration_secs)
if media_item.tmdb_id and episode.tmdb_id != media_item.tmdb_id:
episode.tmdb_id = media_item.tmdb_id
fields_to_update.append("tmdb_id")
if media_item.imdb_id and episode.imdb_id != media_item.imdb_id:
episode.imdb_id = media_item.imdb_id
fields_to_update.append("imdb_id")
quality_info = _collect_quality_info(media_item)
custom_updates = {
"source": "library",
"library_id": media_item.library_id,
"library_item_id": media_item.id,
"quality": quality_info,
}
merged_custom = _merge_custom_properties(episode.custom_properties, custom_updates)
if merged_custom != episode.custom_properties:
episode.custom_properties = merged_custom
fields_to_update.append("custom_properties")
if episode.series_id != series.id:
episode.series = series
fields_to_update.append("series")
if fields_to_update:
episode.save(update_fields=fields_to_update + ["updated_at"])
return episode
@transaction.atomic
def sync_media_item_to_vod(media_item: MediaItem) -> None:
"""
Ensure the provided media item is represented in the VOD catalog if its library
is configured as a VOD source. When disabled, remove any previously linked VOD entries.
"""
if not media_item.pk:
return
library: Library = media_item.library
if not library.use_as_vod_source:
remove_media_item_from_vod(media_item)
return
now = timezone.now()
if media_item.item_type == MediaItem.TYPE_MOVIE:
movie = None
if media_item.vod_movie_id:
movie = Movie.objects.filter(pk=media_item.vod_movie_id).first()
movie_defaults = {
"name": media_item.title or "Untitled Movie",
"description": media_item.synopsis or "",
"year": media_item.release_year,
"rating": media_item.rating or "",
"genre": ", ".join(media_item.genres) if isinstance(media_item.genres, Iterable) else "",
"duration_secs": _duration_seconds(media_item),
"tmdb_id": media_item.tmdb_id,
"imdb_id": media_item.imdb_id,
"custom_properties": {
"source": "library",
"library_id": media_item.library_id,
"library_item_id": media_item.id,
},
}
if not movie and media_item.tmdb_id:
movie, _created = Movie.objects.get_or_create(
tmdb_id=media_item.tmdb_id,
defaults=movie_defaults,
)
if not movie and media_item.imdb_id:
movie, created_with_imdb = Movie.objects.get_or_create(
imdb_id=media_item.imdb_id,
defaults=movie_defaults,
)
if created_with_imdb and media_item.tmdb_id and not movie.tmdb_id:
movie.tmdb_id = media_item.tmdb_id
movie.save(update_fields=["tmdb_id", "updated_at"])
if not movie:
fallback_filters = Q(tmdb_id__isnull=True) & Q(imdb_id__isnull=True)
title = media_item.title or "Untitled Movie"
fallback_filters &= Q(name=title)
if media_item.release_year:
fallback_filters &= Q(year=media_item.release_year)
fallback_candidate = Movie.objects.filter(fallback_filters).first()
if fallback_candidate:
movie = fallback_candidate
if not movie:
savepoint = transaction.savepoint()
try:
movie = Movie.objects.create(**movie_defaults)
transaction.savepoint_commit(savepoint)
except IntegrityError:
transaction.savepoint_rollback(savepoint)
movie = Movie.objects.filter(tmdb_id=media_item.tmdb_id).first()
if not movie and media_item.imdb_id:
movie = Movie.objects.filter(imdb_id=media_item.imdb_id).first()
if not movie:
raise
if movie and media_item.tmdb_id:
conflict_movie = Movie.objects.filter(tmdb_id=media_item.tmdb_id).exclude(pk=movie.pk).first()
if conflict_movie:
movie = conflict_movie
movie = _update_movie_from_media_item(movie, media_item)
if media_item.vod_movie_id != movie.id:
media_item.vod_movie = movie
media_item.save(update_fields=["vod_movie", "updated_at"])
movie.custom_properties["synced_at"] = now.isoformat()
movie.save(update_fields=["custom_properties", "updated_at"])
elif media_item.item_type == MediaItem.TYPE_SHOW:
series = None
if media_item.vod_series_id:
series = Series.objects.filter(pk=media_item.vod_series_id).first()
series_defaults = {
"name": media_item.title or "Untitled Series",
"description": media_item.synopsis or "",
"year": media_item.release_year,
"rating": media_item.rating or "",
"genre": ", ".join(media_item.genres) if isinstance(media_item.genres, Iterable) else "",
"tmdb_id": media_item.tmdb_id,
"imdb_id": media_item.imdb_id,
"custom_properties": {
"source": "library",
"library_id": media_item.library_id,
"library_item_id": media_item.id,
},
}
if not series and media_item.tmdb_id:
series, _created = Series.objects.get_or_create(
tmdb_id=media_item.tmdb_id,
defaults=series_defaults,
)
if not series and media_item.imdb_id:
series, created_with_imdb = Series.objects.get_or_create(
imdb_id=media_item.imdb_id,
defaults=series_defaults,
)
if created_with_imdb and media_item.tmdb_id and not series.tmdb_id:
series.tmdb_id = media_item.tmdb_id
series.save(update_fields=["tmdb_id", "updated_at"])
if not series:
fallback_filters = Q(tmdb_id__isnull=True) & Q(imdb_id__isnull=True)
title = media_item.title or "Untitled Series"
fallback_filters &= Q(name=title)
if media_item.release_year:
fallback_filters &= Q(year=media_item.release_year)
fallback_candidate = Series.objects.filter(fallback_filters).first()
if fallback_candidate:
series = fallback_candidate
if not series:
savepoint = transaction.savepoint()
try:
series = Series.objects.create(**series_defaults)
transaction.savepoint_commit(savepoint)
except IntegrityError:
transaction.savepoint_rollback(savepoint)
series = Series.objects.filter(tmdb_id=media_item.tmdb_id).first()
if not series and media_item.imdb_id:
series = Series.objects.filter(imdb_id=media_item.imdb_id).first()
if not series:
raise
if series and media_item.tmdb_id:
conflict_series = Series.objects.filter(tmdb_id=media_item.tmdb_id).exclude(pk=series.pk).first()
if conflict_series:
series = conflict_series
series = _update_series_from_media_item(series, media_item)
if media_item.vod_series_id != series.id:
media_item.vod_series = series
media_item.save(update_fields=["vod_series", "updated_at"])
series.custom_properties["synced_at"] = now.isoformat()
series.save(update_fields=["custom_properties", "updated_at"])
elif media_item.item_type == MediaItem.TYPE_EPISODE:
parent = media_item.parent
if parent:
sync_media_item_to_vod(parent)
series = parent.vod_series
else:
series = None
if not series:
# Ensure there is a series placeholder to attach this episode.
series = Series.objects.create(
name=media_item.title or "Series",
description="Library-sourced series",
custom_properties={
"source": "library",
"library_id": media_item.library_id,
},
)
episode = None
if media_item.vod_episode_id:
episode = Episode.objects.filter(pk=media_item.vod_episode_id).first()
if not episode and media_item.tmdb_id:
episode = Episode.objects.filter(tmdb_id=media_item.tmdb_id).first()
if not episode and media_item.imdb_id:
episode = Episode.objects.filter(imdb_id=media_item.imdb_id).first()
if not episode:
episode_filters = Q(series=series)
if media_item.season_number is not None:
episode_filters &= Q(season_number=media_item.season_number)
if media_item.episode_number is not None:
episode_filters &= Q(episode_number=media_item.episode_number)
fallback_episode = Episode.objects.filter(episode_filters).first()
if fallback_episode:
episode = fallback_episode
if not episode:
episode = Episode.objects.create(
series=series,
name=media_item.title or "Episode",
description=media_item.synopsis or "",
season_number=media_item.season_number,
episode_number=media_item.episode_number,
duration_secs=_duration_seconds(media_item),
tmdb_id=media_item.tmdb_id,
imdb_id=media_item.imdb_id,
custom_properties={
"source": "library",
"library_id": media_item.library_id,
"library_item_id": media_item.id,
},
)
episode = _update_episode_from_media_item(episode, media_item, series)
if media_item.vod_episode_id != episode.id:
media_item.vod_episode = episode
media_item.save(update_fields=["vod_episode", "updated_at"])
episode.custom_properties["synced_at"] = now.isoformat()
episode.save(update_fields=["custom_properties", "updated_at"])
@transaction.atomic
def remove_media_item_from_vod(media_item: MediaItem) -> None:
"""Detach a media item from the VOD catalog and prune orphaned entries."""
if (media_item.metadata or {}).get("vod_poster_path"):
_clean_local_poster(media_item)
if media_item.vod_movie_id:
movie = Movie.objects.filter(pk=media_item.vod_movie_id).first()
media_item.vod_movie = None
media_item.save(update_fields=["vod_movie", "updated_at"])
if movie and not movie.m3u_relations.exists() and not movie.library_items.exists():
movie.delete()
if media_item.vod_episode_id:
episode = Episode.objects.filter(pk=media_item.vod_episode_id).first()
series = episode.series if episode else None
media_item.vod_episode = None
media_item.save(update_fields=["vod_episode", "updated_at"])
if episode and not episode.m3u_relations.exists() and not episode.library_items.exists():
episode.delete()
if series and not series.m3u_relations.exists() and not series.library_items.exists():
# Only remove episode-less series that were sourced from library content.
if (series.custom_properties or {}).get("source") == "library":
series.delete()
if media_item.vod_series_id and media_item.item_type == MediaItem.TYPE_SHOW:
series = Series.objects.filter(pk=media_item.vod_series_id).first()
media_item.vod_series = None
media_item.save(update_fields=["vod_series", "updated_at"])
if series and not series.m3u_relations.exists() and not series.library_items.exists():
if (series.custom_properties or {}).get("source") == "library":
series.delete()
def sync_library_to_vod(library: Library) -> None:
"""Synchronize all media items in a library with the VOD catalog."""
for media_item in library.items.select_related("parent").all():
sync_media_item_to_vod(media_item)
def unsync_library_from_vod(library: Library) -> None:
"""Remove all VOD links for items in a library."""
for media_item in library.items.all():
remove_media_item_from_vod(media_item)
# Remove any orphaned VOD entries that were created from this library.
Movie.objects.filter(
Q(custom_properties__source="library"),
Q(custom_properties__library_id=library.id),
library_items__isnull=True,
m3u_relations__isnull=True,
).delete()
Series.objects.filter(
Q(custom_properties__source="library"),
Q(custom_properties__library_id=library.id),
library_items__isnull=True,
m3u_relations__isnull=True,
).delete()
Episode.objects.filter(
Q(custom_properties__source="library"),
Q(custom_properties__library_id=library.id),
library_items__isnull=True,
m3u_relations__isnull=True,
).delete()

View file

@ -1,3 +1,4 @@
import json
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.decorators import action
@ -5,7 +6,9 @@ from rest_framework.filters import SearchFilter, OrderingFilter
from rest_framework.pagination import PageNumberPagination
from django_filters.rest_framework import DjangoFilterBackend
from django.shortcuts import get_object_or_404
from django.urls import reverse
import django_filters
from django.db.models import Q
import logging
from apps.accounts.permissions import (
Authenticated,
@ -31,6 +34,15 @@ from datetime import timedelta
logger = logging.getLogger(__name__)
def build_logo_cache_url(request, logo_id):
if not logo_id:
return None
cache_path = reverse("api:channels:logo-cache", args=[logo_id])
if request:
return request.build_absolute_uri(cache_path)
return cache_path
class VODPagination(PageNumberPagination):
page_size = 20 # Default page size to match frontend default
page_size_query_param = "page_size" # Allow clients to specify page size
@ -86,9 +98,15 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
# Only return movies that have active M3U relations
return Movie.objects.filter(
m3u_relations__m3u_account__is_active=True
).distinct().select_related('logo').prefetch_related('m3u_relations__m3u_account')
return (
Movie.objects.filter(
Q(m3u_relations__m3u_account__is_active=True)
| Q(library_items__library__use_as_vod_source=True)
)
.distinct()
.select_related('logo')
.prefetch_related('m3u_relations__m3u_account', 'library_items__library')
)
@action(detail=True, methods=['get'], url_path='providers')
def get_providers(self, request, pk=None):
@ -99,8 +117,39 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
m3u_account__is_active=True
).select_related('m3u_account', 'category')
serializer = M3UMovieRelationSerializer(relations, many=True)
return Response(serializer.data)
relation_payload = M3UMovieRelationSerializer(relations, many=True).data
library_payload = []
for media_item in movie.library_items.select_related("library"):
library = media_item.library
if not library or not library.use_as_vod_source:
continue
library_payload.append(
{
"id": f"library-{media_item.id}",
"provider_type": "library",
"type": "library",
"library_item_id": media_item.id,
"library": {
"id": library.id,
"name": library.name,
},
"stream_id": None,
"m3u_account": {
"id": f"library-{library.id}",
"name": library.name,
"account_type": "library",
},
"quality_info": (movie.custom_properties or {}).get("quality"),
"custom_properties": {
"source": "library",
"library_id": library.id,
"media_item_id": media_item.id,
},
}
)
return Response([*relation_payload, *library_payload])
@action(detail=True, methods=['get'], url_path='provider-info')
@ -108,6 +157,60 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
"""Get detailed movie information from the original provider, throttled to 24h."""
movie = self.get_object()
library_item = movie.library_items.select_related("library").filter(
library__use_as_vod_source=True
).first()
if library_item:
library = library_item.library
custom_props = movie.custom_properties or {}
logo_cache_url = build_logo_cache_url(request, movie.logo_id)
response_data = {
'id': movie.id,
'uuid': movie.uuid,
'stream_id': None,
'name': movie.name,
'description': movie.description,
'plot': movie.description,
'year': movie.year,
'genre': movie.genre,
'rating': movie.rating,
'tmdb_id': movie.tmdb_id,
'imdb_id': movie.imdb_id,
'duration_secs': movie.duration_secs,
'movie_image': logo_cache_url or (movie.logo.url if movie.logo else custom_props.get('poster_url')),
'backdrop_path': (
custom_props.get('backdrop_path')
if isinstance(custom_props.get('backdrop_path'), list)
else ([custom_props.get('backdrop_url')] if custom_props.get('backdrop_url') else [])
),
'video': (custom_props.get('quality') or {}).get('video'),
'audio': (custom_props.get('quality') or {}).get('audio'),
'bitrate': (custom_props.get('quality') or {}).get('bitrate'),
'container_extension': (custom_props.get('quality') or {}).get('container'),
'direct_source': None,
'library_item_id': library_item.id,
'library_sources': [
{
'library_id': library.id,
'library_name': library.name,
'media_item_id': library_item.id,
}
],
'custom_properties': custom_props,
'm3u_account': {
'id': f'library-{library.id}',
'name': library.name,
'account_type': 'library',
},
'logo': {
'id': movie.logo_id,
'name': movie.logo.name if movie.logo else None,
'url': movie.logo.url if movie.logo else custom_props.get('poster_url'),
'cache_url': logo_cache_url,
} if movie.logo else None,
}
return Response(response_data)
# Get the highest priority active relation
relation = M3UMovieRelation.objects.filter(
movie=movie,
@ -136,6 +239,7 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
# Refresh objects from database after task completion
movie.refresh_from_db()
relation.refresh_from_db()
logo_cache_url = build_logo_cache_url(request, movie.logo_id)
# Use refreshed data from database
custom_props = relation.custom_properties or {}
@ -166,7 +270,7 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
'backdrop_path': (movie.custom_properties or {}).get('backdrop_path') or info.get('backdrop_path', []),
'cover': info.get('cover_big', ''),
'cover_big': info.get('cover_big', ''),
'movie_image': movie.logo.url if movie.logo else info.get('movie_image', ''),
'movie_image': logo_cache_url or (movie.logo.url if movie.logo else info.get('movie_image', '')),
'bitrate': info.get('bitrate', 0),
'video': info.get('video', {}),
'audio': info.get('audio', {}),
@ -178,7 +282,13 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
'id': relation.m3u_account.id,
'name': relation.m3u_account.name,
'account_type': relation.m3u_account.account_type
}
},
'logo': {
'id': movie.logo_id,
'name': movie.logo.name if movie.logo else None,
'url': movie.logo.url if movie.logo else info.get('movie_image', ''),
'cache_url': logo_cache_url,
} if movie.logo_id else None
}
return Response(response_data)
@ -242,9 +352,15 @@ class EpisodeViewSet(viewsets.ReadOnlyModelViewSet):
return [Authenticated()]
def get_queryset(self):
return Episode.objects.select_related(
'series', 'm3u_account'
).filter(m3u_account__is_active=True)
return (
Episode.objects.select_related("series")
.filter(
Q(m3u_relations__m3u_account__is_active=True)
| Q(library_items__library__use_as_vod_source=True)
)
.distinct()
.prefetch_related("m3u_relations__m3u_account", "library_items__library")
)
class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
@ -267,9 +383,15 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
# Only return series that have active M3U relations
return Series.objects.filter(
m3u_relations__m3u_account__is_active=True
).distinct().select_related('logo').prefetch_related('episodes', 'm3u_relations__m3u_account')
return (
Series.objects.filter(
Q(m3u_relations__m3u_account__is_active=True)
| Q(library_items__library__use_as_vod_source=True)
)
.distinct()
.select_related('logo')
.prefetch_related('episodes', 'm3u_relations__m3u_account', 'library_items__library')
)
@action(detail=True, methods=['get'], url_path='providers')
def get_providers(self, request, pk=None):
@ -280,8 +402,38 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
m3u_account__is_active=True
).select_related('m3u_account', 'category')
serializer = M3USeriesRelationSerializer(relations, many=True)
return Response(serializer.data)
relation_payload = M3USeriesRelationSerializer(relations, many=True).data
library_payload = []
for media_item in series.library_items.select_related("library"):
library = media_item.library
if not library or not library.use_as_vod_source:
continue
library_payload.append(
{
"id": f"library-{media_item.id}",
"provider_type": "library",
"type": "library",
"library_item_id": media_item.id,
"library": {
"id": library.id,
"name": library.name,
},
"m3u_account": {
"id": f"library-{library.id}",
"name": library.name,
"account_type": "library",
},
"quality_info": None,
"custom_properties": {
"source": "library",
"library_id": library.id,
"media_item_id": media_item.id,
},
}
)
return Response([*relation_payload, *library_payload])
@action(detail=True, methods=['get'], url_path='episodes')
def get_episodes(self, request, pk=None):
@ -302,7 +454,39 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
m3u_account__is_active=True
).select_related('m3u_account')
episode_data['providers'] = M3UEpisodeRelationSerializer(relations, many=True).data
relation_payload = M3UEpisodeRelationSerializer(relations, many=True).data
library_payload = []
for media_item in episode.library_items.select_related("library"):
library = media_item.library
if not library or not library.use_as_vod_source:
continue
library_payload.append(
{
"id": f"library-{media_item.id}",
"provider_type": "library",
"type": "library",
"library_item_id": media_item.id,
"library": {
"id": library.id,
"name": library.name,
},
"m3u_account": {
"id": f"library-{library.id}",
"name": library.name,
"account_type": "library",
},
"stream_id": None,
"quality_info": None,
"custom_properties": {
"source": "library",
"library_id": library.id,
"media_item_id": media_item.id,
},
}
)
episode_data['providers'] = [*relation_payload, *library_payload]
episodes_data.append(episode_data)
return Response(episodes_data)
@ -314,6 +498,75 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
series = self.get_object()
logger.debug(f"Retrieved series: {series.name} (ID: {series.id})")
library_item = series.library_items.select_related("library").filter(
library__use_as_vod_source=True
).first()
if library_item:
library = library_item.library
series_logo_cache_url = build_logo_cache_url(request, series.logo_id)
episodes_by_season: dict[str, list[dict]] = {}
for episode in series.episodes.all().prefetch_related("library_items__library").order_by('season_number', 'episode_number'):
if not episode.library_items.filter(library__use_as_vod_source=True).exists():
continue
season_key = str(episode.season_number or 0)
if season_key not in episodes_by_season:
episodes_by_season[season_key] = []
episodes_by_season[season_key].append(
{
'id': episode.id,
'title': episode.name,
'plot': episode.description,
'season_number': episode.season_number,
'episode_number': episode.episode_number,
'duration_secs': episode.duration_secs,
'rating': episode.rating,
'tmdb_id': episode.tmdb_id,
'imdb_id': episode.imdb_id,
'uuid': str(episode.uuid),
'library_media_item_ids': [
item.id for item in episode.library_items.filter(library__use_as_vod_source=True)
],
}
)
response_data = {
'id': series.id,
'series_id': series.id,
'name': series.name,
'description': series.description,
'year': series.year,
'genre': series.genre,
'rating': series.rating,
'tmdb_id': series.tmdb_id,
'imdb_id': series.imdb_id,
'category_id': None,
'category_name': None,
'cover': {
'id': series.logo.id,
'url': series_logo_cache_url or series.logo.url,
'name': series.logo.name,
'cache_url': series_logo_cache_url,
} if series.logo else None,
'custom_properties': series.custom_properties or {},
'm3u_account': {
'id': f'library-{library.id}',
'name': library.name,
'account_type': 'library',
},
'episodes_fetched': True,
'detailed_fetched': True,
'episodes': episodes_by_season,
'library_item_id': library_item.id,
'library_sources': [
{
'library_id': library.id,
'library_name': library.name,
'media_item_id': library_item.id,
}
],
}
return Response(response_data)
# Get the highest priority active relation
relation = M3USeriesRelation.objects.filter(
series=series,
@ -512,18 +765,36 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
search = request.query_params.get('search', '')
category = request.query_params.get('category', '')
# Build WHERE clauses
where_conditions = [
# Only active content
"movies.id IN (SELECT DISTINCT movie_id FROM vod_m3umovierelation mmr JOIN m3u_m3uaccount ma ON mmr.m3u_account_id = ma.id WHERE ma.is_active = true)",
"series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation msr JOIN m3u_m3uaccount ma ON msr.m3u_account_id = ma.id WHERE ma.is_active = true)"
]
# Build base WHERE clauses
movie_m3u_condition = (
"movies.id IN (SELECT DISTINCT movie_id FROM vod_m3umovierelation mmr "
"JOIN m3u_m3uaccount ma ON mmr.m3u_account_id = ma.id "
"WHERE ma.is_active = true)"
)
movie_library_condition = (
"EXISTS (SELECT 1 FROM media_library_mediaitem mi "
"JOIN media_library_library lib ON mi.library_id = lib.id "
"WHERE mi.vod_movie_id = movies.id AND lib.use_as_vod_source = true)"
)
series_m3u_condition = (
"series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation msr "
"JOIN m3u_m3uaccount ma ON msr.m3u_account_id = ma.id "
"WHERE ma.is_active = true)"
)
series_library_condition = (
"EXISTS (SELECT 1 FROM media_library_mediaitem mi "
"JOIN media_library_library lib ON mi.library_id = lib.id "
"WHERE mi.vod_series_id = series.id AND lib.use_as_vod_source = true)"
)
movie_where = f"(({movie_m3u_condition}) OR ({movie_library_condition}))"
series_where = f"(({series_m3u_condition}) OR ({series_library_condition}))"
params = []
if search:
where_conditions[0] += " AND LOWER(movies.name) LIKE %s"
where_conditions[1] += " AND LOWER(series.name) LIKE %s"
movie_where += " AND LOWER(movies.name) LIKE %s"
series_where += " AND LOWER(series.name) LIKE %s"
search_param = f"%{search.lower()}%"
params.extend([search_param, search_param])
@ -531,16 +802,28 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
if '|' in category:
cat_name, cat_type = category.split('|', 1)
if cat_type == 'movie':
where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
where_conditions[1] = "1=0" # Exclude series
movie_where += (
" AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr "
"JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
)
series_where = "1=0" # Exclude series
params.append(cat_name)
elif cat_type == 'series':
where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
where_conditions[0] = "1=0" # Exclude movies
series_where += (
" AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr "
"JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
)
movie_where = "1=0" # Exclude movies
params.append(cat_name)
else:
where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
movie_where += (
" AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr "
"JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
)
series_where += (
" AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr "
"JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
)
params.extend([category, category])
# Use UNION ALL with ORDER BY and LIMIT/OFFSET for true unified pagination
@ -565,7 +848,7 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
'movie' as content_type
FROM vod_movie movies
LEFT JOIN dispatcharr_channels_logo logo ON movies.logo_id = logo.id
WHERE {where_conditions[0]}
WHERE {movie_where}
UNION ALL
@ -587,7 +870,7 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
'series' as content_type
FROM vod_series series
LEFT JOIN dispatcharr_channels_logo logo ON series.logo_id = logo.id
WHERE {where_conditions[1]}
WHERE {series_where}
)
SELECT * FROM unified_content
ORDER BY LOWER(name), id
@ -606,18 +889,42 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
for row in cursor.fetchall():
item_dict = dict(zip(columns, row))
custom_props = item_dict.get('custom_properties') or {}
if isinstance(custom_props, str):
try:
custom_props = json.loads(custom_props)
except json.JSONDecodeError:
custom_props = {}
# Build logo object in the format expected by frontend
logo_data = None
poster_candidate = None
if item_dict['logo_id']:
cache_url = build_logo_cache_url(request, item_dict['logo_id'])
poster_candidate = cache_url or item_dict.get('logo_url')
logo_data = {
'id': item_dict['logo_id'],
'name': item_dict['logo_name'],
'url': item_dict['logo_url'],
'cache_url': f"/media/logo_cache/{item_dict['logo_id']}.png" if item_dict['logo_id'] else None,
'channel_count': 0, # We don't need this for VOD
'cache_url': cache_url,
'channel_count': 0,
'is_used': True,
'channel_names': [] # We don't need this for VOD
'channel_names': []
}
if not poster_candidate:
poster_candidate = custom_props.get('poster_url') or custom_props.get('cover')
backdrop_values = []
if isinstance(custom_props.get('backdrop_path'), list):
backdrop_values = custom_props['backdrop_path']
elif custom_props.get('backdrop_url'):
backdrop_values = [custom_props['backdrop_url']]
rating_value = item_dict['rating']
try:
rating_parsed = float(rating_value) if rating_value is not None else 0.0
except (TypeError, ValueError):
rating_parsed = rating_value
# Convert to the format expected by frontend
formatted_item = {
@ -626,13 +933,17 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
'name': item_dict['name'],
'description': item_dict['description'] or '',
'year': item_dict['year'],
'rating': float(item_dict['rating']) if item_dict['rating'] else 0.0,
'rating': rating_parsed,
'genre': item_dict['genre'] or '',
'duration': item_dict['duration'],
'duration_secs': item_dict['duration'],
'created_at': item_dict['created_at'].isoformat() if item_dict['created_at'] else None,
'updated_at': item_dict['updated_at'].isoformat() if item_dict['updated_at'] else None,
'custom_properties': item_dict['custom_properties'] or {},
'custom_properties': custom_props,
'logo': logo_data,
'movie_image': poster_candidate if item_dict['content_type'] == 'movie' else None,
'series_image': poster_candidate if item_dict['content_type'] == 'series' else None,
'backdrop_path': backdrop_values,
'content_type': item_dict['content_type']
}
results.append(formatted_item)
@ -643,9 +954,9 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
# Use a separate efficient count query
count_sql = f"""
SELECT COUNT(*) FROM (
SELECT 1 FROM vod_movie movies WHERE {where_conditions[0]}
SELECT 1 FROM vod_movie movies WHERE {movie_where}
UNION ALL
SELECT 1 FROM vod_series series WHERE {where_conditions[1]}
SELECT 1 FROM vod_series series WHERE {series_where}
) as total_count
"""
@ -668,4 +979,4 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
logger.error(f"Error in UnifiedContentViewSet.list(): {e}")
import traceback
logger.error(traceback.format_exc())
return Response({'error': str(e)}, status=500)
return Response({'error': str(e)}, status=500)

View file

@ -1,3 +1,4 @@
from django.urls import reverse
from rest_framework import serializers
from .models import (
Series, VODCategory, Movie, Episode,
@ -30,9 +31,21 @@ class VODCategorySerializer(serializers.ModelSerializer):
"m3u_accounts",
]
def _build_logo_cache_url(request, logo_id):
if not logo_id:
return None
cache_path = reverse("api:channels:logo-cache", args=[logo_id])
if request:
return request.build_absolute_uri(cache_path)
return cache_path
class SeriesSerializer(serializers.ModelSerializer):
logo = LogoSerializer(read_only=True)
episode_count = serializers.SerializerMethodField()
library_sources = serializers.SerializerMethodField()
series_image = serializers.SerializerMethodField()
backdrop_path = serializers.SerializerMethodField()
class Meta:
model = Series
@ -41,22 +54,129 @@ class SeriesSerializer(serializers.ModelSerializer):
def get_episode_count(self, obj):
return obj.episodes.count()
def get_library_sources(self, obj):
sources = []
for item in obj.library_items.select_related("library").filter(library__use_as_vod_source=True):
library = item.library
sources.append(
{
"library_id": library.id,
"library_name": library.name,
"media_item_id": item.id,
}
)
return sources
def get_series_image(self, obj):
request = self.context.get("request")
if obj.logo_id:
cache_url = _build_logo_cache_url(request, obj.logo_id)
return cache_url or (obj.logo.url if obj.logo else None)
custom = obj.custom_properties or {}
return custom.get("poster_url") or custom.get("cover")
def get_backdrop_path(self, obj):
custom = obj.custom_properties or {}
if "backdrop_path" in custom and isinstance(custom["backdrop_path"], list):
return custom["backdrop_path"]
backdrop_url = custom.get("backdrop_url")
if backdrop_url:
return [backdrop_url]
return []
class MovieSerializer(serializers.ModelSerializer):
logo = LogoSerializer(read_only=True)
library_sources = serializers.SerializerMethodField()
movie_image = serializers.SerializerMethodField()
backdrop_path = serializers.SerializerMethodField()
class Meta:
model = Movie
fields = '__all__'
def get_library_sources(self, obj):
sources = []
for item in obj.library_items.select_related("library").filter(library__use_as_vod_source=True):
library = item.library
sources.append(
{
"library_id": library.id,
"library_name": library.name,
"media_item_id": item.id,
}
)
return sources
def get_movie_image(self, obj):
request = self.context.get("request")
if obj.logo_id:
cache_url = _build_logo_cache_url(request, obj.logo_id)
return cache_url or (obj.logo.url if obj.logo else None)
custom = obj.custom_properties or {}
return custom.get("poster_url") or custom.get("cover")
def get_backdrop_path(self, obj):
custom = obj.custom_properties or {}
if "backdrop_path" in custom and isinstance(custom["backdrop_path"], list):
return custom["backdrop_path"]
backdrop_url = custom.get("backdrop_url")
if backdrop_url:
return [backdrop_url]
return []
class EpisodeSerializer(serializers.ModelSerializer):
series = SeriesSerializer(read_only=True)
library_sources = serializers.SerializerMethodField()
movie_image = serializers.SerializerMethodField()
backdrop_path = serializers.SerializerMethodField()
class Meta:
model = Episode
fields = '__all__'
def get_library_sources(self, obj):
sources = []
for item in obj.library_items.select_related("library").filter(library__use_as_vod_source=True):
library = item.library
sources.append(
{
"library_id": library.id,
"library_name": library.name,
"media_item_id": item.id,
}
)
return sources
def get_movie_image(self, obj):
custom = obj.custom_properties or {}
if custom.get("poster_url"):
return custom["poster_url"]
if obj.series_id and obj.series and obj.series.logo_id:
request = self.context.get("request")
return _build_logo_cache_url(request, obj.series.logo_id) or (
obj.series.logo.url if obj.series.logo else None
)
return None
def get_backdrop_path(self, obj):
custom = obj.custom_properties or {}
if isinstance(custom.get("backdrop_path"), list):
return custom["backdrop_path"]
backdrop_url = custom.get("backdrop_url")
if backdrop_url:
return [backdrop_url]
if obj.series_id and obj.series:
series_custom = obj.series.custom_properties or {}
if isinstance(series_custom.get("backdrop_path"), list):
return series_custom["backdrop_path"]
if series_custom.get("backdrop_url"):
return [series_custom["backdrop_url"]]
return []
class M3USeriesRelationSerializer(serializers.ModelSerializer):
series = SeriesSerializer(read_only=True)

View file

@ -3,6 +3,7 @@
import json
import ipaddress
import logging
from typing import Optional
from rest_framework import viewsets, status
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
@ -28,6 +29,7 @@ import socket
import requests
import os
from core.tasks import rehash_streams
from apps.media_library.metadata import check_movie_db_health, validate_tmdb_api_key
from apps.accounts.permissions import (
Authenticated,
)
@ -145,6 +147,71 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
return Response({}, status=status.HTTP_200_OK)
@action(detail=False, methods=["post"], url_path="validate-tmdb")
def validate_tmdb(self, request, *args, **kwargs):
api_key = request.data.get("api_key") or request.data.get("value")
normalized = (api_key or "").strip()
tmdb_configured = bool(normalized)
tmdb_valid = False
tmdb_message: Optional[str] = None
if tmdb_configured:
tmdb_valid, tmdb_message = validate_tmdb_api_key(normalized)
else:
tmdb_message = "TMDB API key not configured."
fallback_available, fallback_message = check_movie_db_health()
if fallback_available and not fallback_message:
fallback_message = "Movie-DB fallback reachable."
provider = "unavailable"
overall_valid = False
summary_message: Optional[str] = None
if tmdb_configured and tmdb_valid:
provider = "tmdb"
overall_valid = True
summary_message = "TMDB key verified successfully. Metadata and artwork will load for your libraries."
elif fallback_available:
provider = "movie-db"
overall_valid = True
base_summary = "Using Movie-DB fallback for metadata."
if tmdb_configured and tmdb_message:
summary_message = f"TMDB unavailable ({tmdb_message}); {base_summary}"
elif not tmdb_configured:
summary_message = f"No TMDB key configured. {base_summary}"
else:
summary_message = base_summary
else:
details = []
if tmdb_message:
details.append(f"TMDB: {tmdb_message}")
if fallback_message:
details.append(f"Movie-DB: {fallback_message}")
summary = "All metadata sources are unavailable."
if details:
summary = f"{summary} {' '.join(details)}"
summary_message = summary
return Response(
{
"overall_valid": overall_valid,
"provider": provider,
"message": summary_message,
"tmdb": {
"configured": tmdb_configured,
"valid": tmdb_valid,
"message": tmdb_message,
},
"fallback": {
"available": fallback_available,
"message": fallback_message,
},
},
status=status.HTTP_200_OK,
)
class ProxySettingsViewSet(viewsets.ViewSet):
"""
API endpoint for proxy settings stored as JSON in CoreSettings.

View file

@ -25,6 +25,7 @@ INSTALLED_APPS = [
"apps.epg",
"apps.hdhr",
"apps.m3u",
"apps.media_library",
"apps.output",
"apps.proxy.apps.ProxyConfig",
"apps.proxy.ts_proxy",

View file

@ -24,6 +24,7 @@
"dayjs": "^1.11.13",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"immer": "^10.1.1",
"lucide-react": "^0.511.0",
"mpegts.js": "^1.8.0",
"react": "^19.1.0",
@ -3423,6 +3424,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",

View file

@ -26,6 +26,7 @@
"@tanstack/react-table": "^8.21.2",
"allotment": "^1.20.4",
"dayjs": "^1.11.13",
"immer": "^10.1.1",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"lucide-react": "^0.511.0",

View file

@ -18,6 +18,7 @@ import PluginsPage from './pages/Plugins';
import Users from './pages/Users';
import LogosPage from './pages/Logos';
import VODsPage from './pages/VODs';
import LibraryPage from './pages/Library';
import useAuthStore from './store/auth';
import useLogosStore from './store/logos';
import FloatingVideo from './components/FloatingVideo';
@ -147,6 +148,8 @@ const App = () => {
<Route path="/settings" element={<Settings />} />
<Route path="/logos" element={<LogosPage />} />
<Route path="/vods" element={<VODsPage />} />
<Route path="/library" element={<Navigate to="/library/movies" replace />} />
<Route path="/library/:mediaType" element={<LibraryPage />} />
</>
) : (
<Route path="/login" element={<Login needsSuperuser />} />

View file

@ -12,6 +12,8 @@ import useChannelsStore from './store/channels';
import useLogosStore from './store/logos';
import usePlaylistsStore from './store/playlists';
import useEPGsStore from './store/epgs';
import useLibraryStore from './store/library';
import useMediaLibraryStore from './store/mediaLibrary';
import { Box, Button, Stack, Alert, Group } from '@mantine/core';
import API from './api';
import useSettingsStore from './store/settings';
@ -37,6 +39,7 @@ export const WebsocketProvider = ({ children }) => {
const updateEPGProgress = useEPGsStore((s) => s.updateEPGProgress);
const updatePlaylist = usePlaylistsStore((s) => s.updatePlaylist);
const applyMediaScanUpdate = useLibraryStore((s) => s.applyScanUpdate);
// Calculate reconnection delay with exponential backoff
const getReconnectDelay = useCallback(() => {
@ -223,6 +226,49 @@ export const WebsocketProvider = ({ children }) => {
}
break;
}
case 'media_scan': {
applyMediaScanUpdate(parsedEvent.data);
if (parsedEvent.data.media_item) {
useMediaLibraryStore.getState().upsertItems([
parsedEvent.data.media_item,
]);
}
if (parsedEvent.data.status === 'completed') {
const { library_id: libraryId } = parsedEvent.data;
const mediaStore = useMediaLibraryStore.getState();
const activeLibraryIds = mediaStore.activeLibraryIds || [];
const shouldRefresh =
activeLibraryIds.length === 0 ||
(libraryId != null &&
activeLibraryIds.includes(Number(libraryId)));
if (shouldRefresh) {
mediaStore.fetchItems(
activeLibraryIds.length > 0 ? activeLibraryIds : undefined
);
}
notifications.show({
title: 'Library scan complete',
message: parsedEvent.data.summary || 'Library scan finished successfully.',
color: 'green',
});
} else if (parsedEvent.data.status === 'failed') {
notifications.show({
title: 'Library scan failed',
message: parsedEvent.data.message || 'Check logs for details.',
color: 'red',
});
}
break;
}
case 'media_item_update': {
if (parsedEvent.data.media_item) {
useMediaLibraryStore.getState().upsertItems([
parsedEvent.data.media_item,
]);
}
break;
}
case 'epg_file':
fetchEPGs();
notifications.show({

View file

@ -1428,6 +1428,19 @@ export default class API {
}
}
static async validateTmdbApiKey(apiKey) {
try {
const response = await request(`${host}/api/core/settings/validate-tmdb/`, {
method: 'POST',
body: { api_key: apiKey },
});
return response;
} catch (e) {
errorNotification('Failed to validate TMDB API key', e);
throw e;
}
}
static async updateSetting(values) {
const { id, ...payload } = values;
@ -2380,4 +2393,392 @@ export default class API {
errorNotification('Failed to update playback position', e);
}
}
// Media Library Methods
static async getMediaLibraries(params = new URLSearchParams()) {
try {
const query = params.toString();
const response = await request(
`${host}/api/media/libraries/${query ? `?${query}` : ''}`
);
return response.results || response;
} catch (e) {
errorNotification('Failed to load media libraries', e);
throw e;
}
}
static async createMediaLibrary(payload) {
try {
const response = await request(`${host}/api/media/libraries/`, {
method: 'POST',
body: payload,
});
return response;
} catch (e) {
errorNotification('Failed to create library', e);
throw e;
}
}
static async updateMediaLibrary(id, payload) {
try {
const response = await request(`${host}/api/media/libraries/${id}/`, {
method: 'PATCH',
body: payload,
});
return response;
} catch (e) {
errorNotification('Failed to update library', e);
throw e;
}
}
static async deleteMediaLibrary(id) {
try {
await request(`${host}/api/media/libraries/${id}/`, {
method: 'DELETE',
});
} catch (e) {
errorNotification('Failed to delete library', e);
throw e;
}
}
static async browseLibraryPath(path = '') {
try {
const params = new URLSearchParams();
if (path) {
params.append('path', path);
}
const query = params.toString();
const response = await request(
`${host}/api/media/libraries/browse/${query ? `?${query}` : ''}`
);
return response;
} catch (e) {
errorNotification('Failed to browse server directories', e);
throw e;
}
}
static async triggerLibraryScan(id, { full = false } = {}) {
try {
const response = await request(
`${host}/api/media/libraries/${id}/scan/`,
{
method: 'POST',
body: { full },
}
);
return response;
} catch (e) {
errorNotification('Failed to start library scan', e);
throw e;
}
}
static async getLibraryScans(params = new URLSearchParams()) {
try {
const query = params.toString();
const response = await request(
`${host}/api/media/scans/${query ? `?${query}` : ''}`
);
return response.results || response;
} catch (e) {
errorNotification('Failed to load library scans', e);
throw e;
}
}
static async cancelLibraryScan(id, payload = {}) {
try {
const response = await request(`${host}/api/media/scans/${id}/cancel/`, {
method: 'POST',
body: payload,
});
return response;
} catch (e) {
errorNotification('Failed to cancel library scan', e);
throw e;
}
}
static async deleteLibraryScan(id) {
try {
await request(`${host}/api/media/scans/${id}/`, {
method: 'DELETE',
});
} catch (e) {
errorNotification('Failed to remove library scan', e);
throw e;
}
}
static async purgeLibraryScans({ library, statuses } = {}) {
try {
const params = new URLSearchParams();
if (library !== undefined && library !== null) {
params.append('library', library);
}
if (Array.isArray(statuses) && statuses.length > 0) {
statuses.forEach((statusValue) => params.append('status', statusValue));
}
const query = params.toString();
const response = await request(
`${host}/api/media/scans/purge/${query ? `?${query}` : ''}`,
{
method: 'DELETE',
}
);
return response;
} catch (e) {
errorNotification('Failed to clear library scans', e);
throw e;
}
}
static async getMediaItems(params = new URLSearchParams()) {
try {
const query = params.toString();
const response = await request(
`${host}/api/media/items/${query ? `?${query}` : ''}`
);
return response.results || response;
} catch (e) {
errorNotification('Failed to load media items', e);
throw e;
}
}
static async getMediaItem(id, options = {}) {
const { suppressErrorNotification = false } = options;
try {
const response = await request(`${host}/api/media/items/${id}/`);
return response;
} catch (e) {
if (!suppressErrorNotification) {
errorNotification('Failed to load media item details', e);
}
throw e;
}
}
static async updateMediaItem(id, payload) {
try {
const response = await request(`${host}/api/media/items/${id}/`, {
method: 'PATCH',
body: payload,
});
return response;
} catch (e) {
errorNotification('Failed to update media item', e);
throw e;
}
}
static async setMediaItemTMDB(id, tmdbId) {
try {
const response = await request(
`${host}/api/media/items/${id}/set-tmdb/`,
{
method: 'POST',
body: { tmdb_id: tmdbId },
}
);
return response;
} catch (e) {
errorNotification('Failed to apply TMDB metadata', e);
throw e;
}
}
static async getMediaItemEpisodes(parentId) {
try {
const params = new URLSearchParams();
params.append('parent', parentId);
params.append('ordering', 'season_number,episode_number');
const response = await request(
`${host}/api/media/items/?${params.toString()}`
);
return response.results || response;
} catch (e) {
errorNotification('Failed to load episodes', e);
throw e;
}
}
static async refreshMediaItemMetadata(id) {
try {
const response = await request(
`${host}/api/media/items/${id}/refresh-metadata/`,
{
method: 'POST',
}
);
return response;
} catch (e) {
errorNotification('Failed to queue metadata refresh', e);
throw e;
}
}
static async getMediaItemFiles(mediaItemId, options = {}) {
const { suppressErrorNotification = false } = options;
try {
const params = new URLSearchParams();
params.append('media_item', mediaItemId);
const response = await request(
`${host}/api/media/files/?${params.toString()}`
);
return response.results || response;
} catch (e) {
if (!suppressErrorNotification) {
errorNotification('Failed to load media item files', e);
}
throw e;
}
}
static async markSeriesWatched(id) {
try {
const response = await request(
`${host}/api/media/items/${id}/mark-series-watched/`,
{
method: 'POST',
}
);
return response;
} catch (e) {
errorNotification('Failed to mark series watched', e);
throw e;
}
}
static async markSeriesUnwatched(id) {
try {
const response = await request(
`${host}/api/media/items/${id}/mark-series-unwatched/`,
{
method: 'POST',
}
);
return response;
} catch (e) {
errorNotification('Failed to mark series unwatched', e);
throw e;
}
}
static async deleteMediaItem(id) {
try {
await request(`${host}/api/media/items/${id}/`, {
method: 'DELETE',
});
return true;
} catch (e) {
errorNotification('Failed to delete media item', e);
throw e;
}
}
static async markMediaItemWatched(id) {
try {
const response = await request(
`${host}/api/media/items/${id}/mark-watched/`,
{
method: 'POST',
}
);
return response;
} catch (e) {
errorNotification('Failed to mark as watched', e);
throw e;
}
}
static async clearMediaItemProgress(id) {
try {
const response = await request(
`${host}/api/media/items/${id}/clear-progress/`,
{
method: 'POST',
}
);
return response;
} catch (e) {
errorNotification('Failed to clear watch progress', e);
throw e;
}
}
static async getMediaWatchProgress(params = new URLSearchParams()) {
try {
const query = params.toString();
const response = await request(
`${host}/api/media/progress/${query ? `?${query}` : ''}`
);
return response.results || response;
} catch (e) {
errorNotification('Failed to load watch progress', e);
throw e;
}
}
static async setMediaWatchProgress(payload) {
try {
const response = await request(`${host}/api/media/progress/set/`, {
method: 'POST',
body: payload,
});
return response;
} catch (e) {
errorNotification('Failed to update watch progress', e);
throw e;
}
}
static async resumeMediaProgress(id) {
try {
const response = await request(
`${host}/api/media/progress/${id}/resume/`,
{
method: 'POST',
}
);
return response;
} catch (e) {
errorNotification('Failed to load resume information', e);
throw e;
}
}
static async streamMediaItem(id, options = {}) {
try {
const params = new URLSearchParams();
if (options.fileId) {
params.append('file', options.fileId);
}
const startCandidate =
options.startMs ??
options.resumeMs ??
(typeof options.startSeconds === 'number'
? Math.round(options.startSeconds * 1000)
: null);
if (startCandidate != null) {
const normalized = Math.floor(Number(startCandidate));
if (!Number.isNaN(normalized) && normalized > 0) {
params.append('start_ms', String(normalized));
}
}
const query = params.toString();
const response = await request(
`${host}/api/media/items/${id}/stream/${query ? `?${query}` : ''}`
);
return response;
} catch (e) {
errorNotification('Failed to load media stream info', e);
throw e;
}
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 273.42 35.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="17.76" x2="273.42" y2="17.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 3</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M191.85,35.37h63.9A17.67,17.67,0,0,0,273.42,17.7h0A17.67,17.67,0,0,0,255.75,0h-63.9A17.67,17.67,0,0,0,174.18,17.7h0A17.67,17.67,0,0,0,191.85,35.37ZM10.1,35.42h7.8V6.92H28V0H0v6.9H10.1Zm28.1,0H46V8.25h.1L55.05,35.4h6L70.3,8.25h.1V35.4h7.8V0H66.45l-8.2,23.1h-.1L50,0H38.2ZM89.14.12h11.7a33.56,33.56,0,0,1,8.08,1,18.52,18.52,0,0,1,6.67,3.08,15.09,15.09,0,0,1,4.53,5.52,18.5,18.5,0,0,1,1.67,8.25,16.91,16.91,0,0,1-1.62,7.58,16.3,16.3,0,0,1-4.38,5.5,19.24,19.24,0,0,1-6.35,3.37,24.53,24.53,0,0,1-7.55,1.15H89.14Zm7.8,28.2h4a21.66,21.66,0,0,0,5-.55A10.58,10.58,0,0,0,110,26a8.73,8.73,0,0,0,2.68-3.35,11.9,11.9,0,0,0,1-5.08,9.87,9.87,0,0,0-1-4.52,9.17,9.17,0,0,0-2.63-3.18A11.61,11.61,0,0,0,106.22,8a17.06,17.06,0,0,0-4.68-.63h-4.6ZM133.09.12h13.2a32.87,32.87,0,0,1,4.63.33,12.66,12.66,0,0,1,4.17,1.3,7.94,7.94,0,0,1,3,2.72,8.34,8.34,0,0,1,1.15,4.65,7.48,7.48,0,0,1-1.67,5,9.13,9.13,0,0,1-4.43,2.82V17a10.28,10.28,0,0,1,3.18,1,8.51,8.51,0,0,1,2.45,1.85,7.79,7.79,0,0,1,1.57,2.62,9.16,9.16,0,0,1,.55,3.2,8.52,8.52,0,0,1-1.2,4.68,9.32,9.32,0,0,1-3.1,3A13.38,13.38,0,0,1,152.32,35a22.5,22.5,0,0,1-4.73.5h-14.5Zm7.8,14.15h5.65a7.65,7.65,0,0,0,1.78-.2,4.78,4.78,0,0,0,1.57-.65,3.43,3.43,0,0,0,1.13-1.2,3.63,3.63,0,0,0,.42-1.8A3.3,3.3,0,0,0,151,8.6a3.42,3.42,0,0,0-1.23-1.13A6.07,6.07,0,0,0,148,6.9a9.9,9.9,0,0,0-1.85-.18h-5.3Zm0,14.65h7a8.27,8.27,0,0,0,1.83-.2,4.67,4.67,0,0,0,1.67-.7,3.93,3.93,0,0,0,1.23-1.3,3.8,3.8,0,0,0,.47-1.95,3.16,3.16,0,0,0-.62-2,4,4,0,0,0-1.58-1.18,8.23,8.23,0,0,0-2-.55,15.12,15.12,0,0,0-2.05-.15h-5.9Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2 KiB

File diff suppressed because it is too large Load diff

View file

@ -21,6 +21,7 @@ import { Play } from 'lucide-react';
import useVODStore from '../store/useVODStore';
import useVideoStore from '../store/useVideoStore';
import useSettingsStore from '../store/settings';
import API from '../api';
const imdbUrl = (imdb_id) =>
imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
@ -35,9 +36,17 @@ const formatDuration = (seconds) => {
};
const formatStreamLabel = (relation) => {
if (relation?.provider_type === 'library' || relation?.type === 'library') {
const libraryName =
relation?.library?.name ||
relation?.m3u_account?.name ||
'Library';
return `${libraryName} - Local Library`;
}
// Create a label for the stream that includes provider name and stream-specific info
const provider = relation.m3u_account.name;
const streamId = relation.stream_id;
const provider = relation?.m3u_account?.name || 'Provider';
const streamId = relation?.stream_id;
// Try to extract quality info - prioritizing the new quality_info field from backend
let qualityInfo = '';
@ -241,15 +250,76 @@ const SeriesModal = ({ series, opened, onClose }) => {
}
}, [opened]);
const handlePlayEpisode = (episode) => {
const resolveLibraryEpisodeMediaItemId = (episode) => {
if (episode?.library_media_item_ids?.length) {
return episode.library_media_item_ids[0];
}
if (episode?.providers) {
const libraryProvider = episode.providers.find(
(relation) =>
relation?.provider_type === 'library' || relation?.type === 'library'
);
if (libraryProvider?.library_item_id) {
return libraryProvider.library_item_id;
}
if (libraryProvider?.custom_properties?.media_item_id) {
return libraryProvider.custom_properties.media_item_id;
}
}
if (selectedProvider?.provider_type === 'library' && selectedProvider.library_item_id) {
return selectedProvider.library_item_id;
}
return null;
};
const handlePlayEpisode = async (episode) => {
let streamUrl = `/proxy/vod/episode/${episode.uuid}`;
if (
selectedProvider?.provider_type === 'library' ||
selectedProvider?.type === 'library'
) {
const mediaItemId = resolveLibraryEpisodeMediaItemId(episode);
if (!mediaItemId) {
console.warn('No library episode media item available for playback');
return;
}
try {
const streamInfo = await API.streamMediaItem(mediaItemId);
if (!streamInfo?.url) {
console.error('Library episode stream did not return a URL');
return;
}
const playbackMeta = {
...episode,
provider_type: 'library',
library_media_item_id: mediaItemId,
mediaItemId,
fileId: streamInfo?.file_id,
resumePositionMs: 0,
resumeHandledByServer: Boolean(streamInfo?.start_offset_ms),
startOffsetMs: streamInfo?.start_offset_ms ?? 0,
requiresTranscode: Boolean(streamInfo?.requires_transcode),
transcodeStatus: streamInfo?.transcode_status ?? null,
durationMs:
streamInfo?.duration_ms ??
episode?.runtime_ms ??
episode?.files?.[0]?.duration_ms ??
null,
};
showVideo(streamInfo.url, 'library', playbackMeta);
} catch (error) {
console.error('Failed to start library episode stream:', error);
}
return;
}
// Add selected provider as query parameter if available
if (selectedProvider) {
// Use stream_id for most specific selection, fallback to account_id
if (selectedProvider.stream_id) {
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
} else {
} else if (selectedProvider?.m3u_account?.id) {
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
}
}
@ -279,6 +349,12 @@ const SeriesModal = ({ series, opened, onClose }) => {
// Use detailed data if available, otherwise use basic series data
const displaySeries = detailedSeries || series;
const seriesPoster =
displaySeries?.series_image ||
displaySeries?.logo?.cache_url ||
displaySeries?.logo?.url ||
displaySeries?.custom_properties?.poster_url ||
null;
return (
<>
@ -341,10 +417,10 @@ const SeriesModal = ({ series, opened, onClose }) => {
{/* Series poster and basic info */}
<Flex gap="md">
{displaySeries.series_image || displaySeries.logo?.url ? (
{seriesPoster ? (
<Box style={{ flexShrink: 0 }}>
<Image
src={displaySeries.series_image || displaySeries.logo.url}
src={seriesPoster}
width={200}
height={300}
alt={displaySeries.name}
@ -498,16 +574,19 @@ const SeriesModal = ({ series, opened, onClose }) => {
</Text>
{providers.length === 0 &&
!loadingProviders &&
displaySeries.m3u_account ? (
(displaySeries.m3u_account ||
(displaySeries.library_sources?.length ?? 0) > 0) ? (
<Group spacing="md">
<Badge color="blue" variant="light">
{displaySeries.m3u_account.name}
{displaySeries.m3u_account?.name ||
displaySeries.library_sources?.[0]?.library_name ||
'Library'}
</Badge>
</Group>
) : providers.length === 1 ? (
<Group spacing="md">
<Badge color="blue" variant="light">
{providers[0].m3u_account.name}
{formatStreamLabel(providers[0])}
</Badge>
{providers[0].stream_id && (
<Badge color="orange" variant="outline" size="xs">
@ -640,10 +719,16 @@ const SeriesModal = ({ series, opened, onClose }) => {
{/* Episode Image and Description Row */}
<Flex gap="md">
{/* Episode Image */}
{episode.movie_image && (
<Box style={{ flexShrink: 0 }}>
<Image
src={episode.movie_image}
{(() => {
const episodeImage =
episode.movie_image || seriesPoster;
if (!episodeImage) {
return null;
}
return (
<Box style={{ flexShrink: 0 }}>
<Image
src={episodeImage}
width={120}
height={160}
alt={episode.name}
@ -651,7 +736,8 @@ const SeriesModal = ({ series, opened, onClose }) => {
style={{ borderRadius: '4px' }}
/>
</Box>
)}
);
})()}
{/* Episode Description */}
<Box style={{ flex: 1 }}>

View file

@ -2,10 +2,11 @@ import React, { useRef, useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { copyToClipboard } from '../utils';
import {
ChevronDown,
ChevronRight,
ListOrdered,
Play,
Database,
SlidersHorizontal,
LayoutGrid,
Settings as LucideSettings,
Copy,
@ -15,6 +16,7 @@ import {
LogOut,
User,
FileImage,
Library,
} from 'lucide-react';
import {
Avatar,
@ -90,6 +92,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const closeUserForm = () => setUserFormOpen(false);
// Navigation Items
const mediaLibraryNav = {
label: 'Media Library',
icon: <Library size={20} />,
children: [
{ label: 'Movies', path: '/library/movies' },
{ label: 'TV Shows', path: '/library/shows' },
],
};
const navItems =
authUser && authUser.user_level == USER_LEVELS.ADMIN
? [
@ -104,6 +115,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
path: '/vods',
icon: <Video size={20} />,
},
...(isAuthenticated ? [mediaLibraryNav] : []),
{
label: 'M3U & EPG Manager',
icon: <Play size={20} />,
@ -137,6 +149,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
badge: `(${Object.keys(channels).length})`,
},
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
...(isAuthenticated ? [mediaLibraryNav] : []),
{
label: 'Settings',
icon: <LucideSettings size={20} />,
@ -144,6 +157,16 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
},
];
const [libraryExpanded, setLibraryExpanded] = useState(
location.pathname.startsWith('/library')
);
useEffect(() => {
if (location.pathname.startsWith('/library')) {
setLibraryExpanded(true);
}
}, [location.pathname]);
// Fetch environment settings including version on component mount
useEffect(() => {
if (!isAuthenticated) {
@ -244,7 +267,64 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{/* Navigation Links */}
<Stack gap="xs" mt="lg">
{navItems.map((item) => {
const isActive = location.pathname === item.path;
const isActive = item.path
? location.pathname.startsWith(item.path)
: false;
if (item.children) {
const childActive = item.children.some((child) =>
location.pathname.startsWith(child.path)
);
const expanded = libraryExpanded || childActive;
return (
<Stack key={item.label} spacing={4}>
<UnstyledButton
className={`navlink ${childActive ? 'navlink-active' : ''} ${collapsed ? 'navlink-collapsed' : ''}`}
onClick={() => setLibraryExpanded((prev) => !prev)}
>
{item.icon}
{!collapsed && (
<Group justify="space-between" w="100%" gap={0}>
<Text
sx={{
opacity: collapsed ? 0 : 1,
transition: 'opacity 0.2s ease-in-out',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: collapsed ? 0 : 150,
}}
>
{item.label}
</Text>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</Group>
)}
</UnstyledButton>
{expanded && !collapsed && (
<Stack spacing={2} ml={32}>
{item.children.map((child) => {
const active = location.pathname.startsWith(child.path);
return (
<UnstyledButton
key={child.path}
component={Link}
to={child.path}
className={`navlink ${active ? 'navlink-active' : ''}`}
style={{
paddingLeft: 18,
fontSize: '0.85rem',
}}
>
<Text size="sm">{child.label}</Text>
</UnstyledButton>
);
})}
</Stack>
)}
</Stack>
);
}
return (
<NavLink

View file

@ -17,6 +17,7 @@ import { Play } from 'lucide-react';
import useVODStore from '../store/useVODStore';
import useVideoStore from '../store/useVideoStore';
import useSettingsStore from '../store/settings';
import API from '../api';
const imdbUrl = (imdb_id) =>
imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
@ -31,9 +32,17 @@ const formatDuration = (seconds) => {
};
const formatStreamLabel = (relation) => {
if (relation?.provider_type === 'library' || relation?.type === 'library') {
const libraryName =
relation?.library?.name ||
relation?.m3u_account?.name ||
'Library';
return `${libraryName} - Local Library`;
}
// Create a label for the stream that includes provider name and stream-specific info
const provider = relation.m3u_account.name;
const streamId = relation.stream_id;
const provider = relation?.m3u_account?.name || 'Provider';
const streamId = relation?.stream_id;
// Try to extract quality info - prioritizing the new quality_info field from backend
let qualityInfo = '';
@ -232,10 +241,63 @@ const VODModal = ({ vod, opened, onClose }) => {
}
}, [opened]);
const handlePlayVOD = () => {
const resolveLibraryMediaItemId = () => {
if (selectedProvider?.library_item_id) return selectedProvider.library_item_id;
if (selectedProvider?.custom_properties?.media_item_id) {
return selectedProvider.custom_properties.media_item_id;
}
const fallback = detailedVOD?.library_sources || vod?.library_sources || [];
if (fallback.length > 0) {
return fallback[0].media_item_id;
}
return null;
};
const handlePlayVOD = async () => {
const vodToPlay = detailedVOD || vod;
if (!vodToPlay) return;
if (
selectedProvider?.provider_type === 'library' ||
selectedProvider?.type === 'library' ||
(vodToPlay.library_sources && vodToPlay.library_sources.length > 0)
) {
const mediaItemId = resolveLibraryMediaItemId();
if (!mediaItemId) {
console.warn('No library media item available for playback');
return;
}
try {
const streamInfo = await API.streamMediaItem(mediaItemId);
if (!streamInfo?.url) {
console.error('Library stream did not return a URL');
return;
}
const playbackMeta = {
...vodToPlay,
provider_type: 'library',
library_media_item_id: mediaItemId,
mediaItemId,
fileId: streamInfo?.file_id,
resumePositionMs: 0,
resumeHandledByServer: Boolean(streamInfo?.start_offset_ms),
startOffsetMs: streamInfo?.start_offset_ms ?? 0,
requiresTranscode: Boolean(streamInfo?.requires_transcode),
transcodeStatus: streamInfo?.transcode_status ?? null,
durationMs:
streamInfo?.duration_ms ??
vodToPlay?.runtime_ms ??
vodToPlay?.files?.[0]?.duration_ms ??
null,
};
showVideo(streamInfo.url, 'library', playbackMeta);
} catch (error) {
console.error('Failed to start library stream:', error);
}
return;
}
let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
// Add selected provider as query parameter if available
@ -243,7 +305,7 @@ const VODModal = ({ vod, opened, onClose }) => {
// Use stream_id for most specific selection, fallback to account_id
if (selectedProvider.stream_id) {
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
} else {
} else if (selectedProvider?.m3u_account?.id) {
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
}
}
@ -269,6 +331,12 @@ const VODModal = ({ vod, opened, onClose }) => {
// Use detailed data if available, otherwise use basic vod data
const displayVOD = detailedVOD || vod;
const posterSrc =
displayVOD?.movie_image ||
displayVOD?.logo?.cache_url ||
displayVOD?.logo?.url ||
displayVOD?.custom_properties?.poster_url ||
null;
return (
<>
@ -330,10 +398,10 @@ const VODModal = ({ vod, opened, onClose }) => {
{/* Movie poster and basic info */}
<Flex gap="md">
{/* Use movie_image or logo */}
{displayVOD.movie_image || displayVOD.logo?.url ? (
{posterSrc ? (
<Box style={{ flexShrink: 0 }}>
<Image
src={displayVOD.movie_image || displayVOD.logo.url}
src={posterSrc}
width={200}
height={300}
alt={displayVOD.name}
@ -504,7 +572,7 @@ const VODModal = ({ vod, opened, onClose }) => {
{providers.length === 1 ? (
<Group spacing="md">
<Badge color="blue" variant="light">
{providers[0].m3u_account.name}
{formatStreamLabel(providers[0])}
</Badge>
</Group>
) : (
@ -531,14 +599,16 @@ const VODModal = ({ vod, opened, onClose }) => {
{/* Fallback provider info if no providers loaded yet */}
{providers.length === 0 &&
!loadingProviders &&
vod?.m3u_account && (
(vod?.m3u_account || (vod?.library_sources?.length ?? 0) > 0) && (
<Box>
<Text size="sm" weight={500} mb={8}>
Stream Selection
</Text>
<Group spacing="md">
<Badge color="blue" variant="light">
{vod.m3u_account.name}
{vod?.m3u_account?.name ||
vod?.library_sources?.[0]?.library_name ||
'Library'}
</Badge>
</Group>
</Box>

View file

@ -0,0 +1,129 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Stack, UnstyledButton, Text } from '@mantine/core';
const letters = ['#', 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'];
const HOTZONE_WIDTH = 20; // px: invisible strip you hover to reveal
const IDLE_FADE_MS = 1500; // ms of no mouse movement before fade out
const LETTER_GAP = 6; // must match Stack spacing
const BASE_FONT_PX = 12; // Mantine "xs" ~ 12px by default
const MAX_SCALE_BOOST = 0.35; // how large letters grow at the cursor
const SIGMA_PX = 26; // controls falloff steepness
export default function AlphabetSidebar({
available = new Set(),
onSelect,
top = 120,
right = 16,
}) {
const [isHot, setIsHot] = useState(false); // pointer inside hot zone or sidebar
const [isIdle, setIsIdle] = useState(false); // idle while still hovered
const [mouseY, setMouseY] = useState(null); // y relative to sidebar
const sidebarRef = useRef(null);
const idleTimerRef = useRef(null);
// Reset idle timer whenever mouse moves inside the sidebar
const poke = () => {
setIsIdle(false);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => setIsIdle(true), IDLE_FADE_MS);
};
useEffect(() => () => idleTimerRef.current && clearTimeout(idleTimerRef.current), []);
const handleMouseMove = (e) => {
if (!sidebarRef.current) return;
const rect = sidebarRef.current.getBoundingClientRect();
setMouseY(e.clientY - rect.top);
poke();
};
const handleMouseLeave = () => {
setIsHot(false);
setIsIdle(false);
setMouseY(null);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
};
// Precompute the center Y for each letter button (approx.)
const centers = useMemo(() => {
// each item height font + vertical padding (~6px top/bottom) + gap
const itemH = BASE_FONT_PX + 12 + LETTER_GAP; // tweak if needed to match your Text styles
return letters.map((_, i) => i * itemH + itemH / 2);
}, []);
// Visible when hovering the hot zone or the sidebar, unless idling.
const visible = isHot && !isIdle;
return (
<>
{/* Invisible hot zone on the far right edge */}
<Box
onMouseEnter={() => setIsHot(true)}
onMouseLeave={handleMouseLeave}
style={{
position: 'fixed',
top,
right: 0,
width: HOTZONE_WIDTH,
height: '70vh',
zIndex: 2,
}}
/>
{/* Sidebar itself */}
<Stack
ref={sidebarRef}
spacing={LETTER_GAP}
align="center"
onMouseEnter={() => setIsHot(true)}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMove}
style={{
position: 'fixed',
top,
right,
zIndex: 3,
background: 'rgba(12, 12, 16, 0.75)',
borderRadius: 12,
padding: '10px 6px',
backdropFilter: 'blur(4px)',
// reveal/fade logic
opacity: visible ? 1 : 0,
pointerEvents: visible ? 'auto' : 'none', // click-through when hidden
transform: visible ? 'translateX(0)' : 'translateX(8px)', // subtle slide
transition: 'opacity 160ms ease, transform 160ms ease',
}}
>
{letters.map((letter, idx) => {
const isEnabled = available.has(letter);
// proximity scale
let scale = 1;
if (mouseY != null) {
const d = Math.abs(mouseY - centers[idx]);
const boost = Math.exp(-(d * d) / (2 * SIGMA_PX * SIGMA_PX)); // 0..1
scale = 1 + MAX_SCALE_BOOST * boost;
}
return (
<UnstyledButton
key={letter}
onClick={() => isEnabled && onSelect?.(letter)}
style={{
opacity: isEnabled ? 1 : 0.3,
cursor: isEnabled ? 'pointer' : 'default',
transform: `scale(${scale})`,
transition: 'transform 80ms linear',
willChange: 'transform',
}}
>
<Text size="xs" fw={700} lh={1}>
{letter}
</Text>
</UnstyledButton>
);
})}
</Stack>
</>
);
}

View file

@ -0,0 +1,152 @@
import React from 'react';
import {
Badge,
Card,
Group,
Stack,
Text,
ActionIcon,
Tooltip,
Button,
} from '@mantine/core';
import {
CircleDashed,
Clock,
Pencil,
PlayCircle,
RefreshCw,
Trash2,
} from 'lucide-react';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
const formatDate = (value) => {
if (!value) return 'Never';
return dayjs(value).fromNow();
};
const LibraryCard = ({
library,
selected,
onSelect,
onEdit,
onDelete,
onScan,
loadingScan = false,
}) => {
return (
<Card
shadow={selected ? 'lg' : 'sm'}
padding="lg"
radius="md"
withBorder
onClick={() => onSelect(library.id)}
style={{
cursor: 'pointer',
borderColor: selected ? '#6366f1' : undefined,
transition: 'transform 150ms ease, border-color 150ms ease',
}}
>
<Stack spacing="xs">
<Group align="center" justify="space-between">
<Text fw={600} size="lg">
{library.name}
</Text>
<Group gap="xs">
<Badge color="violet" variant="light">
{library.library_type?.replace('-', ' ') || 'Unknown'}
</Badge>
{library.auto_scan_enabled ? (
<Badge color="green" variant="outline">
Auto-scan
</Badge>
) : (
<Badge color="gray" variant="outline">
Manual only
</Badge>
)}
</Group>
</Group>
{library.description && (
<Text size="sm" c="dimmed">
{library.description}
</Text>
)}
<Group gap="sm">
<Tooltip label="Last scan">
<Group gap={4} align="center">
<Clock size={16} />
<Text size="xs">{formatDate(library.last_scan_at)}</Text>
</Group>
</Tooltip>
<Tooltip label="Last success">
<Group gap={4} align="center">
<CircleDashed size={16} />
<Text size="xs">{formatDate(library.last_successful_scan_at)}</Text>
</Group>
</Tooltip>
</Group>
<Group justify="space-between" mt="sm">
<Button
size="xs"
variant={selected ? 'filled' : 'light'}
leftSection={<PlayCircle size={16} />}
onClick={(event) => {
event.stopPropagation();
onSelect(library.id);
}}
>
Browse
</Button>
<Group gap="xs">
<Tooltip label="Trigger scan">
<ActionIcon
size="sm"
variant="light"
loading={loadingScan}
onClick={(event) => {
event.stopPropagation();
onScan(library.id);
}}
>
<RefreshCw size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Edit library">
<ActionIcon
size="sm"
variant="light"
onClick={(event) => {
event.stopPropagation();
onEdit(library);
}}
>
<Pencil size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete library">
<ActionIcon
size="sm"
variant="light"
color="red"
onClick={(event) => {
event.stopPropagation();
onDelete(library);
}}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Stack>
</Card>
);
};
export default LibraryCard;

View file

@ -0,0 +1,438 @@
import React, { useEffect, useState } from 'react';
import {
ActionIcon,
Button,
Checkbox,
Loader,
Group,
Modal,
NumberInput,
Select,
Stack,
Switch,
Text,
TextInput,
Textarea,
ScrollArea,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { ArrowUp, FolderOpen, Plus, Trash2 } from 'lucide-react';
import API from '../../api';
const LIBRARY_TYPES = [
{ value: 'movies', label: 'Movies' },
{ value: 'shows', label: 'TV Shows' },
];
const defaultLocation = () => ({
path: '',
include_subdirectories: true,
is_primary: false,
});
const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) => {
const editing = Boolean(library);
const form = useForm({
mode: 'controlled',
initialValues: {
name: '',
description: '',
library_type: 'movies',
metadata_language: 'en',
metadata_country: 'US',
scan_interval_minutes: 1440,
auto_scan_enabled: true,
use_as_vod_source: false,
metadata_options: {},
locations: [defaultLocation()],
},
});
const [browser, setBrowser] = useState({
open: false,
index: null,
path: '',
parent: null,
entries: [],
loading: false,
error: null,
});
useEffect(() => {
if (library) {
form.setValues({
name: library.name || '',
description: library.description || '',
library_type:
LIBRARY_TYPES.some((option) => option.value === library.library_type)
? library.library_type
: 'movies',
metadata_language: library.metadata_language || 'en',
metadata_country: library.metadata_country || 'US',
scan_interval_minutes: library.scan_interval_minutes || 1440,
auto_scan_enabled: library.auto_scan_enabled ?? true,
use_as_vod_source: library.use_as_vod_source ?? false,
metadata_options: library.metadata_options || {},
locations:
library.locations?.length > 0
? library.locations.map((loc) => ({
id: loc.id,
path: loc.path,
include_subdirectories:
loc.include_subdirectories ?? true,
is_primary: loc.is_primary ?? false,
}))
: [defaultLocation()],
});
} else {
form.reset();
form.setFieldValue('locations', [defaultLocation()]);
}
}, [library, opened]);
useEffect(() => {
if (!opened) {
closeBrowser();
}
}, [opened]);
const addLocation = () => {
form.insertListItem('locations', defaultLocation());
};
const removeLocation = (index) => {
const values = form.getValues();
if (values.locations.length === 1) {
form.setFieldValue('locations', [defaultLocation()]);
return;
}
form.removeListItem('locations', index);
};
const loadDirectory = async (targetPath) => {
const normalizedPath = targetPath ?? '';
setBrowser((prev) => ({ ...prev, loading: true, error: null }));
try {
const response = await API.browseLibraryPath(normalizedPath);
setBrowser((prev) => ({
...prev,
path: response.path ?? normalizedPath,
parent: response.parent || null,
entries: Array.isArray(response.entries) ? response.entries : [],
loading: false,
}));
} catch (error) {
console.error('Failed to browse directories', error);
setBrowser((prev) => ({
...prev,
loading: false,
error: 'Unable to load directories. Check permissions and try again.',
}));
}
};
const openDirectoryBrowser = (index) => {
const current = form.values.locations?.[index]?.path || '';
setBrowser({
open: true,
index,
path: current,
parent: null,
entries: [],
loading: true,
error: null,
});
void loadDirectory(current);
};
const closeBrowser = () => {
setBrowser({
open: false,
index: null,
path: '',
parent: null,
entries: [],
loading: false,
error: null,
});
};
const handleSelectDirectory = (path) => {
void loadDirectory(path ?? '');
};
const handleUseDirectory = () => {
if (browser.index == null) {
closeBrowser();
return;
}
const resolvedPath = browser.path || '';
form.setFieldValue(`locations.${browser.index}.path`, resolvedPath);
closeBrowser();
};
const submit = (values) => {
const payload = {
...values,
locations: values.locations.map((loc, index) => ({
...loc,
is_primary: loc.is_primary || index === 0,
})),
};
onSubmit(payload);
};
return (
<>
<Modal
opened={opened}
onClose={onClose}
title={editing ? 'Edit Library' : 'Create Library'}
size="lg"
overlayProps={{ backgroundOpacity: 0.6, blur: 4 }}
zIndex={400}
>
<form onSubmit={form.onSubmit(submit)}>
<Stack spacing="md">
<TextInput
label="Name"
placeholder="My Movies"
required
{...form.getInputProps('name')}
/>
<Textarea
label="Description"
placeholder="Optional description for this library"
autosize
minRows={2}
{...form.getInputProps('description')}
/>
<Group grow>
<Select
label="Library Type"
data={LIBRARY_TYPES}
comboboxProps={{ withinPortal: false }}
{...form.getInputProps('library_type')}
/>
<NumberInput
label="Auto-scan Interval (minutes)"
min={15}
step={15}
{...form.getInputProps('scan_interval_minutes')}
/>
</Group>
<Group grow>
<TextInput
label="Metadata Language"
placeholder="en"
{...form.getInputProps('metadata_language')}
/>
<TextInput
label="Metadata Country"
placeholder="US"
{...form.getInputProps('metadata_country')}
/>
</Group>
<Switch
label="Enable automatic scanning"
checked={form.values.auto_scan_enabled}
onChange={(event) =>
form.setFieldValue('auto_scan_enabled', event.currentTarget.checked)
}
/>
<Switch
label="Use as VOD source"
description="Automatically add matched movies and TV shows from this library to VOD."
checked={form.values.use_as_vod_source}
onChange={(event) =>
form.setFieldValue('use_as_vod_source', event.currentTarget.checked)
}
/>
<Stack spacing="sm">
<Group justify="space-between" align="center">
<Text fw={600}>Locations</Text>
<Button
size="xs"
leftSection={<Plus size={14} />}
variant="light"
onClick={addLocation}
type="button"
>
Add Path
</Button>
</Group>
{form.values.locations.map((location, index) => (
<Stack
key={location.id || index}
p="sm"
style={{
border: '1px solid rgba(148, 163, 184, 0.2)',
borderRadius: 8,
}}
spacing="xs"
>
<Group justify="space-between" align="center">
<Text size="sm" fw={500}>
Location {index + 1}
</Text>
<ActionIcon
size="sm"
color="red"
variant="subtle"
onClick={() => removeLocation(index)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
<Group align="flex-end" gap="sm">
<TextInput
placeholder="/path/to/library"
required
value={location.path}
onChange={(event) =>
form.setFieldValue(
`locations.${index}.path`,
event.currentTarget.value
)
}
style={{ flex: 1 }}
/>
<Button
variant="light"
size="xs"
leftSection={<FolderOpen size={14} />}
onClick={() => openDirectoryBrowser(index)}
type="button"
>
Browse
</Button>
</Group>
<Group>
<Checkbox
label="Include subdirectories"
checked={location.include_subdirectories}
onChange={(event) =>
form.setFieldValue(
`locations.${index}.include_subdirectories`,
event.currentTarget.checked
)
}
/>
<Checkbox
label="Primary"
checked={location.is_primary}
onChange={(event) =>
form.setFieldValue(
`locations.${index}.is_primary`,
event.currentTarget.checked
)
}
/>
</Group>
</Stack>
))}
</Stack>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose} type="button">
Cancel
</Button>
<Button type="submit" loading={submitting}>
{editing ? 'Save changes' : 'Create library'}
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={browser.open}
onClose={closeBrowser}
title="Select library directory"
size="lg"
overlayProps={{ backgroundOpacity: 0.6, blur: 4 }}
zIndex={410}
>
<Stack spacing="md">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{browser.path || '/'}
</Text>
<Button
size="xs"
variant="light"
leftSection={<ArrowUp size={14} />}
onClick={() => handleSelectDirectory(browser.parent)}
disabled={!browser.parent || browser.loading}
type="button"
>
Up one level
</Button>
</Group>
{browser.error && (
<Text size="sm" c="red">
{browser.error}
</Text>
)}
<ScrollArea h={260} offsetScrollbars>
{browser.loading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : browser.entries.length === 0 ? (
<Text c="dimmed" size="sm">
No subdirectories found.
</Text>
) : (
<Stack spacing="xs">
{browser.entries.map((entry) => (
<Button
key={entry.path}
variant="subtle"
fullWidth
justify="space-between"
onClick={() => handleSelectDirectory(entry.path)}
type="button"
>
<span>{entry.name || entry.path}</span>
<Text size="xs" c="dimmed">
{entry.path}
</Text>
</Button>
))}
</Stack>
)}
</ScrollArea>
<Group justify="space-between">
<Button
variant="light"
size="xs"
onClick={() => void loadDirectory(browser.path)}
loading={browser.loading}
type="button"
>
Refresh
</Button>
<Group gap="sm">
<Button variant="subtle" onClick={closeBrowser} type="button">
Cancel
</Button>
<Button onClick={handleUseDirectory} type="button">
Use this folder
</Button>
</Group>
</Group>
</Stack>
</Modal>
</>
);
};
export default LibraryFormModal;

View file

@ -0,0 +1,487 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Divider,
Drawer,
Group,
Progress,
ScrollArea,
Stack,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { Ban, Play, RefreshCcw, Trash2, ScanSearch } from 'lucide-react';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import useLibraryStore from '../../store/library';
import useMediaLibraryStore from '../../store/mediaLibrary';
dayjs.extend(relativeTime);
const EMPTY_SCAN_LIST = [];
const statusColor = {
pending: 'gray',
queued: 'gray',
scheduled: 'gray',
running: 'blue',
started: 'blue',
discovered: 'indigo',
progress: 'blue',
completed: 'green',
failed: 'red',
cancelled: 'yellow',
};
const isRunning = (s) =>
s === 'running' || s === 'started' || s === 'progress' || s === 'discovered';
const isQueued = (s) => s === 'pending' || s === 'queued' || s === 'scheduled';
const stageStatusLabel = {
pending: 'Waiting',
running: 'In progress',
completed: 'Completed',
skipped: 'Skipped',
};
const stageOrder = [
{ key: 'discovery', label: 'File scan' },
{ key: 'metadata', label: 'Metadata fetch' },
{ key: 'artwork', label: 'Artwork' },
];
const EMPTY_STAGE = {
status: 'pending',
processed: 0,
total: 0,
};
const stageColorMap = {
discovery: 'blue',
metadata: 'green',
artwork: 'red',
};
const PROGRESS_REFRESH_DELTA = 25;
const PROGRESS_REFRESH_INTERVAL_MS = 15000;
const LibraryScanDrawer = ({
opened,
onClose,
libraryId,
// Optional actions provided by parent (no-ops by default)
onCancelJob = async () => {},
onDeleteQueuedJob = async () => {},
onStartScan = null, // () => void
onStartFullScan = null, // () => void
}) => {
const scansLoading = useLibraryStore((s) => s.scansLoading);
const scans =
useLibraryStore((s) => s.scans[libraryId || 'all']) ?? EMPTY_SCAN_LIST;
const fetchScans = useLibraryStore((s) => s.fetchScans);
const purgeCompletedScans = useLibraryStore((s) => s.purgeCompletedScans);
const [loaderHold, setLoaderHold] = useState(false);
const [purgeLoading, setPurgeLoading] = useState(false);
const hasRunningRef = useRef(false);
const hasQueuedRef = useRef(false);
const lastProcessedRef = useRef(0);
const lastLibraryRefreshRef = useRef(0);
const refreshInFlightRef = useRef(false);
const handleRefresh = useCallback(
() => fetchScans(libraryId),
[fetchScans, libraryId]
);
const hasRunningScan = useMemo(
() => scans.some((scan) => isRunning(scan.status)),
[scans]
);
const hasQueuedScan = useMemo(
() => scans.some((scan) => isQueued(scan.status)),
[scans]
);
const hasFinishedScans = useMemo(
() =>
scans.some((scan) =>
['completed', 'failed', 'cancelled'].includes(scan.status)
),
[scans]
);
// Keep refs in sync for polling loop
useEffect(() => {
hasRunningRef.current = hasRunningScan;
}, [hasRunningScan]);
useEffect(() => {
hasQueuedRef.current = hasQueuedScan;
}, [hasQueuedScan]);
useEffect(() => {
if (!opened) {
lastProcessedRef.current = 0;
lastLibraryRefreshRef.current = 0;
return undefined;
}
let cancelled = false;
let timer;
const runFetch = async (background) => {
try {
await fetchScans(libraryId, { background });
} catch (error) {
if (!background) {
console.error('Failed to load library scans', error);
}
}
};
const loop = () => {
if (cancelled) return;
const delay = hasRunningRef.current
? 2000
: hasQueuedRef.current
? 4000
: 8000;
timer = setTimeout(async () => {
await runFetch(true);
loop();
}, delay);
};
void runFetch(false).then(loop);
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [opened, libraryId, fetchScans]);
const totalProcessed = useMemo(
() =>
scans.reduce(
(sum, scan) => sum + (Number(scan.processed_files) || 0),
0
),
[scans]
);
useEffect(() => {
if (!opened) return;
if (!hasRunningScan && !hasQueuedScan) {
lastProcessedRef.current = totalProcessed;
return;
}
const now = Date.now();
const prevProcessed = lastProcessedRef.current;
const processedDelta = Math.max(0, totalProcessed - prevProcessed);
const elapsedSinceRefresh = now - lastLibraryRefreshRef.current;
const shouldRefreshLibrary =
processedDelta >= PROGRESS_REFRESH_DELTA ||
elapsedSinceRefresh >= PROGRESS_REFRESH_INTERVAL_MS;
if (!shouldRefreshLibrary || refreshInFlightRef.current) {
return;
}
lastProcessedRef.current = totalProcessed;
lastLibraryRefreshRef.current = now;
refreshInFlightRef.current = true;
const mediaStore = useMediaLibraryStore.getState();
const activeIds = mediaStore.activeLibraryIds || [];
void mediaStore
.fetchItems(activeIds.length > 0 ? activeIds : undefined)
.finally(() => {
refreshInFlightRef.current = false;
});
}, [opened, hasRunningScan, hasQueuedScan, totalProcessed]);
useEffect(() => {
if (!opened) {
setLoaderHold(false);
return undefined;
}
if (scansLoading) {
setLoaderHold(true);
const timeout = setTimeout(() => setLoaderHold(false), 800);
return () => clearTimeout(timeout);
}
setLoaderHold(false);
return undefined;
}, [opened, scansLoading]);
const isInitialLoading =
scans.length === 0 && (scansLoading || loaderHold);
const getStagePercent = (stage) => {
if (!stage) return 0;
const processed = Math.max(0, Number(stage.processed) || 0);
let total = Math.max(0, Number(stage.total) || 0);
if (!total || total <= processed) {
if (stage.status === 'completed') {
total = processed || 1;
} else {
total = processed + 1;
}
}
if (!total) return 0;
return Math.min(100, Math.round((processed / total) * 100));
};
const formatStageCount = (stage, stageKey) => {
if (!stage) return '0';
const processed = stage.processed ?? 0;
const total = stage.total ?? 0;
if (stage.status === 'skipped') {
return 'Not required';
}
if (total > 0 && total >= processed) {
return `${processed} / ${total}`;
}
if (stage.status === 'completed' && processed === 0) {
return 'Done';
}
const suffix =
stageKey === 'discovery'
? 'files scanned'
: stageKey === 'metadata'
? 'metadata items'
: 'artwork assets';
if (processed === 0) {
return 'Waiting…';
}
return `${processed} ${suffix}`;
};
const handleClearFinished = useCallback(async () => {
if (!hasFinishedScans) {
return;
}
setPurgeLoading(true);
try {
await purgeCompletedScans({
library: libraryId ?? undefined,
});
await fetchScans(libraryId, { background: true });
} catch (error) {
console.error('Failed to clear library scans', error);
} finally {
setPurgeLoading(false);
}
}, [hasFinishedScans, purgeCompletedScans, fetchScans, libraryId]);
const header = useMemo(
() => (
<Group justify="space-between" align="center" mb="sm">
<Group gap="xs" align="center">
<ScanSearch size={18} />
<Title order={5} style={{ lineHeight: 1 }}>Library scans</Title>
</Group>
<Group gap="xs">
{onStartScan && (
<Tooltip label="Start quick scan">
<ActionIcon variant="light" onClick={onStartScan}>
<Play size={16} />
</ActionIcon>
</Tooltip>
)}
{onStartFullScan && (
<Button variant="light" size="xs" onClick={onStartFullScan}>
Full scan
</Button>
)}
{hasFinishedScans && (
<Button
variant="light"
size="xs"
onClick={handleClearFinished}
loading={purgeLoading}
>
Clear finished
</Button>
)}
<Tooltip label="Refresh">
<ActionIcon variant="light" onClick={handleRefresh}>
<RefreshCcw size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
),
[onStartScan, onStartFullScan, handleRefresh, hasFinishedScans, purgeLoading, handleClearFinished]
);
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="md"
overlayProps={{ backgroundOpacity: 0.55, blur: 6 }}
withCloseButton
title={header}
>
<ScrollArea style={{ height: '100%' }}>
{isInitialLoading ? (
<Group justify="center" py="lg">
<Text c="dimmed">Loading scans</Text>
</Group>
) : scans.length === 0 ? (
<Stack align="center" py="lg" gap={4}>
<Text c="dimmed">No scans recorded yet.</Text>
{onStartScan && (
<Button size="xs" onClick={onStartScan} mt="xs">
Start a scan
</Button>
)}
</Stack>
) : (
<Stack gap="sm" py="xs">
{scans.map((scan) => {
const status = scan.status || 'pending';
return (
<Card key={scan.id} withBorder shadow="sm" radius="md">
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" align="center">
<Badge color={statusColor[status] || 'gray'} variant="light">
{status}
</Badge>
<Text size="sm" fw={600}>
{scan.summary || 'Scan'}
</Text>
</Group>
<Text size="xs" c="dimmed">
{dayjs(scan.created_at).format('MMM D, YYYY HH:mm')}
</Text>
</Stack>
<Group gap="xs">
{isRunning(status) && (
<Tooltip label="Cancel running scan">
<ActionIcon
color="yellow"
variant="light"
onClick={() => onCancelJob(scan.id)}
>
<Ban size={16} />
</ActionIcon>
</Tooltip>
)}
{isQueued(status) && (
<Tooltip label="Remove from queue">
<ActionIcon
color="red"
variant="light"
onClick={() => onDeleteQueuedJob(scan.id)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<Divider />
<Stack gap="sm">
{stageOrder.map(({ key, label }) => {
const stage = scan.stages?.[key] || EMPTY_STAGE;
const stageStatus = stage.status || 'pending';
const percent = getStagePercent(stage);
const progressColor = stageColorMap[key] || 'gray';
const badgeColor =
stageStatus === 'completed' || stageStatus === 'running'
? progressColor
: 'gray';
const animated = stageStatus === 'running';
const percentDisplay =
stageStatus === 'completed'
? '100%'
: stageStatus === 'skipped'
? null
: `${percent}%`;
return (
<Stack gap={4} key={`${scan.id}-${key}`}>
<Group justify="space-between" align="center">
<Text size="xs" fw={500}>
{label}
</Text>
<Badge color={badgeColor} variant="light" size="xs">
{stageStatusLabel[stageStatus] || stageStatus}
</Badge>
</Group>
<Group justify="space-between" align="center">
<Text size="xs" c="dimmed">
{formatStageCount(stage, key)}
</Text>
{percentDisplay && (
<Text size="xs" c="dimmed">
{percentDisplay}
</Text>
)}
</Group>
<Progress
value={percent}
size="sm"
striped={animated}
animated={animated}
color={progressColor}
/>
</Stack>
);
})}
</Stack>
<Divider />
<Stack gap={4}>
<Text size="xs" c="dimmed">
Started {scan.started_at ? dayjs(scan.started_at).fromNow() : 'n/a'} · Finished{' '}
{scan.finished_at ? dayjs(scan.finished_at).fromNow() : 'n/a'}
</Text>
<Text size="xs" c="dimmed">
Files {scan.total_files ?? '—'} · New {scan.new_files ?? '—'} · Updated{' '}
{scan.updated_files ?? '—'} · Removed {scan.removed_files ?? '—'}
</Text>
{scan.unmatched_files > 0 && (
<Text size="xs" c="yellow.4">
Unmatched files: {scan.unmatched_files}
</Text>
)}
{scan.log && (
<Text size="xs" c="dimmed" style={{ whiteSpace: 'pre-wrap' }}>
{scan.log}
</Text>
)}
</Stack>
</Stack>
</Card>
);
})}
</Stack>
)}
</ScrollArea>
</Drawer>
);
};
export default LibraryScanDrawer;

View file

@ -0,0 +1,166 @@
import React, { memo } from 'react';
import {
Badge,
Box,
Card,
Group,
Image,
RingProgress,
Stack,
Text,
} from '@mantine/core';
import { Film, Library as LibraryIcon, Tv2 } from 'lucide-react';
const typeIcon = {
movie: <Film size={18} />,
episode: <Tv2 size={18} />,
show: <LibraryIcon size={18} />,
};
const formatRuntime = (runtimeMs) => {
if (!runtimeMs) return null;
const mins = Math.round(runtimeMs / 60000);
if (mins < 60) return `${mins} min`;
const hours = Math.floor(mins / 60);
const minutes = mins % 60;
return `${hours}h ${minutes}m`;
};
const MediaCard = ({
item,
onClick,
onContextMenu,
size = 'md',
showTypeBadge = true,
}) => {
const handleContextMenu = (event) => {
if (onContextMenu) {
event.preventDefault();
onContextMenu(event, item);
}
};
const height = size === 'sm' ? 220 : size === 'lg' ? 320 : 260;
const progress = item.watch_progress;
const watchSummary = item.watch_summary;
const status = watchSummary?.status;
return (
<Card
shadow="sm"
padding="sm"
radius="md"
withBorder
style={{ cursor: 'pointer', background: 'rgba(12, 15, 27, 0.75)' }}
onClick={() => onClick?.(item)}
onContextMenu={handleContextMenu}
>
<Stack spacing="xs">
<Box style={{ position: 'relative' }}>
{item.poster_url ? (
<Box
h={height}
style={{
borderRadius: 12,
background: 'rgba(12, 17, 32, 0.65)',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Image
src={item.poster_url}
alt={item.title}
height="100%"
width="100%"
fit="contain"
/>
</Box>
) : (
<Stack
align="center"
justify="center"
h={height}
style={{
borderRadius: 12,
background: 'rgba(30, 41, 59, 0.6)',
}}
>
{typeIcon[item.item_type] || <LibraryIcon size={24} />}
</Stack>
)}
{progress && progress.percentage ? (
<RingProgress
style={{ position: 'absolute', top: 10, right: 10 }}
size={48}
thickness={4}
sections={[
{
value: Math.min(100, progress.percentage * 100),
color: progress.completed ? 'green' : 'cyan',
},
]}
label={
<Text size="xs" c="white">
{Math.round(progress.percentage * 100)}%
</Text>
}
/>
) : null}
</Box>
<Stack spacing={4}>
<Group justify="space-between" align="flex-start" gap="xs">
<Text fw={600} size="sm" lineClamp={2}>
{item.title}
</Text>
{item.release_year && (
<Badge variant="outline" size="xs">
{item.release_year}
</Badge>
)}
</Group>
<Group gap={6} wrap="wrap">
{showTypeBadge && (
<Badge size="xs" color="violet" variant="light" tt="capitalize">
{item.item_type}
</Badge>
)}
{status === 'watched' && (
<Badge size="xs" color="green" variant="filled">
Watched
</Badge>
)}
{status === 'in_progress' && (
<Badge size="xs" color="yellow" variant="light">
In progress
</Badge>
)}
{item.item_type === 'show' && watchSummary?.total_episodes ? (
<Badge size="xs" color="blue" variant="outline">
{watchSummary.completed_episodes || 0}/
{watchSummary.total_episodes} episodes
</Badge>
) : null}
</Group>
{Array.isArray(item.genres) && item.genres.length > 0 ? (
<Group gap={4} wrap="wrap">
{item.genres.slice(0, 3).map((genre) => (
<Badge key={`${item.id}-${genre}`} size="xs" color="blue" variant="light">
{genre}
</Badge>
))}
</Group>
) : null}
{formatRuntime(item.runtime_ms) && (
<Text size="xs" c="dimmed">
{formatRuntime(item.runtime_ms)}
</Text>
)}
</Stack>
</Stack>
</Card>
);
};
export default memo(MediaCard);

View file

@ -0,0 +1,109 @@
import React, { useMemo, useRef } from 'react';
import { ActionIcon, Box, Group, ScrollArea, Stack, Text, rem } from '@mantine/core';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import MediaCard from './MediaCard';
const CARD_WIDTH = {
sm: 160, // wider
md: 200, // wider
lg: 240, // wider
};
const SCROLL_STEP = 4;
const MediaCarousel = ({
title,
items,
onSelect,
onContextMenu,
cardSize = 'sm',
emptyMessage = null,
}) => {
const viewportRef = useRef(null);
const cardWidth = useMemo(() => CARD_WIDTH[cardSize], [cardSize]);
const snapGap = 16;
const bottomPad = 14; // extra space so card shadows/badges aren't clipped
if (!items || items.length === 0) {
if (!emptyMessage) return null;
return (
<Stack gap={4}>
<Text fw={600} size="lg">{title}</Text>
<Text size="sm" c="dimmed">{emptyMessage}</Text>
</Stack>
);
}
const scrollByCards = (dir) => {
const vp = viewportRef.current;
if (!vp) return;
vp.scrollBy({
left: dir * (cardWidth + snapGap) * SCROLL_STEP,
behavior: 'smooth',
});
};
return (
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={600} size="lg">{title}</Text>
<Group gap="xs">
<ActionIcon variant="subtle" aria-label="Scroll left" onClick={() => scrollByCards(-1)}>
<ChevronLeft size={18} />
</ActionIcon>
<ActionIcon variant="subtle" aria-label="Scroll right" onClick={() => scrollByCards(1)}>
<ChevronRight size={18} />
</ActionIcon>
</Group>
</Group>
<ScrollArea
type="auto"
scrollbarSize={8}
offsetScrollbars
viewportRef={viewportRef}
styles={{
viewport: {
paddingBottom: rem(bottomPad), // <- keep bottoms visible
scrollSnapType: 'x proximity',
},
}}
>
<Box
// add a touch of padding to the row too so shadows never collide
style={{
display: 'flex',
flexWrap: 'nowrap',
gap: rem(snapGap),
alignItems: 'stretch',
minWidth: 'max-content',
paddingBottom: rem(2),
}}
>
{items.map((item) => (
<Box
key={item.id}
style={{
flex: `0 0 ${rem(cardWidth)}`,
width: rem(cardWidth),
scrollSnapAlign: 'start',
}}
>
<MediaCard
item={item}
onClick={onSelect}
onContextMenu={onContextMenu}
size={cardSize}
showTypeBadge={false}
// ensure the card fills the wrapper (prevents internal overflow)
style={{ width: '100%', height: '100%' }}
/>
</Box>
))}
</Box>
</ScrollArea>
</Stack>
);
};
export default MediaCarousel;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,349 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button,
Group,
Loader,
Modal,
NumberInput,
Stack,
Text,
TextInput,
Textarea,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { RefreshCcw } from 'lucide-react';
import { notifications } from '@mantine/notifications';
import API from '../../api';
import useMediaLibraryStore from '../../store/mediaLibrary';
const emptyValues = {
title: '',
synopsis: '',
release_year: null,
rating: '',
genres: '',
tags: '',
studios: '',
tmdb_id: '',
imdb_id: '',
poster_url: '',
backdrop_url: '',
};
const listToString = (value) =>
Array.isArray(value) && value.length > 0 ? value.join(', ') : '';
const toList = (value) =>
typeof value === 'string'
? value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
: Array.isArray(value)
? value
: [];
const MediaEditModal = ({ opened, onClose, mediaItemId, onSaved }) => {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [applyingTmdb, setApplyingTmdb] = useState(false);
const [mediaItem, setMediaItem] = useState(null);
const form = useForm({
initialValues: emptyValues,
});
const populateForm = (item) => {
form.setValues({
title: item.title || '',
synopsis: item.synopsis || '',
release_year: item.release_year || null,
rating: item.rating || '',
genres: listToString(item.genres),
tags: listToString(item.tags),
studios: listToString(item.studios),
tmdb_id: item.tmdb_id || '',
imdb_id: item.imdb_id || '',
poster_url: item.poster_url || '',
backdrop_url: item.backdrop_url || '',
});
};
useEffect(() => {
if (!opened || !mediaItemId) {
setMediaItem(null);
form.setValues(emptyValues);
return;
}
setLoading(true);
API.getMediaItem(mediaItemId)
.then((item) => {
setMediaItem(item);
populateForm(item);
})
.catch((error) => {
notifications.show({
color: 'red',
title: 'Failed to load media item',
message: error.message || 'Unable to load media item details.',
});
})
.finally(() => setLoading(false));
}, [opened, mediaItemId]);
const applyMediaItemUpdate = async (data) => {
let normalized = data;
if (!normalized || typeof normalized !== 'object') {
try {
normalized = await API.getMediaItem(mediaItemId);
} catch (error) {
notifications.show({
color: 'red',
title: 'Failed to refresh item',
message: error.message || 'Unable to refresh media item after update.',
});
return false;
}
}
setMediaItem(normalized);
populateForm(normalized);
try {
await useMediaLibraryStore.getState().openItem(mediaItemId);
} catch (error) {
// Log but do not block success UX
console.debug('Failed to refresh active item state', error);
}
if (typeof onSaved === 'function') {
await onSaved(normalized);
}
return true;
};
const handleApplyTmdb = async () => {
if (!form.values.tmdb_id) {
notifications.show({
color: 'yellow',
title: 'TMDB ID required',
message: 'Enter a TMDB ID before applying metadata.',
});
return;
}
setApplyingTmdb(true);
try {
const updated = await API.setMediaItemTMDB(
mediaItemId,
form.values.tmdb_id
);
const applied = await applyMediaItemUpdate(updated);
if (applied) {
notifications.show({
color: 'green',
title: 'Metadata updated',
message: 'TMDB details applied successfully.',
});
}
} catch (error) {
// errorNotification already handled in API helper
} finally {
setApplyingTmdb(false);
}
};
const handleSubmit = async (values) => {
if (!mediaItem) return;
const payload = {};
const assignIfChanged = (field, value) => {
const current = mediaItem[field];
const normalizedValue = value ?? '';
const normalizedCurrent = current ?? '';
if (normalizedValue === '' && normalizedCurrent === '') {
return;
}
if (normalizedValue === '' && normalizedCurrent !== '') {
payload[field] = '';
return;
}
if (normalizedValue !== normalizedCurrent) {
payload[field] = value;
}
};
assignIfChanged('title', values.title);
assignIfChanged('synopsis', values.synopsis);
if (values.release_year !== mediaItem.release_year) {
payload.release_year = values.release_year || null;
}
assignIfChanged('rating', values.rating);
const genresList = toList(values.genres);
if (JSON.stringify(genresList) !== JSON.stringify(mediaItem.genres || [])) {
payload.genres = genresList;
}
const tagsList = toList(values.tags);
if (JSON.stringify(tagsList) !== JSON.stringify(mediaItem.tags || [])) {
payload.tags = tagsList;
}
const studiosList = toList(values.studios);
if (JSON.stringify(studiosList) !== JSON.stringify(mediaItem.studios || [])) {
payload.studios = studiosList;
}
assignIfChanged('tmdb_id', values.tmdb_id);
assignIfChanged('imdb_id', values.imdb_id);
assignIfChanged('poster_url', values.poster_url);
assignIfChanged('backdrop_url', values.backdrop_url);
// Remove unchanged keys
Object.keys(payload).forEach((key) => {
const value = payload[key];
if (
value === undefined ||
(Array.isArray(value) && value.length === 0 && !['genres', 'tags', 'studios'].includes(key))
) {
delete payload[key];
}
});
if (Object.keys(payload).length === 0) {
notifications.show({
color: 'blue',
title: 'No changes detected',
message: 'Update the fields before saving.',
});
return;
}
setSaving(true);
try {
const updated = await API.updateMediaItem(mediaItemId, payload);
const applied = await applyMediaItemUpdate(updated);
if (applied) {
notifications.show({
color: 'green',
title: 'Media item saved',
message: 'Changes were applied successfully.',
});
onClose();
}
} catch (error) {
// errorNotification already displayed
} finally {
setSaving(false);
}
};
const modalTitle = useMemo(() => {
if (!mediaItem) return 'Edit Media';
return `Edit ${mediaItem.title || 'Media'}`;
}, [mediaItem]);
return (
<Modal opened={opened} onClose={onClose} title={modalTitle} size="lg" centered>
{loading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : !mediaItem ? (
<Text size="sm" c="dimmed">
Unable to load media item details.
</Text>
) : (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<TextInput
label="Title"
placeholder="Movie title"
{...form.getInputProps('title')}
/>
<Textarea
label="Synopsis"
placeholder="Plot summary"
minRows={3}
{...form.getInputProps('synopsis')}
/>
<Group grow>
<NumberInput
label="Release Year"
min={1895}
max={3000}
{...form.getInputProps('release_year')}
/>
<TextInput
label="Rating"
placeholder="PG-13"
{...form.getInputProps('rating')}
/>
</Group>
<TextInput
label="Genres"
placeholder="Comma separated"
{...form.getInputProps('genres')}
/>
<TextInput
label="Tags"
placeholder="Comma separated"
{...form.getInputProps('tags')}
/>
<TextInput
label="Studios"
placeholder="Comma separated"
{...form.getInputProps('studios')}
/>
<Group grow align="end">
<TextInput
label="TMDB ID"
placeholder="Enter TMDB ID"
{...form.getInputProps('tmdb_id')}
/>
<Button
variant="light"
leftSection={<RefreshCcw size={14} />}
onClick={handleApplyTmdb}
loading={applyingTmdb}
type="button"
>
Apply TMDB Metadata
</Button>
</Group>
<TextInput
label="IMDB ID"
placeholder="tt1234567"
{...form.getInputProps('imdb_id')}
/>
<TextInput
label="Poster URL"
placeholder="https://..."
{...form.getInputProps('poster_url')}
/>
<TextInput
label="Backdrop URL"
placeholder="https://..."
{...form.getInputProps('backdrop_url')}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose} type="button">
Cancel
</Button>
<Button type="submit" loading={saving}>
Save Changes
</Button>
</Group>
</Stack>
</form>
)}
</Modal>
);
};
export default MediaEditModal;

View file

@ -0,0 +1,169 @@
import React, { useMemo } from 'react';
import { Box, Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeGrid as VirtualGrid } from 'react-window';
import MediaCard from './MediaCard';
const groupItemsByLetter = (items) => {
const map = new Map();
items.forEach((item) => {
const name = item.sort_title || item.title || '';
const firstChar = name.charAt(0).toUpperCase();
const key = /[A-Z]/.test(firstChar) ? firstChar : '#';
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(item);
});
return map;
};
const GRID_SPACING = 24;
const getColumnCount = (width, columns) => {
if (!width) return columns.base || 1;
if (width >= 1400 && columns.xl) return columns.xl;
if (width >= 1200 && columns.lg) return columns.lg;
if (width >= 992 && columns.md) return columns.md;
if (width >= 768 && columns.sm) return columns.sm;
return columns.base || 1;
};
const CARD_HEIGHT_MAP = {
sm: 220,
md: 260,
lg: 320,
};
const VirtualizedCell = ({ columnIndex, rowIndex, style, data }) => {
const { items, columnCount, onSelect, onContextMenu, cardSize } = data;
const index = rowIndex * columnCount + columnIndex;
if (index >= items.length) {
return null;
}
const item = items[index];
return (
<Box
style={{
...style,
padding: GRID_SPACING / 2,
boxSizing: 'border-box',
}}
>
<MediaCard
item={item}
onClick={onSelect}
onContextMenu={onContextMenu}
size={cardSize}
/>
</Box>
);
};
const MediaGrid = ({
items,
loading,
onSelect,
onContextMenu,
groupByLetter = false,
letterRefs,
columns = { base: 1, sm: 2, md: 4, lg: 5 },
cardSize = 'md',
}) => {
const rowHeight = useMemo(() => {
const base = CARD_HEIGHT_MAP[cardSize] ?? CARD_HEIGHT_MAP.md;
return base + GRID_SPACING;
}, [cardSize]);
if (loading) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
if (!items || items.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No media found.
</Text>
);
}
if (groupByLetter) {
const grouped = groupItemsByLetter(items);
const sortedKeys = Array.from(grouped.keys()).sort();
return (
<Stack spacing="xl">
{sortedKeys.map((letter) => {
const refCallback = (el) => {
if (letterRefs && el) {
letterRefs.current[letter] = el;
}
};
return (
<Stack key={letter} spacing="md" ref={refCallback}>
<Text fw={700} size="lg">
{letter}
</Text>
<SimpleGrid cols={columns} spacing="lg">
{grouped.get(letter).map((item) => (
<MediaCard
key={item.id}
item={item}
onClick={onSelect}
onContextMenu={onContextMenu}
size={cardSize}
/>
))}
</SimpleGrid>
</Stack>
);
})}
</Stack>
);
}
return (
<Box
style={{
width: '100%',
height: '70vh',
minHeight: 480,
}}
>
<AutoSizer>
{({ height, width }) => {
if (!width || !height) {
return null;
}
const columnCount = getColumnCount(width, columns);
const rowCount = Math.ceil(items.length / columnCount);
const columnWidth = width / columnCount;
return (
<VirtualGrid
columnCount={columnCount}
columnWidth={columnWidth}
height={height}
rowCount={rowCount}
rowHeight={rowHeight}
width={width}
itemData={{
items,
columnCount,
onSelect,
onContextMenu,
cardSize,
}}
>
{VirtualizedCell}
</VirtualGrid>
);
}}
</AutoSizer>
</Box>
);
};
export default MediaGrid;

View file

@ -0,0 +1,757 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ActionIcon, Box, Button, Divider, Group, Paper, Portal, Select, Stack, Text, TextInput, Title, SegmentedControl } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useDebouncedValue } from '@mantine/hooks';
import { ListChecks, Play, RefreshCcw, Search, Trash2, XCircle } from 'lucide-react';
import useLibraryStore from '../store/library';
import useMediaLibraryStore from '../store/mediaLibrary';
import MediaDetailModal from '../components/library/MediaDetailModal';
import LibraryScanDrawer from '../components/library/LibraryScanDrawer';
import MediaCarousel from '../components/library/MediaCarousel';
import MediaGrid from '../components/library/MediaGrid';
import AlphabetSidebar from '../components/library/AlphabetSidebar';
import API from '../api';
const TABS = [
{ label: 'Recommended', value: 'recommended' },
{ label: 'Library', value: 'library' },
{ label: 'Categories', value: 'categories' },
];
const SORT_OPTIONS = [
{ label: 'Default', value: 'default' },
{ label: 'Name (A-Z)', value: 'alpha' },
{ label: 'Release Year', value: 'year' },
{ label: 'Recently Added', value: 'recent' },
{ label: 'Genre', value: 'genre' },
];
const parseDate = (value) => {
if (!value) return 0;
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? 0 : timestamp;
};
const LibraryPage = () => {
const navigate = useNavigate();
const { mediaType } = useParams();
const normalizedMediaType =
mediaType === 'shows' ? 'shows' : mediaType === 'movies' ? 'movies' : null;
useEffect(() => {
if (!normalizedMediaType) {
navigate('/library/movies', { replace: true });
}
}, [normalizedMediaType, navigate]);
const isMovies = normalizedMediaType !== 'shows';
const itemTypeFilter = isMovies ? 'movie' : 'show';
const [scanDrawerOpen, setScanDrawerOpen] = useState(false);
const [playbackModalOpen, setPlaybackModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState('recommended');
const [sortOption, setSortOption] = useState('default');
const [searchTerm, setSearchTerm] = useState('');
const [contextMenu, setContextMenu] = useState(null);
const contextMenuPosition = useMemo(() => {
if (!contextMenu) return null;
if (typeof window === 'undefined') {
return { top: contextMenu.y, left: contextMenu.x };
}
return {
top: Math.min(contextMenu.y, window.innerHeight - 220),
left: Math.min(contextMenu.x, window.innerWidth - 260),
};
}, [contextMenu]);
const [debouncedSearch] = useDebouncedValue(searchTerm, 350);
// Library store hooks
const libraries = useLibraryStore((s) => s.libraries);
const fetchLibraries = useLibraryStore((s) => s.fetchLibraries);
const triggerScan = useLibraryStore((s) => s.triggerScan);
const upsertScan = useLibraryStore((s) => s.upsertScan);
const removeScan = useLibraryStore((s) => s.removeScan);
// Media store hooks
const items = useMediaLibraryStore((s) => s.items);
const itemsLoading = useMediaLibraryStore((s) => s.loading);
const setItemFilters = useMediaLibraryStore((s) => s.setFilters);
const fetchItems = useMediaLibraryStore((s) => s.fetchItems);
const setSelectedMediaLibrary = useMediaLibraryStore((s) => s.setSelectedLibraryId);
const openItem = useMediaLibraryStore((s) => s.openItem);
const closeItem = useMediaLibraryStore((s) => s.closeItem);
const removeItems = useMediaLibraryStore((s) => s.removeItems);
const upsertItems = useMediaLibraryStore((s) => s.upsertItems);
// Fetch libraries on mount
useEffect(() => {
fetchLibraries();
}, [fetchLibraries]);
const relevantLibraryIds = useMemo(() => {
if (!libraries || libraries.length === 0) return [];
return libraries
.filter((lib) =>
isMovies
? lib.library_type === 'movies'
: lib.library_type === 'shows'
)
.map((lib) => lib.id);
}, [libraries, isMovies]);
// Sync media filters with current type and search
useEffect(() => {
setItemFilters({
type: itemTypeFilter,
search: debouncedSearch,
});
}, [itemTypeFilter, debouncedSearch, setItemFilters]);
// Fetch items when library changes or filters update
useEffect(() => {
if (!libraries || libraries.length === 0) {
setSelectedMediaLibrary(null);
fetchItems([]);
return;
}
const ids = relevantLibraryIds;
setSelectedMediaLibrary(ids.length === 1 ? ids[0] : null);
fetchItems(ids);
}, [libraries, relevantLibraryIds, fetchItems, setSelectedMediaLibrary, debouncedSearch, itemTypeFilter]);
const filteredItems = useMemo(() => {
const typeFiltered = items.filter((item) => item.item_type === itemTypeFilter);
if (!debouncedSearch) return typeFiltered;
const query = debouncedSearch.toLowerCase();
return typeFiltered.filter((item) =>
(item.title || '').toLowerCase().includes(query)
);
}, [items, itemTypeFilter, debouncedSearch]);
const continueWatching = useMemo(() => {
return filteredItems
.filter((item) => {
if (item.item_type === 'show') {
return item.watch_summary?.status === 'in_progress';
}
const progress = item.watch_progress;
return progress && !progress.completed;
})
.sort((a, b) => {
const aTime = parseDate(a.watch_progress?.last_watched_at || a.updated_at);
const bTime = parseDate(b.watch_progress?.last_watched_at || b.updated_at);
return bTime - aTime;
})
.slice(0, 20);
}, [filteredItems]);
const recentlyReleased = useMemo(() => {
return [...filteredItems]
.filter((item) => item.release_year)
.sort((a, b) => (b.release_year || 0) - (a.release_year || 0))
.slice(0, 30);
}, [filteredItems]);
const recentlyAdded = useMemo(() => {
return [...filteredItems]
.sort((a, b) => parseDate(b.first_imported_at) - parseDate(a.first_imported_at))
.slice(0, 30);
}, [filteredItems]);
const genresMap = useMemo(() => {
const map = new Map();
filteredItems.forEach((item) => {
const genres = Array.isArray(item.genres) ? item.genres : [];
if (genres.length === 0) return;
const primary = genres[0];
if (!map.has(primary)) {
map.set(primary, []);
}
map.get(primary).push(item);
});
return map;
}, [filteredItems]);
const genreCarousels = useMemo(() => {
const entries = Array.from(genresMap.entries());
return entries
.map(([genre, genreItems]) => ({
genre,
items: genreItems
.slice()
.sort((a, b) => parseDate(b.first_imported_at) - parseDate(a.first_imported_at))
.slice(0, 25),
}))
.filter((entry) => entry.items.length > 0)
.slice(0, 12);
}, [genresMap]);
const primaryLibraryId = useMemo(
() => (relevantLibraryIds.length === 1 ? relevantLibraryIds[0] : null),
[relevantLibraryIds]
);
const hasLibraries = relevantLibraryIds.length > 0;
const aggregatedSubtitle = useMemo(() => {
if (!libraries || libraries.length === 0) {
return 'No libraries configured yet.';
}
if (!hasLibraries) {
return isMovies
? 'No movie libraries configured.'
: 'No TV show libraries configured.';
}
const label = isMovies ? 'movie' : 'TV show';
const count = relevantLibraryIds.length;
return `Aggregating ${count} ${label} librar${count === 1 ? 'y' : 'ies'}.`;
}, [libraries, hasLibraries, relevantLibraryIds, isMovies]);
const canManageSingleLibrary = Boolean(primaryLibraryId);
const sortedLibraryItems = useMemo(() => {
switch (sortOption) {
case 'alpha':
return [...filteredItems].sort((a, b) => {
const aTitle = (a.sort_title || a.title || '').toLowerCase();
const bTitle = (b.sort_title || b.title || '').toLowerCase();
return aTitle.localeCompare(bTitle);
});
case 'year':
return [...filteredItems].sort((a, b) => (b.release_year || 0) - (a.release_year || 0));
case 'recent':
return [...filteredItems].sort((a, b) => parseDate(b.first_imported_at) - parseDate(a.first_imported_at));
default:
return filteredItems;
}
}, [filteredItems, sortOption]);
const availableLetters = useMemo(() => {
if (sortOption !== 'alpha') return new Set();
const letters = new Set();
sortedLibraryItems.forEach((item) => {
const name = item.sort_title || item.title || '';
const firstChar = name.charAt(0).toUpperCase();
const key = /[A-Z]/.test(firstChar) ? firstChar : '#';
letters.add(key);
});
return letters;
}, [sortedLibraryItems, sortOption]);
const letterRefs = useRef({});
const handleLetterSelect = (letter) => {
const node = letterRefs.current[letter];
if (node && node.scrollIntoView) {
node.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
const handleOpenItem = (item) => {
setPlaybackModalOpen(true);
openItem(item.id).catch((error) => {
console.error('Failed to open media item', error);
setPlaybackModalOpen(false);
notifications.show({
title: 'Error loading media',
message: 'Unable to open media details.',
color: 'red',
});
});
};
const refreshItem = async (id) => {
try {
const data = await API.getMediaItem(id);
upsertItems([data]);
} catch (error) {
console.error('Failed to refresh media item', error);
}
};
const handleMarkWatched = async (item) => {
try {
if (item.item_type === 'show') {
const response = await API.markSeriesWatched(item.id);
if (response?.item) {
upsertItems([response.item]);
} else {
await refreshItem(item.id);
}
notifications.show({
title: 'Series updated',
message: 'All episodes marked as watched.',
color: 'green',
});
} else {
await API.markMediaItemWatched(item.id);
await refreshItem(item.id);
notifications.show({
title: 'Marked as watched',
message: `${item.title} marked as watched.`,
color: 'green',
});
}
} catch (error) {
console.error('Failed to mark watched', error);
notifications.show({
title: 'Action failed',
message: 'Unable to mark item as watched at this time.',
color: 'red',
});
}
};
const handleMarkUnwatched = async (item) => {
try {
if (item.item_type === 'show') {
const response = await API.markSeriesUnwatched(item.id);
if (response?.item) {
upsertItems([response.item]);
} else {
await refreshItem(item.id);
}
notifications.show({
title: 'Series updated',
message: 'Watch history cleared.',
color: 'blue',
});
} else {
await API.clearMediaItemProgress(item.id);
await refreshItem(item.id);
notifications.show({
title: 'Progress cleared',
message: `${item.title} marked as unwatched.`,
color: 'blue',
});
}
} catch (error) {
console.error('Failed to mark unwatched', error);
notifications.show({
title: 'Action failed',
message: 'Unable to update watch state right now.',
color: 'red',
});
}
};
const handleDeleteItem = async (item) => {
const label = item.item_type === 'show' ? 'series and all episodes' : 'media item';
if (!window.confirm(`Delete ${item.title}? This will remove the ${label} from your library.`)) {
return;
}
try {
await API.deleteMediaItem(item.id);
removeItems(item.id);
notifications.show({
title: 'Deleted',
message: `${item.title} removed from your library.`,
color: 'red',
});
} catch (error) {
console.error('Failed to delete media item', error);
notifications.show({
title: 'Delete failed',
message: 'Unable to delete this item right now.',
color: 'red',
});
}
};
const handleContextMenu = (event, item) => {
event.preventDefault();
setContextMenu({
x: event.clientX,
y: event.clientY,
item,
});
};
useEffect(() => {
if (!contextMenu) return;
const handleOutsideClick = () => setContextMenu(null);
document.addEventListener('click', handleOutsideClick);
return () => document.removeEventListener('click', handleOutsideClick);
}, [contextMenu]);
// --- SCAN CONTROLS ---
// Open the drawer only (do NOT start a scan)
const handleOpenScanDrawer = () => {
if (!hasLibraries) {
notifications.show({
title: 'No libraries configured',
message: 'Add a library to manage scans.',
color: 'yellow',
});
return;
}
if (!canManageSingleLibrary) {
notifications.show({
title: 'Multiple libraries detected',
message: 'Open the Libraries page to manage individual scans.',
color: 'yellow',
});
return;
}
setScanDrawerOpen(true);
};
// Explicitly start a scan (quick or full)
const handleStartScan = async (full = false) => {
if (!hasLibraries) {
notifications.show({
title: 'Scan unavailable',
message: 'Add a library before starting a scan.',
color: 'yellow',
});
return;
}
if (!primaryLibraryId) {
notifications.show({
title: 'Scan unavailable',
message: 'Choose a specific library from the Libraries page to start a scan.',
color: 'yellow',
});
return;
}
try {
await triggerScan(primaryLibraryId, { full });
notifications.show({
title: full ? 'Full scan started' : 'Scan started',
message: full
? 'A full library scan has been queued.'
: 'Library scan has been queued.',
color: 'blue',
});
setScanDrawerOpen(true);
} catch (error) {
console.error('Failed to start scan', error);
notifications.show({
title: 'Scan failed',
message: 'Unable to start scan at this time.',
color: 'red',
});
}
};
// Cancel a running scan by job id
const handleCancelScanJob = async (jobId) => {
try {
const updated = await API.cancelLibraryScan(jobId);
if (updated) {
upsertScan(updated);
}
notifications.show({
title: 'Scan canceled',
message: 'The running scan has been stopped.',
color: 'yellow',
});
} catch (e) {
console.error(e);
notifications.show({
title: 'Cancel failed',
message: 'Could not cancel this scan.',
color: 'red',
});
}
};
// Remove a queued scan by job id
const handleDeleteQueuedScan = async (jobId) => {
try {
await API.deleteLibraryScan(jobId);
removeScan(jobId);
notifications.show({
title: 'Removed from queue',
message: 'The queued scan was removed.',
color: 'green',
});
} catch (e) {
console.error(e);
notifications.show({
title: 'Remove failed',
message: 'Could not remove this queued scan.',
color: 'red',
});
}
};
const recommendedView = (
<Stack spacing="xl">
<MediaCarousel
title="Continue Watching"
items={continueWatching}
onSelect={handleOpenItem}
onContextMenu={handleContextMenu}
emptyMessage="Start watching to see items here."
/>
<MediaCarousel
title="Recently Released"
items={recentlyReleased}
onSelect={handleOpenItem}
onContextMenu={handleContextMenu}
/>
<MediaCarousel
title="Recently Added"
items={recentlyAdded}
onSelect={handleOpenItem}
onContextMenu={handleContextMenu}
/>
</Stack>
);
const libraryView = (() => {
if (sortOption === 'alpha') {
letterRefs.current = {};
}
return (
<Box style={{ position: 'relative' }}>
<Stack spacing="lg">
<Group justify="space-between" align="center">
<Select
label="Sort by"
data={SORT_OPTIONS}
value={sortOption}
onChange={(value) => setSortOption(value || 'default')}
w={220}
/>
<Button
variant="subtle"
leftSection={<RefreshCcw size={16} />}
onClick={() => fetchItems(relevantLibraryIds)}
>
Refresh
</Button>
</Group>
{sortOption === 'default' ? (
recommendedView
) : sortOption === 'genre' ? (
<Stack spacing="xl">
{genreCarousels.map(({ genre, items: genreItems }) => (
<MediaCarousel
key={genre}
title={genre}
items={genreItems}
onSelect={handleOpenItem}
onContextMenu={handleContextMenu}
/>
))}
</Stack>
) : (
<Box style={{ position: 'relative' }}>
{sortOption === 'alpha' && availableLetters.size > 0 && (
<AlphabetSidebar available={availableLetters} onSelect={handleLetterSelect} />
)}
<MediaGrid
items={sortedLibraryItems}
loading={itemsLoading}
onSelect={handleOpenItem}
onContextMenu={handleContextMenu}
groupByLetter={sortOption === 'alpha'}
letterRefs={letterRefs}
cardSize="md"
/>
</Box>
)}
</Stack>
</Box>
);
})();
const categoriesView = (
<Stack spacing="xl">
{genreCarousels.length === 0 ? (
<Text c="dimmed">No categories available.</Text>
) : (
genreCarousels.map(({ genre, items: genreItems }) => (
<MediaCarousel
key={genre}
title={genre}
items={genreItems}
onSelect={handleOpenItem}
onContextMenu={handleContextMenu}
/>
))
)}
</Stack>
);
const contextItem = contextMenu?.item;
const contextStatus = contextItem?.watch_summary?.status;
return (
<Box p="lg">
<Stack spacing="xl">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack spacing={4}>
<Title order={2}>{isMovies ? 'Movies' : 'TV Shows'}</Title>
<Text c="dimmed" size="sm">
{aggregatedSubtitle}
</Text>
</Stack>
<Group align="center" gap="sm">
<ActionIcon
variant="light"
color="blue"
onClick={handleOpenScanDrawer}
title="View recent scans"
>
<ListChecks size={18} />
</ActionIcon>
<ActionIcon
variant="filled"
color="blue"
onClick={() => handleStartScan(false)}
title="Start library scan"
>
<RefreshCcw size={18} />
</ActionIcon>
</Group>
</Group>
<Group justify="space-between" align="center" wrap="wrap">
<SegmentedControl value={activeTab} onChange={setActiveTab} data={TABS} />
<Group align="center" gap="sm">
<TextInput
leftSection={<Search size={16} />}
placeholder="Search library"
value={searchTerm}
onChange={(event) => setSearchTerm(event.currentTarget.value)}
w={260}
/>
</Group>
</Group>
{relevantLibraryIds.length > 0 ? (
<div>
{activeTab === 'recommended' && recommendedView}
{activeTab === 'library' && libraryView}
{activeTab === 'categories' && categoriesView}
</div>
) : (
<Stack align="center" py="xl" spacing="md">
<Text c="dimmed">Add a media library to begin importing content.</Text>
</Stack>
)}
</Stack>
<LibraryScanDrawer
opened={scanDrawerOpen && canManageSingleLibrary}
onClose={() => setScanDrawerOpen(false)}
libraryId={primaryLibraryId}
// NEW: enable controls inside the drawer
onCancelJob={handleCancelScanJob}
onDeleteQueuedJob={handleDeleteQueuedScan}
onStartScan={() => handleStartScan(false)}
onStartFullScan={() => handleStartScan(true)}
/>
<MediaDetailModal
opened={playbackModalOpen}
onClose={() => {
setPlaybackModalOpen(false);
closeItem();
}}
/>
{contextMenu && contextItem && contextMenuPosition && (
<Portal>
<Paper
shadow="md"
p="xs"
withBorder
style={{
position: 'fixed',
top: contextMenuPosition.top,
left: contextMenuPosition.left,
zIndex: 1000,
minWidth: 220,
background: 'rgba(18, 21, 35, 0.97)',
}}
>
<Stack spacing={4}>
<Button
variant="subtle"
leftSection={<Play size={16} />}
onClick={() => {
handleOpenItem(contextItem);
setContextMenu(null);
}}
>
{contextStatus === 'in_progress' ? 'Continue Watching' : 'Play'}
</Button>
<Button
variant="subtle"
leftSection={<RefreshCcw size={16} />}
onClick={async () => {
await API.refreshMediaItemMetadata(contextItem.id);
notifications.show({
title: 'Metadata queued',
message: 'Metadata refresh has been requested.',
color: 'blue',
});
setContextMenu(null);
}}
>
Refresh Metadata
</Button>
<Divider my="xs" />
{contextStatus === 'watched' ? (
<Button
variant="subtle"
onClick={async () => {
await handleMarkUnwatched(contextItem);
setContextMenu(null);
}}
>
Mark unwatched
</Button>
) : (
<>
<Button
variant="subtle"
onClick={async () => {
await handleMarkWatched(contextItem);
setContextMenu(null);
}}
>
Mark watched
</Button>
{contextStatus === 'in_progress' && (
<Button
variant="subtle"
leftSection={<XCircle size={16} />}
onClick={async () => {
await handleMarkUnwatched(contextItem);
setContextMenu(null);
}}
>
Clear Watch Progress
</Button>
)}
</>
)}
<Button
variant="subtle"
color="red"
leftSection={<Trash2 size={16} />}
onClick={async () => {
await handleDeleteItem(contextItem);
setContextMenu(null);
}}
>
Delete
</Button>
</Stack>
</Paper>
</Portal>
)}
</Box>
);
};
export default LibraryPage;

View file

@ -11,13 +11,19 @@ import useUserAgentsStore from '../store/userAgents';
import useStreamProfilesStore from '../store/streamProfiles';
import {
Accordion,
ActionIcon,
Alert,
Anchor,
Badge,
Box,
Button,
Center,
Flex,
Group,
Loader,
FileInput,
List,
Modal,
MultiSelect,
Select,
Stack,
@ -25,6 +31,7 @@ import {
Text,
TextInput,
NumberInput,
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 { shallow } from 'zustand/shallow';
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';
const TIMEZONE_FALLBACKS = [
'UTC',
@ -183,6 +195,14 @@ const SettingsPage = () => {
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);
const [networkAccessError, setNetworkAccessError] = useState(null);
@ -199,6 +219,22 @@ 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 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 [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);
const [comskipFile, setComskipFile] = useState(null);
@ -400,6 +436,12 @@ const SettingsPage = () => {
loadComskipConfig();
}, []);
useEffect(() => {
if (authUser?.user_level === USER_LEVELS.ADMIN) {
fetchMediaLibraries();
}
}, [authUser?.user_level, fetchMediaLibraries]);
const onSubmit = async () => {
const values = form.getValues();
const changedSettings = {};
@ -449,6 +491,213 @@ const SettingsPage = () => {
}
};
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 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);
});
}, [tmdbSetting?.value, validateTmdbKeyValue]);
const handleSaveTmdbKey = async () => {
const trimmedKey = tmdbKey.trim();
setSavingTmdbKey(true);
try {
let validationResult = null;
if (trimmedKey) {
validationResult = await validateTmdbKeyValue(trimmedKey);
if (!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;
}
} else {
validationResult = await validateTmdbKeyValue('');
}
if (tmdbSetting && tmdbSetting.id) {
await API.updateSetting({
...tmdbSetting,
value: trimmedKey,
});
} else {
await API.createSetting({
key: 'tmdb-api-key',
name: 'TMDB API Key',
value: trimmedKey,
});
}
const provider = validationResult?.provider || activeMetadataSource;
const usingFallback = provider === 'movie-db';
notifications.show({
title:
provider === 'tmdb'
? 'TMDB key saved'
: trimmedKey
? 'Saved with fallback'
: 'Using fallback metadata',
message:
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.',
color:
provider === 'tmdb'
? 'green'
: usingFallback
? 'blue'
: 'red',
});
} 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 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 onNetworkAccessSubmit = async () => {
setNetworkAccessSaved(false);
setNetworkAccessError(null);
@ -706,6 +955,174 @@ 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)}
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>
)}
</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>
@ -1184,9 +1601,66 @@ const SettingsPage = () => {
</Accordion.Panel>
</Accordion.Item>
</>
)}
</Accordion>
</Box>
)}
</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 keysDispatcharr 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}

View file

@ -34,6 +34,11 @@ const formatDuration = (seconds) => {
const VODCard = ({ vod, onClick }) => {
const isEpisode = vod.type === 'episode';
const posterSrc =
vod.movie_image || vod.logo?.cache_url || vod.logo?.url || null;
const fallbackPoster =
vod.custom_properties?.poster_url || vod.custom_properties?.cover || null;
const imageSrc = posterSrc || fallbackPoster;
const getDisplayTitle = () => {
if (isEpisode && vod.series) {
@ -71,9 +76,9 @@ const VODCard = ({ vod, onClick }) => {
>
<Card.Section>
<Box style={{ position: 'relative', height: 300 }}>
{vod.logo?.url ? (
{imageSrc ? (
<Image
src={vod.logo.url}
src={imageSrc}
height={300}
alt={vod.name}
fit="contain"
@ -163,6 +168,12 @@ const VODCard = ({ vod, onClick }) => {
};
const SeriesCard = ({ series, onClick }) => {
const seriesCover =
series.series_image ||
series.logo?.cache_url ||
series.logo?.url ||
series.custom_properties?.poster_url ||
null;
return (
<Card
shadow="sm"
@ -174,9 +185,9 @@ const SeriesCard = ({ series, onClick }) => {
>
<Card.Section>
<Box style={{ position: 'relative', height: 300 }}>
{series.logo?.url ? (
{seriesCover ? (
<Image
src={series.logo.url}
src={seriesCover}
height={300}
alt={series.name}
fit="contain"

View file

@ -0,0 +1,349 @@
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import API from '../api';
import useMediaLibraryStore from './mediaLibrary';
const DEFAULT_STAGE = {
status: 'pending',
processed: 0,
total: 0,
};
const toNumber = (value) => {
const num = Number(value);
return Number.isFinite(num) ? num : 0;
};
const normalizeStage = (stage = {}, fallback = {}) => {
const source = {
...DEFAULT_STAGE,
...fallback,
...stage,
};
return {
status: source.status || 'pending',
processed: toNumber(source.processed),
total: toNumber(source.total),
};
};
const normalizeStages = (scan = {}) => {
const stageFromFields = (prefix) => ({
status: scan?.[`${prefix}_status`],
processed: scan?.[`${prefix}_processed`],
total: scan?.[`${prefix}_total`],
});
const sourceStages = scan?.stages || {};
return {
discovery: normalizeStage(sourceStages.discovery, stageFromFields('discovery')),
metadata: normalizeStage(sourceStages.metadata, stageFromFields('metadata')),
artwork: normalizeStage(sourceStages.artwork, stageFromFields('artwork')),
};
};
const normalizeScanEntry = (scan) => {
if (!scan) return scan;
let processed = scan.processed_files ?? scan.processed;
if (processed == null && scan.status === 'completed' && scan.total_files != null) {
processed = scan.total_files;
}
if (processed == null) {
processed = 0;
}
processed = toNumber(processed);
const stages = normalizeStages(scan);
return {
...scan,
processed,
processed_files: processed,
stages,
discovery_status: stages.discovery.status,
discovery_processed: stages.discovery.processed,
discovery_total: stages.discovery.total,
metadata_status: stages.metadata.status,
metadata_processed: stages.metadata.processed,
metadata_total: stages.metadata.total,
artwork_status: stages.artwork.status,
artwork_processed: stages.artwork.processed,
artwork_total: stages.artwork.total,
};
};
const useLibraryStore = create(
immer((set, get) => ({
libraries: [],
loading: false,
scans: {},
scansLoading: false,
error: null,
selectedLibraryId: null,
filters: {
search: '',
type: 'all',
autoScan: 'all',
},
setSelectedLibrary: (id) =>
set((state) => {
state.selectedLibraryId = id;
}),
setFilters: (filters) =>
set((state) => {
state.filters = { ...state.filters, ...filters };
}),
fetchLibraries: async () => {
set((state) => {
state.loading = true;
state.error = null;
});
try {
const params = new URLSearchParams();
const { filters } = get();
if (filters.type !== 'all') {
params.append('library_type', filters.type);
}
if (filters.autoScan !== 'all') {
params.append('auto_scan_enabled', filters.autoScan === 'enabled');
}
if (filters.search) {
params.append('search', filters.search);
}
const data = await API.getMediaLibraries(params);
set((state) => {
state.libraries = Array.isArray(data) ? data : data.results || [];
state.loading = false;
});
} catch (error) {
console.error('Failed to fetch libraries', error);
set((state) => {
state.error = 'Failed to load libraries';
state.loading = false;
});
}
},
createLibrary: async (payload) => {
const response = await API.createMediaLibrary(payload);
set((state) => {
state.libraries.push(response);
});
return response;
},
updateLibrary: async (id, payload) => {
const response = await API.updateMediaLibrary(id, payload);
set((state) => {
const index = state.libraries.findIndex((lib) => lib.id === id);
if (index >= 0) {
state.libraries[index] = { ...state.libraries[index], ...response };
}
});
return response;
},
deleteLibrary: async (id) => {
await API.deleteMediaLibrary(id);
set((state) => {
state.libraries = state.libraries.filter((lib) => lib.id !== id);
if (state.selectedLibraryId === id) {
state.selectedLibraryId = null;
}
delete state.scans[id];
});
},
purgeCompletedScans: async (options = {}) => {
const response = await API.purgeLibraryScans(options);
const statuses =
Array.isArray(options.statuses) && options.statuses.length > 0
? options.statuses
: ['completed', 'failed', 'cancelled'];
const libraryFilter =
options.library !== undefined && options.library !== null
? Number(options.library)
: null;
set((state) => {
const keysToUpdate = new Set(['all']);
if (libraryFilter !== null) {
keysToUpdate.add(libraryFilter);
} else {
Object.keys(state.scans).forEach((key) => keysToUpdate.add(key));
}
keysToUpdate.forEach((key) => {
const list = state.scans[key];
if (!Array.isArray(list)) return;
state.scans[key] = list.filter((scan) => {
const statusMatch = statuses.includes(scan.status);
const libraryMatch =
libraryFilter === null || Number(scan.library) === libraryFilter;
return !(statusMatch && libraryMatch);
});
});
});
return response;
},
triggerScan: async (id, options = {}) => {
const response = await API.triggerLibraryScan(id, options);
set((state) => {
if (!state.scans[id]) {
state.scans[id] = [];
}
const normalized = normalizeScanEntry(response);
state.scans[id] = [normalized, ...(state.scans[id] || [])];
state.scans['all'] = [normalized, ...(state.scans['all'] || [])];
});
return response;
},
fetchScans: async (libraryId, options = {}) => {
const { background = false } = options;
if (!background) {
set((state) => {
state.scansLoading = true;
});
}
try {
const params = new URLSearchParams();
if (libraryId) {
params.append('library', libraryId);
}
const response = await API.getLibraryScans(params);
set((state) => {
const payload = Array.isArray(response)
? response
: response.results || [];
state.scans[libraryId || 'all'] = payload.map((scan) =>
normalizeScanEntry(scan)
);
if (!background) {
state.scansLoading = false;
}
});
} catch (error) {
console.error('Failed to fetch scans', error);
if (!background) {
set((state) => {
state.scansLoading = false;
});
}
}
},
applyScanUpdate: (event) =>
set((state) => {
if (!event?.scan_id) return;
const scanId = event.scan_id;
const libraryId = event.library_id || null;
if (event.media_item) {
useMediaLibraryStore.getState().upsertItems([event.media_item]);
}
const updateList = (list) => {
const items = list ? [...list] : [];
const index = items.findIndex((scan) => String(scan.id) === String(scanId));
const existing = index >= 0 ? items[index] : undefined;
const processedValue = toNumber(
event.processed_files ??
event.processed ??
existing?.processed_files ??
existing?.processed ??
0
);
const stageSource = {
...(existing || {}),
...event,
stages: event.stages ?? existing?.stages,
};
const stages = normalizeStages(stageSource);
const updatedEntry = {
id: scanId,
library: libraryId ?? existing?.library ?? null,
library_name: event.library_name || existing?.library_name || '',
status: event.status || 'running',
summary: event.summary || event.message || items[index]?.summary || '',
matched_items: event.matched ?? items[index]?.matched_items ?? null,
unmatched_files: event.unmatched ?? items[index]?.unmatched_files ?? null,
total_files: event.total ?? event.files ?? items[index]?.total_files ?? null,
new_files: event.new_files ?? items[index]?.new_files ?? null,
updated_files: event.updated_files ?? items[index]?.updated_files ?? null,
removed_files: event.removed_files ?? items[index]?.removed_files ?? null,
processed: processedValue,
processed_files: processedValue,
stages,
discovery_status: stages.discovery.status,
discovery_processed: stages.discovery.processed,
discovery_total: stages.discovery.total,
metadata_status: stages.metadata.status,
metadata_processed: stages.metadata.processed,
metadata_total: stages.metadata.total,
artwork_status: stages.artwork.status,
artwork_processed: stages.artwork.processed,
artwork_total: stages.artwork.total,
created_at:
(items[index]?.created_at || new Date().toISOString()),
finished_at:
['completed', 'failed', 'cancelled'].includes(event.status)
? new Date().toISOString()
: items[index]?.finished_at || null,
updated_at: new Date().toISOString(),
};
if (index >= 0) {
items[index] = normalizeScanEntry({ ...items[index], ...updatedEntry });
} else {
items.unshift(normalizeScanEntry(updatedEntry));
}
return items;
};
const keysToUpdate = [libraryId || 'all'];
if (libraryId && libraryId !== 'all') {
keysToUpdate.push('all');
}
keysToUpdate.forEach((key) => {
state.scans[key] = updateList(state.scans[key]);
});
}),
upsertScan: (scan) =>
set((state) => {
if (!scan || !scan.id) return;
const normalized = normalizeScanEntry(scan);
const targetKeys = new Set(['all']);
if (normalized.library) {
targetKeys.add(normalized.library);
}
targetKeys.forEach((key) => {
const items = state.scans[key] ? [...state.scans[key]] : [];
const index = items.findIndex((entry) => String(entry.id) === String(normalized.id));
if (index >= 0) {
items[index] = { ...items[index], ...normalized };
} else {
items.unshift(normalized);
}
state.scans[key] = items;
});
}),
removeScan: (scanId) =>
set((state) => {
if (!scanId) return;
Object.keys(state.scans).forEach((key) => {
const list = state.scans[key];
if (!Array.isArray(list)) return;
state.scans[key] = list.filter((scan) => String(scan.id) !== String(scanId));
});
}),
}))
);
export default useLibraryStore;

View file

@ -0,0 +1,411 @@
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import API from '../api';
import useAuthStore from './auth';
const METADATA_REFRESH_COOLDOWN_MS = 60 * 1000;
const metadataRequestCache = new Map();
const shouldRefreshMetadata = (item) => {
if (!item || !item.id) return false;
return !item.poster_url || !item.metadata_last_synced_at;
};
const queueMetadataRefresh = (items, { force = false } = {}) => {
if (!Array.isArray(items) || items.length === 0) return;
if (!force) {
// Discovery pipeline now queues metadata/artwork on the backend.
// Skip client-side batching to avoid flooding the API when large scans run.
items.forEach((item) => {
if (item?.id) {
metadataRequestCache.delete(item.id);
}
});
return;
}
const now = Date.now();
items.forEach((item) => {
if (!shouldRefreshMetadata(item)) {
if (item?.id) {
metadataRequestCache.delete(item.id);
}
return;
}
const lastRequested = metadataRequestCache.get(item.id) || 0;
if (!force && now - lastRequested < METADATA_REFRESH_COOLDOWN_MS) return;
metadataRequestCache.set(item.id, now);
API.refreshMediaItemMetadata(item.id).catch((error) => {
console.debug('Auto metadata refresh failed', error);
metadataRequestCache.delete(item.id);
});
});
};
const initialFilters = {
type: 'all',
search: '',
status: 'all',
year: '',
};
const getAuthSnapshot = () => {
const authState = useAuthStore.getState();
return {
isAuthenticated: authState.isAuthenticated,
userId: authState.user?.id ?? null,
};
};
const resetStateForUser = (state, userId) => {
state.items = [];
state.loading = false;
state.error = null;
state.total = 0;
state.activeItem = null;
state.activeItemError = null;
state.activeProgress = null;
state.activeItemLoading = false;
state.resumePrompt = null;
state.selectedLibraryId = null;
state.activeLibraryIds = [];
state.filters = { ...initialFilters };
state.ownerUserId = userId ?? null;
};
const useMediaLibraryStore = create(
immer((set, get) => ({
ownerUserId: null,
items: [],
loading: false,
error: null,
total: 0,
activeItem: null,
activeItemError: null,
activeProgress: null,
activeItemLoading: false,
resumePrompt: null,
selectedLibraryId: null,
activeLibraryIds: [],
filters: { ...initialFilters },
applyUserContext: (userId) =>
set((state) => {
const normalized = userId ?? null;
if (state.ownerUserId === normalized) {
return;
}
resetStateForUser(state, normalized);
}),
setFilters: (updated) =>
set((state) => {
state.filters = { ...state.filters, ...updated };
}),
setSelectedLibraryId: (libraryId) =>
set((state) => {
const { userId } = getAuthSnapshot();
if (state.ownerUserId == null) {
state.ownerUserId = userId ?? null;
} else if (userId !== null && state.ownerUserId !== userId) {
resetStateForUser(state, userId);
}
state.selectedLibraryId = libraryId;
state.activeLibraryIds = libraryId != null ? [Number(libraryId)] : [];
}),
resetFilters: () =>
set((state) => {
state.filters = { ...initialFilters };
}),
upsertItems: (itemsToUpsert) =>
set((state) => {
const { userId } = getAuthSnapshot();
if (!userId) {
return;
}
if (state.ownerUserId == null) {
state.ownerUserId = userId;
} else if (state.ownerUserId !== userId) {
return;
}
if (!Array.isArray(itemsToUpsert) || itemsToUpsert.length === 0) {
return;
}
const activeLibraryIds = get().activeLibraryIds || [];
const filterByLibrary = activeLibraryIds.length > 0;
const byId = new Map();
state.items.forEach((item) => {
byId.set(item.id, item);
});
itemsToUpsert.forEach((incoming) => {
if (!incoming || typeof incoming !== 'object' || !incoming.id) {
return;
}
if (
filterByLibrary &&
incoming.library &&
!activeLibraryIds.includes(Number(incoming.library))
) {
return;
}
const existing = byId.get(incoming.id) || {};
byId.set(incoming.id, { ...existing, ...incoming });
});
const sorted = Array.from(byId.values()).sort((a, b) => {
const aTitle = (a.sort_title || a.title || '').toLowerCase();
const bTitle = (b.sort_title || b.title || '').toLowerCase();
return aTitle.localeCompare(bTitle);
});
state.items = sorted;
state.total = sorted.length;
}),
removeItems: (ids) =>
set((state) => {
const { userId } = getAuthSnapshot();
if (!userId) {
return;
}
if (state.ownerUserId == null) {
state.ownerUserId = userId;
} else if (state.ownerUserId !== userId) {
return;
}
const idSet = new Set(Array.isArray(ids) ? ids : [ids]);
state.items = state.items.filter((item) => !idSet.has(item.id));
state.total = state.items.length;
}),
fetchItems: async (libraryIds) => {
const { isAuthenticated, userId } = getAuthSnapshot();
if (!isAuthenticated || !userId) {
set((state) => {
if (state.ownerUserId == null) {
state.ownerUserId = userId ?? null;
} else if (userId !== null && state.ownerUserId !== userId) {
resetStateForUser(state, userId);
return;
}
});
return;
}
const normalizedIds = Array.isArray(libraryIds)
? Array.from(
new Set(
libraryIds
.map((id) => Number(id))
.filter((id) => Number.isFinite(id))
)
)
: libraryIds != null
? [Number(libraryIds)].filter((id) => Number.isFinite(id))
: [];
set((state) => {
if (state.ownerUserId == null) {
state.ownerUserId = userId;
} else if (state.ownerUserId !== userId) {
resetStateForUser(state, userId);
}
state.loading = true;
state.error = null;
state.activeLibraryIds = normalizedIds;
state.selectedLibraryId = normalizedIds.length > 0 ? normalizedIds[0] : null;
});
try {
const { filters } = get();
const params = new URLSearchParams();
normalizedIds.forEach((id) => params.append('library', id));
if (filters.type !== 'all') {
params.append('item_type', filters.type);
}
if (filters.status !== 'all') {
params.append('status', filters.status);
}
if (filters.year) {
params.append('release_year', filters.year);
}
if (filters.search) {
params.append('search', filters.search);
}
const response = await API.getMediaItems(params);
const results = response.results || response;
const itemsArray = Array.isArray(results) ? results : [];
set((state) => {
if (state.ownerUserId !== userId) {
return;
}
state.activeLibraryIds = normalizedIds;
state.selectedLibraryId = normalizedIds.length > 0 ? normalizedIds[0] : null;
state.items = itemsArray;
state.total = response.count || itemsArray.length || 0;
state.loading = false;
});
queueMetadataRefresh(itemsArray);
} catch (error) {
console.error('Failed to fetch media items', error);
set((state) => {
if (state.ownerUserId !== userId) {
return;
}
state.error = 'Failed to load media items';
state.loading = false;
});
}
},
openItem: async (id) => {
const { isAuthenticated, userId } = getAuthSnapshot();
if (!isAuthenticated || !userId) {
throw new Error('Authentication required');
}
const currentItems = get().items || [];
const fallbackItem =
currentItems.find((item) => item.id === id) || null;
set((state) => {
if (state.ownerUserId == null) {
state.ownerUserId = userId;
} else if (state.ownerUserId !== userId) {
resetStateForUser(state, userId);
}
state.activeItemLoading = true;
state.resumePrompt = null;
state.activeProgress = null;
state.activeItemError = null;
state.activeItem = fallbackItem ? { ...fallbackItem } : null;
});
try {
const response = await API.getMediaItem(id, {
suppressErrorNotification: true,
});
const progress = response.watch_progress || null;
set((state) => {
if (state.ownerUserId !== userId) {
return;
}
state.activeItem = response;
state.activeItemLoading = false;
state.activeProgress = progress;
state.activeItemError = null;
});
get().upsertItems([response]);
queueMetadataRefresh([response], { force: true });
return response;
} catch (error) {
console.error('Failed to load media item', error);
if (fallbackItem) {
let files = [];
try {
files = await API.getMediaItemFiles(id, {
suppressErrorNotification: true,
});
} catch (fileError) {
console.error('Failed to load media files for fallback item', fileError);
}
const fallbackDetail = {
...fallbackItem,
files,
metadataPending: true,
};
set((state) => {
if (state.ownerUserId !== userId) {
return;
}
state.activeItem = fallbackDetail;
state.activeItemLoading = false;
state.activeProgress = fallbackItem.watch_progress || null;
state.activeItemError = 'metadata_pending';
});
queueMetadataRefresh([fallbackItem]);
return fallbackDetail;
}
set((state) => {
if (state.ownerUserId === userId) {
state.activeItemLoading = false;
state.activeItemError = 'load_failed';
}
});
throw error;
}
},
closeItem: () =>
set((state) => {
state.activeItem = null;
state.activeItemError = null;
state.resumePrompt = null;
state.activeProgress = null;
}),
setActiveProgress: (progress) =>
set((state) => {
const { userId } = getAuthSnapshot();
if (!userId || state.ownerUserId !== userId) {
return;
}
if (state.activeItem) {
state.activeItem = { ...state.activeItem, watch_progress: progress };
}
state.items = state.items.map((item) =>
item.id === state.activeItem?.id
? { ...item, watch_progress: progress }
: item
);
state.activeProgress = progress;
}),
requestResume: async (progressId) => {
const { isAuthenticated, userId } = getAuthSnapshot();
if (!isAuthenticated || !userId || !progressId) return null;
try {
const response = await API.resumeMediaProgress(progressId);
set((state) => {
if (state.ownerUserId !== userId) {
return;
}
state.resumePrompt = response;
});
return response;
} catch (error) {
console.error('Failed to get resume info', error);
return null;
}
},
clearResumePrompt: () =>
set((state) => {
state.resumePrompt = null;
}),
}))
);
if (typeof window !== 'undefined') {
const initialUserId = getAuthSnapshot().userId;
useMediaLibraryStore.getState().applyUserContext(initialUserId);
useAuthStore.subscribe(
(state) => state.user?.id ?? null,
(userId) => {
useMediaLibraryStore.getState().applyUserContext(userId);
}
);
}
export default useMediaLibraryStore;

View file

@ -210,6 +210,17 @@ const useVODStore = create((set, get) => ({
bitrate: response.bitrate || 0,
video: response.video || {},
audio: response.audio || {},
library_sources:
response.library_sources ||
(response.library_item_id
? [
{
library_id: response.m3u_account?.id || null,
library_name: response.m3u_account?.name || 'Library',
media_item_id: response.library_item_id,
},
]
: []),
};
set({ loading: false }); // Only update loading state
@ -332,6 +343,8 @@ const useVODStore = create((set, get) => ({
tmdb_id: response.tmdb_id || '',
imdb_id: response.imdb_id || '',
episode_count: response.episode_count || 0,
library_sources: response.library_sources || [],
library_item_id: response.library_item_id || null,
// Additional provider fields
backdrop_path: response.custom_properties?.backdrop_path || [],
release_date: response.release_date || '',
@ -351,9 +364,9 @@ const useVODStore = create((set, get) => ({
seasonEpisodes.forEach((episode) => {
const episodeData = {
id: episode.id,
stream_id: episode.id,
name: episode.title || '',
description: episode.plot || '',
stream_id: episode.stream_id || episode.id,
name: episode.title || episode.name || '',
description: episode.plot || episode.description || '',
season_number: parseInt(seasonNumber) || 0,
episode_number: episode.episode_number || 0,
duration_secs: episode.duration_secs || null,
@ -364,12 +377,15 @@ const useVODStore = create((set, get) => ({
name: seriesInfo.name,
},
type: 'episode',
uuid: episode.id, // Use the stream ID as UUID for playback
uuid: episode.uuid || episode.id,
logo: episode.movie_image ? { url: episode.movie_image } : null,
air_date: episode.air_date || null,
movie_image: episode.movie_image || null,
tmdb_id: episode.tmdb_id || '',
imdb_id: episode.imdb_id || '',
library_media_item_ids:
episode.library_media_item_ids || [],
providers: episode.providers || [],
};
episodesData[episode.id] = episodeData;
});

View file

@ -15,7 +15,14 @@ const useVideoStore = create((set) => ({
isVisible: true,
streamUrl: url,
contentType: type,
metadata: metadata,
metadata: metadata
? {
startOffsetMs: 0,
requiresTranscode: false,
transcodeStatus: null,
...metadata,
}
: null,
}),
hideVideo: () =>