diff --git a/apps/api/urls.py b/apps/api/urls.py index 7d9edb52..e4567b83 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -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')), diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 862de7f9..4d083aec 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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 diff --git a/apps/media_library/__init__.py b/apps/media_library/__init__.py new file mode 100644 index 00000000..aa665ecb --- /dev/null +++ b/apps/media_library/__init__.py @@ -0,0 +1 @@ +default_app_config = "apps.media_library.apps.MediaLibraryConfig" diff --git a/apps/media_library/api_urls.py b/apps/media_library/api_urls.py new file mode 100644 index 00000000..b94fa3d3 --- /dev/null +++ b/apps/media_library/api_urls.py @@ -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//", views.stream_media_file, name="stream-file"), +] diff --git a/apps/media_library/api_views.py b/apps/media_library/api_views.py new file mode 100644 index 00000000..6dcd2341 --- /dev/null +++ b/apps/media_library/api_views.py @@ -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) diff --git a/apps/media_library/apps.py b/apps/media_library/apps.py new file mode 100644 index 00000000..31d2281e --- /dev/null +++ b/apps/media_library/apps.py @@ -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 diff --git a/apps/media_library/metadata.py b/apps/media_library/metadata.py new file mode 100644 index 00000000..30949f1c --- /dev/null +++ b/apps/media_library/metadata.py @@ -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) diff --git a/apps/media_library/migrations/0001_initial.py b/apps/media_library/migrations/0001_initial.py new file mode 100644 index 00000000..379b9cbe --- /dev/null +++ b/apps/media_library/migrations/0001_initial.py @@ -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')}, + ), + ] diff --git a/apps/media_library/migrations/__init__.py b/apps/media_library/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/media_library/models.py b/apps/media_library/models.py new file mode 100644 index 00000000..2c0528bb --- /dev/null +++ b/apps/media_library/models.py @@ -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 diff --git a/apps/media_library/serializers.py b/apps/media_library/serializers.py new file mode 100644 index 00000000..c04c77cc --- /dev/null +++ b/apps/media_library/serializers.py @@ -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 diff --git a/apps/media_library/tasks.py b/apps/media_library/tasks.py new file mode 100644 index 00000000..771ac9f2 --- /dev/null +++ b/apps/media_library/tasks.py @@ -0,0 +1,1149 @@ +import logging +from datetime import timedelta +from typing import Optional, Set +from collections import deque + +from celery import shared_task, states +from celery.result import AsyncResult +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.conf import settings +from django.db import transaction +from django.db.models import Q +from django.utils import timezone + +from apps.media_library.metadata import sync_metadata +from apps.media_library.models import Library, LibraryScan, MediaFile, MediaItem +from apps.media_library import serializers +from apps.media_library.utils import ( + LibraryScanner, + apply_probe_metadata, + classify_media_file, + probe_media_file, + resolve_media_item, +) +from apps.media_library.transcode import ensure_browser_ready_source +from apps.media_library.vod_sync import ( + sync_library_to_vod, + sync_media_item_to_vod, + unsync_library_from_vod, +) + +logger = logging.getLogger(__name__) + +METADATA_TASK_QUEUE = getattr(settings, "CELERY_METADATA_QUEUE", None) +METADATA_TASK_PRIORITY = getattr(settings, "CELERY_METADATA_PRIORITY", None) +PROBE_TASK_QUEUE = getattr(settings, "CELERY_MEDIA_PROBE_QUEUE", None) +PROBE_TASK_PRIORITY = getattr(settings, "CELERY_MEDIA_PROBE_PRIORITY", None) +STALE_SCAN_THRESHOLD_SECONDS = getattr(settings, "MEDIA_LIBRARY_STALE_SCAN_THRESHOLD_SECONDS", 180) + + +def _start_next_scan(library: Library) -> None: + if library.scans.filter(status=LibraryScan.STATUS_RUNNING).exists(): + return + + if library.scans.filter(status=LibraryScan.STATUS_PENDING, task_id__isnull=False).exists(): + return + + pending_scan = ( + library.scans.filter( + status=LibraryScan.STATUS_PENDING, + task_id__isnull=True, + ) + .order_by("created_at") + .first() + ) + + if not pending_scan: + return + + extra = pending_scan.extra or {} + force_full = bool(extra.get("force_full")) + rescan_item_id = extra.get("rescan_item_id") + + async_result = scan_library_task.apply_async( + kwargs={ + "scan_id": str(pending_scan.id), + "library_id": pending_scan.library_id, + "force_full": force_full, + "rescan_item_id": rescan_item_id, + } + ) + pending_scan.task_id = async_result.id + pending_scan.save(update_fields=["task_id", "updated_at"]) + + +def resume_orphaned_scans(*, library_id: int | None = None) -> int: + """ + Ensure scans that were marked as running but lost their worker (e.g. due to a restart) + are re-queued so progress can continue. + """ + qs = LibraryScan.objects.filter(status=LibraryScan.STATUS_RUNNING) + if library_id is not None: + qs = qs.filter(library_id=library_id) + + now = timezone.now() + stale_before = now - timedelta(seconds=max(30, STALE_SCAN_THRESHOLD_SECONDS)) + resumed = 0 + + for scan in qs.select_related("library"): + should_resume = False + task_state = None + if scan.task_id: + try: + task_result = AsyncResult(scan.task_id) + task_state = task_result.state + except Exception as exc: # noqa: BLE001 + logger.debug("Unable to inspect scan task %s: %s", scan.task_id, exc) + task_state = None + + if not scan.task_id: + should_resume = True + elif task_state in states.READY_STATES or task_state in {states.RETRY, states.REVOKED}: + should_resume = True + elif scan.updated_at and scan.updated_at < stale_before: + should_resume = True + + if scan.discovery_status == LibraryScan.STAGE_STATUS_COMPLETED: + continue + + if not should_resume or not scan.library: + continue + + logger.info( + "Resuming interrupted library scan %s for library %s (task=%s state=%s)", + scan.id, + scan.library_id, + scan.task_id, + task_state, + ) + + summary_note = "Resuming after interruption." + summary_text = scan.summary or "" + if summary_note not in summary_text: + if summary_text: + summary_text = f"{summary_text.strip()}\n{summary_note}" + else: + summary_text = summary_note + else: + summary_text = summary_text.strip() + + scan.processed_files = 0 + scan.total_files = 0 + scan.new_files = 0 + scan.updated_files = 0 + scan.removed_files = 0 + scan.matched_items = 0 + scan.unmatched_files = 0 + scan.discovery_status = LibraryScan.STAGE_STATUS_PENDING + scan.discovery_total = 0 + scan.discovery_processed = 0 + scan.metadata_status = LibraryScan.STAGE_STATUS_PENDING + scan.metadata_total = 0 + scan.metadata_processed = 0 + scan.artwork_status = LibraryScan.STAGE_STATUS_PENDING + scan.artwork_total = 0 + scan.artwork_processed = 0 + scan.status = LibraryScan.STATUS_PENDING + scan.task_id = None + scan.finished_at = None + scan.summary = summary_text + scan.updated_at = now + scan.save( + update_fields=[ + "processed_files", + "total_files", + "new_files", + "updated_files", + "removed_files", + "matched_items", + "unmatched_files", + "discovery_status", + "discovery_total", + "discovery_processed", + "metadata_status", + "metadata_total", + "metadata_processed", + "artwork_status", + "artwork_total", + "artwork_processed", + "status", + "task_id", + "finished_at", + "summary", + "updated_at", + ] + ) + + _start_next_scan(scan.library) + resumed += 1 + + return resumed + + +def enqueue_library_scan( + *, + library_id: int, + user_id: int | None = None, + force_full: bool = False, + rescan_item_id: int | None = None, +) -> LibraryScan: + library = Library.objects.get(pk=library_id) + scan = LibraryScan.objects.create( + library=library, + created_by_id=user_id, + status=LibraryScan.STATUS_PENDING, + extra={ + "force_full": force_full, + "rescan_item_id": rescan_item_id, + }, + ) + + _start_next_scan(library) + return scan + + +def _revoke_scan_task(task_id: str | None, *, terminate: bool = False) -> None: + if not task_id: + return + try: + AsyncResult(task_id).revoke(terminate=terminate) + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to revoke scan task %s: %s", task_id, exc) + + +def start_next_library_scan(library: Library) -> None: + """Public helper for kicking off the next pending scan.""" + + _start_next_scan(library) + + +def revoke_scan_task(task_id: str | None, *, terminate: bool = False) -> None: + """Public helper to revoke a Celery scan task safely.""" + + _revoke_scan_task(task_id, terminate=terminate) + + +def cancel_library_scan(scan: LibraryScan, *, summary: str | None = None) -> LibraryScan: + """Cancel a running or pending library scan.""" + + if scan.status not in {LibraryScan.STATUS_RUNNING, LibraryScan.STATUS_PENDING}: + raise ValueError("Only running or pending scans can be cancelled") + + library = scan.library + terminate = scan.status == LibraryScan.STATUS_RUNNING + _revoke_scan_task(scan.task_id, terminate=terminate) + + now = timezone.now() + scan.status = LibraryScan.STATUS_CANCELLED + scan.finished_at = now + update_fields = ["status", "finished_at", "updated_at"] + + if summary: + scan.summary = summary + update_fields.append("summary") + elif not scan.summary: + scan.summary = "Cancelled by user" + update_fields.append("summary") + + if scan.task_id: + scan.task_id = None + update_fields.append("task_id") + + scan.save(update_fields=update_fields) + + _send_scan_event( + { + "status": "cancelled", + "scan_id": str(scan.id), + "library_id": scan.library_id, + "library_name": library.name if library else "", + "summary": scan.summary, + "processed": scan.processed_files, + "processed_files": scan.processed_files, + "total": scan.total_files, + } + ) + + _start_next_scan(library) + scan.refresh_from_db() + return scan + + +def _send_scan_event(event: dict) -> None: + try: + channel_layer = get_channel_layer() + except Exception: # noqa: BLE001 + return + if not channel_layer: + return + payload = {"success": True, "type": "media_scan"} + payload.update(event) + async_to_sync(channel_layer.group_send)( + "updates", + {"type": "update", "data": payload}, + ) + + +def _stage_snapshot(scan: LibraryScan) -> dict[str, dict[str, int | str]]: + return scan.stage_snapshot(normalize=True) + + +def _emit_scan_update(scan: LibraryScan, *, status: str, extra: dict | None = None) -> None: + library_name = "" + if getattr(scan, "library", None): + library_name = scan.library.name + + resolved_status = status + if status != LibraryScan.STATUS_COMPLETED and scan.status == LibraryScan.STATUS_COMPLETED: + resolved_status = LibraryScan.STATUS_COMPLETED + + payload = { + "status": resolved_status, + "scan_id": str(scan.id), + "library_id": scan.library_id, + "library_name": library_name, + "processed": scan.processed_files, + "processed_files": scan.processed_files, + "total": scan.total_files, + "matched": scan.matched_items, + "unmatched": scan.unmatched_files, + "new_files": scan.new_files, + "updated_files": scan.updated_files, + "removed_files": scan.removed_files, + "stages": _stage_snapshot(scan), + } + if extra: + payload.update(extra) + logger.debug( + "Scan %s update status=%s processed=%s/%s stages=%s", + payload["scan_id"], + status, + payload.get("processed"), + payload.get("total"), + payload.get("stages"), + ) + _send_scan_event(payload) + + +def _advance_metadata_stage( + scan: LibraryScan, + *, + metadata_increment: int = 0, + artwork_increment: int = 0, +) -> None: + if not metadata_increment and not artwork_increment: + return + + updated = False + + with transaction.atomic(): + locked = ( + LibraryScan.objects.select_for_update() + .only( + "metadata_status", + "metadata_total", + "metadata_processed", + "artwork_status", + "artwork_total", + "artwork_processed", + ) + .get(pk=scan.pk) + ) + + update_fields: list[str] = [] + now = timezone.now() + + if metadata_increment: + current_processed = locked.metadata_processed or 0 + new_processed = current_processed + metadata_increment + if locked.metadata_total: + new_processed = min(new_processed, locked.metadata_total) + + if new_processed != current_processed: + locked.metadata_processed = new_processed + update_fields.append("metadata_processed") + + target_status = locked.metadata_status + if target_status == LibraryScan.STAGE_STATUS_PENDING: + target_status = LibraryScan.STAGE_STATUS_RUNNING + if locked.metadata_total and new_processed >= locked.metadata_total: + target_status = LibraryScan.STAGE_STATUS_COMPLETED + + if target_status != locked.metadata_status: + locked.metadata_status = target_status + update_fields.append("metadata_status") + + if artwork_increment: + current_processed = locked.artwork_processed or 0 + new_processed = current_processed + artwork_increment + if locked.artwork_total: + new_processed = min(new_processed, locked.artwork_total) + + if new_processed != current_processed: + locked.artwork_processed = new_processed + update_fields.append("artwork_processed") + + target_status = locked.artwork_status + if target_status == LibraryScan.STAGE_STATUS_PENDING: + target_status = LibraryScan.STAGE_STATUS_RUNNING + if locked.artwork_total and new_processed >= locked.artwork_total: + target_status = LibraryScan.STAGE_STATUS_COMPLETED + + if target_status != locked.artwork_status: + locked.artwork_status = target_status + update_fields.append("artwork_status") + + if update_fields: + locked.updated_at = now + locked.save(update_fields=update_fields + ["updated_at"]) + updated = True + + # Sync original scan instance with locked values so future calls have fresh data. + scan.metadata_status = locked.metadata_status + scan.metadata_total = locked.metadata_total + scan.metadata_processed = locked.metadata_processed or 0 + scan.artwork_status = locked.artwork_status + scan.artwork_total = locked.artwork_total + scan.artwork_processed = locked.artwork_processed or 0 + + if updated: + _emit_scan_update(scan, status="progress") + _maybe_mark_scan_completed(scan) + + +def _maybe_mark_scan_completed(scan: LibraryScan) -> None: + if scan.status == LibraryScan.STATUS_COMPLETED: + return + + if ( + scan.metadata_total + and scan.metadata_processed >= scan.metadata_total + and scan.metadata_status not in (LibraryScan.STAGE_STATUS_COMPLETED, LibraryScan.STAGE_STATUS_SKIPPED) + ): + scan.record_stage_progress("metadata", status=LibraryScan.STAGE_STATUS_COMPLETED) + scan.metadata_status = LibraryScan.STAGE_STATUS_COMPLETED + + if ( + scan.artwork_total + and scan.artwork_processed >= scan.artwork_total + and scan.artwork_status not in (LibraryScan.STAGE_STATUS_COMPLETED, LibraryScan.STAGE_STATUS_SKIPPED) + ): + scan.record_stage_progress("artwork", status=LibraryScan.STAGE_STATUS_COMPLETED) + scan.artwork_status = LibraryScan.STAGE_STATUS_COMPLETED + + if scan._ensure_completed(): + _emit_scan_update(scan, status="completed") + + +def _send_media_item_update(media_item: MediaItem, *, status: str = "updated") -> None: + try: + channel_layer = get_channel_layer() + except Exception: # noqa: BLE001 + return + if not channel_layer: + return + + data = serializers.MediaItemListSerializer(media_item, context={"request": None}).data + payload = { + "type": "media_item_update", + "status": status, + "library_id": media_item.library_id, + "media_item": data, + } + async_to_sync(channel_layer.group_send)( + "updates", + {"type": "update", "data": payload}, + ) + + +@shared_task(bind=True, name="media_library.scan_library") +def scan_library_task( + self, + *, + scan_id: str, + library_id: int, + force_full: bool = False, + rescan_item_id: int | None = None, +): + try: + scan = LibraryScan.objects.select_related("library").get(pk=scan_id) + except LibraryScan.DoesNotExist: + logger.warning("LibraryScan %s not found", scan_id) + return + + scan.mark_running(task_id=self.request.id if self.request else None) + library = scan.library + logger.info("Starting scan for library %s (id=%s)", library.name, library.id) + processed = 0 + total_files = 0 + matched = 0 + unmatched = 0 + media_item_ids: Set[int] = set() + metadata_queue_ids: Set[int] = set() + metadata_accounted_ids: Set[int] = set() + pending_probe_ids: set[int] = set() + probes_scheduled = False + metadata_total_count = scan.metadata_total or 0 + metadata_processed_count = scan.metadata_processed or 0 + artwork_total_count = scan.artwork_total or 0 + artwork_processed_count = scan.artwork_processed or 0 + last_progress_emit = timezone.now() + progress_interval = 0.4 # seconds + + initial_total_estimate = ( + MediaFile.objects.filter(library=library).count() + ) + discovery_total_estimate = initial_total_estimate or 0 + + scan.record_progress( + processed=0, + matched=0, + unmatched=0, + total=discovery_total_estimate, + ) + scan.record_stage_progress( + "discovery", + status=LibraryScan.STAGE_STATUS_RUNNING, + total=discovery_total_estimate, + processed=0, + ) + scan.record_stage_progress( + "metadata", + status=LibraryScan.STAGE_STATUS_PENDING, + total=0, + processed=0, + ) + scan.record_stage_progress( + "artwork", + status=LibraryScan.STAGE_STATUS_PENDING, + total=0, + processed=0, + ) + scan.total_files = max(scan.total_files, discovery_total_estimate) + _emit_scan_update( + scan, + status="started", + ) + + try: + scanner = LibraryScanner( + library=library, + scan=scan, + force_full=force_full, + rescan_item_id=rescan_item_id, + ) + + discovery_buffer: deque = deque() + discovery_total_known = 0 + discovery_processed = 0 + discovery_finished = False + total_files = discovery_total_estimate + backlog_target = 25 + + def maybe_emit_progress(force: bool = False, status: str = "progress") -> None: + nonlocal last_progress_emit + now = timezone.now() + if ( + force + or discovery_processed == 0 + or (now - last_progress_emit).total_seconds() >= progress_interval + ): + last_progress_emit = now + _emit_scan_update(scan, status=status) + + def queue_metadata(item: MediaItem) -> bool: + if item.id in metadata_queue_ids: + return False + metadata_queue_ids.add(item.id) + args = (item.id,) + kwargs = {"scan_id": str(scan.id)} + options = {} + if METADATA_TASK_QUEUE: + options["queue"] = METADATA_TASK_QUEUE + if METADATA_TASK_PRIORITY is not None: + options["priority"] = METADATA_TASK_PRIORITY + sync_metadata_task.apply_async(args=args, kwargs=kwargs, **options) + return True + + def enqueue_probe_tasks() -> None: + nonlocal probes_scheduled + if probes_scheduled: + return + if not pending_probe_ids: + probes_scheduled = True + return + options = {} + if PROBE_TASK_QUEUE: + options["queue"] = PROBE_TASK_QUEUE + if PROBE_TASK_PRIORITY is not None: + options["priority"] = PROBE_TASK_PRIORITY + for file_id in sorted(pending_probe_ids): + probe_media_task.apply_async(args=(file_id,), **options) + probes_scheduled = True + pending_probe_ids.clear() + + def refresh_stage_counters() -> None: + nonlocal metadata_total_count, metadata_processed_count, artwork_total_count, artwork_processed_count, total_files + progress_cap = max(scan.total_files, discovery_total_estimate, total_files) + snapshot = ( + LibraryScan.objects.filter(pk=scan.pk) + .values( + "metadata_total", + "metadata_processed", + "artwork_total", + "artwork_processed", + "metadata_status", + "artwork_status", + ) + .first() + ) + if not snapshot: + return + + metadata_snapshot_total = snapshot.get("metadata_total") or 0 + artwork_snapshot_total = snapshot.get("artwork_total") or 0 + + metadata_total_count = min( + progress_cap, max(metadata_total_count, metadata_snapshot_total) + ) + metadata_processed_count = max( + metadata_processed_count, + min(progress_cap, snapshot.get("metadata_processed") or 0), + ) + artwork_total_count = min( + progress_cap, max(artwork_total_count, artwork_snapshot_total) + ) + artwork_processed_count = max( + artwork_processed_count, + min(progress_cap, snapshot.get("artwork_processed") or 0), + ) + + scan.metadata_total = metadata_total_count + scan.metadata_processed = metadata_processed_count + scan.artwork_total = artwork_total_count + scan.artwork_processed = artwork_processed_count + metadata_status = snapshot.get("metadata_status") + artwork_status = snapshot.get("artwork_status") + if metadata_status: + scan.metadata_status = metadata_status + if artwork_status: + scan.artwork_status = artwork_status + + def register_metadata_item(item: MediaItem | None, needs_metadata: bool) -> None: + nonlocal metadata_total_count, metadata_processed_count, artwork_total_count, artwork_processed_count + if not item or not item.id or item.id in metadata_accounted_ids: + return + metadata_accounted_ids.add(item.id) + + refresh_stage_counters() + + progress_cap = max(scan.total_files, discovery_total_estimate, total_files) + + metadata_total_count = min( + progress_cap, max(metadata_total_count, scan.metadata_total or 0) + 1 + ) + metadata_processed_count = min( + progress_cap, max(metadata_processed_count, scan.metadata_processed or 0) + ) + artwork_total_count = min( + progress_cap, max(artwork_total_count, scan.artwork_total or 0) + 1 + ) + artwork_processed_count = min( + progress_cap, max(artwork_processed_count, scan.artwork_processed or 0) + ) + + if scan.metadata_status != LibraryScan.STAGE_STATUS_RUNNING: + scan.record_stage_progress("metadata", status=LibraryScan.STAGE_STATUS_RUNNING) + + if needs_metadata: + scan.record_stage_progress("metadata", total=metadata_total_count) + queued = queue_metadata(item) + if queued: + maybe_emit_progress(force=True) + else: + metadata_processed_count += 1 + scan.record_stage_progress( + "metadata", + total=metadata_total_count, + processed=metadata_processed_count, + ) + + if scan.artwork_status != LibraryScan.STAGE_STATUS_RUNNING: + scan.record_stage_progress("artwork", status=LibraryScan.STAGE_STATUS_RUNNING) + + if needs_metadata or not item.poster_url: + scan.record_stage_progress("artwork", total=artwork_total_count) + else: + artwork_processed_count += 1 + scan.record_stage_progress( + "artwork", + total=artwork_total_count, + processed=artwork_processed_count, + ) + + maybe_emit_progress() + + discovery_iter = scanner.discover_files_iter() + + def fill_discovery_buffer() -> None: + nonlocal discovery_total_known, discovery_finished, total_files + while not discovery_finished and len(discovery_buffer) < backlog_target: + try: + result = next(discovery_iter) + except StopIteration: + discovery_finished = True + break + discovery_buffer.append(result) + discovery_total_known += 1 + current_total = max(discovery_total_estimate, discovery_total_known) + scan.total_files = max(scan.total_files, current_total) + scan.record_stage_progress( + "discovery", + status=LibraryScan.STAGE_STATUS_RUNNING, + total=current_total, + ) + total_files = max(total_files, current_total) + scan.record_progress(total=current_total) + maybe_emit_progress() + + def process_discovery_result(result) -> None: + nonlocal discovery_processed, matched, unmatched, processed, total_files + discovery_processed += 1 + identify_result = _identify_media_file( + library=library, + file_id=result.file_id, + target_item_id=scanner.target_item.id if scanner.target_item else None, + ) + matched += identify_result.get("matched", 0) + unmatched += identify_result.get("unmatched", 0) + media_id = identify_result.get("media_item_id") + parent_media_id = identify_result.get("parent_media_item_id") + candidate_ids = { + candidate_id + for candidate_id in (media_id, parent_media_id) + if candidate_id + } + for candidate_id in candidate_ids: + is_new_media = candidate_id not in media_item_ids + media_item_ids.add(candidate_id) + try: + media_obj = MediaItem.objects.select_related("library").get(pk=candidate_id) + except MediaItem.DoesNotExist: + continue + else: + if is_new_media: + _send_media_item_update(media_obj, status="progress") + needs_metadata = ( + force_full + or media_obj.metadata_last_synced_at is None + or not media_obj.poster_url + ) + register_metadata_item(media_obj, needs_metadata) + + if result.requires_probe: + pending_probe_ids.add(result.file_id) + + processed = discovery_processed + scan.record_stage_progress("discovery", processed=discovery_processed) + current_total = max(discovery_total_estimate, discovery_total_known) + total_files = max(total_files, current_total) + scan.record_progress( + processed=processed, + matched=matched, + unmatched=unmatched, + total=current_total, + ) + maybe_emit_progress() + + fill_discovery_buffer() + while discovery_buffer: + result = discovery_buffer.popleft() + process_discovery_result(result) + if len(discovery_buffer) < max(1, backlog_target // 2): + fill_discovery_buffer() + + while not discovery_finished: + fill_discovery_buffer() + while discovery_buffer: + process_discovery_result(discovery_buffer.popleft()) + + scanner.mark_missing_files() + + if media_item_ids: + metadata_qs = ( + MediaItem.objects.filter(pk__in=media_item_ids) + .filter( + Q(metadata_last_synced_at__isnull=True) + | Q(poster_url__isnull=True) + | Q(poster_url="") + ) + ) + for item in metadata_qs: + queue_metadata(item) + + enqueue_probe_tasks() + + scan.total_files = max(scan.total_files, discovery_processed) + scan.record_stage_progress( + "discovery", + status=LibraryScan.STAGE_STATUS_COMPLETED, + processed=discovery_processed, + total=max(discovery_total_estimate, discovery_total_known), + ) + _emit_scan_update( + scan, + status="discovered", + extra={"files": max(discovery_total_estimate, discovery_total_known)}, + ) + + summary = ( + f"Processed {scan.total_files} files; " + f"new={scan.new_files}, updated={scan.updated_files}, " + f"removed={scan.removed_files}, matched={matched}, " + f"unmatched={unmatched}" + ) + + if not metadata_queue_ids: + if metadata_total_count == 0: + scan.record_stage_progress( + "metadata", + status=LibraryScan.STAGE_STATUS_SKIPPED, + total=0, + processed=0, + ) + scan.record_stage_progress( + "artwork", + status=LibraryScan.STAGE_STATUS_SKIPPED, + total=0, + processed=0, + ) + else: + scan.record_stage_progress("metadata", status=LibraryScan.STAGE_STATUS_COMPLETED) + scan.record_stage_progress("artwork", status=LibraryScan.STAGE_STATUS_COMPLETED) + else: + if scan.metadata_status == LibraryScan.STAGE_STATUS_PENDING: + scan.record_stage_progress( + "metadata", + status=LibraryScan.STAGE_STATUS_RUNNING, + ) + if scan.artwork_status == LibraryScan.STAGE_STATUS_PENDING: + scan.record_stage_progress( + "artwork", + status=LibraryScan.STAGE_STATUS_RUNNING, + ) + + scanner.finalize(matched=matched, unmatched=unmatched, summary=summary) + logger.info("Completed scan stage for library %s", library.name) + + metadata_done = scan.metadata_status in ( + LibraryScan.STAGE_STATUS_COMPLETED, + LibraryScan.STAGE_STATUS_SKIPPED, + ) + artwork_done = scan.artwork_status in ( + LibraryScan.STAGE_STATUS_COMPLETED, + LibraryScan.STAGE_STATUS_SKIPPED, + ) + + if metadata_done and artwork_done: + _maybe_mark_scan_completed(scan) + else: + scan.status = LibraryScan.STATUS_RUNNING + scan.finished_at = None + scan.save(update_fields=["status", "finished_at", "updated_at"]) + _emit_scan_update( + scan, + status="progress", + extra={ + "summary": summary, + "matched": matched, + "unmatched": unmatched, + "new_files": scan.new_files, + "updated_files": scan.updated_files, + "removed_files": scan.removed_files, + }, + ) + _start_next_scan(library) + except Exception as exc: # noqa: BLE001 + enqueue_probe_tasks() + logger.exception("Library scan failed for %s", library.name) + scan.record_progress( + processed=processed, + matched=matched, + unmatched=unmatched, + total=max(total_files, discovery_total_estimate, discovery_total_known, processed), + summary=str(exc), + ) + scan.mark_failed(summary=str(exc)) + _emit_scan_update( + scan, + status="failed", + extra={ + "success": False, + "message": str(exc), + "processed": processed, + "processed_files": processed, + "total": max( + total_files, + discovery_total_estimate, + discovery_total_known, + processed, + ), + }, + ) + _start_next_scan(library) + raise + + +def _discover_media(scan: LibraryScan, scanner: LibraryScanner): + return list(scanner.discover_files_iter()) + + +@shared_task(name="media_library.discover_media") +def discover_media_task(scan_id: str) -> list[dict]: + scan = LibraryScan.objects.select_related("library").get(pk=scan_id) + scanner = LibraryScanner(scan.library, scan, force_full=False) + discoveries = _discover_media(scan, scanner) + return [{"file_id": d.file_id, "requires_probe": d.requires_probe} for d in discoveries] + + +def _identify_media_file( + *, + library: Library, + file_id: int, + target_item_id: Optional[int] = None, +) -> dict: + try: + file_record = MediaFile.objects.select_related("library", "media_item").get( + pk=file_id, library=library + ) + except MediaFile.DoesNotExist: + return {"matched": 0, "unmatched": 0} + + classification = classify_media_file(file_record.file_name) + if library.library_type == Library.LIBRARY_TYPE_MOVIES: + classification.detected_type = MediaItem.TYPE_MOVIE + elif library.library_type == Library.LIBRARY_TYPE_SHOWS: + if classification.detected_type == MediaItem.TYPE_MOVIE: + classification.detected_type = MediaItem.TYPE_SHOW + # mixed/other retain detected type + target_item = None + if target_item_id: + target_item = MediaItem.objects.filter(pk=target_item_id, library=library).first() + + media_item = resolve_media_item(library, classification, target_item=target_item) + matched = 0 + unmatched = 0 + if media_item: + if file_record.media_item_id != media_item.id: + file_record.media_item = media_item + file_record.save(update_fields=["media_item", "updated_at"]) + if classification.detected_type == MediaItem.TYPE_OTHER: + if media_item.status != MediaItem.STATUS_FAILED: + media_item.status = MediaItem.STATUS_FAILED + media_item.save(update_fields=["status", "updated_at"]) + unmatched = 1 + else: + if media_item.status != MediaItem.STATUS_MATCHED: + media_item.status = MediaItem.STATUS_MATCHED + media_item.save(update_fields=["status", "updated_at"]) + if ( + classification.detected_type == MediaItem.TYPE_EPISODE + and media_item.parent_id + ): + parent = MediaItem.objects.filter(pk=media_item.parent_id).first() + if parent: + if parent.status != MediaItem.STATUS_MATCHED: + parent.status = MediaItem.STATUS_MATCHED + parent.save(update_fields=["status", "updated_at"]) + if not parent.metadata_last_synced_at or not parent.poster_url: + sync_metadata_task.delay(parent.id) + matched = 1 + + # Synchronize to VOD catalog when configured. + sync_media_item_to_vod(media_item) + else: + unmatched = 1 + + return { + "file_id": file_id, + "media_item_id": media_item.id if media_item else None, + "parent_media_item_id": media_item.parent_id if media_item else None, + "matched": matched, + "unmatched": unmatched, + } + + +@shared_task(name="media_library.identify_media") +def identify_media_task(library_id: int, file_id: int, target_item_id: Optional[int] = None): + library = Library.objects.get(pk=library_id) + return _identify_media_file( + library=library, file_id=file_id, target_item_id=target_item_id + ) + + +def _probe_media_file(*, file_id: int) -> None: + try: + file_record = MediaFile.objects.get(pk=file_id) + except MediaFile.DoesNotExist: + return + + probe_data = probe_media_file(file_record.absolute_path) + apply_probe_metadata(file_record, probe_data) + + checksum = file_record.calculate_checksum() + if checksum and checksum != file_record.checksum: + file_record.checksum = checksum + file_record.save(update_fields=["checksum", "updated_at"]) + + if file_record.requires_transcode and ( + file_record.transcode_status + in ( + MediaFile.TRANSCODE_STATUS_PENDING, + MediaFile.TRANSCODE_STATUS_FAILED, + ) + or not file_record.transcoded_path + ): + transcode_media_file_task.delay(file_record.id) + + +@shared_task(name="media_library.probe_media") +def probe_media_task(file_id: int): + _probe_media_file(file_id=file_id) + + +def _sync_metadata(media_item_id: int, scan_id: str | None = None) -> None: + try: + media_item = MediaItem.objects.get(pk=media_item_id) + except MediaItem.DoesNotExist: + return + scan: LibraryScan | None = None + if scan_id: + try: + scan = LibraryScan.objects.select_related("library").get(pk=scan_id) + except LibraryScan.DoesNotExist: + scan = None + + result = sync_metadata(media_item) + if result: + _send_media_item_update(result, status="metadata") + + if not scan: + return + + metadata_increment = 1 if scan.metadata_total else 0 + artwork_increment = 1 if scan.artwork_total else 0 + + if scan.metadata_status == LibraryScan.STAGE_STATUS_PENDING and scan.metadata_total: + scan.record_stage_progress("metadata", status=LibraryScan.STAGE_STATUS_RUNNING) + if scan.artwork_status == LibraryScan.STAGE_STATUS_PENDING and scan.artwork_total: + scan.record_stage_progress("artwork", status=LibraryScan.STAGE_STATUS_RUNNING) + + progressed = False + if metadata_increment or artwork_increment: + _advance_metadata_stage( + scan, + metadata_increment=metadata_increment, + artwork_increment=artwork_increment, + ) + progressed = True + + if scan: + if progressed: + try: + scan.refresh_from_db( + fields=[ + "metadata_status", + "metadata_total", + "metadata_processed", + "artwork_status", + "artwork_total", + "artwork_processed", + "status", + "finished_at", + "updated_at", + ] + ) + except LibraryScan.DoesNotExist: + scan = None + if scan: + _maybe_mark_scan_completed(scan) + + +@shared_task(name="media_library.sync_metadata") +def sync_metadata_task(media_item_id: int, scan_id: str | None = None): + _sync_metadata(media_item_id, scan_id=scan_id) + + +@shared_task(name="media_library.cleanup_missing") +def cleanup_missing_task(library_id: int): + library = Library.objects.get(pk=library_id) + dummy_scan = LibraryScan( + library=library, + status=LibraryScan.STATUS_RUNNING, + ) + scanner = LibraryScanner(library=library, scan=dummy_scan) + return scanner.mark_missing_files() + + +@shared_task(name="media_library.prune_stale_scans") +def prune_stale_scans(max_age_hours: int = 72): + threshold = timezone.now() - timedelta(hours=max_age_hours) + deleted, _ = LibraryScan.objects.filter( + status__in=[LibraryScan.STATUS_COMPLETED, LibraryScan.STATUS_FAILED], + created_at__lt=threshold, + ).delete() + if deleted: + logger.info("Pruned %s stale library scan records", deleted) + + +@shared_task(name="media_library.transcode_media_file") +def transcode_media_file_task(file_id: int, force: bool = False): + try: + media_file = MediaFile.objects.get(pk=file_id) + except MediaFile.DoesNotExist: + logger.warning("Media file %s not found for transcoding", file_id) + return + + try: + ensure_browser_ready_source(media_file, force=force) + except FileNotFoundError: + logger.warning("Source file missing for media file %s", file_id) + except Exception as exc: # noqa: BLE001 + logger.exception("Transcode failed for media file %s: %s", file_id, exc) + raise + + +@shared_task(name="media_library.schedule_auto_scans") +def schedule_auto_scans(): + now = timezone.now() + for library in Library.objects.filter(auto_scan_enabled=True): + if not library.last_scan_at: + enqueue_library_scan(library_id=library.id, user_id=None) + continue + next_scan_due = library.last_scan_at + timedelta(minutes=library.scan_interval_minutes) + if next_scan_due <= now: + enqueue_library_scan(library_id=library.id, user_id=None) + + +@shared_task(name="media_library.sync_library_vod") +def sync_library_to_vod_task(library_id: int): + try: + library = Library.objects.get(pk=library_id) + except Library.DoesNotExist: + return + sync_library_to_vod(library) + + +@shared_task(name="media_library.unsync_library_vod") +def unsync_library_from_vod_task(library_id: int): + try: + library = Library.objects.get(pk=library_id) + except Library.DoesNotExist: + return + unsync_library_from_vod(library) + + +def refresh_metadata_for_item(media_item_id: int, user_id: int | None = None): + logger.debug("Metadata refresh requested for media_item=%s by user=%s", media_item_id, user_id) + sync_metadata_task.delay(media_item_id) diff --git a/apps/media_library/transcode.py b/apps/media_library/transcode.py new file mode 100644 index 00000000..e619f589 --- /dev/null +++ b/apps/media_library/transcode.py @@ -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() diff --git a/apps/media_library/utils.py b/apps/media_library/utils.py new file mode 100644 index 00000000..fb6ef13e --- /dev/null +++ b/apps/media_library/utils.py @@ -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 diff --git a/apps/media_library/views.py b/apps/media_library/views.py new file mode 100644 index 00000000..21ffb60b --- /dev/null +++ b/apps/media_library/views.py @@ -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 diff --git a/apps/media_library/vod_sync.py b/apps/media_library/vod_sync.py new file mode 100644 index 00000000..08688547 --- /dev/null +++ b/apps/media_library/vod_sync.py @@ -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() diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index 517038a6..5f2e1cef 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -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) \ No newline at end of file + return Response({'error': str(e)}, status=500) diff --git a/apps/vod/serializers.py b/apps/vod/serializers.py index 5a672b33..592ae5c0 100644 --- a/apps/vod/serializers.py +++ b/apps/vod/serializers.py @@ -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) diff --git a/core/api_views.py b/core/api_views.py index 9de5aa5a..2cfea1bb 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -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. diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 057780de..b1ad430a 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -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", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 780aabe1..7ae9ab63 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index fea6b73e..06a9313b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4b701533..2f674d42 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 = () => { } /> } /> } /> + } /> + } /> ) : ( } /> diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 23a9a656..ccb1cf27 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -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({ diff --git a/frontend/src/api.js b/frontend/src/api.js index 4ef5f97e..d050e4a7 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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; + } + } } diff --git a/frontend/src/assets/tmdb-logo-blue.svg b/frontend/src/assets/tmdb-logo-blue.svg new file mode 100644 index 00000000..4b21ded3 --- /dev/null +++ b/frontend/src/assets/tmdb-logo-blue.svg @@ -0,0 +1 @@ +Asset 3 \ No newline at end of file diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index 6aaeecda..ed9690cc 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -3,7 +3,37 @@ import React, { useEffect, useRef, useState } from 'react'; import Draggable from 'react-draggable'; import useVideoStore from '../store/useVideoStore'; import mpegts from 'mpegts.js'; -import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core'; +import { + CloseButton, + Flex, + Loader, + Text, + Box, + Button, + Progress, + Group, + Slider, + ActionIcon, +} from '@mantine/core'; +import { Play, Pause, Volume2, VolumeX, Maximize, Minimize } from 'lucide-react'; +import API from '../api'; +import useAuthStore from '../store/auth'; + +const CONTROL_HIDE_DELAY = 2500; + +const formatTime = (value) => { + if (!Number.isFinite(value)) { + return '0:00'; + } + const totalSeconds = Math.max(0, Math.floor(value)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + return `${minutes}:${String(seconds).padStart(2, '0')}`; +}; export default function FloatingVideo() { const isVisible = useVideoStore((s) => s.isVisible); @@ -18,6 +48,380 @@ export default function FloatingVideo() { const [loadError, setLoadError] = useState(null); const [showOverlay, setShowOverlay] = useState(true); const overlayTimeoutRef = useRef(null); + const lastProgressSentRef = useRef(0); + const [nextAutoplay, setNextAutoplay] = useState(null); + const [autoplayCountdown, setAutoplayCountdown] = useState(null); + const autoPlayTimerRef = useRef(null); + const countdownIntervalRef = useRef(null); + const AUTOPLAY_SECONDS = 10; + const authUser = useAuthStore((s) => s.user); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTimeSeconds, setCurrentTimeSeconds] = useState( + (metadata?.startOffsetMs ?? 0) / 1000 + ); + const [durationSeconds, setDurationSeconds] = useState( + metadata?.durationMs ? metadata.durationMs / 1000 : 0 + ); + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubValueSeconds, setScrubValueSeconds] = useState( + (metadata?.startOffsetMs ?? 0) / 1000 + ); + const [showControls, setShowControls] = useState(true); + const controlsTimeoutRef = useRef(null); + const serverSeekInProgressRef = useRef(false); + const lastServerSeekAbsoluteRef = useRef((metadata?.startOffsetMs ?? 0) / 1000); + const wasPlayingBeforeScrubRef = useRef(false); + const [volume, setVolume] = useState(1); + const [isMuted, setIsMuted] = useState(false); + const previousVolumeRef = useRef(1); + const [showVolumeControl, setShowVolumeControl] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + + const clearControlsTimeout = () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + controlsTimeoutRef.current = null; + } + }; + + useEffect(() => { + if (!showControls) { + setShowVolumeControl(false); + } + }, [showControls]); + + const handlePointerActivity = () => { + setShowControls(true); + clearControlsTimeout(); + controlsTimeoutRef.current = setTimeout(() => { + if (!isScrubbing && !serverSeekInProgressRef.current) { + setShowControls(false); + } + }, CONTROL_HIDE_DELAY); + }; + + useEffect(() => { + const video = videoRef.current; + if (video) { + video.volume = volume; + video.muted = isMuted || volume === 0; + } + }, [volume, isMuted]); + + useEffect(() => { + const video = videoRef.current; + if (video) { + video.volume = volume; + video.muted = isMuted || volume === 0; + } + }, [streamUrl]); + + useEffect(() => { + const handleFullscreenChange = () => { + const container = videoContainerRef.current; + setIsFullscreen(document.fullscreenElement === container); + }; + + document.addEventListener('fullscreenchange', handleFullscreenChange); + document.addEventListener('webkitfullscreenchange', handleFullscreenChange); + document.addEventListener('mozfullscreenchange', handleFullscreenChange); + document.addEventListener('MSFullscreenChange', handleFullscreenChange); + + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange); + document.removeEventListener('webkitfullscreenchange', handleFullscreenChange); + document.removeEventListener('mozfullscreenchange', handleFullscreenChange); + document.removeEventListener('MSFullscreenChange', handleFullscreenChange); + }; + }, []); + + const toggleFullscreen = () => { + const container = videoContainerRef.current; + if (!container) return; + handlePointerActivity(); + const request = container.requestFullscreen + || container.webkitRequestFullscreen + || container.mozRequestFullScreen + || container.msRequestFullscreen; + const exit = + document.exitFullscreen || + document.webkitExitFullscreen || + document.mozCancelFullScreen || + document.msExitFullscreen; + + if (!document.fullscreenElement && request) { + request.call(container).catch(() => {}); + } else if (document.fullscreenElement === container && exit) { + exit.call(document).catch(() => {}); + } else if (request) { + exit?.call(document).finally(() => { + request.call(container).catch(() => {}); + }); + } + }; + + const handleVolumeSliderChange = (value) => { + if (!Number.isFinite(value)) { + return; + } + handlePointerActivity(); + const clamped = Math.max(0, Math.min(1, value)); + setVolume(clamped); + if (clamped > 0) { + previousVolumeRef.current = clamped; + setIsMuted(false); + } else { + setIsMuted(true); + } + }; + + const toggleMute = () => { + handlePointerActivity(); + const video = videoRef.current; + if (!video) { + return; + } + if (isMuted || volume === 0 || video.muted) { + const restored = previousVolumeRef.current > 0 ? previousVolumeRef.current : 0.5; + setVolume(restored); + setIsMuted(false); + } else { + previousVolumeRef.current = volume > 0 ? volume : 0.5; + setVolume(0); + setIsMuted(true); + } + }; + + const handleVolumeButtonClick = () => { + handlePointerActivity(); + if (!showVolumeControl) { + setShowVolumeControl(true); + return; + } + toggleMute(); + }; + + const togglePlayback = () => { + const video = videoRef.current; + if (!video || serverSeekInProgressRef.current || isLoading) return; + handlePointerActivity(); + if (video.paused) { + video.play().catch(() => {}); + } else { + video.pause(); + } + }; + + const performSeek = (targetSeconds) => { + if (!Number.isFinite(targetSeconds)) { + return; + } + + const sanitized = Math.max(0, targetSeconds); + const startOffsetSeconds = (metadata?.startOffsetMs ?? 0) / 1000; + const video = videoRef.current; + + const isLocalSeek = + !metadata?.requiresTranscode || metadata?.transcodeStatus === 'ready' || !metadata?.mediaItemId; + + if (isLocalSeek) { + if (video) { + const shouldResume = wasPlayingBeforeScrubRef.current || !video.paused; + try { + video.currentTime = Math.max(0, sanitized - startOffsetSeconds); + } catch (err) { + console.debug('Failed to adjust local playback position', err); + } + if (shouldResume) { + video.play().catch(() => {}); + } + setIsPlaying(!video.paused); + } + setCurrentTimeSeconds(sanitized); + setScrubValueSeconds(sanitized); + lastServerSeekAbsoluteRef.current = sanitized; + serverSeekInProgressRef.current = false; + setIsLoading(false); + handlePointerActivity(); + wasPlayingBeforeScrubRef.current = false; + return; + } + + if (!metadata?.fileId) { + console.debug('Seek requested without file identifier; aborting'); + serverSeekInProgressRef.current = false; + setIsLoading(false); + return; + } + + serverSeekInProgressRef.current = true; + lastServerSeekAbsoluteRef.current = sanitized; + setIsLoading(true); + setLoadError(null); + setShowControls(true); + clearControlsTimeout(); + + if (video) { + try { + video.pause(); + } catch (pauseError) { + console.debug('Failed to pause prior to server seek', pauseError); + } + } + setIsPlaying(false); + + const startMs = Math.round(sanitized * 1000); + + API.streamMediaItem(metadata.mediaItemId, { + fileId: metadata.fileId, + startMs, + }) + .then((streamInfo) => { + const playbackUrl = streamInfo?.url || streamInfo?.stream_url; + if (!playbackUrl) { + throw new Error('Streaming endpoint did not return a playable URL.'); + } + + const startOffsetMs = streamInfo?.start_offset_ms ?? 0; + const resumeHandledByServer = startOffsetMs > 0; + const requiresTranscode = Boolean(streamInfo?.requires_transcode); + const transcodeStatus = streamInfo?.transcode_status ?? metadata?.transcodeStatus ?? null; + const derivedDurationMs = + streamInfo?.duration_ms ?? + metadata?.durationMs ?? + (videoRef.current?.duration + ? Math.round( + ((metadata?.startOffsetMs ?? 0) / 1000 + videoRef.current.duration) * 1000 + ) + : undefined); + + const nextMetadata = { + ...metadata, + resumePositionMs: startMs, + resumeHandledByServer, + startOffsetMs: + startOffsetMs || (resumeHandledByServer ? startMs : metadata?.startOffsetMs ?? 0), + requiresTranscode, + transcodeStatus, + durationMs: derivedDurationMs, + }; + + wasPlayingBeforeScrubRef.current = false; + useVideoStore.getState().showVideo(playbackUrl, 'library', nextMetadata); + }) + .catch((err) => { + console.error('Failed to perform server-side seek', err); + setLoadError('Unable to seek in this stream.'); + setIsLoading(false); + serverSeekInProgressRef.current = false; + if (video) { + try { + video.play(); + } catch (playErr) { + console.debug('Failed to resume after seek failure', playErr); + } + } + wasPlayingBeforeScrubRef.current = false; + }); + }; + + const handleScrubChange = (value) => { + const video = videoRef.current; + if (!isScrubbing) { + setIsScrubbing(true); + wasPlayingBeforeScrubRef.current = video ? !video.paused : false; + } + if (video) { + try { + video.pause(); + } catch (pauseError) { + console.debug('Failed to pause video while scrubbing', pauseError); + } + setIsPlaying(false); + } + setScrubValueSeconds(value); + handlePointerActivity(); + }; + + const handleScrubEnd = (value) => { + setIsScrubbing(false); + performSeek(value); + }; + + const sendLibraryProgress = (positionSeconds, durationSeconds, completed = false) => { + if (contentType !== 'library') return; + if (!metadata?.mediaItemId || !authUser?.id) return; + const startOffsetMs = metadata?.startOffsetMs ?? 0; + const relativePosition = Number.isFinite(positionSeconds) ? positionSeconds : 0; + const absolutePositionSeconds = Math.max(0, startOffsetMs / 1000 + relativePosition); + + let totalDurationMs; + if (metadata?.durationMs) { + totalDurationMs = metadata.durationMs; + } else { + const relativeDuration = Number.isFinite(durationSeconds) ? durationSeconds : 0; + const fallbackDurationSeconds = + relativeDuration > 0 + ? relativeDuration + : videoRef.current?.duration + ? videoRef.current.duration + : 0; + totalDurationMs = Math.round( + Math.max(absolutePositionSeconds, startOffsetMs / 1000 + fallbackDurationSeconds) * 1000 + ); + } + + let positionMs = Math.round(absolutePositionSeconds * 1000); + if (completed) { + positionMs = totalDurationMs; + } else { + positionMs = Math.min(positionMs, totalDurationMs); + } + + const payload = { + user: authUser.id, + media_item: metadata.mediaItemId, + position_ms: Math.max(0, positionMs), + duration_ms: Math.max(0, totalDurationMs), + completed, + }; + API.setMediaWatchProgress(payload).catch((error) => { + console.debug('Failed to update watch progress', error); + }); + }; + + useEffect(() => { + const start = (metadata?.startOffsetMs ?? 0) / 1000; + setCurrentTimeSeconds(start); + setScrubValueSeconds(start); + if (metadata?.durationMs) { + setDurationSeconds(metadata.durationMs / 1000); + } + wasPlayingBeforeScrubRef.current = false; + }, [metadata?.mediaItemId, metadata?.startOffsetMs, metadata?.durationMs]); + + useEffect(() => { + if (isScrubbing) { + setShowControls(true); + clearControlsTimeout(); + } + }, [isScrubbing]); + + + useEffect(() => () => clearControlsTimeout(), []); + + const clearAutoPlayTimers = () => { + if (autoPlayTimerRef.current) { + clearTimeout(autoPlayTimerRef.current); + autoPlayTimerRef.current = null; + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + setNextAutoplay(null); + setAutoplayCountdown(null); + }; // Safely destroy the mpegts player to prevent errors const safeDestroyPlayer = () => { @@ -25,6 +429,9 @@ export default function FloatingVideo() { if (playerRef.current) { setIsLoading(false); setLoadError(null); + setIsPlaying(false); + clearControlsTimeout(); + setShowControls(true); if (videoRef.current) { videoRef.current.removeAttribute('src'); @@ -55,11 +462,15 @@ export default function FloatingVideo() { playerRef.current = null; } + lastProgressSentRef.current = 0; + // Clear overlay timer if (overlayTimeoutRef.current) { clearTimeout(overlayTimeoutRef.current); overlayTimeoutRef.current = null; } + + clearAutoPlayTimers(); }; // Start overlay auto-hide timer @@ -72,6 +483,120 @@ export default function FloatingVideo() { }, 4000); // Hide after 4 seconds }; + const playEpisodeAtIndex = async (index) => { + const sequence = metadata?.playbackSequence; + const episodeIds = sequence?.episodeIds || []; + const nextEpisodeId = episodeIds[index]; + if (!nextEpisodeId) { + clearAutoPlayTimers(); + return; + } + + clearAutoPlayTimers(); + try { + const episodeDetail = await API.getMediaItem(nextEpisodeId); + const fileId = episodeDetail.files?.[0]?.id; + if (!fileId) { + setLoadError('Next episode is missing media files.'); + return; + } + const summary = episodeDetail.watch_summary; + const resumePositionMs = + summary?.status === 'in_progress' + ? summary.position_ms || 0 + : episodeDetail.watch_progress?.position_ms || 0; + const initialDurationMs = + summary?.duration_ms ?? + episodeDetail.watch_progress?.duration_ms ?? + episodeDetail.runtime_ms ?? + episodeDetail.files?.[0]?.duration_ms ?? + null; + + const streamInfo = await API.streamMediaItem(episodeDetail.id, { + fileId, + startMs: resumePositionMs, + }); + const playbackUrl = streamInfo?.url || streamInfo?.stream_url; + if (!playbackUrl) { + setLoadError('Streaming endpoint did not return a playable URL.'); + return; + } + + const startOffsetMs = streamInfo?.start_offset_ms ?? 0; + const resumeHandledByServer = startOffsetMs > 0; + const requiresTranscode = Boolean(streamInfo?.requires_transcode); + const transcodeStatus = streamInfo?.transcode_status ?? null; + const durationMs = + streamInfo?.duration_ms ?? initialDurationMs; + + const playbackSequence = { + episodeIds, + currentIndex: index, + }; + + useVideoStore.getState().showVideo(playbackUrl, 'library', { + mediaItemId: episodeDetail.id, + mediaTitle: episodeDetail.title, + showId: metadata?.showId, + showTitle: metadata?.showTitle, + name: episodeDetail.title, + year: episodeDetail.release_year, + logo: + episodeDetail.poster_url + ? { url: episodeDetail.poster_url } + : metadata?.logo || (metadata?.showPoster ? { url: metadata.showPoster } : undefined), + progressId: episodeDetail.watch_progress?.id, + resumePositionMs, + resumeHandledByServer, + startOffsetMs, + requiresTranscode, + transcodeStatus, + durationMs, + fileId, + playbackSequence, + }); + } catch (error) { + console.error('Failed to auto-play next episode', error); + setLoadError('Unable to start the next episode automatically.'); + } + }; + + const startAutoPlayCountdown = (nextIndex) => { + const sequence = metadata?.playbackSequence; + if (!sequence?.episodeIds || nextIndex >= sequence.episodeIds.length) { + return; + } + clearAutoPlayTimers(); + setNextAutoplay({ index: nextIndex, episodeId: sequence.episodeIds[nextIndex] }); + setAutoplayCountdown(AUTOPLAY_SECONDS); + countdownIntervalRef.current = setInterval(() => { + setAutoplayCountdown((prev) => { + if (prev === null) return null; + if (prev <= 1) { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + return 0; + } + return prev - 1; + }); + }, 1000); + autoPlayTimerRef.current = setTimeout(() => { + playEpisodeAtIndex(nextIndex); + }, AUTOPLAY_SECONDS * 1000); + }; + + const cancelAutoPlay = () => { + clearAutoPlayTimers(); + }; + + const playNextEpisodeNow = () => { + if (nextAutoplay) { + playEpisodeAtIndex(nextAutoplay.index); + } + }; + // Initialize VOD player (native HTML5 with enhanced controls) const initializeVODPlayer = () => { if (!videoRef.current || !streamUrl) return; @@ -83,14 +608,35 @@ export default function FloatingVideo() { console.log('Initializing VOD player for:', streamUrl); const video = videoRef.current; + let resumeApplied = false; // Enhanced video element configuration for VOD video.preload = 'metadata'; video.crossOrigin = 'anonymous'; // Set up event listeners - const handleLoadStart = () => setIsLoading(true); - const handleLoadedData = () => setIsLoading(false); + const handleLoadStart = () => { + setIsLoading(true); + handlePointerActivity(); + }; + const handleLoadedData = () => { + setIsLoading(false); + handlePointerActivity(); + }; + const handleLoadedMetadata = () => { + const startOffsetSeconds = (metadata?.startOffsetMs ?? 0) / 1000; + const videoDuration = Number.isFinite(video.duration) ? video.duration : 0; + const resolvedDuration = metadata?.durationMs + ? Math.max(videoDuration + startOffsetSeconds, metadata.durationMs / 1000) + : videoDuration + startOffsetSeconds; + if (resolvedDuration > 0) { + setDurationSeconds(resolvedDuration); + } + const absolutePosition = startOffsetSeconds + video.currentTime; + setCurrentTimeSeconds(absolutePosition); + setScrubValueSeconds(absolutePosition); + }; + const handleCanPlay = () => { setIsLoading(false); // Auto-play for VOD content @@ -98,9 +644,38 @@ export default function FloatingVideo() { console.log('Auto-play prevented:', e); setLoadError('Auto-play was prevented. Click play to start.'); }); + if ( + contentType === 'library' && + metadata?.resumePositionMs && + !metadata?.resumeHandledByServer && + !resumeApplied + ) { + try { + video.currentTime = metadata.resumePositionMs / 1000; + resumeApplied = true; + } catch (error) { + console.debug('Failed to set resume position', error); + } + } + const startOffsetSeconds = (metadata?.startOffsetMs ?? 0) / 1000; + const absolutePosition = startOffsetSeconds + video.currentTime; + setCurrentTimeSeconds(absolutePosition); + setScrubValueSeconds(absolutePosition); + setIsPlaying(!video.paused); + handlePointerActivity(); // Start overlay timer when video is ready startOverlayTimer(); }; + + const handlePlay = () => { + setIsPlaying(true); + handlePointerActivity(); + }; + + const handlePause = () => { + setIsPlaying(false); + handlePointerActivity(); + }; const handleError = (e) => { setIsLoading(false); const error = e.target.error; @@ -140,12 +715,63 @@ export default function FloatingVideo() { } }; + const handleTimeUpdate = () => { + if (contentType !== 'library') return; + const startOffsetSeconds = (metadata?.startOffsetMs ?? 0) / 1000; + const relativePosition = Number.isFinite(video.currentTime) ? video.currentTime : 0; + const absolutePosition = startOffsetSeconds + relativePosition; + setCurrentTimeSeconds(absolutePosition); + if (!isScrubbing) { + setScrubValueSeconds(absolutePosition); + } + + if (Number.isFinite(video.duration) && video.duration > 0) { + const potentialDuration = Math.max( + durationSeconds, + startOffsetSeconds + video.duration, + metadata?.durationMs ? metadata.durationMs / 1000 : 0 + ); + if (potentialDuration > durationSeconds) { + setDurationSeconds(potentialDuration); + } + } + + const now = Date.now(); + if (now - lastProgressSentRef.current < 5000) { + return; + } + lastProgressSentRef.current = now; + sendLibraryProgress(video.currentTime, video.duration, false); + }; + + const handleEnded = () => { + if (contentType !== 'library') return; + if (!video.duration || Number.isNaN(video.duration)) return; + const startOffsetSeconds = (metadata?.startOffsetMs ?? 0) / 1000; + const totalSeconds = Math.max(durationSeconds, startOffsetSeconds + video.duration); + setCurrentTimeSeconds(totalSeconds); + setScrubValueSeconds(totalSeconds); + sendLibraryProgress(video.duration, video.duration, true); + const sequence = metadata?.playbackSequence; + if (sequence?.episodeIds?.length) { + const nextIndex = (sequence.currentIndex ?? -1) + 1; + if (nextIndex < sequence.episodeIds.length) { + startAutoPlayCountdown(nextIndex); + } + } + }; + // Add event listeners video.addEventListener('loadstart', handleLoadStart); + video.addEventListener('loadedmetadata', handleLoadedMetadata); video.addEventListener('loadeddata', handleLoadedData); video.addEventListener('canplay', handleCanPlay); + video.addEventListener('play', handlePlay); + video.addEventListener('pause', handlePause); video.addEventListener('error', handleError); video.addEventListener('progress', handleProgress); + video.addEventListener('timeupdate', handleTimeUpdate); + video.addEventListener('ended', handleEnded); // Set the source video.src = streamUrl; @@ -155,10 +781,15 @@ export default function FloatingVideo() { playerRef.current = { destroy: () => { video.removeEventListener('loadstart', handleLoadStart); + video.removeEventListener('loadedmetadata', handleLoadedMetadata); video.removeEventListener('loadeddata', handleLoadedData); video.removeEventListener('canplay', handleCanPlay); + video.removeEventListener('play', handlePlay); + video.removeEventListener('pause', handlePause); video.removeEventListener('error', handleError); video.removeEventListener('progress', handleProgress); + video.removeEventListener('timeupdate', handleTimeUpdate); + video.removeEventListener('ended', handleEnded); video.removeAttribute('src'); video.load(); }, @@ -291,7 +922,7 @@ export default function FloatingVideo() { safeDestroyPlayer(); // Initialize the appropriate player based on content type - if (contentType === 'vod') { + if (contentType === 'vod' || contentType === 'library') { initializeVODPlayer(); } else { initializeLivePlayer(); @@ -303,12 +934,48 @@ export default function FloatingVideo() { }; }, [isVisible, streamUrl, contentType]); + + useEffect(() => { + if (isVisible) { + setShowControls(true); + handlePointerActivity(); + } else { + clearControlsTimeout(); + } + }, [isVisible]); + useEffect(() => { + serverSeekInProgressRef.current = false; + }, [streamUrl]); + + useEffect(() => { + lastServerSeekAbsoluteRef.current = (metadata?.startOffsetMs ?? 0) / 1000; + }, [metadata?.startOffsetMs]); + + useEffect(() => { + clearAutoPlayTimers(); + }, [metadata?.mediaItemId]); + // Modified hideVideo handler to clean up player first const handleClose = (e) => { if (e) { e.stopPropagation(); e.preventDefault(); } + if ( + typeof document !== 'undefined' && + (document.fullscreenElement === videoContainerRef.current || + document.webkitFullscreenElement === videoContainerRef.current || + document.mozFullScreenElement === videoContainerRef.current || + document.msFullscreenElement === videoContainerRef.current) + ) { + const exit = + document.exitFullscreen || + document.webkitExitFullscreen || + document.mozCancelFullScreen || + document.msExitFullscreen; + exit?.call(document).catch(() => {}); + } + setShowVolumeControl(false); safeDestroyPlayer(); setTimeout(() => { hideVideo(); @@ -320,21 +987,65 @@ export default function FloatingVideo() { return null; } + const baseDurationSeconds = + Number.isFinite(durationSeconds) && durationSeconds > 0 + ? durationSeconds + : metadata?.durationMs + ? metadata.durationMs / 1000 + : 0; + const sliderMaxValue = Number.isFinite(baseDurationSeconds) && baseDurationSeconds > 0 + ? baseDurationSeconds + : Math.max(scrubValueSeconds, currentTimeSeconds + 1); + const sliderValue = Math.min( + Math.max(isScrubbing ? scrubValueSeconds : currentTimeSeconds, 0), + Math.max(sliderMaxValue, 1) + ); + const formattedDurationLabel = + Number.isFinite(baseDurationSeconds) && baseDurationSeconds > 0 + ? formatTime(sliderMaxValue) + : '--:--'; + const formattedCurrentTime = sliderMaxValue > 0 ? formatTime(sliderValue) : '--:--'; + const showPlaybackControls = contentType !== 'live'; + const volumeIcon = isMuted || volume === 0 ? : ; + const fullscreenIcon = isFullscreen ? : ; + + const containerStyle = isFullscreen + ? { + position: 'fixed', + top: 0, + left: 0, + width: '100vw', + height: '100vh', + zIndex: 9999, + backgroundColor: '#000', + borderRadius: 0, + overflow: 'hidden', + boxShadow: 'none', + } + : { + position: 'fixed', + bottom: '20px', + right: '20px', + width: '320px', + zIndex: 9999, + backgroundColor: '#333', + borderRadius: '8px', + overflow: 'hidden', + boxShadow: '0 2px 10px rgba(0,0,0,0.7)', + }; + + const videoStyles = { + width: '100%', + height: isFullscreen ? '100%' : '180px', + backgroundColor: '#000', + objectFit: 'contain', + }; + return ( - +
{/* Simple header row with a close button */} { - if (contentType === 'vod' && !isLoading) { + if (contentType !== 'live' && !isLoading) { setShowOverlay(true); if (overlayTimeoutRef.current) { clearTimeout(overlayTimeoutRef.current); @@ -369,7 +1082,7 @@ export default function FloatingVideo() { } }} onMouseLeave={() => { - if (contentType === 'vod' && !isLoading) { + if (contentType !== 'live' && !isLoading) { startOverlayTimer(); } }} @@ -377,25 +1090,19 @@ export default function FloatingVideo() { {/* Enhanced video element with better controls for VOD */}