mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
Media Server Init
This commit is contained in:
parent
d5f9ba7e5e
commit
58ea3fa9e3
46 changed files with 13380 additions and 119 deletions
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1
apps/media_library/__init__.py
Normal file
1
apps/media_library/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
default_app_config = "apps.media_library.apps.MediaLibraryConfig"
|
||||
16
apps/media_library/api_urls.py
Normal file
16
apps/media_library/api_urls.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.media_library import api_views, views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"libraries", api_views.LibraryViewSet, basename="library")
|
||||
router.register(r"scans", api_views.LibraryScanViewSet, basename="libraryscan")
|
||||
router.register(r"items", api_views.MediaItemViewSet, basename="mediaitem")
|
||||
router.register(r"files", api_views.MediaFileViewSet, basename="mediafile")
|
||||
router.register(r"progress", api_views.WatchProgressViewSet, basename="watchprogress")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
path("stream/<str:token>/", views.stream_media_file, name="stream-file"),
|
||||
]
|
||||
591
apps/media_library/api_views.py
Normal file
591
apps/media_library/api_views.py
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.signing import TimestampSigner
|
||||
from django.db.models import Prefetch, Q
|
||||
from django.urls import reverse
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
|
||||
from apps.accounts.permissions import Authenticated
|
||||
from apps.media_library import models, serializers
|
||||
from apps.media_library.metadata import (
|
||||
resolve_available_metadata_source,
|
||||
sync_metadata,
|
||||
)
|
||||
from apps.media_library.tasks import (
|
||||
enqueue_library_scan,
|
||||
sync_metadata_task,
|
||||
cancel_library_scan,
|
||||
revoke_scan_task,
|
||||
start_next_library_scan,
|
||||
resume_orphaned_scans,
|
||||
sync_library_to_vod_task,
|
||||
unsync_library_from_vod_task,
|
||||
)
|
||||
from apps.media_library.vod_sync import unsync_library_from_vod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibraryViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Library.objects.all().prefetch_related("locations")
|
||||
serializer_class = serializers.LibrarySerializer
|
||||
permission_classes = [Authenticated]
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
filterset_fields = ["library_type", "auto_scan_enabled"]
|
||||
search_fields = ["name", "description"]
|
||||
ordering_fields = ["name", "created_at", "updated_at", "last_scan_at"]
|
||||
ordering = ["name"]
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="browse")
|
||||
def browse(self, request):
|
||||
raw_path = request.query_params.get("path")
|
||||
|
||||
if not raw_path:
|
||||
if os.name == "nt":
|
||||
import string
|
||||
|
||||
entries = []
|
||||
for letter in string.ascii_uppercase:
|
||||
drive = Path(f"{letter}:/")
|
||||
if drive.exists():
|
||||
entries.append(
|
||||
{
|
||||
"name": f"{letter}:",
|
||||
"path": str(drive.resolve()),
|
||||
}
|
||||
)
|
||||
return Response({"path": "", "parent": None, "entries": entries})
|
||||
|
||||
root = Path("/").resolve()
|
||||
entries = []
|
||||
try:
|
||||
for child in sorted(root.iterdir(), key=lambda p: p.name.lower()):
|
||||
if child.is_dir():
|
||||
entries.append(
|
||||
{
|
||||
"name": child.name or str(child),
|
||||
"path": str(child),
|
||||
}
|
||||
)
|
||||
except PermissionError:
|
||||
entries = []
|
||||
|
||||
return Response({"path": str(root), "parent": None, "entries": entries})
|
||||
|
||||
try:
|
||||
target = Path(raw_path).expanduser()
|
||||
if not target.exists():
|
||||
raise ValidationError({"detail": "Directory not found."})
|
||||
if not target.is_dir():
|
||||
target = target.parent
|
||||
target = target.resolve()
|
||||
except (ValueError, OSError, RuntimeError):
|
||||
raise ValidationError({"detail": "Invalid path."})
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for child in sorted(target.iterdir(), key=lambda p: p.name.lower()):
|
||||
if child.is_dir():
|
||||
entries.append(
|
||||
{
|
||||
"name": child.name or str(child),
|
||||
"path": str(child),
|
||||
}
|
||||
)
|
||||
except PermissionError:
|
||||
entries = []
|
||||
|
||||
parent = str(target.parent) if target != target.parent else None
|
||||
return Response(
|
||||
{
|
||||
"path": str(target),
|
||||
"parent": parent,
|
||||
"entries": entries,
|
||||
}
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
provider, error = resolve_available_metadata_source()
|
||||
if not provider:
|
||||
raise ValidationError(
|
||||
{
|
||||
"detail": error
|
||||
or "All metadata sources are unavailable. Configure a TMDB API key or try again later."
|
||||
}
|
||||
)
|
||||
|
||||
library = serializer.save()
|
||||
if library.auto_scan_enabled:
|
||||
enqueue_library_scan(library_id=library.id, user_id=self.request.user.id)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
previous_use_as_vod = bool(serializer.instance.use_as_vod_source)
|
||||
library = serializer.save()
|
||||
if previous_use_as_vod != library.use_as_vod_source:
|
||||
if library.use_as_vod_source:
|
||||
sync_library_to_vod_task.delay(library.id)
|
||||
else:
|
||||
unsync_library_from_vod_task.delay(library.id)
|
||||
if library.auto_scan_enabled and self.request.data.get("trigger_scan"):
|
||||
enqueue_library_scan(library_id=library.id, user_id=self.request.user.id)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
from django.db import transaction
|
||||
|
||||
with transaction.atomic():
|
||||
unsync_library_from_vod(instance)
|
||||
models.MediaItem.objects.filter(library=instance).delete()
|
||||
models.MediaFile.objects.filter(library=instance).delete()
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="scan")
|
||||
def scan(self, request, pk=None):
|
||||
library = self.get_object()
|
||||
user_id = request.user.id if request.user and request.user.is_authenticated else None
|
||||
scan = enqueue_library_scan(library_id=library.id, user_id=user_id, force_full=request.data.get("full", False))
|
||||
serializer = serializers.LibraryScanSerializer(scan)
|
||||
return Response(serializer.data, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
class LibraryScanViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
queryset = models.LibraryScan.objects.select_related("library", "created_by")
|
||||
serializer_class = serializers.LibraryScanSerializer
|
||||
permission_classes = [Authenticated]
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
||||
filterset_fields = ["library", "status"]
|
||||
ordering_fields = ["created_at", "started_at", "finished_at"]
|
||||
ordering = ["-created_at"]
|
||||
http_method_names = ["get", "head", "options", "delete", "post"]
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
library_param = request.query_params.get("library")
|
||||
library_id = None
|
||||
if library_param:
|
||||
try:
|
||||
library_id = int(library_param)
|
||||
except (TypeError, ValueError):
|
||||
library_id = None
|
||||
try:
|
||||
resume_orphaned_scans(library_id=library_id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Failed to resume orphaned scans for library %s", library_param)
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
scan = self.get_object()
|
||||
|
||||
if scan.status == models.LibraryScan.STATUS_RUNNING:
|
||||
raise ValidationError({"detail": "Cannot remove a running scan."})
|
||||
|
||||
if scan.status == models.LibraryScan.STATUS_PENDING:
|
||||
revoke_scan_task(scan.task_id, terminate=False)
|
||||
library = scan.library
|
||||
response = super().destroy(request, *args, **kwargs)
|
||||
start_next_library_scan(library)
|
||||
return response
|
||||
|
||||
response = super().destroy(request, *args, **kwargs)
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="cancel")
|
||||
def cancel(self, request, pk=None):
|
||||
scan = self.get_object()
|
||||
summary = request.data.get("summary")
|
||||
try:
|
||||
cancelled = cancel_library_scan(scan, summary=summary)
|
||||
except ValueError as exc: # noqa: BLE001
|
||||
raise ValidationError({"detail": str(exc)})
|
||||
|
||||
serializer = self.get_serializer(cancelled)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=["delete"], url_path="purge")
|
||||
def purge_completed(self, request):
|
||||
valid_statuses = {
|
||||
models.LibraryScan.STATUS_COMPLETED,
|
||||
models.LibraryScan.STATUS_FAILED,
|
||||
models.LibraryScan.STATUS_CANCELLED,
|
||||
}
|
||||
requested_statuses = request.query_params.getlist("status")
|
||||
if requested_statuses:
|
||||
statuses = [value for value in requested_statuses if value in valid_statuses]
|
||||
if not statuses:
|
||||
raise ValidationError({"detail": "No valid statuses provided."})
|
||||
else:
|
||||
statuses = list(valid_statuses)
|
||||
|
||||
queryset = self.filter_queryset(
|
||||
self.get_queryset().filter(status__in=statuses)
|
||||
)
|
||||
library_id = request.query_params.get("library")
|
||||
if library_id:
|
||||
queryset = queryset.filter(library_id=library_id)
|
||||
|
||||
deleted, _ = queryset.delete()
|
||||
return Response({"deleted": deleted}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class MediaItemViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = serializers.MediaItemSerializer
|
||||
permission_classes = [Authenticated]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = [
|
||||
"library",
|
||||
"item_type",
|
||||
"status",
|
||||
"release_year",
|
||||
"season_number",
|
||||
"parent",
|
||||
]
|
||||
search_fields = ["title", "synopsis", "tags"]
|
||||
ordering_fields = [
|
||||
"sort_title",
|
||||
"release_year",
|
||||
"first_imported_at",
|
||||
"updated_at",
|
||||
"season_number",
|
||||
"episode_number",
|
||||
]
|
||||
ordering = ["sort_title"]
|
||||
http_method_names = ["get", "head", "options", "patch", "post"]
|
||||
pagination_class = None
|
||||
_stream_signer = TimestampSigner(salt="media-library-stream")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return serializers.MediaItemListSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
def get_queryset(self):
|
||||
user = getattr(self.request, "user", None)
|
||||
if user and user.is_authenticated:
|
||||
watch_prefetch = Prefetch(
|
||||
"watch_progress",
|
||||
queryset=models.WatchProgress.objects.filter(user=user),
|
||||
to_attr="_user_watch_progress",
|
||||
)
|
||||
episode_watch_prefetch = Prefetch(
|
||||
"watch_progress",
|
||||
queryset=models.WatchProgress.objects.filter(user=user),
|
||||
to_attr="_user_watch_progress",
|
||||
)
|
||||
else:
|
||||
watch_prefetch = Prefetch(
|
||||
"watch_progress",
|
||||
queryset=models.WatchProgress.objects.none(),
|
||||
to_attr="_user_watch_progress",
|
||||
)
|
||||
episode_watch_prefetch = Prefetch(
|
||||
"watch_progress",
|
||||
queryset=models.WatchProgress.objects.none(),
|
||||
to_attr="_user_watch_progress",
|
||||
)
|
||||
|
||||
base_queryset = models.MediaItem.objects.select_related(
|
||||
"library",
|
||||
"parent",
|
||||
"vod_movie",
|
||||
"vod_series",
|
||||
"vod_episode",
|
||||
)
|
||||
|
||||
if self.action == "list":
|
||||
children_qs = (
|
||||
models.MediaItem.objects.filter(item_type=models.MediaItem.TYPE_EPISODE)
|
||||
.select_related("parent")
|
||||
.prefetch_related(episode_watch_prefetch)
|
||||
.order_by("season_number", "episode_number", "id")
|
||||
)
|
||||
return base_queryset.prefetch_related(
|
||||
watch_prefetch,
|
||||
Prefetch(
|
||||
"children",
|
||||
queryset=children_qs,
|
||||
to_attr="_prefetched_children",
|
||||
),
|
||||
)
|
||||
|
||||
return base_queryset.prefetch_related("files", "artwork", watch_prefetch)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
queryset = super().filter_queryset(queryset)
|
||||
search = self.request.query_params.get("search")
|
||||
if search:
|
||||
search = search.strip()
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search)
|
||||
| Q(synopsis__icontains=search)
|
||||
| Q(tags__icontains=search)
|
||||
)
|
||||
return queryset
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context["request"] = self.request
|
||||
return context
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
allowed_fields = {
|
||||
"title",
|
||||
"release_year",
|
||||
"synopsis",
|
||||
"tagline",
|
||||
"genres",
|
||||
"studios",
|
||||
"cast",
|
||||
"crew",
|
||||
"tags",
|
||||
"poster_url",
|
||||
"backdrop_url",
|
||||
"rating",
|
||||
"tmdb_id",
|
||||
"imdb_id",
|
||||
"runtime_ms",
|
||||
"metadata",
|
||||
"status",
|
||||
}
|
||||
unknown = {key for key in request.data.keys() if key not in allowed_fields}
|
||||
if unknown:
|
||||
raise ValidationError(
|
||||
{"detail": f"Cannot update fields: {', '.join(sorted(unknown))}"}
|
||||
)
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="refresh-metadata")
|
||||
def refresh_metadata(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
sync_metadata_task.delay(item.id)
|
||||
return Response({"status": "queued"}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="set-tmdb")
|
||||
def set_tmdb(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
tmdb_id = request.data.get("tmdb_id")
|
||||
if tmdb_id in (None, ""):
|
||||
raise ValidationError({"tmdb_id": "TMDB ID is required."})
|
||||
|
||||
tmdb_id_str = str(tmdb_id).strip()
|
||||
if not tmdb_id_str:
|
||||
raise ValidationError({"tmdb_id": "TMDB ID is required."})
|
||||
|
||||
if item.tmdb_id != tmdb_id_str:
|
||||
item.tmdb_id = tmdb_id_str
|
||||
item.save(update_fields=["tmdb_id", "updated_at"])
|
||||
|
||||
refreshed = sync_metadata(item)
|
||||
if not refreshed:
|
||||
raise ValidationError(
|
||||
{"detail": "Unable to fetch TMDB metadata for this item."}
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(refreshed)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="stream")
|
||||
def stream(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
file_id = request.query_params.get("file")
|
||||
files_qs = item.files.all()
|
||||
if file_id:
|
||||
file = files_qs.filter(pk=file_id).first()
|
||||
if not file:
|
||||
raise NotFound("Requested media file not found")
|
||||
else:
|
||||
file = files_qs.order_by("id").first()
|
||||
if not file:
|
||||
return Response(
|
||||
{"detail": "No media files available for this item."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
start_ms = 0
|
||||
start_ms_param = request.query_params.get("start_ms")
|
||||
if start_ms_param not in (None, "", "0"):
|
||||
try:
|
||||
start_ms = max(0, int(start_ms_param))
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError({"start_ms": "Start offset must be an integer number of milliseconds."})
|
||||
|
||||
applied_start_ms = 0
|
||||
should_embed_start = False
|
||||
if start_ms > 0 and file.requires_transcode:
|
||||
cached_ready = (
|
||||
file.transcode_status == models.MediaFile.TRANSCODE_STATUS_READY
|
||||
and file.transcoded_path
|
||||
and os.path.exists(file.transcoded_path)
|
||||
)
|
||||
if not cached_ready:
|
||||
applied_start_ms = start_ms
|
||||
should_embed_start = True
|
||||
|
||||
duration_ms = file.effective_duration_ms or item.runtime_ms or 0
|
||||
|
||||
payload = {"file_id": file.id, "user_id": request.user.id}
|
||||
if should_embed_start:
|
||||
payload["start_ms"] = applied_start_ms
|
||||
|
||||
token = self._stream_signer.sign_object(payload)
|
||||
stream_url = request.build_absolute_uri(
|
||||
reverse("api:media:stream-file", args=[token])
|
||||
)
|
||||
ttl = getattr(settings, "MEDIA_LIBRARY_STREAM_TOKEN_TTL", 3600)
|
||||
return Response(
|
||||
{
|
||||
"url": stream_url,
|
||||
"file_id": file.id,
|
||||
"expires_in": ttl,
|
||||
"type": "direct",
|
||||
"duration_ms": duration_ms,
|
||||
"bit_rate": file.bit_rate,
|
||||
"container": file.container,
|
||||
"requires_transcode": file.requires_transcode,
|
||||
"transcode_status": file.transcode_status,
|
||||
"start_offset_ms": applied_start_ms,
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="mark-watched")
|
||||
def mark_watched(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
duration = item.runtime_ms
|
||||
if not duration:
|
||||
primary_file = item.files.order_by("-duration_ms").first()
|
||||
duration = primary_file.duration_ms if primary_file else 0
|
||||
if not duration:
|
||||
duration = 1000 # default to one second to allow completion state
|
||||
|
||||
progress, _ = models.WatchProgress.objects.update_or_create(
|
||||
user=request.user,
|
||||
media_item=item,
|
||||
defaults={
|
||||
"position_ms": duration or 0,
|
||||
"duration_ms": duration or 0,
|
||||
"completed": True,
|
||||
},
|
||||
)
|
||||
progress.update_progress(position_ms=duration or 0, duration_ms=duration or 0)
|
||||
return Response({"status": "ok"})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="mark-series-watched")
|
||||
def mark_series_watched(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
if item.item_type != models.MediaItem.TYPE_SHOW:
|
||||
return Response(
|
||||
{"detail": "Series-level actions are only available for shows."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
episodes = item.children.filter(item_type=models.MediaItem.TYPE_EPISODE)
|
||||
updated = 0
|
||||
for episode in episodes:
|
||||
duration = episode.runtime_ms
|
||||
if not duration:
|
||||
primary_file = episode.files.order_by("-duration_ms").first()
|
||||
duration = primary_file.duration_ms if primary_file else 0
|
||||
if not duration:
|
||||
duration = 1000
|
||||
models.WatchProgress.objects.update_or_create(
|
||||
user=request.user,
|
||||
media_item=episode,
|
||||
defaults={
|
||||
"position_ms": duration,
|
||||
"duration_ms": duration,
|
||||
"completed": True,
|
||||
},
|
||||
)
|
||||
updated += 1
|
||||
|
||||
models.WatchProgress.objects.update_or_create(
|
||||
user=request.user,
|
||||
media_item=item,
|
||||
defaults={
|
||||
"position_ms": 0,
|
||||
"duration_ms": item.runtime_ms or 0,
|
||||
"completed": True,
|
||||
},
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(item)
|
||||
return Response({"updated": updated, "item": serializer.data})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="mark-series-unwatched")
|
||||
def mark_series_unwatched(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
if item.item_type != models.MediaItem.TYPE_SHOW:
|
||||
return Response(
|
||||
{"detail": "Series-level actions are only available for shows."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
episodes = item.children.filter(item_type=models.MediaItem.TYPE_EPISODE)
|
||||
cleared, _ = models.WatchProgress.objects.filter(
|
||||
user=request.user,
|
||||
media_item__in=episodes,
|
||||
).delete()
|
||||
models.WatchProgress.objects.filter(user=request.user, media_item=item).delete()
|
||||
serializer = self.get_serializer(item)
|
||||
return Response({"cleared": cleared, "item": serializer.data})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="clear-progress")
|
||||
def clear_progress(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
models.WatchProgress.objects.filter(user=request.user, media_item=item).delete()
|
||||
return Response({"status": "cleared"})
|
||||
|
||||
|
||||
class MediaFileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = models.MediaFile.objects.select_related("library", "media_item", "location")
|
||||
serializer_class = serializers.MediaFileSerializer
|
||||
permission_classes = [Authenticated]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["library", "media_item", "location", "has_subtitles"]
|
||||
search_fields = ["relative_path", "file_name", "absolute_path"]
|
||||
ordering_fields = ["relative_path", "file_name", "size_bytes", "updated_at", "last_seen_at"]
|
||||
ordering = ["relative_path"]
|
||||
|
||||
|
||||
class WatchProgressViewSet(mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
|
||||
serializer_class = serializers.WatchProgressSerializer
|
||||
permission_classes = [Authenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = models.WatchProgress.objects.select_related("media_item", "user")
|
||||
user_only = self.request.query_params.get("mine", "true").lower() != "false"
|
||||
if user_only:
|
||||
queryset = queryset.filter(user=self.request.user)
|
||||
return queryset
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="set")
|
||||
def set_progress(self, request):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
progress = serializer.save()
|
||||
progress.update_progress(
|
||||
position_ms=serializer.validated_data.get("position_ms", 0),
|
||||
duration_ms=serializer.validated_data.get("duration_ms"),
|
||||
)
|
||||
return Response(self.get_serializer(progress).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="resume")
|
||||
def resume(self, request, pk=None):
|
||||
progress = self.get_object()
|
||||
if progress.duration_ms:
|
||||
percentage = progress.position_ms / progress.duration_ms
|
||||
else:
|
||||
percentage = 0
|
||||
remaining_ms = max(progress.duration_ms - progress.position_ms, 0)
|
||||
data = {
|
||||
"position_ms": progress.position_ms,
|
||||
"duration_ms": progress.duration_ms,
|
||||
"percentage": percentage,
|
||||
"remaining_ms": remaining_ms,
|
||||
"completed": progress.completed,
|
||||
"resume_allowed": progress.duration_ms * 0.04 < remaining_ms,
|
||||
}
|
||||
return Response(data)
|
||||
14
apps/media_library/apps.py
Normal file
14
apps/media_library/apps.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MediaLibraryConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.media_library"
|
||||
verbose_name = "Media Library"
|
||||
|
||||
def ready(self):
|
||||
# Import signals when needed without causing circular dependencies
|
||||
try:
|
||||
import apps.media_library.signals # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
582
apps/media_library/metadata.py
Normal file
582
apps/media_library/metadata.py
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
import logging
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
import tmdbsimple as tmdb
|
||||
from dateutil import parser as date_parser
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.media_library.models import ArtworkAsset, MediaItem
|
||||
from core.models import CoreSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TMDB_API_KEY_SETTING = "tmdb-api-key"
|
||||
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original"
|
||||
METADATA_CACHE_TIMEOUT = 60 * 60 * 6 # 6 hours
|
||||
TMDB_VALIDATION_CACHE_SUCCESS_TTL = 60 * 30 # 30 minutes
|
||||
TMDB_VALIDATION_CACHE_FAILURE_TTL = 60 * 5 # 5 minutes
|
||||
MOVIEDB_SEARCH_URL = "https://movie-db.org/api.php"
|
||||
MOVIEDB_HEALTH_CACHE_KEY = "movie-db:health"
|
||||
MOVIEDB_HEALTH_SUCCESS_TTL = 60 * 15 # 15 minutes
|
||||
MOVIEDB_HEALTH_FAILURE_TTL = 60 * 2 # 2 minutes
|
||||
_REQUESTS_SESSION = requests.Session()
|
||||
_REQUESTS_SESSION.mount(
|
||||
"https://",
|
||||
requests.adapters.HTTPAdapter(pool_connections=20, pool_maxsize=40, max_retries=3),
|
||||
)
|
||||
tmdb.REQUESTS_SESSION = _REQUESTS_SESSION
|
||||
|
||||
|
||||
def validate_tmdb_api_key(api_key: str, *, use_cache: bool = True) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Validate a TMDB API key by calling the configuration endpoint.
|
||||
|
||||
Returns a tuple of (is_valid, message). On success, message is None.
|
||||
"""
|
||||
normalized_key = (api_key or "").strip()
|
||||
if not normalized_key:
|
||||
return False, "TMDB API key is required."
|
||||
|
||||
cache_key = f"tmdb-key-validation:{normalized_key}"
|
||||
if use_cache:
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached.get("valid", False), cached.get("message")
|
||||
|
||||
try:
|
||||
response = _REQUESTS_SESSION.get(
|
||||
"https://api.themoviedb.org/3/configuration",
|
||||
params={"api_key": normalized_key},
|
||||
timeout=5,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
logger.warning("Unable to validate TMDB API key due to network error: %s", exc)
|
||||
message = "Could not reach TMDB to validate the API key."
|
||||
return False, message
|
||||
|
||||
if response.status_code == 200:
|
||||
if use_cache:
|
||||
cache.set(
|
||||
cache_key,
|
||||
{"valid": True, "message": None},
|
||||
TMDB_VALIDATION_CACHE_SUCCESS_TTL,
|
||||
)
|
||||
return True, None
|
||||
|
||||
if response.status_code == 401:
|
||||
message = "TMDB rejected the API key (HTTP 401 Unauthorized)."
|
||||
else:
|
||||
message = f"TMDB returned status {response.status_code} while validating the API key."
|
||||
|
||||
if use_cache:
|
||||
cache.set(
|
||||
cache_key,
|
||||
{"valid": False, "message": message},
|
||||
TMDB_VALIDATION_CACHE_FAILURE_TTL,
|
||||
)
|
||||
return False, message
|
||||
|
||||
|
||||
def check_movie_db_health(*, use_cache: bool = True) -> Tuple[bool, str | None]:
|
||||
if use_cache:
|
||||
cached = cache.get(MOVIEDB_HEALTH_CACHE_KEY)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
response = _REQUESTS_SESSION.get(
|
||||
MOVIEDB_SEARCH_URL,
|
||||
params={"type": "tv", "query": "The Simpsons"},
|
||||
timeout=5,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
message = f"Movie-DB request failed: {exc}"
|
||||
cache.set(MOVIEDB_HEALTH_CACHE_KEY, (False, message), MOVIEDB_HEALTH_FAILURE_TTL)
|
||||
return False, message
|
||||
|
||||
if response.status_code != 200:
|
||||
message = f"Movie-DB returned status {response.status_code}."
|
||||
cache.set(MOVIEDB_HEALTH_CACHE_KEY, (False, message), MOVIEDB_HEALTH_FAILURE_TTL)
|
||||
return False, message
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
message = "Movie-DB returned an unexpected response format."
|
||||
cache.set(MOVIEDB_HEALTH_CACHE_KEY, (False, message), MOVIEDB_HEALTH_FAILURE_TTL)
|
||||
return False, message
|
||||
|
||||
results = []
|
||||
if isinstance(payload, dict):
|
||||
results = payload.get("results") or []
|
||||
elif isinstance(payload, list):
|
||||
results = payload
|
||||
|
||||
if not results:
|
||||
message = "Movie-DB did not return any results."
|
||||
cache.set(MOVIEDB_HEALTH_CACHE_KEY, (False, message), MOVIEDB_HEALTH_FAILURE_TTL)
|
||||
return False, message
|
||||
|
||||
cache.set(MOVIEDB_HEALTH_CACHE_KEY, (True, None), MOVIEDB_HEALTH_SUCCESS_TTL)
|
||||
return True, None
|
||||
|
||||
|
||||
def get_tmdb_api_key() -> Optional[str]:
|
||||
# Prefer CoreSettings, fallback to environment variable
|
||||
try:
|
||||
setting = CoreSettings.objects.get(key=TMDB_API_KEY_SETTING)
|
||||
if setting.value:
|
||||
return setting.value.strip()
|
||||
except CoreSettings.DoesNotExist:
|
||||
pass
|
||||
|
||||
return getattr(settings, "TMDB_API_KEY", None)
|
||||
|
||||
|
||||
def _configure_tmdb(api_key: str | None) -> bool:
|
||||
if not api_key:
|
||||
logger.debug("TMDB API key missing; skipping remote metadata fetch")
|
||||
return False
|
||||
|
||||
if tmdb.API_KEY != api_key:
|
||||
# we only set when changed
|
||||
tmdb.API_KEY = api_key
|
||||
return True
|
||||
|
||||
|
||||
def build_image_url(path: Optional[str]) -> Optional[str]:
|
||||
if not path:
|
||||
return None
|
||||
return f"{TMDB_IMAGE_BASE}{path}"
|
||||
|
||||
|
||||
def _to_serializable(obj):
|
||||
"""Ensure TMDB data is safe to store in JSON fields."""
|
||||
if isinstance(obj, dict):
|
||||
return {key: _to_serializable(value) for key, value in obj.items()}
|
||||
if isinstance(obj, (list, tuple, set)):
|
||||
return [_to_serializable(item) for item in obj]
|
||||
# Keep primitive JSON types; convert everything else to string
|
||||
if isinstance(obj, (str, int, float, bool)) or obj is None:
|
||||
return obj
|
||||
return str(obj)
|
||||
|
||||
|
||||
def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]:
|
||||
api_key = get_tmdb_api_key()
|
||||
if not _configure_tmdb(api_key):
|
||||
return None
|
||||
|
||||
normalized = (media_item.normalized_title or "").replace(" ", "_")
|
||||
cache_key_parts = [
|
||||
str(media_item.item_type),
|
||||
normalized,
|
||||
str(media_item.release_year or ""),
|
||||
]
|
||||
if media_item.tmdb_id:
|
||||
cache_key_parts.append(f"id:{media_item.tmdb_id}")
|
||||
cache_key = f"media-metadata:{':'.join(cache_key_parts)}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
info = None
|
||||
credits = None
|
||||
tmdb_id = media_item.tmdb_id
|
||||
|
||||
if tmdb_id:
|
||||
try:
|
||||
lookup_id = int(tmdb_id)
|
||||
except (TypeError, ValueError):
|
||||
lookup_id = tmdb_id
|
||||
|
||||
try:
|
||||
if media_item.is_movie:
|
||||
movie = tmdb.Movies(lookup_id)
|
||||
info = movie.info(append_to_response="credits,images")
|
||||
credits = info.get("credits", {})
|
||||
elif media_item.item_type == MediaItem.TYPE_SHOW:
|
||||
tv = tmdb.TV(lookup_id)
|
||||
info = tv.info(append_to_response="credits,images")
|
||||
credits = info.get("credits", {})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"Failed to retrieve TMDB info for %s using id %s", media_item, tmdb_id
|
||||
)
|
||||
info = None
|
||||
credits = None
|
||||
|
||||
if info is None or credits is None:
|
||||
search = tmdb.Search()
|
||||
try:
|
||||
if media_item.is_movie:
|
||||
response = search.movie(query=media_item.title, year=media_item.release_year)
|
||||
results = response.get("results", [])
|
||||
elif media_item.item_type == MediaItem.TYPE_SHOW:
|
||||
response = search.tv(
|
||||
query=media_item.title, first_air_date_year=media_item.release_year
|
||||
)
|
||||
results = response.get("results", [])
|
||||
else:
|
||||
results = []
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("TMDB lookup failed for %s", media_item)
|
||||
return None
|
||||
|
||||
if not results:
|
||||
logger.debug("No TMDB matches found for %s", media_item.title)
|
||||
return None
|
||||
|
||||
best_match = results[0]
|
||||
tmdb_id = best_match.get("id")
|
||||
if not tmdb_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
if media_item.is_movie:
|
||||
movie = tmdb.Movies(tmdb_id)
|
||||
info = movie.info(append_to_response="credits,images")
|
||||
credits = info.get("credits", {})
|
||||
elif media_item.item_type == MediaItem.TYPE_SHOW:
|
||||
tv = tmdb.TV(tmdb_id)
|
||||
info = tv.info(append_to_response="credits,images")
|
||||
credits = info.get("credits", {})
|
||||
else:
|
||||
return None
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Failed to retrieve TMDB info for %s", media_item)
|
||||
return None
|
||||
|
||||
if not tmdb_id and info:
|
||||
tmdb_id = info.get("id")
|
||||
|
||||
genres = [g.get("name") for g in info.get("genres", []) if g.get("name")]
|
||||
runtime = None
|
||||
if media_item.is_movie:
|
||||
runtime = info.get("runtime")
|
||||
elif media_item.item_type == MediaItem.TYPE_SHOW:
|
||||
episode_run_time = info.get("episode_run_time") or []
|
||||
runtime = episode_run_time[0] if episode_run_time else None
|
||||
|
||||
cast = []
|
||||
for cast_member in credits.get("cast", [])[:12]:
|
||||
name = cast_member.get("name")
|
||||
if not name:
|
||||
continue
|
||||
cast.append(
|
||||
{
|
||||
"name": name,
|
||||
"character": cast_member.get("character"),
|
||||
"profile_url": build_image_url(cast_member.get("profile_path")),
|
||||
}
|
||||
)
|
||||
|
||||
crew = []
|
||||
for member in credits.get("crew", [])[:15]:
|
||||
name = member.get("name")
|
||||
job = member.get("job")
|
||||
if not name:
|
||||
continue
|
||||
crew.append(
|
||||
{
|
||||
"name": name,
|
||||
"job": job,
|
||||
"department": member.get("department"),
|
||||
"profile_url": build_image_url(member.get("profile_path")),
|
||||
}
|
||||
)
|
||||
|
||||
release_year = media_item.release_year
|
||||
release_date = info.get("release_date") or info.get("first_air_date")
|
||||
if release_date:
|
||||
try:
|
||||
release_year = date_parser.parse(release_date).year
|
||||
except (ValueError, TypeError): # noqa: PERF203
|
||||
release_year = media_item.release_year
|
||||
|
||||
metadata = _to_serializable(
|
||||
{
|
||||
"tmdb_id": str(tmdb_id) if tmdb_id is not None else media_item.tmdb_id,
|
||||
"imdb_id": info.get("imdb_id") or media_item.imdb_id,
|
||||
"title": info.get("title") or info.get("name") or media_item.title,
|
||||
"synopsis": info.get("overview") or media_item.synopsis,
|
||||
"tagline": info.get("tagline") or media_item.tagline,
|
||||
"release_year": release_year,
|
||||
"genres": genres,
|
||||
"runtime_minutes": runtime,
|
||||
"poster": build_image_url(info.get("poster_path")),
|
||||
"backdrop": build_image_url(info.get("backdrop_path")),
|
||||
"cast": cast,
|
||||
"crew": crew,
|
||||
"source": "tmdb",
|
||||
"raw": info,
|
||||
}
|
||||
)
|
||||
|
||||
cache.set(cache_key, metadata, METADATA_CACHE_TIMEOUT)
|
||||
return metadata
|
||||
|
||||
|
||||
def _parse_release_year(value: Optional[str]) -> Optional[int]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date_parser.parse(value).year
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def fetch_movie_db_metadata(media_item: MediaItem) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
if media_item.item_type not in {
|
||||
MediaItem.TYPE_MOVIE,
|
||||
MediaItem.TYPE_SHOW,
|
||||
MediaItem.TYPE_EPISODE,
|
||||
}:
|
||||
return None, "Movie-DB fallback does not support this media type."
|
||||
|
||||
media_type = "movie" if media_item.is_movie else "tv"
|
||||
try:
|
||||
response = _REQUESTS_SESSION.get(
|
||||
MOVIEDB_SEARCH_URL,
|
||||
params={"type": media_type, "query": media_item.title},
|
||||
timeout=6,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
message = f"Movie-DB search failed: {exc}"
|
||||
logger.warning(message)
|
||||
return None, message
|
||||
|
||||
if response.status_code != 200:
|
||||
message = f"Movie-DB search returned status {response.status_code}."
|
||||
logger.debug(message)
|
||||
return None, message
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
message = "Movie-DB search returned an unexpected response format."
|
||||
logger.debug(message)
|
||||
return None, message
|
||||
|
||||
results = []
|
||||
if isinstance(payload, dict):
|
||||
results = payload.get("results") or []
|
||||
elif isinstance(payload, list):
|
||||
results = payload
|
||||
|
||||
if not results:
|
||||
message = "Movie-DB search returned no matches."
|
||||
logger.debug(message)
|
||||
return None, message
|
||||
|
||||
candidate = results[0]
|
||||
release_date = candidate.get("release_date") or candidate.get("first_air_date")
|
||||
release_year = _parse_release_year(release_date) or media_item.release_year
|
||||
|
||||
metadata = _to_serializable(
|
||||
{
|
||||
"tmdb_id": str(candidate.get("id") or media_item.tmdb_id or ""),
|
||||
"title": candidate.get("title")
|
||||
or candidate.get("name")
|
||||
or media_item.title,
|
||||
"synopsis": candidate.get("overview") or media_item.synopsis,
|
||||
"release_year": release_year,
|
||||
"poster": build_image_url(candidate.get("poster_path")),
|
||||
"backdrop": build_image_url(candidate.get("backdrop_path")),
|
||||
"runtime_minutes": candidate.get("runtime"),
|
||||
"genres": [],
|
||||
"cast": [],
|
||||
"crew": [],
|
||||
"source": "movie-db",
|
||||
"raw": candidate,
|
||||
}
|
||||
)
|
||||
|
||||
return metadata, None
|
||||
|
||||
|
||||
def resolve_available_metadata_source() -> Tuple[Optional[str], Optional[str]]:
|
||||
tmdb_key = get_tmdb_api_key()
|
||||
tmdb_error: Optional[str] = None
|
||||
if tmdb_key:
|
||||
tmdb_valid, tmdb_message = validate_tmdb_api_key(tmdb_key)
|
||||
if tmdb_valid:
|
||||
return "tmdb", None
|
||||
tmdb_error = tmdb_message or "TMDB API key is invalid."
|
||||
else:
|
||||
tmdb_error = "TMDB API key not configured."
|
||||
|
||||
movie_db_available, movie_db_message = check_movie_db_health()
|
||||
if movie_db_available:
|
||||
return "movie-db", None
|
||||
|
||||
combined = "All metadata sources are unavailable."
|
||||
details: list[str] = []
|
||||
if tmdb_error:
|
||||
details.append(f"TMDB: {tmdb_error}")
|
||||
if movie_db_message:
|
||||
details.append(f"Movie-DB: {movie_db_message}")
|
||||
if details:
|
||||
combined = f"{combined} {' '.join(details)}"
|
||||
return None, combined
|
||||
|
||||
|
||||
def fetch_metadata_with_fallback(media_item: MediaItem) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
metadata = None
|
||||
tmdb_key = get_tmdb_api_key()
|
||||
tmdb_error: Optional[str] = None
|
||||
|
||||
if tmdb_key:
|
||||
tmdb_valid, tmdb_message = validate_tmdb_api_key(tmdb_key)
|
||||
if tmdb_valid:
|
||||
metadata = fetch_tmdb_metadata(media_item)
|
||||
if metadata:
|
||||
return metadata, None
|
||||
tmdb_error = "TMDB returned no metadata for this item."
|
||||
else:
|
||||
tmdb_error = tmdb_message or "TMDB API key is invalid."
|
||||
else:
|
||||
tmdb_error = "TMDB API key not configured."
|
||||
|
||||
fallback_metadata, fallback_error = fetch_movie_db_metadata(media_item)
|
||||
if fallback_metadata:
|
||||
return fallback_metadata, None
|
||||
|
||||
details = []
|
||||
if tmdb_error:
|
||||
details.append(tmdb_error)
|
||||
if fallback_error:
|
||||
details.append(fallback_error)
|
||||
message = "All metadata sources are unavailable."
|
||||
if details:
|
||||
message = f"{message} {' '.join(details)}"
|
||||
return None, message
|
||||
|
||||
|
||||
def apply_metadata(media_item: MediaItem, metadata: Dict[str, Any]) -> MediaItem:
|
||||
changed = False
|
||||
update_fields: list[str] = []
|
||||
if metadata.get("tmdb_id") and metadata.get("tmdb_id") != media_item.tmdb_id:
|
||||
media_item.tmdb_id = metadata["tmdb_id"]
|
||||
update_fields.append("tmdb_id")
|
||||
changed = True
|
||||
if metadata.get("imdb_id") and metadata.get("imdb_id") != media_item.imdb_id:
|
||||
media_item.imdb_id = metadata["imdb_id"]
|
||||
update_fields.append("imdb_id")
|
||||
changed = True
|
||||
|
||||
title = metadata.get("title")
|
||||
if title and title != media_item.title:
|
||||
media_item.title = title
|
||||
update_fields.append("title")
|
||||
changed = True
|
||||
|
||||
synopsis = metadata.get("synopsis")
|
||||
if synopsis and synopsis != media_item.synopsis:
|
||||
media_item.synopsis = synopsis
|
||||
update_fields.append("synopsis")
|
||||
changed = True
|
||||
|
||||
tagline = metadata.get("tagline")
|
||||
if tagline and tagline != media_item.tagline:
|
||||
media_item.tagline = tagline
|
||||
update_fields.append("tagline")
|
||||
changed = True
|
||||
|
||||
release_year = metadata.get("release_year")
|
||||
if release_year and release_year != media_item.release_year:
|
||||
try:
|
||||
media_item.release_year = int(release_year)
|
||||
except (TypeError, ValueError): # noqa: PERF203
|
||||
media_item.release_year = media_item.release_year
|
||||
else:
|
||||
changed = True
|
||||
update_fields.append("release_year")
|
||||
|
||||
runtime_minutes = metadata.get("runtime_minutes")
|
||||
if runtime_minutes:
|
||||
new_runtime_ms = int(float(runtime_minutes) * 60 * 1000)
|
||||
if media_item.runtime_ms != new_runtime_ms:
|
||||
media_item.runtime_ms = new_runtime_ms
|
||||
update_fields.append("runtime_ms")
|
||||
changed = True
|
||||
|
||||
genres = metadata.get("genres")
|
||||
if genres and genres != media_item.genres:
|
||||
media_item.genres = genres
|
||||
update_fields.append("genres")
|
||||
changed = True
|
||||
|
||||
cast = metadata.get("cast")
|
||||
if cast and cast != media_item.cast:
|
||||
media_item.cast = cast
|
||||
update_fields.append("cast")
|
||||
changed = True
|
||||
|
||||
crew = metadata.get("crew")
|
||||
if crew and crew != media_item.crew:
|
||||
media_item.crew = crew
|
||||
update_fields.append("crew")
|
||||
changed = True
|
||||
|
||||
poster = metadata.get("poster")
|
||||
if poster and poster != media_item.poster_url:
|
||||
media_item.poster_url = poster
|
||||
update_fields.append("poster_url")
|
||||
changed = True
|
||||
|
||||
backdrop = metadata.get("backdrop")
|
||||
if backdrop and backdrop != media_item.backdrop_url:
|
||||
media_item.backdrop_url = backdrop
|
||||
update_fields.append("backdrop_url")
|
||||
changed = True
|
||||
|
||||
raw_payload = metadata.get("raw")
|
||||
if raw_payload and raw_payload != media_item.metadata:
|
||||
media_item.metadata = raw_payload
|
||||
update_fields.append("metadata")
|
||||
changed = True
|
||||
|
||||
new_source = metadata.get("source", media_item.metadata_source or "unknown")
|
||||
if new_source and new_source != media_item.metadata_source:
|
||||
media_item.metadata_source = new_source
|
||||
update_fields.append("metadata_source")
|
||||
changed = True
|
||||
|
||||
if changed or not media_item.metadata_last_synced_at:
|
||||
media_item.metadata_last_synced_at = timezone.now()
|
||||
if "metadata_last_synced_at" not in update_fields:
|
||||
update_fields.append("metadata_last_synced_at")
|
||||
|
||||
if update_fields:
|
||||
media_item.save(update_fields=list(dict.fromkeys(update_fields)))
|
||||
|
||||
# Update artwork assets so frontend can access gallery
|
||||
if poster:
|
||||
ArtworkAsset.objects.update_or_create(
|
||||
media_item=media_item,
|
||||
asset_type=ArtworkAsset.TYPE_POSTER,
|
||||
source=metadata.get("source", "unknown"),
|
||||
defaults={"external_url": poster},
|
||||
)
|
||||
if backdrop:
|
||||
ArtworkAsset.objects.update_or_create(
|
||||
media_item=media_item,
|
||||
asset_type=ArtworkAsset.TYPE_BACKDROP,
|
||||
source=metadata.get("source", "unknown"),
|
||||
defaults={"external_url": backdrop},
|
||||
)
|
||||
|
||||
return media_item
|
||||
|
||||
|
||||
def sync_metadata(media_item: MediaItem) -> Optional[MediaItem]:
|
||||
metadata, error = fetch_metadata_with_fallback(media_item)
|
||||
if not metadata:
|
||||
if error:
|
||||
logger.debug("Metadata sync skipped for %s: %s", media_item, error)
|
||||
return None
|
||||
return apply_metadata(media_item, metadata)
|
||||
240
apps/media_library/migrations/0001_initial.py
Normal file
240
apps/media_library/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Generated by Django 5.2.4 on 2025-10-11 15:54
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('vod', '0002_add_last_seen_with_default'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Library',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('slug', models.SlugField(editable=False, max_length=255, unique=True)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('library_type', models.CharField(choices=[('movies', 'Movies'), ('shows', 'TV Shows'), ('mixed', 'Mixed'), ('other', 'Other')], default='mixed', max_length=16)),
|
||||
('auto_scan_enabled', models.BooleanField(default=True)),
|
||||
('scan_interval_minutes', models.PositiveIntegerField(default=1440, help_text='How often to auto-scan library paths when auto-scan is enabled.')),
|
||||
('metadata_language', models.CharField(default='en', help_text='Primary language for metadata lookups (ISO-639-1).', max_length=8)),
|
||||
('metadata_country', models.CharField(default='US', help_text='Primary country/region for metadata lookups (ISO-3166-1 alpha-2).', max_length=8)),
|
||||
('use_as_vod_source', models.BooleanField(default=False, help_text='When enabled, media matched in this library syncs into the VOD catalog.')),
|
||||
('metadata_options', models.JSONField(blank=True, help_text='Structured configuration for metadata providers and overrides.', null=True)),
|
||||
('last_scan_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_successful_scan_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LibraryLocation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('path', models.CharField(max_length=4096)),
|
||||
('include_subdirectories', models.BooleanField(default=True)),
|
||||
('is_primary', models.BooleanField(default=False, help_text='Primary location is used for generating relative paths when multiple roots share structure.')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='media_library.library')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['library__name', 'path'],
|
||||
'unique_together': {('library', 'path')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LibraryScan',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=16)),
|
||||
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||
('total_files', models.PositiveIntegerField(default=0)),
|
||||
('processed_files', models.PositiveIntegerField(default=0)),
|
||||
('new_files', models.PositiveIntegerField(default=0)),
|
||||
('updated_files', models.PositiveIntegerField(default=0)),
|
||||
('removed_files', models.PositiveIntegerField(default=0)),
|
||||
('matched_items', models.PositiveIntegerField(default=0)),
|
||||
('unmatched_files', models.PositiveIntegerField(default=0)),
|
||||
('task_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('summary', models.TextField(blank=True)),
|
||||
('log', models.TextField(blank=True)),
|
||||
('extra', models.JSONField(blank=True, null=True)),
|
||||
('discovery_status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('skipped', 'Skipped')], default='pending', max_length=16)),
|
||||
('discovery_total', models.PositiveIntegerField(default=0)),
|
||||
('discovery_processed', models.PositiveIntegerField(default=0)),
|
||||
('metadata_status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('skipped', 'Skipped')], default='pending', max_length=16)),
|
||||
('metadata_total', models.PositiveIntegerField(default=0)),
|
||||
('metadata_processed', models.PositiveIntegerField(default=0)),
|
||||
('artwork_status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('skipped', 'Skipped')], default='pending', max_length=16)),
|
||||
('artwork_total', models.PositiveIntegerField(default=0)),
|
||||
('artwork_processed', models.PositiveIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='library_scans', to=settings.AUTH_USER_MODEL)),
|
||||
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scans', to='media_library.library')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MediaItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('item_type', models.CharField(choices=[('collection', 'Collection'), ('show', 'Series'), ('season', 'Season'), ('episode', 'Episode'), ('movie', 'Movie'), ('other', 'Other')], default='other', max_length=16)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('matched', 'Matched'), ('failed', 'Failed')], default='pending', max_length=16)),
|
||||
('title', models.CharField(max_length=512)),
|
||||
('sort_title', models.CharField(blank=True, max_length=512)),
|
||||
('normalized_title', models.CharField(blank=True, db_index=True, max_length=512)),
|
||||
('release_year', models.IntegerField(blank=True, null=True)),
|
||||
('season_number', models.IntegerField(blank=True, null=True)),
|
||||
('episode_number', models.IntegerField(blank=True, null=True)),
|
||||
('runtime_ms', models.BigIntegerField(blank=True, null=True)),
|
||||
('synopsis', models.TextField(blank=True)),
|
||||
('tagline', models.CharField(blank=True, max_length=512)),
|
||||
('rating', models.CharField(blank=True, max_length=32)),
|
||||
('genres', models.JSONField(blank=True, null=True)),
|
||||
('studios', models.JSONField(blank=True, null=True)),
|
||||
('cast', models.JSONField(blank=True, null=True)),
|
||||
('crew', models.JSONField(blank=True, null=True)),
|
||||
('tags', models.JSONField(blank=True, null=True)),
|
||||
('poster_url', models.URLField(blank=True)),
|
||||
('backdrop_url', models.URLField(blank=True)),
|
||||
('tmdb_id', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
|
||||
('imdb_id', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
|
||||
('tvdb_id', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
|
||||
('metadata', models.JSONField(blank=True, null=True)),
|
||||
('metadata_last_synced_at', models.DateTimeField(blank=True, null=True)),
|
||||
('metadata_source', models.CharField(blank=True, max_length=64)),
|
||||
('first_imported_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='media_library.library')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='media_library.mediaitem')),
|
||||
('vod_episode', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='library_items', to='vod.episode')),
|
||||
('vod_movie', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='library_items', to='vod.movie')),
|
||||
('vod_series', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='library_items', to='vod.series')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['library__name', 'item_type', 'sort_title', 'title'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MediaFile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('absolute_path', models.CharField(max_length=4096)),
|
||||
('relative_path', models.CharField(blank=True, max_length=4096)),
|
||||
('file_name', models.CharField(max_length=1024)),
|
||||
('size_bytes', models.BigIntegerField(default=0)),
|
||||
('duration_ms', models.BigIntegerField(blank=True, null=True)),
|
||||
('video_codec', models.CharField(blank=True, max_length=128)),
|
||||
('audio_codec', models.CharField(blank=True, max_length=128)),
|
||||
('audio_channels', models.FloatField(blank=True, null=True)),
|
||||
('width', models.IntegerField(blank=True, null=True)),
|
||||
('height', models.IntegerField(blank=True, null=True)),
|
||||
('frame_rate', models.FloatField(blank=True, null=True)),
|
||||
('bit_rate', models.BigIntegerField(blank=True, null=True)),
|
||||
('container', models.CharField(blank=True, max_length=64)),
|
||||
('has_subtitles', models.BooleanField(default=False)),
|
||||
('subtitle_languages', models.JSONField(blank=True, null=True)),
|
||||
('extra_streams', models.JSONField(blank=True, null=True)),
|
||||
('checksum', models.CharField(blank=True, db_index=True, max_length=64)),
|
||||
('fingerprint', models.CharField(blank=True, db_index=True, max_length=64)),
|
||||
('last_modified_at', models.DateTimeField(blank=True, null=True)),
|
||||
('requires_transcode', models.BooleanField(default=False)),
|
||||
('transcode_status', models.CharField(choices=[('not_required', 'Not Required'), ('pending', 'Pending'), ('processing', 'Processing'), ('ready', 'Ready'), ('failed', 'Failed')], default='not_required', max_length=20)),
|
||||
('transcoded_path', models.CharField(blank=True, max_length=4096)),
|
||||
('transcoded_mime_type', models.CharField(blank=True, max_length=128)),
|
||||
('transcode_error', models.TextField(blank=True)),
|
||||
('transcoded_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_seen_at', models.DateTimeField(blank=True, null=True)),
|
||||
('missing_since', models.DateTimeField(blank=True, null=True)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='media_library.library')),
|
||||
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='files', to='media_library.librarylocation')),
|
||||
('media_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='files', to='media_library.mediaitem')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['library', 'relative_path'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ArtworkAsset',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('asset_type', models.CharField(choices=[('poster', 'Poster'), ('backdrop', 'Backdrop'), ('banner', 'Banner'), ('thumb', 'Thumbnail')], max_length=16)),
|
||||
('external_url', models.URLField(blank=True)),
|
||||
('local_path', models.CharField(blank=True, max_length=4096)),
|
||||
('width', models.IntegerField(blank=True, null=True)),
|
||||
('height', models.IntegerField(blank=True, null=True)),
|
||||
('language', models.CharField(blank=True, max_length=16)),
|
||||
('source', models.CharField(blank=True, max_length=64)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('media_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='artwork', to='media_library.mediaitem')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['media_item', 'asset_type'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WatchProgress',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('position_ms', models.BigIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('duration_ms', models.BigIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('completed', models.BooleanField(default=False)),
|
||||
('last_watched_at', models.DateTimeField(auto_now=True)),
|
||||
('media_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='watch_progress', to='media_library.mediaitem')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_progress', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-last_watched_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='mediaitem',
|
||||
index=models.Index(fields=['library', 'item_type', 'normalized_title'], name='media_libra_library_0676e6_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='mediaitem',
|
||||
index=models.Index(fields=['library', 'item_type', 'release_year'], name='media_libra_library_cf3bef_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='mediafile',
|
||||
index=models.Index(fields=['library', 'relative_path'], name='media_libra_library_aacdfb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='mediafile',
|
||||
index=models.Index(fields=['media_item', 'last_seen_at'], name='media_libra_media_i_6f32ca_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='mediafile',
|
||||
unique_together={('library', 'absolute_path')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='artworkasset',
|
||||
unique_together={('media_item', 'asset_type', 'language', 'source')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='watchprogress',
|
||||
unique_together={('user', 'media_item')},
|
||||
),
|
||||
]
|
||||
0
apps/media_library/migrations/__init__.py
Normal file
0
apps/media_library/migrations/__init__.py
Normal file
808
apps/media_library/models.py
Normal file
808
apps/media_library/models.py
Normal file
|
|
@ -0,0 +1,808 @@
|
|||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
|
||||
MEDIA_EXTENSIONS = {
|
||||
".mkv",
|
||||
".mp4",
|
||||
".mov",
|
||||
".avi",
|
||||
".m4v",
|
||||
".ts",
|
||||
".flv",
|
||||
".wmv",
|
||||
".mpg",
|
||||
".mpeg",
|
||||
}
|
||||
|
||||
|
||||
class Library(models.Model):
|
||||
"""Represents a logical grouping of media content (movies, shows, etc.)."""
|
||||
|
||||
LIBRARY_TYPE_MOVIES = "movies"
|
||||
LIBRARY_TYPE_SHOWS = "shows"
|
||||
LIBRARY_TYPE_MIXED = "mixed"
|
||||
LIBRARY_TYPE_OTHER = "other"
|
||||
|
||||
LIBRARY_TYPE_CHOICES = [
|
||||
(LIBRARY_TYPE_MOVIES, "Movies"),
|
||||
(LIBRARY_TYPE_SHOWS, "TV Shows"),
|
||||
(LIBRARY_TYPE_MIXED, "Mixed"),
|
||||
(LIBRARY_TYPE_OTHER, "Other"),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
slug = models.SlugField(max_length=255, unique=True, editable=False)
|
||||
description = models.TextField(blank=True)
|
||||
library_type = models.CharField(
|
||||
max_length=16,
|
||||
choices=LIBRARY_TYPE_CHOICES,
|
||||
default=LIBRARY_TYPE_MIXED,
|
||||
)
|
||||
auto_scan_enabled = models.BooleanField(default=True)
|
||||
scan_interval_minutes = models.PositiveIntegerField(
|
||||
default=24 * 60,
|
||||
help_text="How often to auto-scan library paths when auto-scan is enabled.",
|
||||
)
|
||||
metadata_language = models.CharField(
|
||||
max_length=8,
|
||||
default="en",
|
||||
help_text="Primary language for metadata lookups (ISO-639-1).",
|
||||
)
|
||||
metadata_country = models.CharField(
|
||||
max_length=8,
|
||||
default="US",
|
||||
help_text="Primary country/region for metadata lookups (ISO-3166-1 alpha-2).",
|
||||
)
|
||||
use_as_vod_source = models.BooleanField(
|
||||
default=False,
|
||||
help_text="When enabled, media matched in this library syncs into the VOD catalog.",
|
||||
)
|
||||
metadata_options = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Structured configuration for metadata providers and overrides.",
|
||||
)
|
||||
last_scan_at = models.DateTimeField(blank=True, null=True)
|
||||
last_successful_scan_at = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
base_slug = slugify(self.name)
|
||||
slug_candidate = base_slug
|
||||
counter = 1
|
||||
while Library.objects.filter(slug=slug_candidate).exclude(pk=self.pk).exists():
|
||||
counter += 1
|
||||
slug_candidate = f"{base_slug}-{counter}"
|
||||
self.slug = slug_candidate
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class LibraryLocation(models.Model):
|
||||
"""Physical path backing a library."""
|
||||
|
||||
library = models.ForeignKey(
|
||||
Library,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="locations",
|
||||
)
|
||||
path = models.CharField(max_length=4096)
|
||||
include_subdirectories = models.BooleanField(default=True)
|
||||
is_primary = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Primary location is used for generating relative paths when multiple roots share structure.",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("library", "path")]
|
||||
ordering = ["library__name", "path"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.library.name}: {self.path}"
|
||||
|
||||
|
||||
class LibraryScan(models.Model):
|
||||
"""Tracks an individual scan execution."""
|
||||
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_COMPLETED = "completed"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_CANCELLED = "cancelled"
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_PENDING, "Pending"),
|
||||
(STATUS_RUNNING, "Running"),
|
||||
(STATUS_COMPLETED, "Completed"),
|
||||
(STATUS_FAILED, "Failed"),
|
||||
(STATUS_CANCELLED, "Cancelled"),
|
||||
]
|
||||
|
||||
STAGE_STATUS_PENDING = "pending"
|
||||
STAGE_STATUS_RUNNING = "running"
|
||||
STAGE_STATUS_COMPLETED = "completed"
|
||||
STAGE_STATUS_SKIPPED = "skipped"
|
||||
|
||||
STAGE_STATUS_CHOICES = [
|
||||
(STAGE_STATUS_PENDING, "Pending"),
|
||||
(STAGE_STATUS_RUNNING, "Running"),
|
||||
(STAGE_STATUS_COMPLETED, "Completed"),
|
||||
(STAGE_STATUS_SKIPPED, "Skipped"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
library = models.ForeignKey(
|
||||
Library,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="scans",
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="library_scans",
|
||||
)
|
||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
|
||||
started_at = models.DateTimeField(blank=True, null=True)
|
||||
finished_at = models.DateTimeField(blank=True, null=True)
|
||||
total_files = models.PositiveIntegerField(default=0)
|
||||
processed_files = models.PositiveIntegerField(default=0)
|
||||
new_files = models.PositiveIntegerField(default=0)
|
||||
updated_files = models.PositiveIntegerField(default=0)
|
||||
removed_files = models.PositiveIntegerField(default=0)
|
||||
matched_items = models.PositiveIntegerField(default=0)
|
||||
unmatched_files = models.PositiveIntegerField(default=0)
|
||||
task_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
summary = models.TextField(blank=True)
|
||||
log = models.TextField(blank=True)
|
||||
extra = models.JSONField(blank=True, null=True)
|
||||
|
||||
discovery_status = models.CharField(
|
||||
max_length=16,
|
||||
choices=STAGE_STATUS_CHOICES,
|
||||
default=STAGE_STATUS_PENDING,
|
||||
)
|
||||
discovery_total = models.PositiveIntegerField(default=0)
|
||||
discovery_processed = models.PositiveIntegerField(default=0)
|
||||
|
||||
metadata_status = models.CharField(
|
||||
max_length=16,
|
||||
choices=STAGE_STATUS_CHOICES,
|
||||
default=STAGE_STATUS_PENDING,
|
||||
)
|
||||
metadata_total = models.PositiveIntegerField(default=0)
|
||||
metadata_processed = models.PositiveIntegerField(default=0)
|
||||
|
||||
artwork_status = models.CharField(
|
||||
max_length=16,
|
||||
choices=STAGE_STATUS_CHOICES,
|
||||
default=STAGE_STATUS_PENDING,
|
||||
)
|
||||
artwork_total = models.PositiveIntegerField(default=0)
|
||||
artwork_processed = models.PositiveIntegerField(default=0)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def mark_running(self, task_id: str | None = None):
|
||||
self.status = self.STATUS_RUNNING
|
||||
self.started_at = timezone.now()
|
||||
self.processed_files = 0
|
||||
if task_id:
|
||||
self.task_id = task_id
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"started_at",
|
||||
"task_id",
|
||||
"processed_files",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
def mark_completed(self, summary: str = ""):
|
||||
self.status = self.STATUS_COMPLETED
|
||||
self.finished_at = timezone.now()
|
||||
self.summary = summary
|
||||
self.processed_files = self.total_files
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"finished_at",
|
||||
"summary",
|
||||
"processed_files",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
def mark_failed(self, summary: str = ""):
|
||||
self.status = self.STATUS_FAILED
|
||||
self.finished_at = timezone.now()
|
||||
self.summary = summary
|
||||
self.save(update_fields=["status", "finished_at", "summary", "updated_at"])
|
||||
|
||||
def record_progress(
|
||||
self,
|
||||
*,
|
||||
processed: int | None = None,
|
||||
matched: int | None = None,
|
||||
unmatched: int | None = None,
|
||||
total: int | None = None,
|
||||
summary: str | None = None,
|
||||
) -> None:
|
||||
updates: dict[str, object] = {}
|
||||
|
||||
if processed is not None:
|
||||
self.processed_files = processed
|
||||
updates["processed_files"] = processed
|
||||
if matched is not None:
|
||||
self.matched_items = matched
|
||||
updates["matched_items"] = matched
|
||||
if unmatched is not None:
|
||||
self.unmatched_files = unmatched
|
||||
updates["unmatched_files"] = unmatched
|
||||
if total is not None:
|
||||
total_value = max(0, int(total))
|
||||
self.total_files = total_value
|
||||
updates["total_files"] = total_value
|
||||
if summary is not None:
|
||||
self.summary = summary
|
||||
updates["summary"] = summary
|
||||
|
||||
if not updates:
|
||||
return
|
||||
|
||||
now = timezone.now()
|
||||
updates["updated_at"] = now
|
||||
self.__class__.objects.filter(pk=self.pk).update(**updates)
|
||||
self.updated_at = now
|
||||
|
||||
_stage_field_map = {
|
||||
"discovery": ("discovery_status", "discovery_total", "discovery_processed"),
|
||||
"metadata": ("metadata_status", "metadata_total", "metadata_processed"),
|
||||
"artwork": ("artwork_status", "artwork_total", "artwork_processed"),
|
||||
}
|
||||
|
||||
def _ensure_completed(self) -> bool:
|
||||
"""
|
||||
Bring the overall scan status in sync with stage completion.
|
||||
|
||||
Returns True when the scan transitions to the completed state.
|
||||
"""
|
||||
if self.status == self.STATUS_COMPLETED and self.finished_at:
|
||||
return False
|
||||
|
||||
metadata_done = self.metadata_status in (
|
||||
self.STAGE_STATUS_COMPLETED,
|
||||
self.STAGE_STATUS_SKIPPED,
|
||||
)
|
||||
artwork_done = self.artwork_status in (
|
||||
self.STAGE_STATUS_COMPLETED,
|
||||
self.STAGE_STATUS_SKIPPED,
|
||||
)
|
||||
discovery_done = self.discovery_status == self.STAGE_STATUS_COMPLETED
|
||||
|
||||
if not (metadata_done and artwork_done and discovery_done):
|
||||
return False
|
||||
|
||||
summary = self.summary or ""
|
||||
self.mark_completed(summary=summary)
|
||||
|
||||
if self.library_id:
|
||||
now = timezone.now()
|
||||
Library.objects.filter(pk=self.library_id).update(
|
||||
last_scan_at=now,
|
||||
last_successful_scan_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
if getattr(self, "library", None):
|
||||
self.library.last_scan_at = now
|
||||
self.library.last_successful_scan_at = now
|
||||
self.library.updated_at = now
|
||||
|
||||
return True
|
||||
|
||||
def stage_snapshot(self, *, normalize: bool = False) -> dict[str, dict[str, int | str]]:
|
||||
"""
|
||||
Return the current stage progress for the scan.
|
||||
|
||||
When `normalize` is True, metadata and artwork counts are scaled so that
|
||||
their totals align with the overall `total_files` count. This keeps the
|
||||
progress bars consistent in the UI without affecting the stored values
|
||||
that drive the underlying workflow.
|
||||
"""
|
||||
# Keeping overall status consistent avoids stale "running" badges in the UI.
|
||||
self._ensure_completed()
|
||||
|
||||
snapshot: dict[str, dict[str, int | str]] = {}
|
||||
total_files = max(0, int(self.total_files or 0))
|
||||
|
||||
for stage, (status_field, total_field, processed_field) in self._stage_field_map.items():
|
||||
status = getattr(self, status_field)
|
||||
total_value = max(0, int(getattr(self, total_field) or 0))
|
||||
processed_value = max(0, int(getattr(self, processed_field) or 0))
|
||||
|
||||
if (
|
||||
normalize
|
||||
and stage != "discovery"
|
||||
and status != self.STAGE_STATUS_SKIPPED
|
||||
and total_files > 0
|
||||
):
|
||||
normalized_total = total_files
|
||||
if total_value > 0:
|
||||
if processed_value >= total_value:
|
||||
normalized_processed = normalized_total
|
||||
else:
|
||||
ratio = processed_value / total_value
|
||||
normalized_processed = int(ratio * normalized_total)
|
||||
if processed_value > 0 and normalized_processed == 0:
|
||||
normalized_processed = 1
|
||||
normalized_processed = min(normalized_total, normalized_processed)
|
||||
else:
|
||||
normalized_processed = 0
|
||||
|
||||
total_value = normalized_total
|
||||
processed_value = normalized_processed
|
||||
|
||||
snapshot[stage] = {
|
||||
"status": status,
|
||||
"processed": processed_value,
|
||||
"total": total_value,
|
||||
}
|
||||
|
||||
return snapshot
|
||||
|
||||
def record_stage_progress(
|
||||
self,
|
||||
stage: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
total: int | None = None,
|
||||
processed: int | None = None,
|
||||
) -> None:
|
||||
if stage not in self._stage_field_map:
|
||||
raise ValueError(f"Unknown stage '{stage}'")
|
||||
|
||||
status_field, total_field, processed_field = self._stage_field_map[stage]
|
||||
updates: dict[str, object] = {}
|
||||
|
||||
if status is not None:
|
||||
if status not in dict(self.STAGE_STATUS_CHOICES):
|
||||
raise ValueError(f"Invalid stage status '{status}' for stage '{stage}'")
|
||||
setattr(self, status_field, status)
|
||||
updates[status_field] = status
|
||||
if total is not None:
|
||||
total_value = max(0, int(total))
|
||||
setattr(self, total_field, total_value)
|
||||
updates[total_field] = total_value
|
||||
if processed is not None:
|
||||
processed_value = max(0, int(processed))
|
||||
setattr(self, processed_field, processed_value)
|
||||
updates[processed_field] = processed_value
|
||||
|
||||
if not updates:
|
||||
return
|
||||
|
||||
now = timezone.now()
|
||||
updates["updated_at"] = now
|
||||
self.__class__.objects.filter(pk=self.pk).update(**updates)
|
||||
self.updated_at = now
|
||||
|
||||
|
||||
|
||||
class MediaItem(models.Model):
|
||||
"""Represents a logical piece of media (movie, series, season, episode)."""
|
||||
|
||||
TYPE_COLLECTION = "collection"
|
||||
TYPE_SHOW = "show"
|
||||
TYPE_SEASON = "season"
|
||||
TYPE_EPISODE = "episode"
|
||||
TYPE_MOVIE = "movie"
|
||||
TYPE_OTHER = "other"
|
||||
|
||||
ITEM_TYPE_CHOICES = [
|
||||
(TYPE_COLLECTION, "Collection"),
|
||||
(TYPE_SHOW, "Series"),
|
||||
(TYPE_SEASON, "Season"),
|
||||
(TYPE_EPISODE, "Episode"),
|
||||
(TYPE_MOVIE, "Movie"),
|
||||
(TYPE_OTHER, "Other"),
|
||||
]
|
||||
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_MATCHED = "matched"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_PENDING, "Pending"),
|
||||
(STATUS_MATCHED, "Matched"),
|
||||
(STATUS_FAILED, "Failed"),
|
||||
]
|
||||
|
||||
library = models.ForeignKey(
|
||||
Library,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="items",
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="children",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
item_type = models.CharField(max_length=16, choices=ITEM_TYPE_CHOICES, default=TYPE_OTHER)
|
||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
|
||||
|
||||
title = models.CharField(max_length=512)
|
||||
sort_title = models.CharField(max_length=512, blank=True)
|
||||
normalized_title = models.CharField(max_length=512, blank=True, db_index=True)
|
||||
release_year = models.IntegerField(blank=True, null=True)
|
||||
season_number = models.IntegerField(blank=True, null=True)
|
||||
episode_number = models.IntegerField(blank=True, null=True)
|
||||
runtime_ms = models.BigIntegerField(blank=True, null=True)
|
||||
synopsis = models.TextField(blank=True)
|
||||
tagline = models.CharField(max_length=512, blank=True)
|
||||
rating = models.CharField(max_length=32, blank=True)
|
||||
genres = models.JSONField(blank=True, null=True)
|
||||
studios = models.JSONField(blank=True, null=True)
|
||||
cast = models.JSONField(blank=True, null=True)
|
||||
crew = models.JSONField(blank=True, null=True)
|
||||
tags = models.JSONField(blank=True, null=True)
|
||||
poster_url = models.URLField(blank=True)
|
||||
backdrop_url = models.URLField(blank=True)
|
||||
|
||||
tmdb_id = models.CharField(max_length=64, blank=True, null=True, db_index=True)
|
||||
imdb_id = models.CharField(max_length=64, blank=True, null=True, db_index=True)
|
||||
tvdb_id = models.CharField(max_length=64, blank=True, null=True, db_index=True)
|
||||
|
||||
vod_movie = models.ForeignKey(
|
||||
"vod.Movie",
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="library_items",
|
||||
)
|
||||
vod_series = models.ForeignKey(
|
||||
"vod.Series",
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="library_items",
|
||||
)
|
||||
vod_episode = models.ForeignKey(
|
||||
"vod.Episode",
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="library_items",
|
||||
)
|
||||
|
||||
metadata = models.JSONField(blank=True, null=True)
|
||||
metadata_last_synced_at = models.DateTimeField(blank=True, null=True)
|
||||
metadata_source = models.CharField(max_length=64, blank=True)
|
||||
|
||||
first_imported_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["library", "item_type", "normalized_title"]),
|
||||
models.Index(fields=["library", "item_type", "release_year"]),
|
||||
]
|
||||
ordering = ["library__name", "item_type", "sort_title", "title"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def ensure_sort_title(self):
|
||||
if not self.sort_title:
|
||||
self.sort_title = self.title
|
||||
if not self.normalized_title:
|
||||
self.normalized_title = normalize_title(self.title)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.ensure_sort_title()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_movie(self) -> bool:
|
||||
return self.item_type == self.TYPE_MOVIE
|
||||
|
||||
@property
|
||||
def is_episode(self) -> bool:
|
||||
return self.item_type == self.TYPE_EPISODE
|
||||
|
||||
|
||||
class ArtworkAsset(models.Model):
|
||||
"""Represents an artwork asset linked to a media item."""
|
||||
|
||||
TYPE_POSTER = "poster"
|
||||
TYPE_BACKDROP = "backdrop"
|
||||
TYPE_BANNER = "banner"
|
||||
TYPE_THUMB = "thumb"
|
||||
|
||||
ASSET_TYPE_CHOICES = [
|
||||
(TYPE_POSTER, "Poster"),
|
||||
(TYPE_BACKDROP, "Backdrop"),
|
||||
(TYPE_BANNER, "Banner"),
|
||||
(TYPE_THUMB, "Thumbnail"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
media_item = models.ForeignKey(
|
||||
MediaItem,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="artwork",
|
||||
)
|
||||
asset_type = models.CharField(max_length=16, choices=ASSET_TYPE_CHOICES)
|
||||
external_url = models.URLField(blank=True)
|
||||
local_path = models.CharField(max_length=4096, blank=True)
|
||||
width = models.IntegerField(blank=True, null=True)
|
||||
height = models.IntegerField(blank=True, null=True)
|
||||
language = models.CharField(max_length=16, blank=True)
|
||||
source = models.CharField(max_length=64, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("media_item", "asset_type", "language", "source")]
|
||||
ordering = ["media_item", "asset_type"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.media_item.title} - {self.asset_type}"
|
||||
|
||||
|
||||
class MediaFile(models.Model):
|
||||
"""Physical file on disk that is associated with a media item."""
|
||||
|
||||
TRANSCODE_STATUS_NOT_REQUIRED = "not_required"
|
||||
TRANSCODE_STATUS_PENDING = "pending"
|
||||
TRANSCODE_STATUS_PROCESSING = "processing"
|
||||
TRANSCODE_STATUS_READY = "ready"
|
||||
TRANSCODE_STATUS_FAILED = "failed"
|
||||
|
||||
TRANSCODE_STATUS_CHOICES = [
|
||||
(TRANSCODE_STATUS_NOT_REQUIRED, "Not Required"),
|
||||
(TRANSCODE_STATUS_PENDING, "Pending"),
|
||||
(TRANSCODE_STATUS_PROCESSING, "Processing"),
|
||||
(TRANSCODE_STATUS_READY, "Ready"),
|
||||
(TRANSCODE_STATUS_FAILED, "Failed"),
|
||||
]
|
||||
|
||||
BROWSER_SAFE_CONTAINERS = {"mp4", "m4v"}
|
||||
BROWSER_SAFE_VIDEO_CODECS = {"h264", "avc1"}
|
||||
BROWSER_SAFE_AUDIO_CODECS = {"aac", "mp3", "mp4a", "libmp3lame"}
|
||||
|
||||
library = models.ForeignKey(
|
||||
Library,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="files",
|
||||
)
|
||||
media_item = models.ForeignKey(
|
||||
MediaItem,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="files",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
location = models.ForeignKey(
|
||||
LibraryLocation,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="files",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
absolute_path = models.CharField(max_length=4096)
|
||||
relative_path = models.CharField(max_length=4096, blank=True)
|
||||
file_name = models.CharField(max_length=1024)
|
||||
size_bytes = models.BigIntegerField(default=0)
|
||||
duration_ms = models.BigIntegerField(blank=True, null=True)
|
||||
video_codec = models.CharField(max_length=128, blank=True)
|
||||
audio_codec = models.CharField(max_length=128, blank=True)
|
||||
audio_channels = models.FloatField(blank=True, null=True)
|
||||
width = models.IntegerField(blank=True, null=True)
|
||||
height = models.IntegerField(blank=True, null=True)
|
||||
frame_rate = models.FloatField(blank=True, null=True)
|
||||
bit_rate = models.BigIntegerField(blank=True, null=True)
|
||||
container = models.CharField(max_length=64, blank=True)
|
||||
has_subtitles = models.BooleanField(default=False)
|
||||
subtitle_languages = models.JSONField(blank=True, null=True)
|
||||
extra_streams = models.JSONField(blank=True, null=True)
|
||||
checksum = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
fingerprint = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
last_modified_at = models.DateTimeField(blank=True, null=True)
|
||||
requires_transcode = models.BooleanField(default=False)
|
||||
transcode_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=TRANSCODE_STATUS_CHOICES,
|
||||
default=TRANSCODE_STATUS_NOT_REQUIRED,
|
||||
)
|
||||
transcoded_path = models.CharField(max_length=4096, blank=True)
|
||||
transcoded_mime_type = models.CharField(max_length=128, blank=True)
|
||||
transcode_error = models.TextField(blank=True)
|
||||
transcoded_at = models.DateTimeField(blank=True, null=True)
|
||||
last_seen_at = models.DateTimeField(blank=True, null=True)
|
||||
missing_since = models.DateTimeField(blank=True, null=True)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("library", "absolute_path")]
|
||||
indexes = [
|
||||
models.Index(fields=["library", "relative_path"]),
|
||||
models.Index(fields=["media_item", "last_seen_at"]),
|
||||
]
|
||||
ordering = ["library", "relative_path"]
|
||||
|
||||
def __str__(self):
|
||||
return self.absolute_path
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
return os.path.splitext(self.file_name)[1].lower()
|
||||
|
||||
def _normalized_container(self) -> str:
|
||||
container = (self.container or "").split(",")[0].strip().lower()
|
||||
if container:
|
||||
return container
|
||||
ext = self.extension
|
||||
return ext[1:] if ext.startswith(".") else ext
|
||||
|
||||
@staticmethod
|
||||
def _normalized_codec(codec_value: str) -> str:
|
||||
return (codec_value or "").split(".")[0].strip().lower()
|
||||
|
||||
def is_browser_playable(self) -> bool:
|
||||
container = self._normalized_container()
|
||||
if container not in self.BROWSER_SAFE_CONTAINERS:
|
||||
return False
|
||||
|
||||
video_codec = self._normalized_codec(self.video_codec)
|
||||
if video_codec and video_codec not in self.BROWSER_SAFE_VIDEO_CODECS:
|
||||
return False
|
||||
|
||||
audio_codec = self._normalized_codec(self.audio_codec)
|
||||
if audio_codec and audio_codec not in self.BROWSER_SAFE_AUDIO_CODECS:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def effective_duration_ms(self) -> int | None:
|
||||
"""
|
||||
Return the best-known duration (ms). Falls back to probe metadata and
|
||||
associated media item runtime when the direct field is missing.
|
||||
"""
|
||||
if self.duration_ms:
|
||||
try:
|
||||
return int(self.duration_ms)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
extra = self.extra_streams or {}
|
||||
format_info = extra.get("format") or {}
|
||||
|
||||
candidates: list[tuple[object, float]] = []
|
||||
if "duration_ms" in format_info:
|
||||
candidates.append((format_info.get("duration_ms"), 1.0))
|
||||
if "duration" in format_info:
|
||||
# ffprobe reports seconds in this field.
|
||||
candidates.append((format_info.get("duration"), 1000.0))
|
||||
|
||||
for value, multiplier in candidates:
|
||||
if value in (None, "", 0):
|
||||
continue
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if numeric <= 0:
|
||||
continue
|
||||
return int(numeric * multiplier)
|
||||
|
||||
if self.media_item_id:
|
||||
runtime_ms = self.media_item.runtime_ms
|
||||
if runtime_ms:
|
||||
try:
|
||||
return int(runtime_ms)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def calculate_checksum(self, chunk_size: int = 1024 * 1024) -> str:
|
||||
"""Calculate a SHA1 checksum for the file."""
|
||||
sha1 = hashlib.sha1()
|
||||
try:
|
||||
with open(self.absolute_path, "rb") as handle:
|
||||
while True:
|
||||
data = handle.read(chunk_size)
|
||||
if not data:
|
||||
break
|
||||
sha1.update(data)
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
return sha1.hexdigest()
|
||||
|
||||
|
||||
class WatchProgress(models.Model):
|
||||
"""Tracks where a user left off while watching a media item."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="media_progress",
|
||||
)
|
||||
media_item = models.ForeignKey(
|
||||
MediaItem,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="watch_progress",
|
||||
)
|
||||
position_ms = models.BigIntegerField(default=0, validators=[MinValueValidator(0)])
|
||||
duration_ms = models.BigIntegerField(default=0, validators=[MinValueValidator(0)])
|
||||
completed = models.BooleanField(default=False)
|
||||
last_watched_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("user", "media_item")]
|
||||
ordering = ["-last_watched_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - {self.media_item}"
|
||||
|
||||
def update_progress(self, position_ms: int, duration_ms: int | None = None, completion_threshold: float = 0.96):
|
||||
if duration_ms is not None:
|
||||
self.duration_ms = max(self.duration_ms, duration_ms)
|
||||
self.position_ms = max(position_ms, 0)
|
||||
if self.duration_ms > 0:
|
||||
ratio = self.position_ms / self.duration_ms
|
||||
if ratio >= completion_threshold:
|
||||
self.completed = True
|
||||
self.position_ms = self.duration_ms
|
||||
self.save(update_fields=["position_ms", "duration_ms", "completed", "last_watched_at"])
|
||||
|
||||
|
||||
def normalize_title(text: str | None) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
return "".join(ch for ch in text.lower() if ch.isalnum() or ch.isspace()).strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassificationResult:
|
||||
"""Represents the outcome of parsing a media file name."""
|
||||
|
||||
detected_type: str
|
||||
title: str
|
||||
year: int | None = None
|
||||
season: int | None = None
|
||||
episode: int | None = None
|
||||
episode_title: str | None = None
|
||||
data: dict | None = None
|
||||
525
apps/media_library/serializers.py
Normal file
525
apps/media_library/serializers.py
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
from django.db import transaction
|
||||
from django.db.models import Prefetch
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.media_library import models
|
||||
|
||||
|
||||
class LibraryLocationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.LibraryLocation
|
||||
fields = [
|
||||
"id",
|
||||
"path",
|
||||
"include_subdirectories",
|
||||
"is_primary",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class LibrarySerializer(serializers.ModelSerializer):
|
||||
locations = LibraryLocationSerializer(many=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = models.Library
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"description",
|
||||
"library_type",
|
||||
"auto_scan_enabled",
|
||||
"scan_interval_minutes",
|
||||
"metadata_language",
|
||||
"metadata_country",
|
||||
"use_as_vod_source",
|
||||
"metadata_options",
|
||||
"last_scan_at",
|
||||
"last_successful_scan_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"locations",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"slug",
|
||||
"last_scan_at",
|
||||
"last_successful_scan_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
locations_data = validated_data.pop("locations", [])
|
||||
with transaction.atomic():
|
||||
library = super().create(validated_data)
|
||||
self._sync_locations(library, locations_data)
|
||||
return library
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
locations_data = validated_data.pop("locations", None)
|
||||
with transaction.atomic():
|
||||
library = super().update(instance, validated_data)
|
||||
if locations_data is not None:
|
||||
self._sync_locations(library, locations_data)
|
||||
return library
|
||||
|
||||
def _sync_locations(self, library: models.Library, locations_data):
|
||||
seen_location_ids = set()
|
||||
primary_set = False
|
||||
|
||||
for entry in locations_data:
|
||||
location_id = entry.get("id")
|
||||
entry_data = {
|
||||
"path": entry["path"],
|
||||
"include_subdirectories": entry.get("include_subdirectories", True),
|
||||
"is_primary": entry.get("is_primary", False),
|
||||
}
|
||||
|
||||
if entry_data["is_primary"]:
|
||||
primary_set = True
|
||||
if location_id:
|
||||
updated = models.LibraryLocation.objects.filter(pk=location_id, library=library).update(**entry_data)
|
||||
if updated:
|
||||
seen_location_ids.add(location_id)
|
||||
continue
|
||||
|
||||
defaults = {
|
||||
"include_subdirectories": entry_data["include_subdirectories"],
|
||||
"is_primary": entry_data["is_primary"],
|
||||
}
|
||||
location, _ = models.LibraryLocation.objects.update_or_create(
|
||||
library=library,
|
||||
path=entry_data["path"],
|
||||
defaults=defaults,
|
||||
)
|
||||
seen_location_ids.add(location.id)
|
||||
if location.is_primary:
|
||||
primary_set = True
|
||||
|
||||
# Remove locations not present in payload
|
||||
models.LibraryLocation.objects.filter(library=library).exclude(id__in=seen_location_ids).delete()
|
||||
|
||||
# Ensure a primary location exists
|
||||
if not primary_set:
|
||||
first_location = models.LibraryLocation.objects.filter(library=library).order_by("id").first()
|
||||
if first_location:
|
||||
first_location.is_primary = True
|
||||
first_location.save(update_fields=["is_primary", "updated_at"])
|
||||
|
||||
|
||||
class LibraryScanSerializer(serializers.ModelSerializer):
|
||||
library_name = serializers.CharField(source="library.name", read_only=True)
|
||||
created_by_name = serializers.CharField(source="created_by.username", read_only=True)
|
||||
stages = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.LibraryScan
|
||||
fields = [
|
||||
"id",
|
||||
"library",
|
||||
"library_name",
|
||||
"created_by",
|
||||
"created_by_name",
|
||||
"status",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"total_files",
|
||||
"processed_files",
|
||||
"new_files",
|
||||
"updated_files",
|
||||
"removed_files",
|
||||
"matched_items",
|
||||
"unmatched_files",
|
||||
"task_id",
|
||||
"summary",
|
||||
"log",
|
||||
"extra",
|
||||
"discovery_status",
|
||||
"discovery_total",
|
||||
"discovery_processed",
|
||||
"metadata_status",
|
||||
"metadata_total",
|
||||
"metadata_processed",
|
||||
"artwork_status",
|
||||
"artwork_total",
|
||||
"artwork_processed",
|
||||
"stages",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"library_name",
|
||||
"created_by_name",
|
||||
"status",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"total_files",
|
||||
"processed_files",
|
||||
"new_files",
|
||||
"updated_files",
|
||||
"removed_files",
|
||||
"matched_items",
|
||||
"unmatched_files",
|
||||
"task_id",
|
||||
"summary",
|
||||
"log",
|
||||
"extra",
|
||||
"discovery_status",
|
||||
"discovery_total",
|
||||
"discovery_processed",
|
||||
"metadata_status",
|
||||
"metadata_total",
|
||||
"metadata_processed",
|
||||
"artwork_status",
|
||||
"artwork_total",
|
||||
"artwork_processed",
|
||||
"stages",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_stages(self, obj):
|
||||
return obj.stage_snapshot(normalize=True)
|
||||
|
||||
|
||||
class ArtworkAssetSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.ArtworkAsset
|
||||
fields = [
|
||||
"id",
|
||||
"asset_type",
|
||||
"external_url",
|
||||
"local_path",
|
||||
"width",
|
||||
"height",
|
||||
"language",
|
||||
"source",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class MediaFileSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.MediaFile
|
||||
fields = [
|
||||
"id",
|
||||
"library",
|
||||
"media_item",
|
||||
"location",
|
||||
"absolute_path",
|
||||
"relative_path",
|
||||
"file_name",
|
||||
"size_bytes",
|
||||
"duration_ms",
|
||||
"video_codec",
|
||||
"audio_codec",
|
||||
"audio_channels",
|
||||
"width",
|
||||
"height",
|
||||
"frame_rate",
|
||||
"bit_rate",
|
||||
"container",
|
||||
"has_subtitles",
|
||||
"subtitle_languages",
|
||||
"extra_streams",
|
||||
"checksum",
|
||||
"fingerprint",
|
||||
"last_modified_at",
|
||||
"requires_transcode",
|
||||
"transcode_status",
|
||||
"transcoded_path",
|
||||
"transcoded_mime_type",
|
||||
"transcode_error",
|
||||
"transcoded_at",
|
||||
"last_seen_at",
|
||||
"missing_since",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"last_modified_at",
|
||||
"requires_transcode",
|
||||
"transcode_status",
|
||||
"transcoded_path",
|
||||
"transcoded_mime_type",
|
||||
"transcode_error",
|
||||
"transcoded_at",
|
||||
"last_seen_at",
|
||||
"missing_since",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class MediaItemBaseSerializer(serializers.ModelSerializer):
|
||||
parent_id = serializers.PrimaryKeyRelatedField(source="parent", read_only=True)
|
||||
watch_progress = serializers.SerializerMethodField()
|
||||
watch_summary = serializers.SerializerMethodField()
|
||||
|
||||
def _get_user(self):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return None
|
||||
return user
|
||||
|
||||
def _get_progress(self, obj, user):
|
||||
prefetched = getattr(obj, "_user_watch_progress", None)
|
||||
if prefetched is not None:
|
||||
return prefetched[0] if prefetched else None
|
||||
return obj.watch_progress.filter(user=user).first()
|
||||
|
||||
def get_watch_progress(self, obj):
|
||||
user = self._get_user()
|
||||
if not user:
|
||||
return None
|
||||
progress = self._get_progress(obj, user)
|
||||
if not progress:
|
||||
return None
|
||||
duration = progress.duration_ms or obj.runtime_ms
|
||||
percentage = (progress.position_ms / duration) if duration else 0
|
||||
return {
|
||||
"id": progress.id,
|
||||
"position_ms": progress.position_ms,
|
||||
"duration_ms": duration,
|
||||
"completed": progress.completed,
|
||||
"percentage": percentage,
|
||||
"last_watched_at": progress.last_watched_at,
|
||||
}
|
||||
|
||||
def get_watch_summary(self, obj):
|
||||
user = self._get_user()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if obj.item_type == models.MediaItem.TYPE_SHOW:
|
||||
episodes = getattr(obj, "_prefetched_children", None)
|
||||
if episodes is None:
|
||||
episodes = list(
|
||||
models.MediaItem.objects.filter(
|
||||
parent=obj, item_type=models.MediaItem.TYPE_EPISODE
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"watch_progress",
|
||||
queryset=models.WatchProgress.objects.filter(user=user),
|
||||
to_attr="_user_watch_progress",
|
||||
)
|
||||
)
|
||||
.order_by("season_number", "episode_number", "id")
|
||||
)
|
||||
total = len([ep for ep in episodes if ep.item_type == models.MediaItem.TYPE_EPISODE])
|
||||
completed = 0
|
||||
resume_episode = None
|
||||
resume_progress_time = None
|
||||
next_episode = None
|
||||
last_completed_episode = None
|
||||
last_completed_time = None
|
||||
|
||||
for episode in episodes:
|
||||
if episode.item_type != models.MediaItem.TYPE_EPISODE:
|
||||
continue
|
||||
progress = self._get_progress(episode, user)
|
||||
if progress and progress.completed:
|
||||
completed += 1
|
||||
if not last_completed_time or progress.last_watched_at > last_completed_time:
|
||||
last_completed_time = progress.last_watched_at
|
||||
last_completed_episode = episode
|
||||
elif progress and progress.position_ms:
|
||||
if (
|
||||
resume_progress_time is None
|
||||
or progress.last_watched_at > resume_progress_time
|
||||
):
|
||||
resume_progress_time = progress.last_watched_at
|
||||
resume_episode = episode
|
||||
else:
|
||||
if not next_episode:
|
||||
next_episode = episode
|
||||
|
||||
status = "unwatched"
|
||||
if total == 0:
|
||||
status = "unwatched"
|
||||
elif completed >= total and total > 0:
|
||||
status = "watched"
|
||||
next_episode = None
|
||||
elif resume_episode or completed > 0:
|
||||
status = "in_progress"
|
||||
if not resume_episode:
|
||||
resume_episode = next_episode
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"total_episodes": total,
|
||||
"completed_episodes": completed,
|
||||
"resume_episode_id": resume_episode.id if resume_episode else None,
|
||||
"next_episode_id": next_episode.id if next_episode else None,
|
||||
"last_completed_episode_id": last_completed_episode.id if last_completed_episode else None,
|
||||
}
|
||||
|
||||
progress = self._get_progress(obj, user)
|
||||
if not progress:
|
||||
return {"status": "unwatched"}
|
||||
if progress.completed:
|
||||
return {
|
||||
"status": "watched",
|
||||
"position_ms": progress.position_ms,
|
||||
"duration_ms": progress.duration_ms,
|
||||
}
|
||||
return {
|
||||
"status": "in_progress",
|
||||
"position_ms": progress.position_ms,
|
||||
"duration_ms": progress.duration_ms,
|
||||
"percentage": (progress.position_ms / progress.duration_ms)
|
||||
if progress.duration_ms
|
||||
else 0,
|
||||
}
|
||||
|
||||
|
||||
class MediaItemListSerializer(MediaItemBaseSerializer):
|
||||
class Meta:
|
||||
model = models.MediaItem
|
||||
fields = [
|
||||
"id",
|
||||
"library",
|
||||
"parent_id",
|
||||
"item_type",
|
||||
"status",
|
||||
"title",
|
||||
"sort_title",
|
||||
"poster_url",
|
||||
"backdrop_url",
|
||||
"runtime_ms",
|
||||
"release_year",
|
||||
"season_number",
|
||||
"episode_number",
|
||||
"genres",
|
||||
"tags",
|
||||
"tagline",
|
||||
"metadata_last_synced_at",
|
||||
"metadata_source",
|
||||
"watch_progress",
|
||||
"watch_summary",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class MediaItemSerializer(MediaItemBaseSerializer):
|
||||
files = MediaFileSerializer(many=True, read_only=True)
|
||||
artwork = ArtworkAssetSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.MediaItem
|
||||
fields = [
|
||||
"id",
|
||||
"library",
|
||||
"parent_id",
|
||||
"item_type",
|
||||
"status",
|
||||
"title",
|
||||
"sort_title",
|
||||
"normalized_title",
|
||||
"release_year",
|
||||
"season_number",
|
||||
"episode_number",
|
||||
"runtime_ms",
|
||||
"synopsis",
|
||||
"tagline",
|
||||
"rating",
|
||||
"genres",
|
||||
"studios",
|
||||
"cast",
|
||||
"crew",
|
||||
"tags",
|
||||
"poster_url",
|
||||
"backdrop_url",
|
||||
"tmdb_id",
|
||||
"imdb_id",
|
||||
"tvdb_id",
|
||||
"vod_movie",
|
||||
"vod_series",
|
||||
"vod_episode",
|
||||
"metadata",
|
||||
"metadata_last_synced_at",
|
||||
"metadata_source",
|
||||
"first_imported_at",
|
||||
"updated_at",
|
||||
"files",
|
||||
"artwork",
|
||||
"watch_progress",
|
||||
"watch_summary",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"library",
|
||||
"parent_id",
|
||||
"item_type",
|
||||
"normalized_title",
|
||||
"metadata_last_synced_at",
|
||||
"first_imported_at",
|
||||
"updated_at",
|
||||
"files",
|
||||
"artwork",
|
||||
"vod_movie",
|
||||
"vod_series",
|
||||
"vod_episode",
|
||||
"watch_progress",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"genres": {"required": False, "allow_null": True},
|
||||
"studios": {"required": False, "allow_null": True},
|
||||
"cast": {"required": False, "allow_null": True},
|
||||
"crew": {"required": False, "allow_null": True},
|
||||
"tags": {"required": False, "allow_null": True},
|
||||
"metadata": {"required": False, "allow_null": True},
|
||||
}
|
||||
|
||||
|
||||
class WatchProgressSerializer(serializers.ModelSerializer):
|
||||
user_display = serializers.CharField(source="user.username", read_only=True)
|
||||
media_title = serializers.CharField(source="media_item.title", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.WatchProgress
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"user_display",
|
||||
"media_item",
|
||||
"media_title",
|
||||
"position_ms",
|
||||
"duration_ms",
|
||||
"completed",
|
||||
"last_watched_at",
|
||||
]
|
||||
read_only_fields = ["id", "user", "user_display", "media_title", "last_watched_at"]
|
||||
extra_kwargs = {"user": {"required": False}}
|
||||
validators = [] # DB constraint enforces uniqueness; allow update-or-create writes
|
||||
|
||||
def create(self, validated_data):
|
||||
user = self.context["request"].user
|
||||
progress, _ = models.WatchProgress.objects.update_or_create(
|
||||
user=user,
|
||||
media_item=validated_data["media_item"],
|
||||
defaults={
|
||||
"position_ms": validated_data.get("position_ms", 0),
|
||||
"duration_ms": validated_data.get("duration_ms", 0),
|
||||
"completed": validated_data.get("completed", False),
|
||||
},
|
||||
)
|
||||
return progress
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
instance.position_ms = validated_data.get("position_ms", instance.position_ms)
|
||||
instance.duration_ms = max(
|
||||
instance.duration_ms, validated_data.get("duration_ms", instance.duration_ms)
|
||||
)
|
||||
instance.completed = validated_data.get("completed", instance.completed)
|
||||
instance.save()
|
||||
return instance
|
||||
1149
apps/media_library/tasks.py
Normal file
1149
apps/media_library/tasks.py
Normal file
File diff suppressed because it is too large
Load diff
474
apps/media_library/transcode.py
Normal file
474
apps/media_library/transcode.py
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
import contextlib
|
||||
import hashlib
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import MediaFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 128 * 1024 # 128KB chunks for streaming
|
||||
|
||||
|
||||
def _as_path(value) -> Path:
|
||||
if isinstance(value, Path):
|
||||
return value
|
||||
return Path(str(value))
|
||||
|
||||
|
||||
def _int_setting(name: str, default: int) -> int:
|
||||
value = getattr(settings, name, default)
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
TRANSCODE_ROOT = _as_path(
|
||||
getattr(settings, "MEDIA_LIBRARY_TRANSCODE_DIR", settings.MEDIA_ROOT / "transcoded")
|
||||
)
|
||||
TRANSCODE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
FFMPEG_PATH = getattr(settings, "MEDIA_LIBRARY_FFMPEG_PATH", "ffmpeg")
|
||||
VIDEO_BITRATE = _int_setting("MEDIA_LIBRARY_TRANSCODE_VIDEO_BITRATE", 4500)
|
||||
AUDIO_BITRATE = _int_setting("MEDIA_LIBRARY_TRANSCODE_AUDIO_BITRATE", 192)
|
||||
PRESET = getattr(settings, "MEDIA_LIBRARY_TRANSCODE_PRESET", "veryfast")
|
||||
TARGET_VIDEO_CODEC = getattr(settings, "MEDIA_LIBRARY_TRANSCODE_VIDEO_CODEC", "libx264")
|
||||
TARGET_AUDIO_CODEC = getattr(settings, "MEDIA_LIBRARY_TRANSCODE_AUDIO_CODEC", "aac")
|
||||
AUDIO_CHANNELS = _int_setting("MEDIA_LIBRARY_TRANSCODE_AUDIO_CHANNELS", 2)
|
||||
|
||||
|
||||
def _build_target_path(media_file: MediaFile) -> Path:
|
||||
identifier = media_file.checksum or f"{media_file.absolute_path}:{media_file.size_bytes}"
|
||||
digest = hashlib.sha256(identifier.encode("utf-8")).hexdigest()[:16]
|
||||
filename = f"{media_file.id}_{digest}.mp4"
|
||||
return TRANSCODE_ROOT / filename
|
||||
|
||||
|
||||
def _normalize_mime(path: Path) -> str:
|
||||
mime, _ = mimetypes.guess_type(path.name)
|
||||
return mime or "video/mp4"
|
||||
|
||||
|
||||
def _build_ffmpeg_command(
|
||||
source_path: Path, *, output: str, fragmented: bool, start_seconds: float = 0.0
|
||||
) -> list[str]:
|
||||
command = [FFMPEG_PATH, "-y"]
|
||||
if start_seconds and start_seconds > 0:
|
||||
command.extend(["-ss", f"{start_seconds:.3f}"])
|
||||
|
||||
command.extend(
|
||||
[
|
||||
"-i",
|
||||
str(source_path),
|
||||
]
|
||||
)
|
||||
|
||||
command.extend(
|
||||
[
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"0:a:0?",
|
||||
"-c:v",
|
||||
TARGET_VIDEO_CODEC,
|
||||
"-preset",
|
||||
PRESET,
|
||||
"-profile:v",
|
||||
"high",
|
||||
"-level",
|
||||
"4.0",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-max_muxing_queue_size",
|
||||
"1024",
|
||||
"-c:a",
|
||||
TARGET_AUDIO_CODEC,
|
||||
"-b:a",
|
||||
f"{AUDIO_BITRATE}k",
|
||||
"-ac",
|
||||
str(max(1, AUDIO_CHANNELS)),
|
||||
"-sn",
|
||||
]
|
||||
)
|
||||
|
||||
if VIDEO_BITRATE > 0:
|
||||
command.extend(["-b:v", f"{VIDEO_BITRATE}k"])
|
||||
|
||||
if fragmented:
|
||||
command.extend(["-movflags", "frag_keyframe+empty_moov+faststart", "-f", "mp4", output])
|
||||
else:
|
||||
command.extend(["-movflags", "+faststart", output])
|
||||
|
||||
return command
|
||||
|
||||
|
||||
def ensure_browser_ready_source(
|
||||
media_file: MediaFile, *, force: bool = False
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Ensure the provided media file is playable by major browsers (Chromium, Firefox, Safari).
|
||||
Returns a tuple of (absolute_path, mime_type) pointing at either the original file (if compatible)
|
||||
or a transcoded MP4 fallback.
|
||||
"""
|
||||
if media_file.is_browser_playable() and not force:
|
||||
path = _as_path(media_file.absolute_path)
|
||||
mime_type, _ = mimetypes.guess_type(path.name)
|
||||
return str(path), mime_type or "video/mp4"
|
||||
|
||||
target_path = _ensure_transcode_to_file(media_file, force=force)
|
||||
return str(target_path), "video/mp4"
|
||||
|
||||
|
||||
def _ensure_transcode_to_file(media_file: MediaFile, *, force: bool = False) -> Path:
|
||||
source_path = _as_path(media_file.absolute_path)
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"Media source missing at {source_path}")
|
||||
|
||||
target_path = _build_target_path(media_file)
|
||||
|
||||
# Re-use existing artifact when it is up-to-date unless force=True.
|
||||
if (
|
||||
not force
|
||||
and media_file.transcode_status == MediaFile.TRANSCODE_STATUS_READY
|
||||
and media_file.transcoded_path
|
||||
):
|
||||
cached_path = Path(media_file.transcoded_path)
|
||||
if cached_path.exists():
|
||||
source_mtime = source_path.stat().st_mtime
|
||||
if cached_path.stat().st_mtime >= source_mtime:
|
||||
return cached_path
|
||||
# Cached metadata is stale; clear below.
|
||||
|
||||
# Remove stale artifact if a new digest produced a different path.
|
||||
if media_file.transcoded_path and media_file.transcoded_path != str(target_path):
|
||||
old_path = Path(media_file.transcoded_path)
|
||||
if old_path.exists():
|
||||
try:
|
||||
old_path.unlink()
|
||||
except OSError:
|
||||
logger.debug("Unable to remove old transcode %s", old_path)
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
media_file.transcode_status = MediaFile.TRANSCODE_STATUS_PROCESSING
|
||||
media_file.transcode_error = ""
|
||||
media_file.requires_transcode = True
|
||||
media_file.transcoded_path = ""
|
||||
media_file.transcoded_mime_type = ""
|
||||
media_file.transcoded_at = None
|
||||
media_file.save(
|
||||
update_fields=[
|
||||
"transcode_status",
|
||||
"transcode_error",
|
||||
"requires_transcode",
|
||||
"transcoded_path",
|
||||
"transcoded_mime_type",
|
||||
"transcoded_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
fd, temp_path = tempfile.mkstemp(dir=str(target_path.parent), suffix=".mp4")
|
||||
os.close(fd)
|
||||
|
||||
command = _build_ffmpeg_command(source_path, output=str(temp_path), fragmented=False)
|
||||
|
||||
logger.info(
|
||||
"Transcoding media file %s (%s) to %s for browser playback",
|
||||
media_file.id,
|
||||
source_path,
|
||||
target_path,
|
||||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr_tail = (result.stderr or "").strip()[-4000:]
|
||||
raise RuntimeError(
|
||||
f"ffmpeg exited with status {result.returncode}: {stderr_tail}"
|
||||
)
|
||||
shutil.move(temp_path, target_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
media_file.transcode_status = MediaFile.TRANSCODE_STATUS_FAILED
|
||||
media_file.transcode_error = str(exc)
|
||||
media_file.save(
|
||||
update_fields=["transcode_status", "transcode_error", "updated_at"]
|
||||
)
|
||||
logger.error("Transcoding failed for %s: %s", media_file.id, exc)
|
||||
raise
|
||||
|
||||
media_file.transcode_status = MediaFile.TRANSCODE_STATUS_READY
|
||||
media_file.transcoded_path = str(target_path)
|
||||
media_file.transcoded_mime_type = _normalize_mime(target_path)
|
||||
media_file.transcoded_at = timezone.now()
|
||||
media_file.transcode_error = ""
|
||||
media_file.requires_transcode = True
|
||||
media_file.save(
|
||||
update_fields=[
|
||||
"transcode_status",
|
||||
"transcoded_path",
|
||||
"transcoded_mime_type",
|
||||
"transcoded_at",
|
||||
"transcode_error",
|
||||
"requires_transcode",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
logger.info("Finished transcoding media file %s", media_file.id)
|
||||
return target_path
|
||||
|
||||
|
||||
class LiveTranscodeSession:
|
||||
"""Manage a live ffmpeg transcoding process and stream the output while writing to disk."""
|
||||
|
||||
mime_type = "video/mp4"
|
||||
|
||||
def __init__(self, media_file: MediaFile, *, start_seconds: float = 0.0):
|
||||
self.media_file = media_file
|
||||
self.source_path = _as_path(media_file.absolute_path)
|
||||
if not self.source_path.exists():
|
||||
raise FileNotFoundError(f"Media source missing at {self.source_path}")
|
||||
|
||||
self.target_path = _build_target_path(media_file)
|
||||
self.target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.start_seconds = max(0.0, float(start_seconds))
|
||||
self.cache_enabled = self.start_seconds == 0.0
|
||||
|
||||
if self.cache_enabled:
|
||||
fd, temp_path = tempfile.mkstemp(dir=str(self.target_path.parent), suffix=".mp4")
|
||||
os.close(fd)
|
||||
self.temp_path = Path(temp_path)
|
||||
else:
|
||||
self.temp_path = None
|
||||
|
||||
self.process: subprocess.Popen | None = None
|
||||
self._stderr_thread: threading.Thread | None = None
|
||||
self._stderr_lines: deque[str] = deque(maxlen=200)
|
||||
self._aborted = False
|
||||
self._finalized = False
|
||||
|
||||
# Prepare media file state
|
||||
media_file.requires_transcode = True
|
||||
media_file.transcode_status = MediaFile.TRANSCODE_STATUS_PROCESSING
|
||||
media_file.transcode_error = ""
|
||||
update_fields = [
|
||||
"requires_transcode",
|
||||
"transcode_status",
|
||||
"transcode_error",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
if self.cache_enabled:
|
||||
media_file.transcoded_path = ""
|
||||
media_file.transcoded_mime_type = ""
|
||||
media_file.transcoded_at = None
|
||||
update_fields.extend(
|
||||
[
|
||||
"transcoded_path",
|
||||
"transcoded_mime_type",
|
||||
"transcoded_at",
|
||||
]
|
||||
)
|
||||
|
||||
media_file.save(update_fields=update_fields)
|
||||
|
||||
def start(self) -> "LiveTranscodeSession":
|
||||
command = _build_ffmpeg_command(
|
||||
self.source_path,
|
||||
output="pipe:1",
|
||||
fragmented=True,
|
||||
start_seconds=self.start_seconds,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Starting live transcode for media file %s (%s) [start=%.3fs]",
|
||||
self.media_file.id,
|
||||
self.source_path,
|
||||
self.start_seconds,
|
||||
)
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=CHUNK_SIZE,
|
||||
)
|
||||
|
||||
if self.process.stdout is None or self.process.stderr is None:
|
||||
raise RuntimeError("Failed to capture ffmpeg output streams")
|
||||
|
||||
self._stderr_thread = threading.Thread(
|
||||
target=self._drain_stderr, name=f"ffmpeg-stderr-{self.media_file.id}", daemon=True
|
||||
)
|
||||
self._stderr_thread.start()
|
||||
return self
|
||||
|
||||
def stream(self) -> Iterable[bytes]:
|
||||
if not self.process or not self.process.stdout:
|
||||
raise RuntimeError("Live transcode session is not started")
|
||||
|
||||
try:
|
||||
cache_ctx = (
|
||||
open(self.temp_path, "wb")
|
||||
if self.cache_enabled and self.temp_path
|
||||
else contextlib.nullcontext()
|
||||
)
|
||||
with cache_ctx as cache_fp:
|
||||
while True:
|
||||
chunk = self.process.stdout.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
if cache_fp:
|
||||
cache_fp.write(chunk)
|
||||
cache_fp.flush()
|
||||
yield chunk
|
||||
except GeneratorExit:
|
||||
self._aborted = True
|
||||
logger.debug("Live transcode aborted by client for media file %s", self.media_file.id)
|
||||
self._terminate_process()
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._aborted = True
|
||||
logger.warning(
|
||||
"Live transcode streaming error for media file %s: %s",
|
||||
self.media_file.id,
|
||||
exc,
|
||||
)
|
||||
self._terminate_process()
|
||||
raise
|
||||
finally:
|
||||
self._finalize()
|
||||
|
||||
def _terminate_process(self):
|
||||
if self.process and self.process.poll() is None:
|
||||
try:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
except Exception: # noqa: BLE001
|
||||
self.process.kill()
|
||||
|
||||
def _drain_stderr(self):
|
||||
assert self.process and self.process.stderr
|
||||
for line in iter(self.process.stderr.readline, b""):
|
||||
text = line.decode("utf-8", errors="ignore").strip()
|
||||
if text:
|
||||
self._stderr_lines.append(text)
|
||||
logger.debug("ffmpeg[%s]: %s", self.media_file.id, text)
|
||||
|
||||
def _stderr_tail(self) -> str:
|
||||
return "\n".join(self._stderr_lines)
|
||||
|
||||
def _finalize(self):
|
||||
if self._finalized:
|
||||
return
|
||||
self._finalized = True
|
||||
|
||||
return_code = None
|
||||
if self.process:
|
||||
try:
|
||||
return_code = self.process.poll()
|
||||
if return_code is None:
|
||||
return_code = self.process.wait()
|
||||
except Exception: # noqa: BLE001
|
||||
return_code = -1
|
||||
|
||||
if self._stderr_thread and self._stderr_thread.is_alive():
|
||||
self._stderr_thread.join(timeout=2)
|
||||
|
||||
if (
|
||||
return_code == 0
|
||||
and not self._aborted
|
||||
and self.cache_enabled
|
||||
and self.temp_path
|
||||
and os.path.exists(self.temp_path)
|
||||
):
|
||||
try:
|
||||
shutil.move(self.temp_path, self.target_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Failed to finalize live transcode output for media file %s: %s",
|
||||
self.media_file.id,
|
||||
exc,
|
||||
)
|
||||
return_code = -1
|
||||
|
||||
if return_code == 0 and not self._aborted:
|
||||
if not self.cache_enabled:
|
||||
self.media_file.transcode_status = MediaFile.TRANSCODE_STATUS_PENDING
|
||||
self.media_file.save(update_fields=["transcode_status", "updated_at"])
|
||||
return
|
||||
logger.info("Finished live transcode for media file %s", self.media_file.id)
|
||||
self.media_file.transcode_status = MediaFile.TRANSCODE_STATUS_READY
|
||||
self.media_file.transcoded_path = str(self.target_path)
|
||||
self.media_file.transcoded_mime_type = self.mime_type
|
||||
self.media_file.transcoded_at = timezone.now()
|
||||
self.media_file.transcode_error = ""
|
||||
self.media_file.requires_transcode = True
|
||||
self.media_file.save(
|
||||
update_fields=[
|
||||
"transcode_status",
|
||||
"transcoded_path",
|
||||
"transcoded_mime_type",
|
||||
"transcoded_at",
|
||||
"transcode_error",
|
||||
"requires_transcode",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
else:
|
||||
if self.temp_path and os.path.exists(self.temp_path):
|
||||
try:
|
||||
os.remove(self.temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if self._aborted:
|
||||
# Mark as pending so a future request can retry.
|
||||
self.media_file.transcode_status = MediaFile.TRANSCODE_STATUS_PENDING
|
||||
self.media_file.save(update_fields=["transcode_status", "updated_at"])
|
||||
else:
|
||||
msg = self._stderr_tail() or "Unknown ffmpeg failure"
|
||||
self.media_file.transcode_status = MediaFile.TRANSCODE_STATUS_FAILED
|
||||
self.media_file.transcode_error = msg[-4000:]
|
||||
self.media_file.save(
|
||||
update_fields=["transcode_status", "transcode_error", "updated_at"]
|
||||
)
|
||||
logger.error(
|
||||
"Live transcode failed for media file %s: %s",
|
||||
self.media_file.id,
|
||||
self.media_file.transcode_error,
|
||||
)
|
||||
|
||||
|
||||
def start_streaming_transcode(
|
||||
media_file: MediaFile, *, start_seconds: float = 0.0
|
||||
) -> LiveTranscodeSession:
|
||||
"""Start a live transcoding session for the given media file."""
|
||||
session = LiveTranscodeSession(media_file, start_seconds=start_seconds)
|
||||
return session.start()
|
||||
732
apps/media_library/utils.py
Normal file
732
apps/media_library/utils.py
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from importlib import resources as importlib_resources
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Iterator, List, Optional
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from guessit import guessit
|
||||
|
||||
try:
|
||||
from guessit.rules.properties import website as guessit_website
|
||||
except Exception: # noqa: BLE001
|
||||
guessit_website = None
|
||||
else:
|
||||
# Python 3.13's importlib.resources.files no longer returns a context manager;
|
||||
# wrap it so guessit can still use `with files(...)`.
|
||||
def _compatible_files(package: str): # type: ignore[override]
|
||||
return importlib_resources.as_file(importlib_resources.files(package))
|
||||
|
||||
if guessit_website:
|
||||
guessit_website.files = _compatible_files
|
||||
from pymediainfo import MediaInfo
|
||||
|
||||
from apps.media_library.models import (
|
||||
ClassificationResult,
|
||||
Library,
|
||||
LibraryLocation,
|
||||
LibraryScan,
|
||||
MediaFile,
|
||||
MediaItem,
|
||||
MEDIA_EXTENSIONS,
|
||||
normalize_title,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _json_safe(value):
|
||||
"""Ensure value can be serialized via json.dumps."""
|
||||
if isinstance(value, dict):
|
||||
return {key: _json_safe(val) for key, val in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_json_safe(item) for item in value]
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _first_numeric(value):
|
||||
"""Extract the first integer-like value from guessit responses."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
for item in value:
|
||||
normalized = _first_numeric(item)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
# Some guessit versions wrap values (e.g. {"season": 1})
|
||||
for key in ("season", "episode", "number"):
|
||||
if key in value:
|
||||
normalized = _first_numeric(value[key])
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveredFile:
|
||||
file_id: int
|
||||
requires_probe: bool
|
||||
|
||||
|
||||
class LibraryScanner:
|
||||
"""Performs discovery of files for a library scan."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library: Library,
|
||||
scan: LibraryScan,
|
||||
force_full: bool = False,
|
||||
rescan_item_id: Optional[int] = None,
|
||||
) -> None:
|
||||
self.library = library
|
||||
self.scan = scan
|
||||
self.force_full = force_full or rescan_item_id is not None
|
||||
self.rescan_item_id = rescan_item_id
|
||||
self.now = timezone.now()
|
||||
self.log_messages: list[str] = []
|
||||
self.stats: Dict[str, int] = defaultdict(int)
|
||||
self._seen_paths: set[str] = set()
|
||||
self.target_item = None
|
||||
if rescan_item_id:
|
||||
self.target_item = (
|
||||
MediaItem.objects.filter(pk=rescan_item_id, library=library).first()
|
||||
)
|
||||
if not self.target_item:
|
||||
self._log(
|
||||
f"Target media item {rescan_item_id} not found in library {library.id}."
|
||||
)
|
||||
|
||||
def discover_files(self) -> List[DiscoveredFile]:
|
||||
"""Walk library locations, ensure MediaFile records exist, and return the IDs."""
|
||||
return list(self.discover_files_iter())
|
||||
|
||||
def discover_files_iter(self) -> Iterator[DiscoveredFile]:
|
||||
"""Yield discovered files one-at-a-time for incremental processing."""
|
||||
discovered_count = 0
|
||||
try:
|
||||
if not self.library.locations.exists():
|
||||
self._log(f"Library '{self.library.name}' has no configured locations.")
|
||||
return
|
||||
|
||||
for location in self.library.locations.all():
|
||||
path = Path(location.path).expanduser()
|
||||
if not path.exists():
|
||||
self._log(
|
||||
f"Path '{path}' does not exist for library '{self.library.name}'."
|
||||
)
|
||||
continue
|
||||
if not path.is_dir():
|
||||
self._log(f"Path '{path}' is not a directory; skipping.")
|
||||
continue
|
||||
|
||||
iterator = path.rglob("*") if location.include_subdirectories else path.iterdir()
|
||||
for file_path in iterator:
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
if file_path.suffix.lower() not in MEDIA_EXTENSIONS:
|
||||
continue
|
||||
|
||||
absolute_path = str(file_path)
|
||||
self._seen_paths.add(absolute_path)
|
||||
try:
|
||||
record_data = self._ensure_file_record(location, file_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Failed to process file %s", absolute_path)
|
||||
self._log(f"Failed to process '{absolute_path}': {exc}")
|
||||
continue
|
||||
|
||||
if not record_data:
|
||||
continue
|
||||
|
||||
file_record, requires_probe = record_data
|
||||
if (
|
||||
self.target_item
|
||||
and file_record.media_item_id not in {None, self.target_item.id}
|
||||
):
|
||||
continue
|
||||
|
||||
discovered_count += 1
|
||||
self.stats["total"] = discovered_count
|
||||
yield DiscoveredFile(file_id=file_record.id, requires_probe=requires_probe)
|
||||
finally:
|
||||
self.stats["total"] = discovered_count
|
||||
self.scan.total_files = discovered_count
|
||||
self.scan.new_files = self.stats.get("new", 0)
|
||||
self.scan.updated_files = self.stats.get("updated", 0)
|
||||
self.scan.save(
|
||||
update_fields=[
|
||||
"total_files",
|
||||
"new_files",
|
||||
"updated_files",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
def mark_missing_files(self) -> int:
|
||||
missing_qs = (
|
||||
MediaFile.objects.filter(library=self.library)
|
||||
.exclude(absolute_path__in=self._seen_paths)
|
||||
.exclude(absolute_path="")
|
||||
)
|
||||
count = missing_qs.update(missing_since=self.now)
|
||||
if count:
|
||||
self.stats["removed"] += count
|
||||
self._log(f"Marked {count} files as missing.")
|
||||
self.scan.removed_files = self.stats["removed"]
|
||||
self.scan.save(update_fields=["removed_files", "updated_at"])
|
||||
return count
|
||||
|
||||
def finalize(self, matched: int, unmatched: int, summary: str | None = None) -> None:
|
||||
self.stats["matched"] += matched
|
||||
self.stats["unmatched"] += unmatched
|
||||
self.scan.matched_items = self.stats["matched"]
|
||||
self.scan.unmatched_files = self.stats["unmatched"]
|
||||
if summary:
|
||||
self.scan.summary = summary
|
||||
self.scan.log = "\n".join(self.log_messages)
|
||||
self.scan.save(
|
||||
update_fields=[
|
||||
"matched_items",
|
||||
"unmatched_files",
|
||||
"summary",
|
||||
"log",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
def _ensure_file_record(
|
||||
self, location: LibraryLocation, file_path: Path
|
||||
) -> Optional[tuple[MediaFile, bool]]:
|
||||
relative_path = os.path.relpath(file_path, location.path)
|
||||
stat = file_path.stat()
|
||||
last_modified = datetime.fromtimestamp(stat.st_mtime, tz=dt_timezone.utc)
|
||||
|
||||
with transaction.atomic():
|
||||
file_record, created = MediaFile.objects.select_for_update().get_or_create(
|
||||
library=self.library,
|
||||
absolute_path=str(file_path),
|
||||
defaults={
|
||||
"location": location,
|
||||
"relative_path": relative_path,
|
||||
"file_name": file_path.name,
|
||||
"size_bytes": stat.st_size,
|
||||
"last_modified_at": last_modified,
|
||||
"last_seen_at": self.now,
|
||||
},
|
||||
)
|
||||
|
||||
requires_probe = False
|
||||
checksum_reset = False
|
||||
if created:
|
||||
self.stats["new"] += 1
|
||||
requires_probe = True
|
||||
checksum_reset = True
|
||||
else:
|
||||
should_update = (
|
||||
self.force_full
|
||||
or file_record.last_modified_at is None
|
||||
or last_modified > file_record.last_modified_at
|
||||
)
|
||||
if should_update:
|
||||
file_record.size_bytes = stat.st_size
|
||||
file_record.last_modified_at = last_modified
|
||||
self.stats["updated"] += 1
|
||||
requires_probe = True
|
||||
checksum_reset = True
|
||||
|
||||
file_record.last_seen_at = self.now
|
||||
file_record.location = location
|
||||
file_record.relative_path = relative_path
|
||||
file_record.file_name = file_path.name
|
||||
file_record.missing_since = None
|
||||
update_fields = [
|
||||
"location",
|
||||
"relative_path",
|
||||
"file_name",
|
||||
"size_bytes",
|
||||
"last_modified_at",
|
||||
"last_seen_at",
|
||||
"missing_since",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
if checksum_reset and file_record.checksum:
|
||||
file_record.checksum = ""
|
||||
update_fields.append("checksum")
|
||||
|
||||
file_record.save(update_fields=update_fields)
|
||||
|
||||
# Always probe on forced scans or if checksum missing
|
||||
if self.force_full or not file_record.checksum:
|
||||
requires_probe = True
|
||||
return file_record, requires_probe
|
||||
|
||||
def _log(self, message: str) -> None:
|
||||
logger.debug("[Library %s] %s", self.library.id, message)
|
||||
self.log_messages.append(message)
|
||||
|
||||
|
||||
def classify_media_file(file_name: str) -> ClassificationResult:
|
||||
base_name = Path(file_name).stem
|
||||
try:
|
||||
data = guessit(file_name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("guessit failed for %s: %s", file_name, exc)
|
||||
return ClassificationResult(
|
||||
detected_type=MediaItem.TYPE_OTHER,
|
||||
title=base_name,
|
||||
data={"error": str(exc)},
|
||||
)
|
||||
|
||||
data = _json_safe(data)
|
||||
|
||||
guess_type = data.get("type")
|
||||
detected_type = MediaItem.TYPE_OTHER
|
||||
|
||||
if guess_type == "movie":
|
||||
detected_type = MediaItem.TYPE_MOVIE
|
||||
elif guess_type == "episode":
|
||||
detected_type = MediaItem.TYPE_EPISODE
|
||||
elif guess_type in {"show", "series", "tv"}:
|
||||
detected_type = MediaItem.TYPE_SHOW
|
||||
elif guess_type == "season":
|
||||
detected_type = MediaItem.TYPE_SEASON
|
||||
|
||||
title = data.get("title") or base_name
|
||||
|
||||
classification = ClassificationResult(
|
||||
detected_type=detected_type,
|
||||
title=title,
|
||||
year=_first_numeric(data.get("year")),
|
||||
season=_first_numeric(data.get("season")),
|
||||
episode=_first_numeric(data.get("episode")),
|
||||
episode_title=data.get("episode_title"),
|
||||
data=data,
|
||||
)
|
||||
|
||||
if detected_type == MediaItem.TYPE_EPISODE:
|
||||
classification.data = dict(data)
|
||||
classification.data["series_title"] = data.get("series") or data.get("title") or base_name
|
||||
|
||||
return classification
|
||||
|
||||
|
||||
def resolve_media_item(
|
||||
library: Library,
|
||||
classification: ClassificationResult,
|
||||
target_item: Optional[MediaItem] = None,
|
||||
) -> Optional[MediaItem]:
|
||||
if target_item:
|
||||
return target_item
|
||||
|
||||
title = classification.title or "Unknown"
|
||||
normalized = normalize_title(title)
|
||||
|
||||
if classification.detected_type == MediaItem.TYPE_MOVIE:
|
||||
queryset = MediaItem.objects.filter(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_MOVIE,
|
||||
normalized_title=normalized,
|
||||
)
|
||||
match = None
|
||||
if classification.year:
|
||||
match = queryset.filter(release_year=classification.year).first()
|
||||
if not match:
|
||||
match = queryset.first()
|
||||
if match:
|
||||
if classification.year and match.release_year != classification.year:
|
||||
match.release_year = classification.year
|
||||
match.save(update_fields=["release_year", "updated_at"])
|
||||
return match
|
||||
metadata_payload = _json_safe(classification.data or {})
|
||||
return MediaItem.objects.create(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_MOVIE,
|
||||
status=MediaItem.STATUS_PENDING,
|
||||
title=title,
|
||||
sort_title=title,
|
||||
normalized_title=normalized,
|
||||
release_year=classification.year,
|
||||
metadata=metadata_payload,
|
||||
)
|
||||
|
||||
if classification.detected_type == MediaItem.TYPE_SHOW:
|
||||
metadata_payload = _json_safe(classification.data or {})
|
||||
match = MediaItem.objects.filter(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_SHOW,
|
||||
normalized_title=normalized,
|
||||
).first()
|
||||
if match:
|
||||
return match
|
||||
return MediaItem.objects.create(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_SHOW,
|
||||
status=MediaItem.STATUS_PENDING,
|
||||
title=title,
|
||||
sort_title=title,
|
||||
normalized_title=normalized,
|
||||
metadata=metadata_payload,
|
||||
)
|
||||
|
||||
if classification.detected_type == MediaItem.TYPE_EPISODE:
|
||||
series_title = classification.data.get("series_title") if classification.data else title
|
||||
if not series_title:
|
||||
series_title = title
|
||||
series_normalized = normalize_title(series_title)
|
||||
|
||||
series_item = (
|
||||
MediaItem.objects.filter(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_SHOW,
|
||||
normalized_title=series_normalized,
|
||||
).first()
|
||||
)
|
||||
if not series_item:
|
||||
series_metadata = _json_safe(classification.data or {})
|
||||
series_item = MediaItem.objects.create(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_SHOW,
|
||||
status=MediaItem.STATUS_PENDING,
|
||||
title=series_title,
|
||||
sort_title=series_title,
|
||||
normalized_title=series_normalized,
|
||||
metadata=series_metadata,
|
||||
)
|
||||
|
||||
episode_item = (
|
||||
MediaItem.objects.filter(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_EPISODE,
|
||||
parent=series_item,
|
||||
season_number=classification.season,
|
||||
episode_number=classification.episode,
|
||||
).first()
|
||||
)
|
||||
if episode_item:
|
||||
if classification.episode_title and not episode_item.title:
|
||||
episode_item.title = classification.episode_title
|
||||
episode_item.save(update_fields=["title", "updated_at"])
|
||||
return episode_item
|
||||
|
||||
title_to_use = (
|
||||
classification.episode_title
|
||||
or (
|
||||
f"S{classification.season:02d}E{classification.episode:02d}"
|
||||
if classification.season and classification.episode
|
||||
else title
|
||||
)
|
||||
)
|
||||
|
||||
metadata_payload = _json_safe(classification.data or {})
|
||||
return MediaItem.objects.create(
|
||||
library=library,
|
||||
parent=series_item,
|
||||
item_type=MediaItem.TYPE_EPISODE,
|
||||
status=MediaItem.STATUS_PENDING,
|
||||
title=title_to_use,
|
||||
sort_title=title_to_use,
|
||||
normalized_title=normalize_title(title_to_use),
|
||||
release_year=classification.year,
|
||||
season_number=classification.season,
|
||||
episode_number=classification.episode,
|
||||
metadata=metadata_payload,
|
||||
)
|
||||
|
||||
metadata_payload = _json_safe(classification.data or {})
|
||||
match = MediaItem.objects.filter(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_OTHER,
|
||||
normalized_title=normalized,
|
||||
).first()
|
||||
if match:
|
||||
return match
|
||||
return MediaItem.objects.create(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_OTHER,
|
||||
status=MediaItem.STATUS_PENDING,
|
||||
title=title,
|
||||
sort_title=title,
|
||||
normalized_title=normalized,
|
||||
metadata=metadata_payload,
|
||||
)
|
||||
|
||||
|
||||
def probe_media_file(path: str) -> dict:
|
||||
command = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration,bit_rate,format_name",
|
||||
"-show_streams",
|
||||
"-of",
|
||||
"json",
|
||||
path,
|
||||
]
|
||||
|
||||
try:
|
||||
process = subprocess.run(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
text=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.debug("ffprobe not available; falling back to pymediainfo for %s", path)
|
||||
return _probe_with_mediainfo(path)
|
||||
except subprocess.CalledProcessError as exc: # noqa: BLE001
|
||||
logger.warning("ffprobe failed for %s: %s", path, exc.stderr)
|
||||
fallback = _probe_with_mediainfo(path)
|
||||
if fallback:
|
||||
return fallback
|
||||
return {}
|
||||
|
||||
try:
|
||||
return json.loads(process.stdout)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to parse ffprobe output for %s", path)
|
||||
return {}
|
||||
|
||||
|
||||
def _probe_with_mediainfo(path: str) -> dict:
|
||||
try:
|
||||
media_info = MediaInfo.parse(path)
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
format_info: dict = {}
|
||||
streams: list[dict] = []
|
||||
for track in media_info.tracks:
|
||||
if track.track_type == "General":
|
||||
format_info = {
|
||||
"duration": track.duration / 1000 if track.duration else None,
|
||||
"bit_rate": track.overall_bit_rate,
|
||||
"format_name": track.format,
|
||||
}
|
||||
elif track.track_type == "Video":
|
||||
streams.append(
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": track.format,
|
||||
"width": track.width,
|
||||
"height": track.height,
|
||||
"avg_frame_rate": track.frame_rate,
|
||||
}
|
||||
)
|
||||
elif track.track_type == "Audio":
|
||||
streams.append(
|
||||
{
|
||||
"codec_type": "audio",
|
||||
"codec_name": track.format,
|
||||
"channels": track.channel_s,
|
||||
}
|
||||
)
|
||||
elif track.track_type == "Text":
|
||||
streams.append(
|
||||
{
|
||||
"codec_type": "subtitle",
|
||||
"codec_name": track.format,
|
||||
"tags": {"language": track.language},
|
||||
}
|
||||
)
|
||||
|
||||
return {"format": format_info, "streams": streams}
|
||||
|
||||
|
||||
def apply_probe_metadata(file_record: MediaFile, probe_data: dict) -> None:
|
||||
if not probe_data:
|
||||
return
|
||||
format_info = probe_data.get("format", {})
|
||||
streams = probe_data.get("streams", [])
|
||||
|
||||
duration_ms: int | None = None
|
||||
|
||||
duration = format_info.get("duration")
|
||||
if duration not in (None, "", 0):
|
||||
try:
|
||||
duration_ms = int(float(duration) * 1000)
|
||||
file_record.duration_ms = duration_ms
|
||||
except (TypeError, ValueError): # noqa: PERF203
|
||||
logger.debug("Unable to parse duration '%s' for %s", duration, file_record)
|
||||
|
||||
if duration_ms is None:
|
||||
raw_duration_ms = format_info.get("duration_ms")
|
||||
if raw_duration_ms not in (None, "", 0):
|
||||
try:
|
||||
duration_ms = int(float(raw_duration_ms))
|
||||
file_record.duration_ms = duration_ms
|
||||
except (TypeError, ValueError): # noqa: PERF203
|
||||
logger.debug(
|
||||
"Unable to parse duration_ms '%s' for %s", raw_duration_ms, file_record
|
||||
)
|
||||
|
||||
bit_rate = format_info.get("bit_rate")
|
||||
try:
|
||||
if bit_rate:
|
||||
file_record.bit_rate = int(bit_rate)
|
||||
except (TypeError, ValueError): # noqa: PERF203
|
||||
pass
|
||||
|
||||
if format_info.get("format_name"):
|
||||
file_record.container = format_info["format_name"].split(",")[0]
|
||||
|
||||
video_stream = next((s for s in streams if s.get("codec_type") == "video"), None)
|
||||
audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
||||
subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"]
|
||||
|
||||
if video_stream:
|
||||
file_record.video_codec = video_stream.get("codec_name", "")
|
||||
file_record.width = video_stream.get("width")
|
||||
file_record.height = video_stream.get("height")
|
||||
file_record.frame_rate = _safe_frame_rate(video_stream)
|
||||
|
||||
if audio_stream:
|
||||
file_record.audio_codec = audio_stream.get("codec_name", "")
|
||||
channels = audio_stream.get("channels")
|
||||
if channels is not None:
|
||||
try:
|
||||
file_record.audio_channels = float(channels)
|
||||
except (TypeError, ValueError): # noqa: PERF203
|
||||
file_record.audio_channels = None
|
||||
|
||||
file_record.has_subtitles = bool(subtitle_streams)
|
||||
if subtitle_streams:
|
||||
file_record.subtitle_languages = [
|
||||
stream.get("tags", {}).get("language") for stream in subtitle_streams
|
||||
]
|
||||
|
||||
file_record.extra_streams = {
|
||||
"format": format_info,
|
||||
"streams": streams,
|
||||
}
|
||||
needs_transcode = not file_record.is_browser_playable()
|
||||
file_record.requires_transcode = needs_transcode
|
||||
|
||||
update_fields = [
|
||||
"duration_ms",
|
||||
"bit_rate",
|
||||
"container",
|
||||
"video_codec",
|
||||
"width",
|
||||
"height",
|
||||
"frame_rate",
|
||||
"audio_codec",
|
||||
"audio_channels",
|
||||
"has_subtitles",
|
||||
"subtitle_languages",
|
||||
"extra_streams",
|
||||
"requires_transcode",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
if not needs_transcode:
|
||||
file_record.transcode_status = MediaFile.TRANSCODE_STATUS_NOT_REQUIRED
|
||||
file_record.transcoded_path = ""
|
||||
file_record.transcoded_mime_type = ""
|
||||
file_record.transcode_error = ""
|
||||
file_record.transcoded_at = None
|
||||
update_fields.extend(
|
||||
[
|
||||
"transcode_status",
|
||||
"transcoded_path",
|
||||
"transcoded_mime_type",
|
||||
"transcode_error",
|
||||
"transcoded_at",
|
||||
]
|
||||
)
|
||||
else:
|
||||
if file_record.transcode_status in (
|
||||
MediaFile.TRANSCODE_STATUS_NOT_REQUIRED,
|
||||
"",
|
||||
):
|
||||
file_record.transcode_status = MediaFile.TRANSCODE_STATUS_PENDING
|
||||
update_fields.append("transcode_status")
|
||||
|
||||
if (
|
||||
file_record.transcode_status == MediaFile.TRANSCODE_STATUS_READY
|
||||
and file_record.transcoded_path
|
||||
and not os.path.exists(file_record.transcoded_path)
|
||||
):
|
||||
file_record.transcode_status = MediaFile.TRANSCODE_STATUS_PENDING
|
||||
file_record.transcoded_path = ""
|
||||
file_record.transcoded_mime_type = ""
|
||||
file_record.transcoded_at = None
|
||||
update_fields.extend(
|
||||
[
|
||||
"transcode_status",
|
||||
"transcoded_path",
|
||||
"transcoded_mime_type",
|
||||
"transcoded_at",
|
||||
]
|
||||
)
|
||||
|
||||
file_record.save(update_fields=update_fields)
|
||||
|
||||
if file_record.media_item_id:
|
||||
candidate_duration_ms = duration_ms or file_record.duration_ms
|
||||
try:
|
||||
media_item = file_record.media_item
|
||||
except MediaFile.media_item.RelatedObjectDoesNotExist: # type: ignore[attr-defined]
|
||||
media_item = None
|
||||
|
||||
if not candidate_duration_ms:
|
||||
extra = file_record.extra_streams or {}
|
||||
format_info = extra.get("format") or {}
|
||||
fallback_candidates: list[tuple[object, float]] = []
|
||||
if "duration_ms" in format_info:
|
||||
fallback_candidates.append((format_info.get("duration_ms"), 1.0))
|
||||
if "duration" in format_info:
|
||||
fallback_candidates.append((format_info.get("duration"), 1000.0))
|
||||
|
||||
for value, multiplier in fallback_candidates:
|
||||
if value in (None, "", 0):
|
||||
continue
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError): # noqa: PERF203
|
||||
continue
|
||||
if numeric <= 0:
|
||||
continue
|
||||
candidate_duration_ms = int(numeric * multiplier)
|
||||
break
|
||||
|
||||
if candidate_duration_ms and media_item and (
|
||||
not media_item.runtime_ms or media_item.runtime_ms < candidate_duration_ms
|
||||
):
|
||||
media_item.runtime_ms = int(candidate_duration_ms)
|
||||
media_item.save(update_fields=["runtime_ms", "updated_at"])
|
||||
|
||||
|
||||
def _safe_frame_rate(stream: dict) -> Optional[float]:
|
||||
value = stream.get("avg_frame_rate") or stream.get("r_frame_rate")
|
||||
if not value or value == "0/0":
|
||||
return None
|
||||
try:
|
||||
if "/" in value:
|
||||
numerator, denominator = value.split("/", 1)
|
||||
numerator = float(numerator)
|
||||
denominator = float(denominator)
|
||||
if denominator == 0:
|
||||
return None
|
||||
return round(numerator / denominator, 3)
|
||||
return float(value)
|
||||
except (ValueError, ZeroDivisionError): # noqa: PERF203
|
||||
return None
|
||||
210
apps/media_library/views.py
Normal file
210
apps/media_library/views.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
|
||||
from django.http import (
|
||||
FileResponse,
|
||||
Http404,
|
||||
HttpResponse,
|
||||
HttpResponseForbidden,
|
||||
StreamingHttpResponse,
|
||||
)
|
||||
from django.views.decorators.http import require_GET
|
||||
|
||||
from apps.media_library.models import MediaFile
|
||||
from apps.media_library.transcode import start_streaming_transcode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STREAM_SIGNER = TimestampSigner(salt="media-library-stream")
|
||||
TOKEN_TTL = getattr(settings, "MEDIA_LIBRARY_STREAM_TOKEN_TTL", 3600)
|
||||
|
||||
mimetypes.add_type("video/x-matroska", ".mkv", strict=False)
|
||||
|
||||
|
||||
def _iter_file(file_obj, offset=0, length=None, chunk_size=8192):
|
||||
file_obj.seek(offset)
|
||||
remaining = length
|
||||
while True:
|
||||
if remaining is not None and remaining <= 0:
|
||||
break
|
||||
read_size = chunk_size if remaining is None else min(chunk_size, remaining)
|
||||
data = file_obj.read(read_size)
|
||||
if not data:
|
||||
break
|
||||
if remaining is not None:
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
|
||||
|
||||
def _guess_mime(path: str | None) -> str:
|
||||
if not path:
|
||||
return "application/octet-stream"
|
||||
mime, _ = mimetypes.guess_type(path)
|
||||
return mime or "application/octet-stream"
|
||||
|
||||
|
||||
@require_GET
|
||||
def stream_media_file(request, token: str):
|
||||
try:
|
||||
payload = STREAM_SIGNER.unsign_object(token, max_age=TOKEN_TTL)
|
||||
except SignatureExpired:
|
||||
return HttpResponseForbidden("Stream link expired")
|
||||
except BadSignature:
|
||||
raise Http404("Invalid stream token")
|
||||
|
||||
file_id = payload.get("file_id")
|
||||
user_id = payload.get("user_id")
|
||||
|
||||
if request.user.is_authenticated and request.user.id != user_id:
|
||||
return HttpResponseForbidden("Stream token not issued for this user")
|
||||
|
||||
start_ms = payload.get("start_ms", 0)
|
||||
try:
|
||||
start_ms = int(start_ms)
|
||||
except (TypeError, ValueError):
|
||||
start_ms = 0
|
||||
start_seconds = max(0.0, start_ms / 1000.0)
|
||||
|
||||
try:
|
||||
media_file = MediaFile.objects.get(pk=file_id)
|
||||
except MediaFile.DoesNotExist:
|
||||
raise Http404("Media file not found")
|
||||
|
||||
duration_seconds: float | None = None
|
||||
duration_ms = media_file.effective_duration_ms
|
||||
if duration_ms:
|
||||
try:
|
||||
duration_seconds = float(duration_ms) / 1000.0
|
||||
except (TypeError, ValueError):
|
||||
duration_seconds = None
|
||||
|
||||
original_path = media_file.absolute_path or ""
|
||||
cached_path = media_file.transcoded_path or ""
|
||||
|
||||
playback_path = ""
|
||||
mime_type = "application/octet-stream"
|
||||
download_name = media_file.file_name or f"{media_file.id}.mp4"
|
||||
|
||||
if cached_path and os.path.exists(cached_path):
|
||||
playback_path = cached_path
|
||||
mime_type = media_file.transcoded_mime_type or "video/mp4"
|
||||
base_name, _ = os.path.splitext(media_file.file_name or "")
|
||||
download_name = f"{base_name or media_file.id}.mp4"
|
||||
else:
|
||||
if cached_path and media_file.transcode_status == MediaFile.TRANSCODE_STATUS_READY:
|
||||
# Cached entry missing on disk – reset so we regenerate.
|
||||
media_file.transcode_status = MediaFile.TRANSCODE_STATUS_PENDING
|
||||
media_file.transcoded_path = ""
|
||||
media_file.transcoded_mime_type = ""
|
||||
media_file.transcoded_at = None
|
||||
media_file.save(
|
||||
update_fields=[
|
||||
"transcode_status",
|
||||
"transcoded_path",
|
||||
"transcoded_mime_type",
|
||||
"transcoded_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
if original_path and os.path.exists(original_path) and media_file.is_browser_playable():
|
||||
playback_path = original_path
|
||||
mime_type = _guess_mime(original_path)
|
||||
download_name = media_file.file_name or os.path.basename(original_path)
|
||||
elif original_path and os.path.exists(original_path):
|
||||
# Start live transcode and stream output directly.
|
||||
try:
|
||||
session = start_streaming_transcode(
|
||||
media_file,
|
||||
start_seconds=start_seconds,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise Http404("Media file not found")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Unable to start live transcode for media file %s: %s",
|
||||
media_file.id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return HttpResponse(
|
||||
"Unable to prepare video for playback. Please try again later.",
|
||||
status=500,
|
||||
)
|
||||
|
||||
base_name, _ = os.path.splitext(media_file.file_name or "")
|
||||
download_name = f"{base_name or media_file.id}.mp4"
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
session.stream(),
|
||||
content_type=session.mime_type,
|
||||
)
|
||||
response["Content-Disposition"] = f'inline; filename="{download_name}"'
|
||||
response["Cache-Control"] = "no-store"
|
||||
response["Accept-Ranges"] = "none"
|
||||
if duration_seconds:
|
||||
formatted_duration = f"{duration_seconds:.3f}"
|
||||
response["X-Content-Duration"] = formatted_duration
|
||||
response["Content-Duration"] = formatted_duration
|
||||
return response
|
||||
else:
|
||||
raise Http404("Media file not found")
|
||||
|
||||
if not playback_path or not os.path.exists(playback_path):
|
||||
raise Http404("Media file not found")
|
||||
|
||||
file_size = os.path.getsize(playback_path)
|
||||
mime_type = mime_type or _guess_mime(playback_path)
|
||||
|
||||
range_header = request.headers.get("Range")
|
||||
if range_header:
|
||||
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
|
||||
if range_match:
|
||||
start = int(range_match.group(1))
|
||||
end_raw = range_match.group(2)
|
||||
end = int(end_raw) if end_raw else file_size - 1
|
||||
if start >= file_size:
|
||||
response = HttpResponse(status=416)
|
||||
response["Content-Range"] = f"bytes */{file_size}"
|
||||
return response
|
||||
end = min(end, file_size - 1)
|
||||
length = end - start + 1
|
||||
|
||||
file_handle = open(playback_path, "rb")
|
||||
|
||||
def closing_iterator():
|
||||
try:
|
||||
yield from _iter_file(file_handle, offset=start, length=length)
|
||||
finally:
|
||||
file_handle.close()
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
closing_iterator(), status=206, content_type=mime_type
|
||||
)
|
||||
response["Content-Length"] = str(length)
|
||||
response["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
response["Accept-Ranges"] = "bytes"
|
||||
response["Content-Disposition"] = (
|
||||
f'inline; filename="{download_name}"'
|
||||
)
|
||||
if duration_seconds:
|
||||
formatted_duration = f"{duration_seconds:.3f}"
|
||||
response["X-Content-Duration"] = formatted_duration
|
||||
response["Content-Duration"] = formatted_duration
|
||||
return response
|
||||
|
||||
response = FileResponse(open(playback_path, "rb"), content_type=mime_type)
|
||||
response["Accept-Ranges"] = "bytes"
|
||||
response["Content-Length"] = str(file_size)
|
||||
response["Content-Disposition"] = (
|
||||
f'inline; filename="{download_name}"'
|
||||
)
|
||||
if duration_seconds:
|
||||
formatted_duration = f"{duration_seconds:.3f}"
|
||||
response["X-Content-Duration"] = formatted_duration
|
||||
response["Content-Duration"] = formatted_duration
|
||||
return response
|
||||
657
apps/media_library/vod_sync.py
Normal file
657
apps/media_library/vod_sync.py
Normal file
|
|
@ -0,0 +1,657 @@
|
|||
"""
|
||||
Helpers for synchronizing local media library items with the VOD catalog.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from apps.channels.models import Logo
|
||||
from apps.media_library.models import ArtworkAsset, Library, MediaFile, MediaItem
|
||||
from apps.vod.models import Episode, Movie, Series
|
||||
|
||||
|
||||
def _select_primary_file(media_item: MediaItem) -> MediaFile | None:
|
||||
"""Pick the best representative file for metadata (prefer longest duration)."""
|
||||
return (
|
||||
media_item.files.order_by("-duration_ms", "-size_bytes", "id").first()
|
||||
if media_item.pk
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def _duration_seconds(media_item: MediaItem) -> int | None:
|
||||
if media_item.runtime_ms:
|
||||
return int(media_item.runtime_ms / 1000)
|
||||
file_record = _select_primary_file(media_item)
|
||||
if file_record and file_record.duration_ms:
|
||||
return int(file_record.duration_ms / 1000)
|
||||
return None
|
||||
|
||||
|
||||
def _collect_quality_info(media_item: MediaItem) -> dict[str, Any] | None:
|
||||
file_record = _select_primary_file(media_item)
|
||||
if not file_record:
|
||||
return None
|
||||
payload: dict[str, Any] = {}
|
||||
bit_rate_value = getattr(file_record, "bit_rate", None)
|
||||
if bit_rate_value:
|
||||
payload["bitrate"] = bit_rate_value
|
||||
video_payload: dict[str, Any] = {}
|
||||
if file_record.width and file_record.height:
|
||||
video_payload["width"] = file_record.width
|
||||
video_payload["height"] = file_record.height
|
||||
if file_record.video_codec:
|
||||
video_payload["codec"] = file_record.video_codec
|
||||
if file_record.frame_rate:
|
||||
video_payload["frame_rate"] = file_record.frame_rate
|
||||
if video_payload:
|
||||
payload["video"] = video_payload
|
||||
|
||||
audio_payload: dict[str, Any] = {}
|
||||
if file_record.audio_codec:
|
||||
audio_payload["codec"] = file_record.audio_codec
|
||||
if file_record.audio_channels:
|
||||
audio_payload["channels"] = file_record.audio_channels
|
||||
if audio_payload:
|
||||
payload["audio"] = audio_payload
|
||||
|
||||
if file_record.container:
|
||||
payload["container"] = file_record.container
|
||||
|
||||
if not payload:
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _infer_image_extension(content_type: str | None, source_url: str | None) -> str:
|
||||
"""Return a sensible file extension for downloaded artwork."""
|
||||
if content_type:
|
||||
guess = mimetypes.guess_extension(content_type.split(";")[0].strip())
|
||||
if guess:
|
||||
return guess
|
||||
if source_url:
|
||||
path = urlparse(source_url).path
|
||||
_, ext = os.path.splitext(path)
|
||||
if ext:
|
||||
return ext
|
||||
return ".jpg"
|
||||
|
||||
|
||||
def _build_media_url(stored_path: str) -> str:
|
||||
media_url = getattr(settings, "MEDIA_URL", "/media/") or "/media/"
|
||||
return f"{media_url.rstrip('/')}/{stored_path.lstrip('/')}"
|
||||
|
||||
|
||||
def _download_poster(media_item: MediaItem, source_url: str) -> tuple[str, str] | None:
|
||||
"""Persist poster art to Django storage, returning (path, media_url)."""
|
||||
|
||||
content: bytes | None = None
|
||||
content_type: str | None = None
|
||||
|
||||
if source_url.lower().startswith(("http://", "https://")):
|
||||
try:
|
||||
response = requests.get(source_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
except Exception:
|
||||
return None
|
||||
content = response.content
|
||||
content_type = response.headers.get("Content-Type")
|
||||
else:
|
||||
candidate_path = source_url
|
||||
media_url = getattr(settings, "MEDIA_URL", "/media/") or "/media/"
|
||||
media_root = getattr(settings, "MEDIA_ROOT", "")
|
||||
if candidate_path.startswith(media_url) and media_root:
|
||||
candidate_path = os.path.join(media_root, candidate_path[len(media_url.rstrip("/")) + 1 :])
|
||||
if os.path.isabs(candidate_path) and os.path.exists(candidate_path):
|
||||
try:
|
||||
with open(candidate_path, "rb") as handle:
|
||||
content = handle.read()
|
||||
content_type = mimetypes.guess_type(candidate_path)[0]
|
||||
except Exception:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
if not content:
|
||||
return None
|
||||
|
||||
extension = _infer_image_extension(content_type, source_url)
|
||||
slug = slugify(media_item.title or "library-item") or str(media_item.id)
|
||||
relative_path = f"vod/posters/{media_item.id}-{slug}{extension}"
|
||||
|
||||
if default_storage.exists(relative_path):
|
||||
try:
|
||||
default_storage.delete(relative_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stored_path = default_storage.save(relative_path, ContentFile(content))
|
||||
return stored_path, _build_media_url(stored_path)
|
||||
|
||||
|
||||
def _ensure_logo_for_media_item(media_item: MediaItem) -> tuple[Logo | None, dict[str, Any] | None, str | None]:
|
||||
metadata = dict(media_item.metadata or {})
|
||||
|
||||
poster_source = media_item.poster_url or None
|
||||
if not poster_source:
|
||||
poster_asset = media_item.artwork.filter(asset_type=ArtworkAsset.TYPE_POSTER).first()
|
||||
if poster_asset:
|
||||
poster_source = poster_asset.local_path or poster_asset.external_url
|
||||
|
||||
if not poster_source:
|
||||
return None, None, None
|
||||
|
||||
stored_path = metadata.get("vod_poster_path")
|
||||
stored_url = metadata.get("vod_poster_url")
|
||||
stored_source = metadata.get("vod_poster_source_url")
|
||||
|
||||
needs_refresh = (
|
||||
not stored_path
|
||||
or not default_storage.exists(stored_path or "")
|
||||
or stored_source != poster_source
|
||||
)
|
||||
|
||||
if needs_refresh:
|
||||
download_result = _download_poster(media_item, poster_source)
|
||||
if not download_result:
|
||||
return None, None, None
|
||||
stored_path, stored_url = download_result
|
||||
stored_source = poster_source
|
||||
|
||||
if not stored_path or not stored_url:
|
||||
return None, None, None
|
||||
|
||||
metadata_updates = metadata.copy()
|
||||
metadata_updates.update(
|
||||
{
|
||||
"vod_poster_path": stored_path,
|
||||
"vod_poster_url": stored_url,
|
||||
"vod_poster_source_url": stored_source,
|
||||
}
|
||||
)
|
||||
|
||||
logo_name = (media_item.title or "Library Asset")[:255]
|
||||
logo, _ = Logo.objects.get_or_create(url=stored_url, defaults={"name": logo_name})
|
||||
return logo, metadata_updates, poster_source
|
||||
|
||||
|
||||
def _clean_local_poster(media_item: MediaItem) -> None:
|
||||
metadata = dict(media_item.metadata or {})
|
||||
stored_path = metadata.pop("vod_poster_path", None)
|
||||
metadata.pop("vod_poster_url", None)
|
||||
metadata.pop("vod_poster_source_url", None)
|
||||
if stored_path and default_storage.exists(stored_path):
|
||||
try:
|
||||
default_storage.delete(stored_path)
|
||||
except Exception:
|
||||
pass
|
||||
media_item.metadata = metadata
|
||||
media_item.save(update_fields=["metadata", "updated_at"])
|
||||
|
||||
|
||||
def _merge_custom_properties(existing: dict[str, Any] | None, updates: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(existing or {})
|
||||
data.update({key: value for key, value in updates.items() if value is not None})
|
||||
return data
|
||||
|
||||
|
||||
def _update_movie_from_media_item(movie: Movie, media_item: MediaItem) -> Movie:
|
||||
fields_to_update: list[str] = []
|
||||
|
||||
def set_field(field: str, value: Any):
|
||||
nonlocal fields_to_update
|
||||
if getattr(movie, field) != value and value is not None:
|
||||
setattr(movie, field, value)
|
||||
fields_to_update.append(field)
|
||||
|
||||
set_field("name", media_item.title or movie.name)
|
||||
set_field("description", media_item.synopsis or movie.description)
|
||||
set_field("year", media_item.release_year or movie.year)
|
||||
set_field("rating", media_item.rating or movie.rating)
|
||||
genres = ", ".join(media_item.genres) if isinstance(media_item.genres, Iterable) else None
|
||||
set_field("genre", genres or movie.genre)
|
||||
duration = _duration_seconds(media_item)
|
||||
set_field("duration_secs", duration or movie.duration_secs)
|
||||
|
||||
if media_item.tmdb_id and movie.tmdb_id != media_item.tmdb_id:
|
||||
movie.tmdb_id = media_item.tmdb_id
|
||||
fields_to_update.append("tmdb_id")
|
||||
if media_item.imdb_id and movie.imdb_id != media_item.imdb_id:
|
||||
movie.imdb_id = media_item.imdb_id
|
||||
fields_to_update.append("imdb_id")
|
||||
|
||||
quality_info = _collect_quality_info(media_item) or {}
|
||||
logo, metadata_updates, poster_source = _ensure_logo_for_media_item(media_item)
|
||||
if metadata_updates is not None:
|
||||
current_metadata = media_item.metadata or {}
|
||||
if metadata_updates != current_metadata:
|
||||
media_item.metadata = metadata_updates
|
||||
media_item.save(update_fields=["metadata", "updated_at"])
|
||||
poster_media_url = (
|
||||
metadata_updates.get("vod_poster_url") if metadata_updates else None
|
||||
)
|
||||
poster_source_url = (
|
||||
metadata_updates.get("vod_poster_source_url") if metadata_updates else poster_source
|
||||
)
|
||||
backdrop_entries: list[str] = []
|
||||
if media_item.backdrop_url:
|
||||
backdrop_entries.append(media_item.backdrop_url)
|
||||
|
||||
custom_updates = {
|
||||
"source": "library",
|
||||
"library_id": media_item.library_id,
|
||||
"library_item_id": media_item.id,
|
||||
"poster_url": poster_media_url or poster_source_url or media_item.poster_url,
|
||||
"backdrop_url": media_item.backdrop_url,
|
||||
"backdrop_path": backdrop_entries,
|
||||
"quality": quality_info,
|
||||
}
|
||||
merged_custom = _merge_custom_properties(movie.custom_properties, custom_updates)
|
||||
if merged_custom != movie.custom_properties:
|
||||
movie.custom_properties = merged_custom
|
||||
fields_to_update.append("custom_properties")
|
||||
|
||||
if logo and (movie.logo_id != logo.id):
|
||||
movie.logo = logo
|
||||
fields_to_update.append("logo")
|
||||
|
||||
if fields_to_update:
|
||||
movie.save(update_fields=fields_to_update + ["updated_at"])
|
||||
return movie
|
||||
|
||||
|
||||
def _update_series_from_media_item(series: Series, media_item: MediaItem) -> Series:
|
||||
fields_to_update: list[str] = []
|
||||
|
||||
def set_field(field: str, value: Any):
|
||||
nonlocal fields_to_update
|
||||
if getattr(series, field) != value and value is not None:
|
||||
setattr(series, field, value)
|
||||
fields_to_update.append(field)
|
||||
|
||||
set_field("name", media_item.title or series.name)
|
||||
set_field("description", media_item.synopsis or series.description)
|
||||
set_field("year", media_item.release_year or series.year)
|
||||
set_field("rating", media_item.rating or series.rating)
|
||||
genres = ", ".join(media_item.genres) if isinstance(media_item.genres, Iterable) else None
|
||||
set_field("genre", genres or series.genre)
|
||||
|
||||
if media_item.tmdb_id and series.tmdb_id != media_item.tmdb_id:
|
||||
series.tmdb_id = media_item.tmdb_id
|
||||
fields_to_update.append("tmdb_id")
|
||||
if media_item.imdb_id and series.imdb_id != media_item.imdb_id:
|
||||
series.imdb_id = media_item.imdb_id
|
||||
fields_to_update.append("imdb_id")
|
||||
|
||||
logo, metadata_updates, poster_source = _ensure_logo_for_media_item(media_item)
|
||||
if metadata_updates is not None:
|
||||
current_metadata = media_item.metadata or {}
|
||||
if metadata_updates != current_metadata:
|
||||
media_item.metadata = metadata_updates
|
||||
media_item.save(update_fields=["metadata", "updated_at"])
|
||||
poster_media_url = (
|
||||
metadata_updates.get("vod_poster_url") if metadata_updates else None
|
||||
)
|
||||
poster_source_url = (
|
||||
metadata_updates.get("vod_poster_source_url") if metadata_updates else poster_source
|
||||
)
|
||||
|
||||
backdrop_entries: list[str] = []
|
||||
if media_item.backdrop_url:
|
||||
backdrop_entries.append(media_item.backdrop_url)
|
||||
|
||||
custom_updates = {
|
||||
"source": "library",
|
||||
"library_id": media_item.library_id,
|
||||
"library_item_id": media_item.id,
|
||||
"poster_url": poster_media_url or poster_source_url or media_item.poster_url,
|
||||
"backdrop_url": media_item.backdrop_url,
|
||||
"backdrop_path": backdrop_entries,
|
||||
}
|
||||
merged_custom = _merge_custom_properties(series.custom_properties, custom_updates)
|
||||
if merged_custom != series.custom_properties:
|
||||
series.custom_properties = merged_custom
|
||||
fields_to_update.append("custom_properties")
|
||||
|
||||
if logo and (series.logo_id != logo.id):
|
||||
series.logo = logo
|
||||
fields_to_update.append("logo")
|
||||
|
||||
if fields_to_update:
|
||||
series.save(update_fields=fields_to_update + ["updated_at"])
|
||||
return series
|
||||
|
||||
|
||||
def _update_episode_from_media_item(episode: Episode, media_item: MediaItem, series: Series) -> Episode:
|
||||
fields_to_update: list[str] = []
|
||||
|
||||
def set_field(field: str, value: Any):
|
||||
nonlocal fields_to_update
|
||||
if getattr(episode, field) != value and value is not None:
|
||||
setattr(episode, field, value)
|
||||
fields_to_update.append(field)
|
||||
|
||||
set_field("name", media_item.title or episode.name)
|
||||
set_field("description", media_item.synopsis or episode.description)
|
||||
set_field("season_number", media_item.season_number or episode.season_number)
|
||||
set_field("episode_number", media_item.episode_number or episode.episode_number)
|
||||
duration = _duration_seconds(media_item)
|
||||
set_field("duration_secs", duration or episode.duration_secs)
|
||||
|
||||
if media_item.tmdb_id and episode.tmdb_id != media_item.tmdb_id:
|
||||
episode.tmdb_id = media_item.tmdb_id
|
||||
fields_to_update.append("tmdb_id")
|
||||
if media_item.imdb_id and episode.imdb_id != media_item.imdb_id:
|
||||
episode.imdb_id = media_item.imdb_id
|
||||
fields_to_update.append("imdb_id")
|
||||
|
||||
quality_info = _collect_quality_info(media_item)
|
||||
custom_updates = {
|
||||
"source": "library",
|
||||
"library_id": media_item.library_id,
|
||||
"library_item_id": media_item.id,
|
||||
"quality": quality_info,
|
||||
}
|
||||
merged_custom = _merge_custom_properties(episode.custom_properties, custom_updates)
|
||||
if merged_custom != episode.custom_properties:
|
||||
episode.custom_properties = merged_custom
|
||||
fields_to_update.append("custom_properties")
|
||||
|
||||
if episode.series_id != series.id:
|
||||
episode.series = series
|
||||
fields_to_update.append("series")
|
||||
|
||||
if fields_to_update:
|
||||
episode.save(update_fields=fields_to_update + ["updated_at"])
|
||||
return episode
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_media_item_to_vod(media_item: MediaItem) -> None:
|
||||
"""
|
||||
Ensure the provided media item is represented in the VOD catalog if its library
|
||||
is configured as a VOD source. When disabled, remove any previously linked VOD entries.
|
||||
"""
|
||||
if not media_item.pk:
|
||||
return
|
||||
|
||||
library: Library = media_item.library
|
||||
if not library.use_as_vod_source:
|
||||
remove_media_item_from_vod(media_item)
|
||||
return
|
||||
|
||||
now = timezone.now()
|
||||
|
||||
if media_item.item_type == MediaItem.TYPE_MOVIE:
|
||||
movie = None
|
||||
if media_item.vod_movie_id:
|
||||
movie = Movie.objects.filter(pk=media_item.vod_movie_id).first()
|
||||
|
||||
movie_defaults = {
|
||||
"name": media_item.title or "Untitled Movie",
|
||||
"description": media_item.synopsis or "",
|
||||
"year": media_item.release_year,
|
||||
"rating": media_item.rating or "",
|
||||
"genre": ", ".join(media_item.genres) if isinstance(media_item.genres, Iterable) else "",
|
||||
"duration_secs": _duration_seconds(media_item),
|
||||
"tmdb_id": media_item.tmdb_id,
|
||||
"imdb_id": media_item.imdb_id,
|
||||
"custom_properties": {
|
||||
"source": "library",
|
||||
"library_id": media_item.library_id,
|
||||
"library_item_id": media_item.id,
|
||||
},
|
||||
}
|
||||
|
||||
if not movie and media_item.tmdb_id:
|
||||
movie, _created = Movie.objects.get_or_create(
|
||||
tmdb_id=media_item.tmdb_id,
|
||||
defaults=movie_defaults,
|
||||
)
|
||||
if not movie and media_item.imdb_id:
|
||||
movie, created_with_imdb = Movie.objects.get_or_create(
|
||||
imdb_id=media_item.imdb_id,
|
||||
defaults=movie_defaults,
|
||||
)
|
||||
if created_with_imdb and media_item.tmdb_id and not movie.tmdb_id:
|
||||
movie.tmdb_id = media_item.tmdb_id
|
||||
movie.save(update_fields=["tmdb_id", "updated_at"])
|
||||
|
||||
if not movie:
|
||||
fallback_filters = Q(tmdb_id__isnull=True) & Q(imdb_id__isnull=True)
|
||||
title = media_item.title or "Untitled Movie"
|
||||
fallback_filters &= Q(name=title)
|
||||
if media_item.release_year:
|
||||
fallback_filters &= Q(year=media_item.release_year)
|
||||
fallback_candidate = Movie.objects.filter(fallback_filters).first()
|
||||
if fallback_candidate:
|
||||
movie = fallback_candidate
|
||||
|
||||
if not movie:
|
||||
savepoint = transaction.savepoint()
|
||||
try:
|
||||
movie = Movie.objects.create(**movie_defaults)
|
||||
transaction.savepoint_commit(savepoint)
|
||||
except IntegrityError:
|
||||
transaction.savepoint_rollback(savepoint)
|
||||
movie = Movie.objects.filter(tmdb_id=media_item.tmdb_id).first()
|
||||
if not movie and media_item.imdb_id:
|
||||
movie = Movie.objects.filter(imdb_id=media_item.imdb_id).first()
|
||||
if not movie:
|
||||
raise
|
||||
if movie and media_item.tmdb_id:
|
||||
conflict_movie = Movie.objects.filter(tmdb_id=media_item.tmdb_id).exclude(pk=movie.pk).first()
|
||||
if conflict_movie:
|
||||
movie = conflict_movie
|
||||
movie = _update_movie_from_media_item(movie, media_item)
|
||||
if media_item.vod_movie_id != movie.id:
|
||||
media_item.vod_movie = movie
|
||||
media_item.save(update_fields=["vod_movie", "updated_at"])
|
||||
movie.custom_properties["synced_at"] = now.isoformat()
|
||||
movie.save(update_fields=["custom_properties", "updated_at"])
|
||||
|
||||
elif media_item.item_type == MediaItem.TYPE_SHOW:
|
||||
series = None
|
||||
if media_item.vod_series_id:
|
||||
series = Series.objects.filter(pk=media_item.vod_series_id).first()
|
||||
|
||||
series_defaults = {
|
||||
"name": media_item.title or "Untitled Series",
|
||||
"description": media_item.synopsis or "",
|
||||
"year": media_item.release_year,
|
||||
"rating": media_item.rating or "",
|
||||
"genre": ", ".join(media_item.genres) if isinstance(media_item.genres, Iterable) else "",
|
||||
"tmdb_id": media_item.tmdb_id,
|
||||
"imdb_id": media_item.imdb_id,
|
||||
"custom_properties": {
|
||||
"source": "library",
|
||||
"library_id": media_item.library_id,
|
||||
"library_item_id": media_item.id,
|
||||
},
|
||||
}
|
||||
|
||||
if not series and media_item.tmdb_id:
|
||||
series, _created = Series.objects.get_or_create(
|
||||
tmdb_id=media_item.tmdb_id,
|
||||
defaults=series_defaults,
|
||||
)
|
||||
if not series and media_item.imdb_id:
|
||||
series, created_with_imdb = Series.objects.get_or_create(
|
||||
imdb_id=media_item.imdb_id,
|
||||
defaults=series_defaults,
|
||||
)
|
||||
if created_with_imdb and media_item.tmdb_id and not series.tmdb_id:
|
||||
series.tmdb_id = media_item.tmdb_id
|
||||
series.save(update_fields=["tmdb_id", "updated_at"])
|
||||
|
||||
if not series:
|
||||
fallback_filters = Q(tmdb_id__isnull=True) & Q(imdb_id__isnull=True)
|
||||
title = media_item.title or "Untitled Series"
|
||||
fallback_filters &= Q(name=title)
|
||||
if media_item.release_year:
|
||||
fallback_filters &= Q(year=media_item.release_year)
|
||||
fallback_candidate = Series.objects.filter(fallback_filters).first()
|
||||
if fallback_candidate:
|
||||
series = fallback_candidate
|
||||
|
||||
if not series:
|
||||
savepoint = transaction.savepoint()
|
||||
try:
|
||||
series = Series.objects.create(**series_defaults)
|
||||
transaction.savepoint_commit(savepoint)
|
||||
except IntegrityError:
|
||||
transaction.savepoint_rollback(savepoint)
|
||||
series = Series.objects.filter(tmdb_id=media_item.tmdb_id).first()
|
||||
if not series and media_item.imdb_id:
|
||||
series = Series.objects.filter(imdb_id=media_item.imdb_id).first()
|
||||
if not series:
|
||||
raise
|
||||
if series and media_item.tmdb_id:
|
||||
conflict_series = Series.objects.filter(tmdb_id=media_item.tmdb_id).exclude(pk=series.pk).first()
|
||||
if conflict_series:
|
||||
series = conflict_series
|
||||
series = _update_series_from_media_item(series, media_item)
|
||||
if media_item.vod_series_id != series.id:
|
||||
media_item.vod_series = series
|
||||
media_item.save(update_fields=["vod_series", "updated_at"])
|
||||
series.custom_properties["synced_at"] = now.isoformat()
|
||||
series.save(update_fields=["custom_properties", "updated_at"])
|
||||
|
||||
elif media_item.item_type == MediaItem.TYPE_EPISODE:
|
||||
parent = media_item.parent
|
||||
if parent:
|
||||
sync_media_item_to_vod(parent)
|
||||
series = parent.vod_series
|
||||
else:
|
||||
series = None
|
||||
if not series:
|
||||
# Ensure there is a series placeholder to attach this episode.
|
||||
series = Series.objects.create(
|
||||
name=media_item.title or "Series",
|
||||
description="Library-sourced series",
|
||||
custom_properties={
|
||||
"source": "library",
|
||||
"library_id": media_item.library_id,
|
||||
},
|
||||
)
|
||||
|
||||
episode = None
|
||||
if media_item.vod_episode_id:
|
||||
episode = Episode.objects.filter(pk=media_item.vod_episode_id).first()
|
||||
if not episode and media_item.tmdb_id:
|
||||
episode = Episode.objects.filter(tmdb_id=media_item.tmdb_id).first()
|
||||
if not episode and media_item.imdb_id:
|
||||
episode = Episode.objects.filter(imdb_id=media_item.imdb_id).first()
|
||||
if not episode:
|
||||
episode_filters = Q(series=series)
|
||||
if media_item.season_number is not None:
|
||||
episode_filters &= Q(season_number=media_item.season_number)
|
||||
if media_item.episode_number is not None:
|
||||
episode_filters &= Q(episode_number=media_item.episode_number)
|
||||
fallback_episode = Episode.objects.filter(episode_filters).first()
|
||||
if fallback_episode:
|
||||
episode = fallback_episode
|
||||
|
||||
if not episode:
|
||||
episode = Episode.objects.create(
|
||||
series=series,
|
||||
name=media_item.title or "Episode",
|
||||
description=media_item.synopsis or "",
|
||||
season_number=media_item.season_number,
|
||||
episode_number=media_item.episode_number,
|
||||
duration_secs=_duration_seconds(media_item),
|
||||
tmdb_id=media_item.tmdb_id,
|
||||
imdb_id=media_item.imdb_id,
|
||||
custom_properties={
|
||||
"source": "library",
|
||||
"library_id": media_item.library_id,
|
||||
"library_item_id": media_item.id,
|
||||
},
|
||||
)
|
||||
episode = _update_episode_from_media_item(episode, media_item, series)
|
||||
if media_item.vod_episode_id != episode.id:
|
||||
media_item.vod_episode = episode
|
||||
media_item.save(update_fields=["vod_episode", "updated_at"])
|
||||
episode.custom_properties["synced_at"] = now.isoformat()
|
||||
episode.save(update_fields=["custom_properties", "updated_at"])
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def remove_media_item_from_vod(media_item: MediaItem) -> None:
|
||||
"""Detach a media item from the VOD catalog and prune orphaned entries."""
|
||||
if (media_item.metadata or {}).get("vod_poster_path"):
|
||||
_clean_local_poster(media_item)
|
||||
|
||||
if media_item.vod_movie_id:
|
||||
movie = Movie.objects.filter(pk=media_item.vod_movie_id).first()
|
||||
media_item.vod_movie = None
|
||||
media_item.save(update_fields=["vod_movie", "updated_at"])
|
||||
if movie and not movie.m3u_relations.exists() and not movie.library_items.exists():
|
||||
movie.delete()
|
||||
|
||||
if media_item.vod_episode_id:
|
||||
episode = Episode.objects.filter(pk=media_item.vod_episode_id).first()
|
||||
series = episode.series if episode else None
|
||||
media_item.vod_episode = None
|
||||
media_item.save(update_fields=["vod_episode", "updated_at"])
|
||||
if episode and not episode.m3u_relations.exists() and not episode.library_items.exists():
|
||||
episode.delete()
|
||||
|
||||
if series and not series.m3u_relations.exists() and not series.library_items.exists():
|
||||
# Only remove episode-less series that were sourced from library content.
|
||||
if (series.custom_properties or {}).get("source") == "library":
|
||||
series.delete()
|
||||
|
||||
if media_item.vod_series_id and media_item.item_type == MediaItem.TYPE_SHOW:
|
||||
series = Series.objects.filter(pk=media_item.vod_series_id).first()
|
||||
media_item.vod_series = None
|
||||
media_item.save(update_fields=["vod_series", "updated_at"])
|
||||
if series and not series.m3u_relations.exists() and not series.library_items.exists():
|
||||
if (series.custom_properties or {}).get("source") == "library":
|
||||
series.delete()
|
||||
|
||||
|
||||
def sync_library_to_vod(library: Library) -> None:
|
||||
"""Synchronize all media items in a library with the VOD catalog."""
|
||||
for media_item in library.items.select_related("parent").all():
|
||||
sync_media_item_to_vod(media_item)
|
||||
|
||||
|
||||
def unsync_library_from_vod(library: Library) -> None:
|
||||
"""Remove all VOD links for items in a library."""
|
||||
for media_item in library.items.all():
|
||||
remove_media_item_from_vod(media_item)
|
||||
|
||||
# Remove any orphaned VOD entries that were created from this library.
|
||||
Movie.objects.filter(
|
||||
Q(custom_properties__source="library"),
|
||||
Q(custom_properties__library_id=library.id),
|
||||
library_items__isnull=True,
|
||||
m3u_relations__isnull=True,
|
||||
).delete()
|
||||
Series.objects.filter(
|
||||
Q(custom_properties__source="library"),
|
||||
Q(custom_properties__library_id=library.id),
|
||||
library_items__isnull=True,
|
||||
m3u_relations__isnull=True,
|
||||
).delete()
|
||||
Episode.objects.filter(
|
||||
Q(custom_properties__source="library"),
|
||||
Q(custom_properties__library_id=library.id),
|
||||
library_items__isnull=True,
|
||||
m3u_relations__isnull=True,
|
||||
).delete()
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
|
|
@ -5,7 +6,9 @@ from rest_framework.filters import SearchFilter, OrderingFilter
|
|||
from rest_framework.pagination import PageNumberPagination
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
import logging
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
|
|
@ -31,6 +34,15 @@ from datetime import timedelta
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_logo_cache_url(request, logo_id):
|
||||
if not logo_id:
|
||||
return None
|
||||
cache_path = reverse("api:channels:logo-cache", args=[logo_id])
|
||||
if request:
|
||||
return request.build_absolute_uri(cache_path)
|
||||
return cache_path
|
||||
|
||||
|
||||
class VODPagination(PageNumberPagination):
|
||||
page_size = 20 # Default page size to match frontend default
|
||||
page_size_query_param = "page_size" # Allow clients to specify page size
|
||||
|
|
@ -86,9 +98,15 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
|
||||
def get_queryset(self):
|
||||
# Only return movies that have active M3U relations
|
||||
return Movie.objects.filter(
|
||||
m3u_relations__m3u_account__is_active=True
|
||||
).distinct().select_related('logo').prefetch_related('m3u_relations__m3u_account')
|
||||
return (
|
||||
Movie.objects.filter(
|
||||
Q(m3u_relations__m3u_account__is_active=True)
|
||||
| Q(library_items__library__use_as_vod_source=True)
|
||||
)
|
||||
.distinct()
|
||||
.select_related('logo')
|
||||
.prefetch_related('m3u_relations__m3u_account', 'library_items__library')
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='providers')
|
||||
def get_providers(self, request, pk=None):
|
||||
|
|
@ -99,8 +117,39 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
m3u_account__is_active=True
|
||||
).select_related('m3u_account', 'category')
|
||||
|
||||
serializer = M3UMovieRelationSerializer(relations, many=True)
|
||||
return Response(serializer.data)
|
||||
relation_payload = M3UMovieRelationSerializer(relations, many=True).data
|
||||
|
||||
library_payload = []
|
||||
for media_item in movie.library_items.select_related("library"):
|
||||
library = media_item.library
|
||||
if not library or not library.use_as_vod_source:
|
||||
continue
|
||||
library_payload.append(
|
||||
{
|
||||
"id": f"library-{media_item.id}",
|
||||
"provider_type": "library",
|
||||
"type": "library",
|
||||
"library_item_id": media_item.id,
|
||||
"library": {
|
||||
"id": library.id,
|
||||
"name": library.name,
|
||||
},
|
||||
"stream_id": None,
|
||||
"m3u_account": {
|
||||
"id": f"library-{library.id}",
|
||||
"name": library.name,
|
||||
"account_type": "library",
|
||||
},
|
||||
"quality_info": (movie.custom_properties or {}).get("quality"),
|
||||
"custom_properties": {
|
||||
"source": "library",
|
||||
"library_id": library.id,
|
||||
"media_item_id": media_item.id,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return Response([*relation_payload, *library_payload])
|
||||
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='provider-info')
|
||||
|
|
@ -108,6 +157,60 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
"""Get detailed movie information from the original provider, throttled to 24h."""
|
||||
movie = self.get_object()
|
||||
|
||||
library_item = movie.library_items.select_related("library").filter(
|
||||
library__use_as_vod_source=True
|
||||
).first()
|
||||
if library_item:
|
||||
library = library_item.library
|
||||
custom_props = movie.custom_properties or {}
|
||||
logo_cache_url = build_logo_cache_url(request, movie.logo_id)
|
||||
response_data = {
|
||||
'id': movie.id,
|
||||
'uuid': movie.uuid,
|
||||
'stream_id': None,
|
||||
'name': movie.name,
|
||||
'description': movie.description,
|
||||
'plot': movie.description,
|
||||
'year': movie.year,
|
||||
'genre': movie.genre,
|
||||
'rating': movie.rating,
|
||||
'tmdb_id': movie.tmdb_id,
|
||||
'imdb_id': movie.imdb_id,
|
||||
'duration_secs': movie.duration_secs,
|
||||
'movie_image': logo_cache_url or (movie.logo.url if movie.logo else custom_props.get('poster_url')),
|
||||
'backdrop_path': (
|
||||
custom_props.get('backdrop_path')
|
||||
if isinstance(custom_props.get('backdrop_path'), list)
|
||||
else ([custom_props.get('backdrop_url')] if custom_props.get('backdrop_url') else [])
|
||||
),
|
||||
'video': (custom_props.get('quality') or {}).get('video'),
|
||||
'audio': (custom_props.get('quality') or {}).get('audio'),
|
||||
'bitrate': (custom_props.get('quality') or {}).get('bitrate'),
|
||||
'container_extension': (custom_props.get('quality') or {}).get('container'),
|
||||
'direct_source': None,
|
||||
'library_item_id': library_item.id,
|
||||
'library_sources': [
|
||||
{
|
||||
'library_id': library.id,
|
||||
'library_name': library.name,
|
||||
'media_item_id': library_item.id,
|
||||
}
|
||||
],
|
||||
'custom_properties': custom_props,
|
||||
'm3u_account': {
|
||||
'id': f'library-{library.id}',
|
||||
'name': library.name,
|
||||
'account_type': 'library',
|
||||
},
|
||||
'logo': {
|
||||
'id': movie.logo_id,
|
||||
'name': movie.logo.name if movie.logo else None,
|
||||
'url': movie.logo.url if movie.logo else custom_props.get('poster_url'),
|
||||
'cache_url': logo_cache_url,
|
||||
} if movie.logo else None,
|
||||
}
|
||||
return Response(response_data)
|
||||
|
||||
# Get the highest priority active relation
|
||||
relation = M3UMovieRelation.objects.filter(
|
||||
movie=movie,
|
||||
|
|
@ -136,6 +239,7 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
# Refresh objects from database after task completion
|
||||
movie.refresh_from_db()
|
||||
relation.refresh_from_db()
|
||||
logo_cache_url = build_logo_cache_url(request, movie.logo_id)
|
||||
|
||||
# Use refreshed data from database
|
||||
custom_props = relation.custom_properties or {}
|
||||
|
|
@ -166,7 +270,7 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
'backdrop_path': (movie.custom_properties or {}).get('backdrop_path') or info.get('backdrop_path', []),
|
||||
'cover': info.get('cover_big', ''),
|
||||
'cover_big': info.get('cover_big', ''),
|
||||
'movie_image': movie.logo.url if movie.logo else info.get('movie_image', ''),
|
||||
'movie_image': logo_cache_url or (movie.logo.url if movie.logo else info.get('movie_image', '')),
|
||||
'bitrate': info.get('bitrate', 0),
|
||||
'video': info.get('video', {}),
|
||||
'audio': info.get('audio', {}),
|
||||
|
|
@ -178,7 +282,13 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
'id': relation.m3u_account.id,
|
||||
'name': relation.m3u_account.name,
|
||||
'account_type': relation.m3u_account.account_type
|
||||
}
|
||||
},
|
||||
'logo': {
|
||||
'id': movie.logo_id,
|
||||
'name': movie.logo.name if movie.logo else None,
|
||||
'url': movie.logo.url if movie.logo else info.get('movie_image', ''),
|
||||
'cache_url': logo_cache_url,
|
||||
} if movie.logo_id else None
|
||||
}
|
||||
return Response(response_data)
|
||||
|
||||
|
|
@ -242,9 +352,15 @@ class EpisodeViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
return Episode.objects.select_related(
|
||||
'series', 'm3u_account'
|
||||
).filter(m3u_account__is_active=True)
|
||||
return (
|
||||
Episode.objects.select_related("series")
|
||||
.filter(
|
||||
Q(m3u_relations__m3u_account__is_active=True)
|
||||
| Q(library_items__library__use_as_vod_source=True)
|
||||
)
|
||||
.distinct()
|
||||
.prefetch_related("m3u_relations__m3u_account", "library_items__library")
|
||||
)
|
||||
|
||||
|
||||
class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
|
@ -267,9 +383,15 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
|
||||
def get_queryset(self):
|
||||
# Only return series that have active M3U relations
|
||||
return Series.objects.filter(
|
||||
m3u_relations__m3u_account__is_active=True
|
||||
).distinct().select_related('logo').prefetch_related('episodes', 'm3u_relations__m3u_account')
|
||||
return (
|
||||
Series.objects.filter(
|
||||
Q(m3u_relations__m3u_account__is_active=True)
|
||||
| Q(library_items__library__use_as_vod_source=True)
|
||||
)
|
||||
.distinct()
|
||||
.select_related('logo')
|
||||
.prefetch_related('episodes', 'm3u_relations__m3u_account', 'library_items__library')
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='providers')
|
||||
def get_providers(self, request, pk=None):
|
||||
|
|
@ -280,8 +402,38 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
m3u_account__is_active=True
|
||||
).select_related('m3u_account', 'category')
|
||||
|
||||
serializer = M3USeriesRelationSerializer(relations, many=True)
|
||||
return Response(serializer.data)
|
||||
relation_payload = M3USeriesRelationSerializer(relations, many=True).data
|
||||
|
||||
library_payload = []
|
||||
for media_item in series.library_items.select_related("library"):
|
||||
library = media_item.library
|
||||
if not library or not library.use_as_vod_source:
|
||||
continue
|
||||
library_payload.append(
|
||||
{
|
||||
"id": f"library-{media_item.id}",
|
||||
"provider_type": "library",
|
||||
"type": "library",
|
||||
"library_item_id": media_item.id,
|
||||
"library": {
|
||||
"id": library.id,
|
||||
"name": library.name,
|
||||
},
|
||||
"m3u_account": {
|
||||
"id": f"library-{library.id}",
|
||||
"name": library.name,
|
||||
"account_type": "library",
|
||||
},
|
||||
"quality_info": None,
|
||||
"custom_properties": {
|
||||
"source": "library",
|
||||
"library_id": library.id,
|
||||
"media_item_id": media_item.id,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return Response([*relation_payload, *library_payload])
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='episodes')
|
||||
def get_episodes(self, request, pk=None):
|
||||
|
|
@ -302,7 +454,39 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
m3u_account__is_active=True
|
||||
).select_related('m3u_account')
|
||||
|
||||
episode_data['providers'] = M3UEpisodeRelationSerializer(relations, many=True).data
|
||||
relation_payload = M3UEpisodeRelationSerializer(relations, many=True).data
|
||||
|
||||
library_payload = []
|
||||
for media_item in episode.library_items.select_related("library"):
|
||||
library = media_item.library
|
||||
if not library or not library.use_as_vod_source:
|
||||
continue
|
||||
library_payload.append(
|
||||
{
|
||||
"id": f"library-{media_item.id}",
|
||||
"provider_type": "library",
|
||||
"type": "library",
|
||||
"library_item_id": media_item.id,
|
||||
"library": {
|
||||
"id": library.id,
|
||||
"name": library.name,
|
||||
},
|
||||
"m3u_account": {
|
||||
"id": f"library-{library.id}",
|
||||
"name": library.name,
|
||||
"account_type": "library",
|
||||
},
|
||||
"stream_id": None,
|
||||
"quality_info": None,
|
||||
"custom_properties": {
|
||||
"source": "library",
|
||||
"library_id": library.id,
|
||||
"media_item_id": media_item.id,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
episode_data['providers'] = [*relation_payload, *library_payload]
|
||||
episodes_data.append(episode_data)
|
||||
|
||||
return Response(episodes_data)
|
||||
|
|
@ -314,6 +498,75 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
series = self.get_object()
|
||||
logger.debug(f"Retrieved series: {series.name} (ID: {series.id})")
|
||||
|
||||
library_item = series.library_items.select_related("library").filter(
|
||||
library__use_as_vod_source=True
|
||||
).first()
|
||||
if library_item:
|
||||
library = library_item.library
|
||||
series_logo_cache_url = build_logo_cache_url(request, series.logo_id)
|
||||
episodes_by_season: dict[str, list[dict]] = {}
|
||||
for episode in series.episodes.all().prefetch_related("library_items__library").order_by('season_number', 'episode_number'):
|
||||
if not episode.library_items.filter(library__use_as_vod_source=True).exists():
|
||||
continue
|
||||
season_key = str(episode.season_number or 0)
|
||||
if season_key not in episodes_by_season:
|
||||
episodes_by_season[season_key] = []
|
||||
episodes_by_season[season_key].append(
|
||||
{
|
||||
'id': episode.id,
|
||||
'title': episode.name,
|
||||
'plot': episode.description,
|
||||
'season_number': episode.season_number,
|
||||
'episode_number': episode.episode_number,
|
||||
'duration_secs': episode.duration_secs,
|
||||
'rating': episode.rating,
|
||||
'tmdb_id': episode.tmdb_id,
|
||||
'imdb_id': episode.imdb_id,
|
||||
'uuid': str(episode.uuid),
|
||||
'library_media_item_ids': [
|
||||
item.id for item in episode.library_items.filter(library__use_as_vod_source=True)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
response_data = {
|
||||
'id': series.id,
|
||||
'series_id': series.id,
|
||||
'name': series.name,
|
||||
'description': series.description,
|
||||
'year': series.year,
|
||||
'genre': series.genre,
|
||||
'rating': series.rating,
|
||||
'tmdb_id': series.tmdb_id,
|
||||
'imdb_id': series.imdb_id,
|
||||
'category_id': None,
|
||||
'category_name': None,
|
||||
'cover': {
|
||||
'id': series.logo.id,
|
||||
'url': series_logo_cache_url or series.logo.url,
|
||||
'name': series.logo.name,
|
||||
'cache_url': series_logo_cache_url,
|
||||
} if series.logo else None,
|
||||
'custom_properties': series.custom_properties or {},
|
||||
'm3u_account': {
|
||||
'id': f'library-{library.id}',
|
||||
'name': library.name,
|
||||
'account_type': 'library',
|
||||
},
|
||||
'episodes_fetched': True,
|
||||
'detailed_fetched': True,
|
||||
'episodes': episodes_by_season,
|
||||
'library_item_id': library_item.id,
|
||||
'library_sources': [
|
||||
{
|
||||
'library_id': library.id,
|
||||
'library_name': library.name,
|
||||
'media_item_id': library_item.id,
|
||||
}
|
||||
],
|
||||
}
|
||||
return Response(response_data)
|
||||
|
||||
# Get the highest priority active relation
|
||||
relation = M3USeriesRelation.objects.filter(
|
||||
series=series,
|
||||
|
|
@ -512,18 +765,36 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
search = request.query_params.get('search', '')
|
||||
category = request.query_params.get('category', '')
|
||||
|
||||
# Build WHERE clauses
|
||||
where_conditions = [
|
||||
# Only active content
|
||||
"movies.id IN (SELECT DISTINCT movie_id FROM vod_m3umovierelation mmr JOIN m3u_m3uaccount ma ON mmr.m3u_account_id = ma.id WHERE ma.is_active = true)",
|
||||
"series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation msr JOIN m3u_m3uaccount ma ON msr.m3u_account_id = ma.id WHERE ma.is_active = true)"
|
||||
]
|
||||
# Build base WHERE clauses
|
||||
movie_m3u_condition = (
|
||||
"movies.id IN (SELECT DISTINCT movie_id FROM vod_m3umovierelation mmr "
|
||||
"JOIN m3u_m3uaccount ma ON mmr.m3u_account_id = ma.id "
|
||||
"WHERE ma.is_active = true)"
|
||||
)
|
||||
movie_library_condition = (
|
||||
"EXISTS (SELECT 1 FROM media_library_mediaitem mi "
|
||||
"JOIN media_library_library lib ON mi.library_id = lib.id "
|
||||
"WHERE mi.vod_movie_id = movies.id AND lib.use_as_vod_source = true)"
|
||||
)
|
||||
series_m3u_condition = (
|
||||
"series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation msr "
|
||||
"JOIN m3u_m3uaccount ma ON msr.m3u_account_id = ma.id "
|
||||
"WHERE ma.is_active = true)"
|
||||
)
|
||||
series_library_condition = (
|
||||
"EXISTS (SELECT 1 FROM media_library_mediaitem mi "
|
||||
"JOIN media_library_library lib ON mi.library_id = lib.id "
|
||||
"WHERE mi.vod_series_id = series.id AND lib.use_as_vod_source = true)"
|
||||
)
|
||||
|
||||
movie_where = f"(({movie_m3u_condition}) OR ({movie_library_condition}))"
|
||||
series_where = f"(({series_m3u_condition}) OR ({series_library_condition}))"
|
||||
|
||||
params = []
|
||||
|
||||
if search:
|
||||
where_conditions[0] += " AND LOWER(movies.name) LIKE %s"
|
||||
where_conditions[1] += " AND LOWER(series.name) LIKE %s"
|
||||
movie_where += " AND LOWER(movies.name) LIKE %s"
|
||||
series_where += " AND LOWER(series.name) LIKE %s"
|
||||
search_param = f"%{search.lower()}%"
|
||||
params.extend([search_param, search_param])
|
||||
|
||||
|
|
@ -531,16 +802,28 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
if '|' in category:
|
||||
cat_name, cat_type = category.split('|', 1)
|
||||
if cat_type == 'movie':
|
||||
where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
|
||||
where_conditions[1] = "1=0" # Exclude series
|
||||
movie_where += (
|
||||
" AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr "
|
||||
"JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
|
||||
)
|
||||
series_where = "1=0" # Exclude series
|
||||
params.append(cat_name)
|
||||
elif cat_type == 'series':
|
||||
where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
|
||||
where_conditions[0] = "1=0" # Exclude movies
|
||||
series_where += (
|
||||
" AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr "
|
||||
"JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
|
||||
)
|
||||
movie_where = "1=0" # Exclude movies
|
||||
params.append(cat_name)
|
||||
else:
|
||||
where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
|
||||
where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
|
||||
movie_where += (
|
||||
" AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr "
|
||||
"JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
|
||||
)
|
||||
series_where += (
|
||||
" AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr "
|
||||
"JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
|
||||
)
|
||||
params.extend([category, category])
|
||||
|
||||
# Use UNION ALL with ORDER BY and LIMIT/OFFSET for true unified pagination
|
||||
|
|
@ -565,7 +848,7 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
'movie' as content_type
|
||||
FROM vod_movie movies
|
||||
LEFT JOIN dispatcharr_channels_logo logo ON movies.logo_id = logo.id
|
||||
WHERE {where_conditions[0]}
|
||||
WHERE {movie_where}
|
||||
|
||||
UNION ALL
|
||||
|
||||
|
|
@ -587,7 +870,7 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
'series' as content_type
|
||||
FROM vod_series series
|
||||
LEFT JOIN dispatcharr_channels_logo logo ON series.logo_id = logo.id
|
||||
WHERE {where_conditions[1]}
|
||||
WHERE {series_where}
|
||||
)
|
||||
SELECT * FROM unified_content
|
||||
ORDER BY LOWER(name), id
|
||||
|
|
@ -606,18 +889,42 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
for row in cursor.fetchall():
|
||||
item_dict = dict(zip(columns, row))
|
||||
|
||||
custom_props = item_dict.get('custom_properties') or {}
|
||||
if isinstance(custom_props, str):
|
||||
try:
|
||||
custom_props = json.loads(custom_props)
|
||||
except json.JSONDecodeError:
|
||||
custom_props = {}
|
||||
|
||||
# Build logo object in the format expected by frontend
|
||||
logo_data = None
|
||||
poster_candidate = None
|
||||
if item_dict['logo_id']:
|
||||
cache_url = build_logo_cache_url(request, item_dict['logo_id'])
|
||||
poster_candidate = cache_url or item_dict.get('logo_url')
|
||||
logo_data = {
|
||||
'id': item_dict['logo_id'],
|
||||
'name': item_dict['logo_name'],
|
||||
'url': item_dict['logo_url'],
|
||||
'cache_url': f"/media/logo_cache/{item_dict['logo_id']}.png" if item_dict['logo_id'] else None,
|
||||
'channel_count': 0, # We don't need this for VOD
|
||||
'cache_url': cache_url,
|
||||
'channel_count': 0,
|
||||
'is_used': True,
|
||||
'channel_names': [] # We don't need this for VOD
|
||||
'channel_names': []
|
||||
}
|
||||
if not poster_candidate:
|
||||
poster_candidate = custom_props.get('poster_url') or custom_props.get('cover')
|
||||
|
||||
backdrop_values = []
|
||||
if isinstance(custom_props.get('backdrop_path'), list):
|
||||
backdrop_values = custom_props['backdrop_path']
|
||||
elif custom_props.get('backdrop_url'):
|
||||
backdrop_values = [custom_props['backdrop_url']]
|
||||
|
||||
rating_value = item_dict['rating']
|
||||
try:
|
||||
rating_parsed = float(rating_value) if rating_value is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
rating_parsed = rating_value
|
||||
|
||||
# Convert to the format expected by frontend
|
||||
formatted_item = {
|
||||
|
|
@ -626,13 +933,17 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
'name': item_dict['name'],
|
||||
'description': item_dict['description'] or '',
|
||||
'year': item_dict['year'],
|
||||
'rating': float(item_dict['rating']) if item_dict['rating'] else 0.0,
|
||||
'rating': rating_parsed,
|
||||
'genre': item_dict['genre'] or '',
|
||||
'duration': item_dict['duration'],
|
||||
'duration_secs': item_dict['duration'],
|
||||
'created_at': item_dict['created_at'].isoformat() if item_dict['created_at'] else None,
|
||||
'updated_at': item_dict['updated_at'].isoformat() if item_dict['updated_at'] else None,
|
||||
'custom_properties': item_dict['custom_properties'] or {},
|
||||
'custom_properties': custom_props,
|
||||
'logo': logo_data,
|
||||
'movie_image': poster_candidate if item_dict['content_type'] == 'movie' else None,
|
||||
'series_image': poster_candidate if item_dict['content_type'] == 'series' else None,
|
||||
'backdrop_path': backdrop_values,
|
||||
'content_type': item_dict['content_type']
|
||||
}
|
||||
results.append(formatted_item)
|
||||
|
|
@ -643,9 +954,9 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
# Use a separate efficient count query
|
||||
count_sql = f"""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM vod_movie movies WHERE {where_conditions[0]}
|
||||
SELECT 1 FROM vod_movie movies WHERE {movie_where}
|
||||
UNION ALL
|
||||
SELECT 1 FROM vod_series series WHERE {where_conditions[1]}
|
||||
SELECT 1 FROM vod_series series WHERE {series_where}
|
||||
) as total_count
|
||||
"""
|
||||
|
||||
|
|
@ -668,4 +979,4 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
logger.error(f"Error in UnifiedContentViewSet.list(): {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return Response({'error': str(e)}, status=500)
|
||||
return Response({'error': str(e)}, status=500)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import PluginsPage from './pages/Plugins';
|
|||
import Users from './pages/Users';
|
||||
import LogosPage from './pages/Logos';
|
||||
import VODsPage from './pages/VODs';
|
||||
import LibraryPage from './pages/Library';
|
||||
import useAuthStore from './store/auth';
|
||||
import useLogosStore from './store/logos';
|
||||
import FloatingVideo from './components/FloatingVideo';
|
||||
|
|
@ -147,6 +148,8 @@ const App = () => {
|
|||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/logos" element={<LogosPage />} />
|
||||
<Route path="/vods" element={<VODsPage />} />
|
||||
<Route path="/library" element={<Navigate to="/library/movies" replace />} />
|
||||
<Route path="/library/:mediaType" element={<LibraryPage />} />
|
||||
</>
|
||||
) : (
|
||||
<Route path="/login" element={<Login needsSuperuser />} />
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
frontend/src/assets/tmdb-logo-blue.svg
Normal file
1
frontend/src/assets/tmdb-logo-blue.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 273.42 35.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="17.76" x2="273.42" y2="17.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 3</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M191.85,35.37h63.9A17.67,17.67,0,0,0,273.42,17.7h0A17.67,17.67,0,0,0,255.75,0h-63.9A17.67,17.67,0,0,0,174.18,17.7h0A17.67,17.67,0,0,0,191.85,35.37ZM10.1,35.42h7.8V6.92H28V0H0v6.9H10.1Zm28.1,0H46V8.25h.1L55.05,35.4h6L70.3,8.25h.1V35.4h7.8V0H66.45l-8.2,23.1h-.1L50,0H38.2ZM89.14.12h11.7a33.56,33.56,0,0,1,8.08,1,18.52,18.52,0,0,1,6.67,3.08,15.09,15.09,0,0,1,4.53,5.52,18.5,18.5,0,0,1,1.67,8.25,16.91,16.91,0,0,1-1.62,7.58,16.3,16.3,0,0,1-4.38,5.5,19.24,19.24,0,0,1-6.35,3.37,24.53,24.53,0,0,1-7.55,1.15H89.14Zm7.8,28.2h4a21.66,21.66,0,0,0,5-.55A10.58,10.58,0,0,0,110,26a8.73,8.73,0,0,0,2.68-3.35,11.9,11.9,0,0,0,1-5.08,9.87,9.87,0,0,0-1-4.52,9.17,9.17,0,0,0-2.63-3.18A11.61,11.61,0,0,0,106.22,8a17.06,17.06,0,0,0-4.68-.63h-4.6ZM133.09.12h13.2a32.87,32.87,0,0,1,4.63.33,12.66,12.66,0,0,1,4.17,1.3,7.94,7.94,0,0,1,3,2.72,8.34,8.34,0,0,1,1.15,4.65,7.48,7.48,0,0,1-1.67,5,9.13,9.13,0,0,1-4.43,2.82V17a10.28,10.28,0,0,1,3.18,1,8.51,8.51,0,0,1,2.45,1.85,7.79,7.79,0,0,1,1.57,2.62,9.16,9.16,0,0,1,.55,3.2,8.52,8.52,0,0,1-1.2,4.68,9.32,9.32,0,0,1-3.1,3A13.38,13.38,0,0,1,152.32,35a22.5,22.5,0,0,1-4.73.5h-14.5Zm7.8,14.15h5.65a7.65,7.65,0,0,0,1.78-.2,4.78,4.78,0,0,0,1.57-.65,3.43,3.43,0,0,0,1.13-1.2,3.63,3.63,0,0,0,.42-1.8A3.3,3.3,0,0,0,151,8.6a3.42,3.42,0,0,0-1.23-1.13A6.07,6.07,0,0,0,148,6.9a9.9,9.9,0,0,0-1.85-.18h-5.3Zm0,14.65h7a8.27,8.27,0,0,0,1.83-.2,4.67,4.67,0,0,0,1.67-.7,3.93,3.93,0,0,0,1.23-1.3,3.8,3.8,0,0,0,.47-1.95,3.16,3.16,0,0,0-.62-2,4,4,0,0,0-1.58-1.18,8.23,8.23,0,0,0-2-.55,15.12,15.12,0,0,0-2.05-.15h-5.9Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
File diff suppressed because it is too large
Load diff
|
|
@ -21,6 +21,7 @@ import { Play } from 'lucide-react';
|
|||
import useVODStore from '../store/useVODStore';
|
||||
import useVideoStore from '../store/useVideoStore';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import API from '../api';
|
||||
|
||||
const imdbUrl = (imdb_id) =>
|
||||
imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
|
||||
|
|
@ -35,9 +36,17 @@ const formatDuration = (seconds) => {
|
|||
};
|
||||
|
||||
const formatStreamLabel = (relation) => {
|
||||
if (relation?.provider_type === 'library' || relation?.type === 'library') {
|
||||
const libraryName =
|
||||
relation?.library?.name ||
|
||||
relation?.m3u_account?.name ||
|
||||
'Library';
|
||||
return `${libraryName} - Local Library`;
|
||||
}
|
||||
|
||||
// Create a label for the stream that includes provider name and stream-specific info
|
||||
const provider = relation.m3u_account.name;
|
||||
const streamId = relation.stream_id;
|
||||
const provider = relation?.m3u_account?.name || 'Provider';
|
||||
const streamId = relation?.stream_id;
|
||||
|
||||
// Try to extract quality info - prioritizing the new quality_info field from backend
|
||||
let qualityInfo = '';
|
||||
|
|
@ -241,15 +250,76 @@ const SeriesModal = ({ series, opened, onClose }) => {
|
|||
}
|
||||
}, [opened]);
|
||||
|
||||
const handlePlayEpisode = (episode) => {
|
||||
const resolveLibraryEpisodeMediaItemId = (episode) => {
|
||||
if (episode?.library_media_item_ids?.length) {
|
||||
return episode.library_media_item_ids[0];
|
||||
}
|
||||
if (episode?.providers) {
|
||||
const libraryProvider = episode.providers.find(
|
||||
(relation) =>
|
||||
relation?.provider_type === 'library' || relation?.type === 'library'
|
||||
);
|
||||
if (libraryProvider?.library_item_id) {
|
||||
return libraryProvider.library_item_id;
|
||||
}
|
||||
if (libraryProvider?.custom_properties?.media_item_id) {
|
||||
return libraryProvider.custom_properties.media_item_id;
|
||||
}
|
||||
}
|
||||
if (selectedProvider?.provider_type === 'library' && selectedProvider.library_item_id) {
|
||||
return selectedProvider.library_item_id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handlePlayEpisode = async (episode) => {
|
||||
let streamUrl = `/proxy/vod/episode/${episode.uuid}`;
|
||||
|
||||
if (
|
||||
selectedProvider?.provider_type === 'library' ||
|
||||
selectedProvider?.type === 'library'
|
||||
) {
|
||||
const mediaItemId = resolveLibraryEpisodeMediaItemId(episode);
|
||||
if (!mediaItemId) {
|
||||
console.warn('No library episode media item available for playback');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const streamInfo = await API.streamMediaItem(mediaItemId);
|
||||
if (!streamInfo?.url) {
|
||||
console.error('Library episode stream did not return a URL');
|
||||
return;
|
||||
}
|
||||
const playbackMeta = {
|
||||
...episode,
|
||||
provider_type: 'library',
|
||||
library_media_item_id: mediaItemId,
|
||||
mediaItemId,
|
||||
fileId: streamInfo?.file_id,
|
||||
resumePositionMs: 0,
|
||||
resumeHandledByServer: Boolean(streamInfo?.start_offset_ms),
|
||||
startOffsetMs: streamInfo?.start_offset_ms ?? 0,
|
||||
requiresTranscode: Boolean(streamInfo?.requires_transcode),
|
||||
transcodeStatus: streamInfo?.transcode_status ?? null,
|
||||
durationMs:
|
||||
streamInfo?.duration_ms ??
|
||||
episode?.runtime_ms ??
|
||||
episode?.files?.[0]?.duration_ms ??
|
||||
null,
|
||||
};
|
||||
showVideo(streamInfo.url, 'library', playbackMeta);
|
||||
} catch (error) {
|
||||
console.error('Failed to start library episode stream:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add selected provider as query parameter if available
|
||||
if (selectedProvider) {
|
||||
// Use stream_id for most specific selection, fallback to account_id
|
||||
if (selectedProvider.stream_id) {
|
||||
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
|
||||
} else {
|
||||
} else if (selectedProvider?.m3u_account?.id) {
|
||||
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -279,6 +349,12 @@ const SeriesModal = ({ series, opened, onClose }) => {
|
|||
|
||||
// Use detailed data if available, otherwise use basic series data
|
||||
const displaySeries = detailedSeries || series;
|
||||
const seriesPoster =
|
||||
displaySeries?.series_image ||
|
||||
displaySeries?.logo?.cache_url ||
|
||||
displaySeries?.logo?.url ||
|
||||
displaySeries?.custom_properties?.poster_url ||
|
||||
null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -341,10 +417,10 @@ const SeriesModal = ({ series, opened, onClose }) => {
|
|||
|
||||
{/* Series poster and basic info */}
|
||||
<Flex gap="md">
|
||||
{displaySeries.series_image || displaySeries.logo?.url ? (
|
||||
{seriesPoster ? (
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<Image
|
||||
src={displaySeries.series_image || displaySeries.logo.url}
|
||||
src={seriesPoster}
|
||||
width={200}
|
||||
height={300}
|
||||
alt={displaySeries.name}
|
||||
|
|
@ -498,16 +574,19 @@ const SeriesModal = ({ series, opened, onClose }) => {
|
|||
</Text>
|
||||
{providers.length === 0 &&
|
||||
!loadingProviders &&
|
||||
displaySeries.m3u_account ? (
|
||||
(displaySeries.m3u_account ||
|
||||
(displaySeries.library_sources?.length ?? 0) > 0) ? (
|
||||
<Group spacing="md">
|
||||
<Badge color="blue" variant="light">
|
||||
{displaySeries.m3u_account.name}
|
||||
{displaySeries.m3u_account?.name ||
|
||||
displaySeries.library_sources?.[0]?.library_name ||
|
||||
'Library'}
|
||||
</Badge>
|
||||
</Group>
|
||||
) : providers.length === 1 ? (
|
||||
<Group spacing="md">
|
||||
<Badge color="blue" variant="light">
|
||||
{providers[0].m3u_account.name}
|
||||
{formatStreamLabel(providers[0])}
|
||||
</Badge>
|
||||
{providers[0].stream_id && (
|
||||
<Badge color="orange" variant="outline" size="xs">
|
||||
|
|
@ -640,10 +719,16 @@ const SeriesModal = ({ series, opened, onClose }) => {
|
|||
{/* Episode Image and Description Row */}
|
||||
<Flex gap="md">
|
||||
{/* Episode Image */}
|
||||
{episode.movie_image && (
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<Image
|
||||
src={episode.movie_image}
|
||||
{(() => {
|
||||
const episodeImage =
|
||||
episode.movie_image || seriesPoster;
|
||||
if (!episodeImage) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<Image
|
||||
src={episodeImage}
|
||||
width={120}
|
||||
height={160}
|
||||
alt={episode.name}
|
||||
|
|
@ -651,7 +736,8 @@ const SeriesModal = ({ series, opened, onClose }) => {
|
|||
style={{ borderRadius: '4px' }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Episode Description */}
|
||||
<Box style={{ flex: 1 }}>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import React, { useRef, useEffect, useState } from 'react';
|
|||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { copyToClipboard } from '../utils';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ListOrdered,
|
||||
Play,
|
||||
Database,
|
||||
SlidersHorizontal,
|
||||
LayoutGrid,
|
||||
Settings as LucideSettings,
|
||||
Copy,
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
LogOut,
|
||||
User,
|
||||
FileImage,
|
||||
Library,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar,
|
||||
|
|
@ -90,6 +92,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
const closeUserForm = () => setUserFormOpen(false);
|
||||
|
||||
// Navigation Items
|
||||
const mediaLibraryNav = {
|
||||
label: 'Media Library',
|
||||
icon: <Library size={20} />,
|
||||
children: [
|
||||
{ label: 'Movies', path: '/library/movies' },
|
||||
{ label: 'TV Shows', path: '/library/shows' },
|
||||
],
|
||||
};
|
||||
|
||||
const navItems =
|
||||
authUser && authUser.user_level == USER_LEVELS.ADMIN
|
||||
? [
|
||||
|
|
@ -104,6 +115,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
path: '/vods',
|
||||
icon: <Video size={20} />,
|
||||
},
|
||||
...(isAuthenticated ? [mediaLibraryNav] : []),
|
||||
{
|
||||
label: 'M3U & EPG Manager',
|
||||
icon: <Play size={20} />,
|
||||
|
|
@ -137,6 +149,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
badge: `(${Object.keys(channels).length})`,
|
||||
},
|
||||
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
|
||||
...(isAuthenticated ? [mediaLibraryNav] : []),
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: <LucideSettings size={20} />,
|
||||
|
|
@ -144,6 +157,16 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
},
|
||||
];
|
||||
|
||||
const [libraryExpanded, setLibraryExpanded] = useState(
|
||||
location.pathname.startsWith('/library')
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith('/library')) {
|
||||
setLibraryExpanded(true);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
// Fetch environment settings including version on component mount
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
|
|
@ -244,7 +267,64 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
{/* Navigation Links */}
|
||||
<Stack gap="xs" mt="lg">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
const isActive = item.path
|
||||
? location.pathname.startsWith(item.path)
|
||||
: false;
|
||||
|
||||
if (item.children) {
|
||||
const childActive = item.children.some((child) =>
|
||||
location.pathname.startsWith(child.path)
|
||||
);
|
||||
const expanded = libraryExpanded || childActive;
|
||||
return (
|
||||
<Stack key={item.label} spacing={4}>
|
||||
<UnstyledButton
|
||||
className={`navlink ${childActive ? 'navlink-active' : ''} ${collapsed ? 'navlink-collapsed' : ''}`}
|
||||
onClick={() => setLibraryExpanded((prev) => !prev)}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && (
|
||||
<Group justify="space-between" w="100%" gap={0}>
|
||||
<Text
|
||||
sx={{
|
||||
opacity: collapsed ? 0 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: collapsed ? 0 : 150,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</Group>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
{expanded && !collapsed && (
|
||||
<Stack spacing={2} ml={32}>
|
||||
{item.children.map((child) => {
|
||||
const active = location.pathname.startsWith(child.path);
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={child.path}
|
||||
component={Link}
|
||||
to={child.path}
|
||||
className={`navlink ${active ? 'navlink-active' : ''}`}
|
||||
style={{
|
||||
paddingLeft: 18,
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
<Text size="sm">{child.label}</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { Play } from 'lucide-react';
|
|||
import useVODStore from '../store/useVODStore';
|
||||
import useVideoStore from '../store/useVideoStore';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import API from '../api';
|
||||
|
||||
const imdbUrl = (imdb_id) =>
|
||||
imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
|
||||
|
|
@ -31,9 +32,17 @@ const formatDuration = (seconds) => {
|
|||
};
|
||||
|
||||
const formatStreamLabel = (relation) => {
|
||||
if (relation?.provider_type === 'library' || relation?.type === 'library') {
|
||||
const libraryName =
|
||||
relation?.library?.name ||
|
||||
relation?.m3u_account?.name ||
|
||||
'Library';
|
||||
return `${libraryName} - Local Library`;
|
||||
}
|
||||
|
||||
// Create a label for the stream that includes provider name and stream-specific info
|
||||
const provider = relation.m3u_account.name;
|
||||
const streamId = relation.stream_id;
|
||||
const provider = relation?.m3u_account?.name || 'Provider';
|
||||
const streamId = relation?.stream_id;
|
||||
|
||||
// Try to extract quality info - prioritizing the new quality_info field from backend
|
||||
let qualityInfo = '';
|
||||
|
|
@ -232,10 +241,63 @@ const VODModal = ({ vod, opened, onClose }) => {
|
|||
}
|
||||
}, [opened]);
|
||||
|
||||
const handlePlayVOD = () => {
|
||||
const resolveLibraryMediaItemId = () => {
|
||||
if (selectedProvider?.library_item_id) return selectedProvider.library_item_id;
|
||||
if (selectedProvider?.custom_properties?.media_item_id) {
|
||||
return selectedProvider.custom_properties.media_item_id;
|
||||
}
|
||||
const fallback = detailedVOD?.library_sources || vod?.library_sources || [];
|
||||
if (fallback.length > 0) {
|
||||
return fallback[0].media_item_id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handlePlayVOD = async () => {
|
||||
const vodToPlay = detailedVOD || vod;
|
||||
if (!vodToPlay) return;
|
||||
|
||||
if (
|
||||
selectedProvider?.provider_type === 'library' ||
|
||||
selectedProvider?.type === 'library' ||
|
||||
(vodToPlay.library_sources && vodToPlay.library_sources.length > 0)
|
||||
) {
|
||||
const mediaItemId = resolveLibraryMediaItemId();
|
||||
if (!mediaItemId) {
|
||||
console.warn('No library media item available for playback');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const streamInfo = await API.streamMediaItem(mediaItemId);
|
||||
if (!streamInfo?.url) {
|
||||
console.error('Library stream did not return a URL');
|
||||
return;
|
||||
}
|
||||
const playbackMeta = {
|
||||
...vodToPlay,
|
||||
provider_type: 'library',
|
||||
library_media_item_id: mediaItemId,
|
||||
mediaItemId,
|
||||
fileId: streamInfo?.file_id,
|
||||
resumePositionMs: 0,
|
||||
resumeHandledByServer: Boolean(streamInfo?.start_offset_ms),
|
||||
startOffsetMs: streamInfo?.start_offset_ms ?? 0,
|
||||
requiresTranscode: Boolean(streamInfo?.requires_transcode),
|
||||
transcodeStatus: streamInfo?.transcode_status ?? null,
|
||||
durationMs:
|
||||
streamInfo?.duration_ms ??
|
||||
vodToPlay?.runtime_ms ??
|
||||
vodToPlay?.files?.[0]?.duration_ms ??
|
||||
null,
|
||||
};
|
||||
showVideo(streamInfo.url, 'library', playbackMeta);
|
||||
} catch (error) {
|
||||
console.error('Failed to start library stream:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
|
||||
|
||||
// Add selected provider as query parameter if available
|
||||
|
|
@ -243,7 +305,7 @@ const VODModal = ({ vod, opened, onClose }) => {
|
|||
// Use stream_id for most specific selection, fallback to account_id
|
||||
if (selectedProvider.stream_id) {
|
||||
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
|
||||
} else {
|
||||
} else if (selectedProvider?.m3u_account?.id) {
|
||||
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -269,6 +331,12 @@ const VODModal = ({ vod, opened, onClose }) => {
|
|||
|
||||
// Use detailed data if available, otherwise use basic vod data
|
||||
const displayVOD = detailedVOD || vod;
|
||||
const posterSrc =
|
||||
displayVOD?.movie_image ||
|
||||
displayVOD?.logo?.cache_url ||
|
||||
displayVOD?.logo?.url ||
|
||||
displayVOD?.custom_properties?.poster_url ||
|
||||
null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -330,10 +398,10 @@ const VODModal = ({ vod, opened, onClose }) => {
|
|||
{/* Movie poster and basic info */}
|
||||
<Flex gap="md">
|
||||
{/* Use movie_image or logo */}
|
||||
{displayVOD.movie_image || displayVOD.logo?.url ? (
|
||||
{posterSrc ? (
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<Image
|
||||
src={displayVOD.movie_image || displayVOD.logo.url}
|
||||
src={posterSrc}
|
||||
width={200}
|
||||
height={300}
|
||||
alt={displayVOD.name}
|
||||
|
|
@ -504,7 +572,7 @@ const VODModal = ({ vod, opened, onClose }) => {
|
|||
{providers.length === 1 ? (
|
||||
<Group spacing="md">
|
||||
<Badge color="blue" variant="light">
|
||||
{providers[0].m3u_account.name}
|
||||
{formatStreamLabel(providers[0])}
|
||||
</Badge>
|
||||
</Group>
|
||||
) : (
|
||||
|
|
@ -531,14 +599,16 @@ const VODModal = ({ vod, opened, onClose }) => {
|
|||
{/* Fallback provider info if no providers loaded yet */}
|
||||
{providers.length === 0 &&
|
||||
!loadingProviders &&
|
||||
vod?.m3u_account && (
|
||||
(vod?.m3u_account || (vod?.library_sources?.length ?? 0) > 0) && (
|
||||
<Box>
|
||||
<Text size="sm" weight={500} mb={8}>
|
||||
Stream Selection
|
||||
</Text>
|
||||
<Group spacing="md">
|
||||
<Badge color="blue" variant="light">
|
||||
{vod.m3u_account.name}
|
||||
{vod?.m3u_account?.name ||
|
||||
vod?.library_sources?.[0]?.library_name ||
|
||||
'Library'}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Box>
|
||||
|
|
|
|||
129
frontend/src/components/library/AlphabetSidebar.jsx
Normal file
129
frontend/src/components/library/AlphabetSidebar.jsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Stack, UnstyledButton, Text } from '@mantine/core';
|
||||
|
||||
const letters = ['#', 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'];
|
||||
|
||||
const HOTZONE_WIDTH = 20; // px: invisible strip you hover to reveal
|
||||
const IDLE_FADE_MS = 1500; // ms of no mouse movement before fade out
|
||||
const LETTER_GAP = 6; // must match Stack spacing
|
||||
const BASE_FONT_PX = 12; // Mantine "xs" ~ 12px by default
|
||||
const MAX_SCALE_BOOST = 0.35; // how large letters grow at the cursor
|
||||
const SIGMA_PX = 26; // controls falloff steepness
|
||||
|
||||
export default function AlphabetSidebar({
|
||||
available = new Set(),
|
||||
onSelect,
|
||||
top = 120,
|
||||
right = 16,
|
||||
}) {
|
||||
const [isHot, setIsHot] = useState(false); // pointer inside hot zone or sidebar
|
||||
const [isIdle, setIsIdle] = useState(false); // idle while still hovered
|
||||
const [mouseY, setMouseY] = useState(null); // y relative to sidebar
|
||||
const sidebarRef = useRef(null);
|
||||
const idleTimerRef = useRef(null);
|
||||
|
||||
// Reset idle timer whenever mouse moves inside the sidebar
|
||||
const poke = () => {
|
||||
setIsIdle(false);
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
idleTimerRef.current = setTimeout(() => setIsIdle(true), IDLE_FADE_MS);
|
||||
};
|
||||
|
||||
useEffect(() => () => idleTimerRef.current && clearTimeout(idleTimerRef.current), []);
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!sidebarRef.current) return;
|
||||
const rect = sidebarRef.current.getBoundingClientRect();
|
||||
setMouseY(e.clientY - rect.top);
|
||||
poke();
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHot(false);
|
||||
setIsIdle(false);
|
||||
setMouseY(null);
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
};
|
||||
|
||||
// Precompute the center Y for each letter button (approx.)
|
||||
const centers = useMemo(() => {
|
||||
// each item height ≈ font + vertical padding (~6px top/bottom) + gap
|
||||
const itemH = BASE_FONT_PX + 12 + LETTER_GAP; // tweak if needed to match your Text styles
|
||||
return letters.map((_, i) => i * itemH + itemH / 2);
|
||||
}, []);
|
||||
|
||||
// Visible when hovering the hot zone or the sidebar, unless idling.
|
||||
const visible = isHot && !isIdle;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Invisible hot zone on the far right edge */}
|
||||
<Box
|
||||
onMouseEnter={() => setIsHot(true)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top,
|
||||
right: 0,
|
||||
width: HOTZONE_WIDTH,
|
||||
height: '70vh',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Sidebar itself */}
|
||||
<Stack
|
||||
ref={sidebarRef}
|
||||
spacing={LETTER_GAP}
|
||||
align="center"
|
||||
onMouseEnter={() => setIsHot(true)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top,
|
||||
right,
|
||||
zIndex: 3,
|
||||
background: 'rgba(12, 12, 16, 0.75)',
|
||||
borderRadius: 12,
|
||||
padding: '10px 6px',
|
||||
backdropFilter: 'blur(4px)',
|
||||
// reveal/fade logic
|
||||
opacity: visible ? 1 : 0,
|
||||
pointerEvents: visible ? 'auto' : 'none', // click-through when hidden
|
||||
transform: visible ? 'translateX(0)' : 'translateX(8px)', // subtle slide
|
||||
transition: 'opacity 160ms ease, transform 160ms ease',
|
||||
}}
|
||||
>
|
||||
{letters.map((letter, idx) => {
|
||||
const isEnabled = available.has(letter);
|
||||
// proximity scale
|
||||
let scale = 1;
|
||||
if (mouseY != null) {
|
||||
const d = Math.abs(mouseY - centers[idx]);
|
||||
const boost = Math.exp(-(d * d) / (2 * SIGMA_PX * SIGMA_PX)); // 0..1
|
||||
scale = 1 + MAX_SCALE_BOOST * boost;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={letter}
|
||||
onClick={() => isEnabled && onSelect?.(letter)}
|
||||
style={{
|
||||
opacity: isEnabled ? 1 : 0.3,
|
||||
cursor: isEnabled ? 'pointer' : 'default',
|
||||
transform: `scale(${scale})`,
|
||||
transition: 'transform 80ms linear',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} lh={1}>
|
||||
{letter}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
152
frontend/src/components/library/LibraryCard.jsx
Normal file
152
frontend/src/components/library/LibraryCard.jsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Button,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
CircleDashed,
|
||||
Clock,
|
||||
Pencil,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return 'Never';
|
||||
return dayjs(value).fromNow();
|
||||
};
|
||||
|
||||
const LibraryCard = ({
|
||||
library,
|
||||
selected,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onScan,
|
||||
loadingScan = false,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
shadow={selected ? 'lg' : 'sm'}
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
onClick={() => onSelect(library.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: selected ? '#6366f1' : undefined,
|
||||
transition: 'transform 150ms ease, border-color 150ms ease',
|
||||
}}
|
||||
>
|
||||
<Stack spacing="xs">
|
||||
<Group align="center" justify="space-between">
|
||||
<Text fw={600} size="lg">
|
||||
{library.name}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color="violet" variant="light">
|
||||
{library.library_type?.replace('-', ' ') || 'Unknown'}
|
||||
</Badge>
|
||||
{library.auto_scan_enabled ? (
|
||||
<Badge color="green" variant="outline">
|
||||
Auto-scan
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="gray" variant="outline">
|
||||
Manual only
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{library.description && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{library.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Last scan">
|
||||
<Group gap={4} align="center">
|
||||
<Clock size={16} />
|
||||
<Text size="xs">{formatDate(library.last_scan_at)}</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
<Tooltip label="Last success">
|
||||
<Group gap={4} align="center">
|
||||
<CircleDashed size={16} />
|
||||
<Text size="xs">{formatDate(library.last_successful_scan_at)}</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" mt="sm">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={selected ? 'filled' : 'light'}
|
||||
leftSection={<PlayCircle size={16} />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect(library.id);
|
||||
}}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Tooltip label="Trigger scan">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="light"
|
||||
loading={loadingScan}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onScan(library.id);
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit library">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit(library);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete library">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(library);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryCard;
|
||||
438
frontend/src/components/library/LibraryFormModal.jsx
Normal file
438
frontend/src/components/library/LibraryFormModal.jsx
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Checkbox,
|
||||
Loader,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
ScrollArea,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { ArrowUp, FolderOpen, Plus, Trash2 } from 'lucide-react';
|
||||
import API from '../../api';
|
||||
|
||||
const LIBRARY_TYPES = [
|
||||
{ value: 'movies', label: 'Movies' },
|
||||
{ value: 'shows', label: 'TV Shows' },
|
||||
];
|
||||
|
||||
const defaultLocation = () => ({
|
||||
path: '',
|
||||
include_subdirectories: true,
|
||||
is_primary: false,
|
||||
});
|
||||
|
||||
const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) => {
|
||||
const editing = Boolean(library);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'controlled',
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
library_type: 'movies',
|
||||
metadata_language: 'en',
|
||||
metadata_country: 'US',
|
||||
scan_interval_minutes: 1440,
|
||||
auto_scan_enabled: true,
|
||||
use_as_vod_source: false,
|
||||
metadata_options: {},
|
||||
locations: [defaultLocation()],
|
||||
},
|
||||
});
|
||||
|
||||
const [browser, setBrowser] = useState({
|
||||
open: false,
|
||||
index: null,
|
||||
path: '',
|
||||
parent: null,
|
||||
entries: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (library) {
|
||||
form.setValues({
|
||||
name: library.name || '',
|
||||
description: library.description || '',
|
||||
library_type:
|
||||
LIBRARY_TYPES.some((option) => option.value === library.library_type)
|
||||
? library.library_type
|
||||
: 'movies',
|
||||
metadata_language: library.metadata_language || 'en',
|
||||
metadata_country: library.metadata_country || 'US',
|
||||
scan_interval_minutes: library.scan_interval_minutes || 1440,
|
||||
auto_scan_enabled: library.auto_scan_enabled ?? true,
|
||||
use_as_vod_source: library.use_as_vod_source ?? false,
|
||||
metadata_options: library.metadata_options || {},
|
||||
locations:
|
||||
library.locations?.length > 0
|
||||
? library.locations.map((loc) => ({
|
||||
id: loc.id,
|
||||
path: loc.path,
|
||||
include_subdirectories:
|
||||
loc.include_subdirectories ?? true,
|
||||
is_primary: loc.is_primary ?? false,
|
||||
}))
|
||||
: [defaultLocation()],
|
||||
});
|
||||
} else {
|
||||
form.reset();
|
||||
form.setFieldValue('locations', [defaultLocation()]);
|
||||
}
|
||||
}, [library, opened]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
closeBrowser();
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const addLocation = () => {
|
||||
form.insertListItem('locations', defaultLocation());
|
||||
};
|
||||
|
||||
const removeLocation = (index) => {
|
||||
const values = form.getValues();
|
||||
if (values.locations.length === 1) {
|
||||
form.setFieldValue('locations', [defaultLocation()]);
|
||||
return;
|
||||
}
|
||||
form.removeListItem('locations', index);
|
||||
};
|
||||
|
||||
const loadDirectory = async (targetPath) => {
|
||||
const normalizedPath = targetPath ?? '';
|
||||
setBrowser((prev) => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const response = await API.browseLibraryPath(normalizedPath);
|
||||
setBrowser((prev) => ({
|
||||
...prev,
|
||||
path: response.path ?? normalizedPath,
|
||||
parent: response.parent || null,
|
||||
entries: Array.isArray(response.entries) ? response.entries : [],
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to browse directories', error);
|
||||
setBrowser((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: 'Unable to load directories. Check permissions and try again.',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const openDirectoryBrowser = (index) => {
|
||||
const current = form.values.locations?.[index]?.path || '';
|
||||
setBrowser({
|
||||
open: true,
|
||||
index,
|
||||
path: current,
|
||||
parent: null,
|
||||
entries: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
void loadDirectory(current);
|
||||
};
|
||||
|
||||
const closeBrowser = () => {
|
||||
setBrowser({
|
||||
open: false,
|
||||
index: null,
|
||||
path: '',
|
||||
parent: null,
|
||||
entries: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectDirectory = (path) => {
|
||||
void loadDirectory(path ?? '');
|
||||
};
|
||||
|
||||
const handleUseDirectory = () => {
|
||||
if (browser.index == null) {
|
||||
closeBrowser();
|
||||
return;
|
||||
}
|
||||
const resolvedPath = browser.path || '';
|
||||
form.setFieldValue(`locations.${browser.index}.path`, resolvedPath);
|
||||
closeBrowser();
|
||||
};
|
||||
|
||||
const submit = (values) => {
|
||||
const payload = {
|
||||
...values,
|
||||
locations: values.locations.map((loc, index) => ({
|
||||
...loc,
|
||||
is_primary: loc.is_primary || index === 0,
|
||||
})),
|
||||
};
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={editing ? 'Edit Library' : 'Create Library'}
|
||||
size="lg"
|
||||
overlayProps={{ backgroundOpacity: 0.6, blur: 4 }}
|
||||
zIndex={400}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(submit)}>
|
||||
<Stack spacing="md">
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="My Movies"
|
||||
required
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="Optional description for this library"
|
||||
autosize
|
||||
minRows={2}
|
||||
{...form.getInputProps('description')}
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Library Type"
|
||||
data={LIBRARY_TYPES}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
{...form.getInputProps('library_type')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Auto-scan Interval (minutes)"
|
||||
min={15}
|
||||
step={15}
|
||||
{...form.getInputProps('scan_interval_minutes')}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Metadata Language"
|
||||
placeholder="en"
|
||||
{...form.getInputProps('metadata_language')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Metadata Country"
|
||||
placeholder="US"
|
||||
{...form.getInputProps('metadata_country')}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Switch
|
||||
label="Enable automatic scanning"
|
||||
checked={form.values.auto_scan_enabled}
|
||||
onChange={(event) =>
|
||||
form.setFieldValue('auto_scan_enabled', event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label="Use as VOD source"
|
||||
description="Automatically add matched movies and TV shows from this library to VOD."
|
||||
checked={form.values.use_as_vod_source}
|
||||
onChange={(event) =>
|
||||
form.setFieldValue('use_as_vod_source', event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack spacing="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600}>Locations</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
variant="light"
|
||||
onClick={addLocation}
|
||||
type="button"
|
||||
>
|
||||
Add Path
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.locations.map((location, index) => (
|
||||
<Stack
|
||||
key={location.id || index}
|
||||
p="sm"
|
||||
style={{
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
spacing="xs"
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" fw={500}>
|
||||
Location {index + 1}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => removeLocation(index)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Group align="flex-end" gap="sm">
|
||||
<TextInput
|
||||
placeholder="/path/to/library"
|
||||
required
|
||||
value={location.path}
|
||||
onChange={(event) =>
|
||||
form.setFieldValue(
|
||||
`locations.${index}.path`,
|
||||
event.currentTarget.value
|
||||
)
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<FolderOpen size={14} />}
|
||||
onClick={() => openDirectoryBrowser(index)}
|
||||
type="button"
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</Group>
|
||||
<Group>
|
||||
<Checkbox
|
||||
label="Include subdirectories"
|
||||
checked={location.include_subdirectories}
|
||||
onChange={(event) =>
|
||||
form.setFieldValue(
|
||||
`locations.${index}.include_subdirectories`,
|
||||
event.currentTarget.checked
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Primary"
|
||||
checked={location.is_primary}
|
||||
onChange={(event) =>
|
||||
form.setFieldValue(
|
||||
`locations.${index}.is_primary`,
|
||||
event.currentTarget.checked
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={submitting}>
|
||||
{editing ? 'Save changes' : 'Create library'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal
|
||||
opened={browser.open}
|
||||
onClose={closeBrowser}
|
||||
title="Select library directory"
|
||||
size="lg"
|
||||
overlayProps={{ backgroundOpacity: 0.6, blur: 4 }}
|
||||
zIndex={410}
|
||||
>
|
||||
<Stack spacing="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{browser.path || '/'}
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<ArrowUp size={14} />}
|
||||
onClick={() => handleSelectDirectory(browser.parent)}
|
||||
disabled={!browser.parent || browser.loading}
|
||||
type="button"
|
||||
>
|
||||
Up one level
|
||||
</Button>
|
||||
</Group>
|
||||
{browser.error && (
|
||||
<Text size="sm" c="red">
|
||||
{browser.error}
|
||||
</Text>
|
||||
)}
|
||||
<ScrollArea h={260} offsetScrollbars>
|
||||
{browser.loading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : browser.entries.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
No subdirectories found.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack spacing="xs">
|
||||
{browser.entries.map((entry) => (
|
||||
<Button
|
||||
key={entry.path}
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
justify="space-between"
|
||||
onClick={() => handleSelectDirectory(entry.path)}
|
||||
type="button"
|
||||
>
|
||||
<span>{entry.name || entry.path}</span>
|
||||
<Text size="xs" c="dimmed">
|
||||
{entry.path}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => void loadDirectory(browser.path)}
|
||||
loading={browser.loading}
|
||||
type="button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
<Button variant="subtle" onClick={closeBrowser} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUseDirectory} type="button">
|
||||
Use this folder
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryFormModal;
|
||||
487
frontend/src/components/library/LibraryScanDrawer.jsx
Normal file
487
frontend/src/components/library/LibraryScanDrawer.jsx
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { Ban, Play, RefreshCcw, Trash2, ScanSearch } from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import useLibraryStore from '../../store/library';
|
||||
import useMediaLibraryStore from '../../store/mediaLibrary';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const EMPTY_SCAN_LIST = [];
|
||||
|
||||
const statusColor = {
|
||||
pending: 'gray',
|
||||
queued: 'gray',
|
||||
scheduled: 'gray',
|
||||
running: 'blue',
|
||||
started: 'blue',
|
||||
discovered: 'indigo',
|
||||
progress: 'blue',
|
||||
completed: 'green',
|
||||
failed: 'red',
|
||||
cancelled: 'yellow',
|
||||
};
|
||||
|
||||
const isRunning = (s) =>
|
||||
s === 'running' || s === 'started' || s === 'progress' || s === 'discovered';
|
||||
|
||||
const isQueued = (s) => s === 'pending' || s === 'queued' || s === 'scheduled';
|
||||
|
||||
const stageStatusLabel = {
|
||||
pending: 'Waiting',
|
||||
running: 'In progress',
|
||||
completed: 'Completed',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
|
||||
const stageOrder = [
|
||||
{ key: 'discovery', label: 'File scan' },
|
||||
{ key: 'metadata', label: 'Metadata fetch' },
|
||||
{ key: 'artwork', label: 'Artwork' },
|
||||
];
|
||||
|
||||
const EMPTY_STAGE = {
|
||||
status: 'pending',
|
||||
processed: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const stageColorMap = {
|
||||
discovery: 'blue',
|
||||
metadata: 'green',
|
||||
artwork: 'red',
|
||||
};
|
||||
|
||||
const PROGRESS_REFRESH_DELTA = 25;
|
||||
const PROGRESS_REFRESH_INTERVAL_MS = 15000;
|
||||
|
||||
const LibraryScanDrawer = ({
|
||||
opened,
|
||||
onClose,
|
||||
libraryId,
|
||||
// Optional actions provided by parent (no-ops by default)
|
||||
onCancelJob = async () => {},
|
||||
onDeleteQueuedJob = async () => {},
|
||||
onStartScan = null, // () => void
|
||||
onStartFullScan = null, // () => void
|
||||
}) => {
|
||||
const scansLoading = useLibraryStore((s) => s.scansLoading);
|
||||
const scans =
|
||||
useLibraryStore((s) => s.scans[libraryId || 'all']) ?? EMPTY_SCAN_LIST;
|
||||
const fetchScans = useLibraryStore((s) => s.fetchScans);
|
||||
const purgeCompletedScans = useLibraryStore((s) => s.purgeCompletedScans);
|
||||
const [loaderHold, setLoaderHold] = useState(false);
|
||||
const [purgeLoading, setPurgeLoading] = useState(false);
|
||||
const hasRunningRef = useRef(false);
|
||||
const hasQueuedRef = useRef(false);
|
||||
const lastProcessedRef = useRef(0);
|
||||
const lastLibraryRefreshRef = useRef(0);
|
||||
const refreshInFlightRef = useRef(false);
|
||||
|
||||
const handleRefresh = useCallback(
|
||||
() => fetchScans(libraryId),
|
||||
[fetchScans, libraryId]
|
||||
);
|
||||
const hasRunningScan = useMemo(
|
||||
() => scans.some((scan) => isRunning(scan.status)),
|
||||
[scans]
|
||||
);
|
||||
const hasQueuedScan = useMemo(
|
||||
() => scans.some((scan) => isQueued(scan.status)),
|
||||
[scans]
|
||||
);
|
||||
const hasFinishedScans = useMemo(
|
||||
() =>
|
||||
scans.some((scan) =>
|
||||
['completed', 'failed', 'cancelled'].includes(scan.status)
|
||||
),
|
||||
[scans]
|
||||
);
|
||||
|
||||
// Keep refs in sync for polling loop
|
||||
useEffect(() => {
|
||||
hasRunningRef.current = hasRunningScan;
|
||||
}, [hasRunningScan]);
|
||||
useEffect(() => {
|
||||
hasQueuedRef.current = hasQueuedScan;
|
||||
}, [hasQueuedScan]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
lastProcessedRef.current = 0;
|
||||
lastLibraryRefreshRef.current = 0;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timer;
|
||||
|
||||
const runFetch = async (background) => {
|
||||
try {
|
||||
await fetchScans(libraryId, { background });
|
||||
} catch (error) {
|
||||
if (!background) {
|
||||
console.error('Failed to load library scans', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loop = () => {
|
||||
if (cancelled) return;
|
||||
const delay = hasRunningRef.current
|
||||
? 2000
|
||||
: hasQueuedRef.current
|
||||
? 4000
|
||||
: 8000;
|
||||
timer = setTimeout(async () => {
|
||||
await runFetch(true);
|
||||
loop();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
void runFetch(false).then(loop);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [opened, libraryId, fetchScans]);
|
||||
|
||||
const totalProcessed = useMemo(
|
||||
() =>
|
||||
scans.reduce(
|
||||
(sum, scan) => sum + (Number(scan.processed_files) || 0),
|
||||
0
|
||||
),
|
||||
[scans]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
|
||||
if (!hasRunningScan && !hasQueuedScan) {
|
||||
lastProcessedRef.current = totalProcessed;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const prevProcessed = lastProcessedRef.current;
|
||||
const processedDelta = Math.max(0, totalProcessed - prevProcessed);
|
||||
const elapsedSinceRefresh = now - lastLibraryRefreshRef.current;
|
||||
|
||||
const shouldRefreshLibrary =
|
||||
processedDelta >= PROGRESS_REFRESH_DELTA ||
|
||||
elapsedSinceRefresh >= PROGRESS_REFRESH_INTERVAL_MS;
|
||||
|
||||
if (!shouldRefreshLibrary || refreshInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastProcessedRef.current = totalProcessed;
|
||||
lastLibraryRefreshRef.current = now;
|
||||
refreshInFlightRef.current = true;
|
||||
|
||||
const mediaStore = useMediaLibraryStore.getState();
|
||||
const activeIds = mediaStore.activeLibraryIds || [];
|
||||
|
||||
void mediaStore
|
||||
.fetchItems(activeIds.length > 0 ? activeIds : undefined)
|
||||
.finally(() => {
|
||||
refreshInFlightRef.current = false;
|
||||
});
|
||||
}, [opened, hasRunningScan, hasQueuedScan, totalProcessed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setLoaderHold(false);
|
||||
return undefined;
|
||||
}
|
||||
if (scansLoading) {
|
||||
setLoaderHold(true);
|
||||
const timeout = setTimeout(() => setLoaderHold(false), 800);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
setLoaderHold(false);
|
||||
return undefined;
|
||||
}, [opened, scansLoading]);
|
||||
|
||||
const isInitialLoading =
|
||||
scans.length === 0 && (scansLoading || loaderHold);
|
||||
|
||||
const getStagePercent = (stage) => {
|
||||
if (!stage) return 0;
|
||||
const processed = Math.max(0, Number(stage.processed) || 0);
|
||||
let total = Math.max(0, Number(stage.total) || 0);
|
||||
|
||||
if (!total || total <= processed) {
|
||||
if (stage.status === 'completed') {
|
||||
total = processed || 1;
|
||||
} else {
|
||||
total = processed + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!total) return 0;
|
||||
return Math.min(100, Math.round((processed / total) * 100));
|
||||
};
|
||||
|
||||
const formatStageCount = (stage, stageKey) => {
|
||||
if (!stage) return '0';
|
||||
const processed = stage.processed ?? 0;
|
||||
const total = stage.total ?? 0;
|
||||
|
||||
if (stage.status === 'skipped') {
|
||||
return 'Not required';
|
||||
}
|
||||
if (total > 0 && total >= processed) {
|
||||
return `${processed} / ${total}`;
|
||||
}
|
||||
if (stage.status === 'completed' && processed === 0) {
|
||||
return 'Done';
|
||||
}
|
||||
|
||||
const suffix =
|
||||
stageKey === 'discovery'
|
||||
? 'files scanned'
|
||||
: stageKey === 'metadata'
|
||||
? 'metadata items'
|
||||
: 'artwork assets';
|
||||
|
||||
if (processed === 0) {
|
||||
return 'Waiting…';
|
||||
}
|
||||
|
||||
return `${processed} ${suffix}`;
|
||||
};
|
||||
|
||||
const handleClearFinished = useCallback(async () => {
|
||||
if (!hasFinishedScans) {
|
||||
return;
|
||||
}
|
||||
setPurgeLoading(true);
|
||||
try {
|
||||
await purgeCompletedScans({
|
||||
library: libraryId ?? undefined,
|
||||
});
|
||||
await fetchScans(libraryId, { background: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to clear library scans', error);
|
||||
} finally {
|
||||
setPurgeLoading(false);
|
||||
}
|
||||
}, [hasFinishedScans, purgeCompletedScans, fetchScans, libraryId]);
|
||||
|
||||
const header = useMemo(
|
||||
() => (
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<Group gap="xs" align="center">
|
||||
<ScanSearch size={18} />
|
||||
<Title order={5} style={{ lineHeight: 1 }}>Library scans</Title>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
{onStartScan && (
|
||||
<Tooltip label="Start quick scan">
|
||||
<ActionIcon variant="light" onClick={onStartScan}>
|
||||
<Play size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onStartFullScan && (
|
||||
<Button variant="light" size="xs" onClick={onStartFullScan}>
|
||||
Full scan
|
||||
</Button>
|
||||
)}
|
||||
{hasFinishedScans && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={handleClearFinished}
|
||||
loading={purgeLoading}
|
||||
>
|
||||
Clear finished
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip label="Refresh">
|
||||
<ActionIcon variant="light" onClick={handleRefresh}>
|
||||
<RefreshCcw size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
),
|
||||
[onStartScan, onStartFullScan, handleRefresh, hasFinishedScans, purgeLoading, handleClearFinished]
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
position="right"
|
||||
size="md"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 6 }}
|
||||
withCloseButton
|
||||
title={header}
|
||||
>
|
||||
<ScrollArea style={{ height: '100%' }}>
|
||||
{isInitialLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Text c="dimmed">Loading scans…</Text>
|
||||
</Group>
|
||||
) : scans.length === 0 ? (
|
||||
<Stack align="center" py="lg" gap={4}>
|
||||
<Text c="dimmed">No scans recorded yet.</Text>
|
||||
{onStartScan && (
|
||||
<Button size="xs" onClick={onStartScan} mt="xs">
|
||||
Start a scan
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="sm" py="xs">
|
||||
{scans.map((scan) => {
|
||||
const status = scan.status || 'pending';
|
||||
return (
|
||||
<Card key={scan.id} withBorder shadow="sm" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Badge color={statusColor[status] || 'gray'} variant="light">
|
||||
{status}
|
||||
</Badge>
|
||||
<Text size="sm" fw={600}>
|
||||
{scan.summary || 'Scan'}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{dayjs(scan.created_at).format('MMM D, YYYY HH:mm')}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
{isRunning(status) && (
|
||||
<Tooltip label="Cancel running scan">
|
||||
<ActionIcon
|
||||
color="yellow"
|
||||
variant="light"
|
||||
onClick={() => onCancelJob(scan.id)}
|
||||
>
|
||||
<Ban size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isQueued(status) && (
|
||||
<Tooltip label="Remove from queue">
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={() => onDeleteQueuedJob(scan.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="sm">
|
||||
{stageOrder.map(({ key, label }) => {
|
||||
const stage = scan.stages?.[key] || EMPTY_STAGE;
|
||||
const stageStatus = stage.status || 'pending';
|
||||
const percent = getStagePercent(stage);
|
||||
const progressColor = stageColorMap[key] || 'gray';
|
||||
const badgeColor =
|
||||
stageStatus === 'completed' || stageStatus === 'running'
|
||||
? progressColor
|
||||
: 'gray';
|
||||
const animated = stageStatus === 'running';
|
||||
const percentDisplay =
|
||||
stageStatus === 'completed'
|
||||
? '100%'
|
||||
: stageStatus === 'skipped'
|
||||
? null
|
||||
: `${percent}%`;
|
||||
return (
|
||||
<Stack gap={4} key={`${scan.id}-${key}`}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="xs" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
<Badge color={badgeColor} variant="light" size="xs">
|
||||
{stageStatusLabel[stageStatus] || stageStatus}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatStageCount(stage, key)}
|
||||
</Text>
|
||||
{percentDisplay && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{percentDisplay}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Progress
|
||||
value={percent}
|
||||
size="sm"
|
||||
striped={animated}
|
||||
animated={animated}
|
||||
color={progressColor}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Started {scan.started_at ? dayjs(scan.started_at).fromNow() : 'n/a'} · Finished{' '}
|
||||
{scan.finished_at ? dayjs(scan.finished_at).fromNow() : 'n/a'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Files {scan.total_files ?? '—'} · New {scan.new_files ?? '—'} · Updated{' '}
|
||||
{scan.updated_files ?? '—'} · Removed {scan.removed_files ?? '—'}
|
||||
</Text>
|
||||
{scan.unmatched_files > 0 && (
|
||||
<Text size="xs" c="yellow.4">
|
||||
Unmatched files: {scan.unmatched_files}
|
||||
</Text>
|
||||
)}
|
||||
{scan.log && (
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{scan.log}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryScanDrawer;
|
||||
166
frontend/src/components/library/MediaCard.jsx
Normal file
166
frontend/src/components/library/MediaCard.jsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import React, { memo } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { Film, Library as LibraryIcon, Tv2 } from 'lucide-react';
|
||||
|
||||
const typeIcon = {
|
||||
movie: <Film size={18} />,
|
||||
episode: <Tv2 size={18} />,
|
||||
show: <LibraryIcon size={18} />,
|
||||
};
|
||||
|
||||
const formatRuntime = (runtimeMs) => {
|
||||
if (!runtimeMs) return null;
|
||||
const mins = Math.round(runtimeMs / 60000);
|
||||
if (mins < 60) return `${mins} min`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
const minutes = mins % 60;
|
||||
return `${hours}h ${minutes}m`;
|
||||
};
|
||||
|
||||
const MediaCard = ({
|
||||
item,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
size = 'md',
|
||||
showTypeBadge = true,
|
||||
}) => {
|
||||
const handleContextMenu = (event) => {
|
||||
if (onContextMenu) {
|
||||
event.preventDefault();
|
||||
onContextMenu(event, item);
|
||||
}
|
||||
};
|
||||
|
||||
const height = size === 'sm' ? 220 : size === 'lg' ? 320 : 260;
|
||||
const progress = item.watch_progress;
|
||||
const watchSummary = item.watch_summary;
|
||||
const status = watchSummary?.status;
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer', background: 'rgba(12, 15, 27, 0.75)' }}
|
||||
onClick={() => onClick?.(item)}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Stack spacing="xs">
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{item.poster_url ? (
|
||||
<Box
|
||||
h={height}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: 'rgba(12, 17, 32, 0.65)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={item.poster_url}
|
||||
alt={item.title}
|
||||
height="100%"
|
||||
width="100%"
|
||||
fit="contain"
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
h={height}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: 'rgba(30, 41, 59, 0.6)',
|
||||
}}
|
||||
>
|
||||
{typeIcon[item.item_type] || <LibraryIcon size={24} />}
|
||||
</Stack>
|
||||
)}
|
||||
{progress && progress.percentage ? (
|
||||
<RingProgress
|
||||
style={{ position: 'absolute', top: 10, right: 10 }}
|
||||
size={48}
|
||||
thickness={4}
|
||||
sections={[
|
||||
{
|
||||
value: Math.min(100, progress.percentage * 100),
|
||||
color: progress.completed ? 'green' : 'cyan',
|
||||
},
|
||||
]}
|
||||
label={
|
||||
<Text size="xs" c="white">
|
||||
{Math.round(progress.percentage * 100)}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
<Stack spacing={4}>
|
||||
<Group justify="space-between" align="flex-start" gap="xs">
|
||||
<Text fw={600} size="sm" lineClamp={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.release_year && (
|
||||
<Badge variant="outline" size="xs">
|
||||
{item.release_year}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap={6} wrap="wrap">
|
||||
{showTypeBadge && (
|
||||
<Badge size="xs" color="violet" variant="light" tt="capitalize">
|
||||
{item.item_type}
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'watched' && (
|
||||
<Badge size="xs" color="green" variant="filled">
|
||||
Watched
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'in_progress' && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
In progress
|
||||
</Badge>
|
||||
)}
|
||||
{item.item_type === 'show' && watchSummary?.total_episodes ? (
|
||||
<Badge size="xs" color="blue" variant="outline">
|
||||
{watchSummary.completed_episodes || 0}/
|
||||
{watchSummary.total_episodes} episodes
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{Array.isArray(item.genres) && item.genres.length > 0 ? (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{item.genres.slice(0, 3).map((genre) => (
|
||||
<Badge key={`${item.id}-${genre}`} size="xs" color="blue" variant="light">
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
{formatRuntime(item.runtime_ms) && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatRuntime(item.runtime_ms)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MediaCard);
|
||||
109
frontend/src/components/library/MediaCarousel.jsx
Normal file
109
frontend/src/components/library/MediaCarousel.jsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { useMemo, useRef } from 'react';
|
||||
import { ActionIcon, Box, Group, ScrollArea, Stack, Text, rem } from '@mantine/core';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import MediaCard from './MediaCard';
|
||||
|
||||
const CARD_WIDTH = {
|
||||
sm: 160, // wider
|
||||
md: 200, // wider
|
||||
lg: 240, // wider
|
||||
};
|
||||
|
||||
const SCROLL_STEP = 4;
|
||||
|
||||
const MediaCarousel = ({
|
||||
title,
|
||||
items,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
cardSize = 'sm',
|
||||
emptyMessage = null,
|
||||
}) => {
|
||||
const viewportRef = useRef(null);
|
||||
const cardWidth = useMemo(() => CARD_WIDTH[cardSize], [cardSize]);
|
||||
const snapGap = 16;
|
||||
const bottomPad = 14; // extra space so card shadows/badges aren't clipped
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
if (!emptyMessage) return null;
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text fw={600} size="lg">{title}</Text>
|
||||
<Text size="sm" c="dimmed">{emptyMessage}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const scrollByCards = (dir) => {
|
||||
const vp = viewportRef.current;
|
||||
if (!vp) return;
|
||||
vp.scrollBy({
|
||||
left: dir * (cardWidth + snapGap) * SCROLL_STEP,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="lg">{title}</Text>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" aria-label="Scroll left" onClick={() => scrollByCards(-1)}>
|
||||
<ChevronLeft size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" aria-label="Scroll right" onClick={() => scrollByCards(1)}>
|
||||
<ChevronRight size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ScrollArea
|
||||
type="auto"
|
||||
scrollbarSize={8}
|
||||
offsetScrollbars
|
||||
viewportRef={viewportRef}
|
||||
styles={{
|
||||
viewport: {
|
||||
paddingBottom: rem(bottomPad), // <- keep bottoms visible
|
||||
scrollSnapType: 'x proximity',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
// add a touch of padding to the row too so shadows never collide
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
gap: rem(snapGap),
|
||||
alignItems: 'stretch',
|
||||
minWidth: 'max-content',
|
||||
paddingBottom: rem(2),
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
style={{
|
||||
flex: `0 0 ${rem(cardWidth)}`,
|
||||
width: rem(cardWidth),
|
||||
scrollSnapAlign: 'start',
|
||||
}}
|
||||
>
|
||||
<MediaCard
|
||||
item={item}
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
size={cardSize}
|
||||
showTypeBadge={false}
|
||||
// ensure the card fills the wrapper (prevents internal overflow)
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaCarousel;
|
||||
1136
frontend/src/components/library/MediaDetailModal.jsx
Normal file
1136
frontend/src/components/library/MediaDetailModal.jsx
Normal file
File diff suppressed because it is too large
Load diff
349
frontend/src/components/library/MediaEditModal.jsx
Normal file
349
frontend/src/components/library/MediaEditModal.jsx
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
import API from '../../api';
|
||||
import useMediaLibraryStore from '../../store/mediaLibrary';
|
||||
|
||||
const emptyValues = {
|
||||
title: '',
|
||||
synopsis: '',
|
||||
release_year: null,
|
||||
rating: '',
|
||||
genres: '',
|
||||
tags: '',
|
||||
studios: '',
|
||||
tmdb_id: '',
|
||||
imdb_id: '',
|
||||
poster_url: '',
|
||||
backdrop_url: '',
|
||||
};
|
||||
|
||||
const listToString = (value) =>
|
||||
Array.isArray(value) && value.length > 0 ? value.join(', ') : '';
|
||||
|
||||
const toList = (value) =>
|
||||
typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: Array.isArray(value)
|
||||
? value
|
||||
: [];
|
||||
|
||||
const MediaEditModal = ({ opened, onClose, mediaItemId, onSaved }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [applyingTmdb, setApplyingTmdb] = useState(false);
|
||||
const [mediaItem, setMediaItem] = useState(null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: emptyValues,
|
||||
});
|
||||
|
||||
const populateForm = (item) => {
|
||||
form.setValues({
|
||||
title: item.title || '',
|
||||
synopsis: item.synopsis || '',
|
||||
release_year: item.release_year || null,
|
||||
rating: item.rating || '',
|
||||
genres: listToString(item.genres),
|
||||
tags: listToString(item.tags),
|
||||
studios: listToString(item.studios),
|
||||
tmdb_id: item.tmdb_id || '',
|
||||
imdb_id: item.imdb_id || '',
|
||||
poster_url: item.poster_url || '',
|
||||
backdrop_url: item.backdrop_url || '',
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened || !mediaItemId) {
|
||||
setMediaItem(null);
|
||||
form.setValues(emptyValues);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
API.getMediaItem(mediaItemId)
|
||||
.then((item) => {
|
||||
setMediaItem(item);
|
||||
populateForm(item);
|
||||
})
|
||||
.catch((error) => {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Failed to load media item',
|
||||
message: error.message || 'Unable to load media item details.',
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [opened, mediaItemId]);
|
||||
|
||||
const applyMediaItemUpdate = async (data) => {
|
||||
let normalized = data;
|
||||
if (!normalized || typeof normalized !== 'object') {
|
||||
try {
|
||||
normalized = await API.getMediaItem(mediaItemId);
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Failed to refresh item',
|
||||
message: error.message || 'Unable to refresh media item after update.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setMediaItem(normalized);
|
||||
populateForm(normalized);
|
||||
|
||||
try {
|
||||
await useMediaLibraryStore.getState().openItem(mediaItemId);
|
||||
} catch (error) {
|
||||
// Log but do not block success UX
|
||||
console.debug('Failed to refresh active item state', error);
|
||||
}
|
||||
|
||||
if (typeof onSaved === 'function') {
|
||||
await onSaved(normalized);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleApplyTmdb = async () => {
|
||||
if (!form.values.tmdb_id) {
|
||||
notifications.show({
|
||||
color: 'yellow',
|
||||
title: 'TMDB ID required',
|
||||
message: 'Enter a TMDB ID before applying metadata.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setApplyingTmdb(true);
|
||||
try {
|
||||
const updated = await API.setMediaItemTMDB(
|
||||
mediaItemId,
|
||||
form.values.tmdb_id
|
||||
);
|
||||
const applied = await applyMediaItemUpdate(updated);
|
||||
if (applied) {
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
title: 'Metadata updated',
|
||||
message: 'TMDB details applied successfully.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// errorNotification already handled in API helper
|
||||
} finally {
|
||||
setApplyingTmdb(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
if (!mediaItem) return;
|
||||
|
||||
const payload = {};
|
||||
|
||||
const assignIfChanged = (field, value) => {
|
||||
const current = mediaItem[field];
|
||||
const normalizedValue = value ?? '';
|
||||
const normalizedCurrent = current ?? '';
|
||||
|
||||
if (normalizedValue === '' && normalizedCurrent === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedValue === '' && normalizedCurrent !== '') {
|
||||
payload[field] = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedValue !== normalizedCurrent) {
|
||||
payload[field] = value;
|
||||
}
|
||||
};
|
||||
|
||||
assignIfChanged('title', values.title);
|
||||
assignIfChanged('synopsis', values.synopsis);
|
||||
if (values.release_year !== mediaItem.release_year) {
|
||||
payload.release_year = values.release_year || null;
|
||||
}
|
||||
assignIfChanged('rating', values.rating);
|
||||
const genresList = toList(values.genres);
|
||||
if (JSON.stringify(genresList) !== JSON.stringify(mediaItem.genres || [])) {
|
||||
payload.genres = genresList;
|
||||
}
|
||||
const tagsList = toList(values.tags);
|
||||
if (JSON.stringify(tagsList) !== JSON.stringify(mediaItem.tags || [])) {
|
||||
payload.tags = tagsList;
|
||||
}
|
||||
const studiosList = toList(values.studios);
|
||||
if (JSON.stringify(studiosList) !== JSON.stringify(mediaItem.studios || [])) {
|
||||
payload.studios = studiosList;
|
||||
}
|
||||
assignIfChanged('tmdb_id', values.tmdb_id);
|
||||
assignIfChanged('imdb_id', values.imdb_id);
|
||||
assignIfChanged('poster_url', values.poster_url);
|
||||
assignIfChanged('backdrop_url', values.backdrop_url);
|
||||
|
||||
// Remove unchanged keys
|
||||
Object.keys(payload).forEach((key) => {
|
||||
const value = payload[key];
|
||||
if (
|
||||
value === undefined ||
|
||||
(Array.isArray(value) && value.length === 0 && !['genres', 'tags', 'studios'].includes(key))
|
||||
) {
|
||||
delete payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
notifications.show({
|
||||
color: 'blue',
|
||||
title: 'No changes detected',
|
||||
message: 'Update the fields before saving.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await API.updateMediaItem(mediaItemId, payload);
|
||||
const applied = await applyMediaItemUpdate(updated);
|
||||
|
||||
if (applied) {
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
title: 'Media item saved',
|
||||
message: 'Changes were applied successfully.',
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
// errorNotification already displayed
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const modalTitle = useMemo(() => {
|
||||
if (!mediaItem) return 'Edit Media';
|
||||
return `Edit ${mediaItem.title || 'Media'}`;
|
||||
}, [mediaItem]);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={modalTitle} size="lg" centered>
|
||||
{loading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : !mediaItem ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Unable to load media item details.
|
||||
</Text>
|
||||
) : (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Title"
|
||||
placeholder="Movie title"
|
||||
{...form.getInputProps('title')}
|
||||
/>
|
||||
<Textarea
|
||||
label="Synopsis"
|
||||
placeholder="Plot summary"
|
||||
minRows={3}
|
||||
{...form.getInputProps('synopsis')}
|
||||
/>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Release Year"
|
||||
min={1895}
|
||||
max={3000}
|
||||
{...form.getInputProps('release_year')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Rating"
|
||||
placeholder="PG-13"
|
||||
{...form.getInputProps('rating')}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Genres"
|
||||
placeholder="Comma separated"
|
||||
{...form.getInputProps('genres')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Tags"
|
||||
placeholder="Comma separated"
|
||||
{...form.getInputProps('tags')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Studios"
|
||||
placeholder="Comma separated"
|
||||
{...form.getInputProps('studios')}
|
||||
/>
|
||||
<Group grow align="end">
|
||||
<TextInput
|
||||
label="TMDB ID"
|
||||
placeholder="Enter TMDB ID"
|
||||
{...form.getInputProps('tmdb_id')}
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<RefreshCcw size={14} />}
|
||||
onClick={handleApplyTmdb}
|
||||
loading={applyingTmdb}
|
||||
type="button"
|
||||
>
|
||||
Apply TMDB Metadata
|
||||
</Button>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="IMDB ID"
|
||||
placeholder="tt1234567"
|
||||
{...form.getInputProps('imdb_id')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Poster URL"
|
||||
placeholder="https://..."
|
||||
{...form.getInputProps('poster_url')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Backdrop URL"
|
||||
placeholder="https://..."
|
||||
{...form.getInputProps('backdrop_url')}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={saving}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaEditModal;
|
||||
169
frontend/src/components/library/MediaGrid.jsx
Normal file
169
frontend/src/components/library/MediaGrid.jsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Box, Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeGrid as VirtualGrid } from 'react-window';
|
||||
import MediaCard from './MediaCard';
|
||||
|
||||
const groupItemsByLetter = (items) => {
|
||||
const map = new Map();
|
||||
items.forEach((item) => {
|
||||
const name = item.sort_title || item.title || '';
|
||||
const firstChar = name.charAt(0).toUpperCase();
|
||||
const key = /[A-Z]/.test(firstChar) ? firstChar : '#';
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key).push(item);
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
const GRID_SPACING = 24;
|
||||
|
||||
const getColumnCount = (width, columns) => {
|
||||
if (!width) return columns.base || 1;
|
||||
if (width >= 1400 && columns.xl) return columns.xl;
|
||||
if (width >= 1200 && columns.lg) return columns.lg;
|
||||
if (width >= 992 && columns.md) return columns.md;
|
||||
if (width >= 768 && columns.sm) return columns.sm;
|
||||
return columns.base || 1;
|
||||
};
|
||||
|
||||
const CARD_HEIGHT_MAP = {
|
||||
sm: 220,
|
||||
md: 260,
|
||||
lg: 320,
|
||||
};
|
||||
|
||||
const VirtualizedCell = ({ columnIndex, rowIndex, style, data }) => {
|
||||
const { items, columnCount, onSelect, onContextMenu, cardSize } = data;
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
if (index >= items.length) {
|
||||
return null;
|
||||
}
|
||||
const item = items[index];
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
...style,
|
||||
padding: GRID_SPACING / 2,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<MediaCard
|
||||
item={item}
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
size={cardSize}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const MediaGrid = ({
|
||||
items,
|
||||
loading,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
groupByLetter = false,
|
||||
letterRefs,
|
||||
columns = { base: 1, sm: 2, md: 4, lg: 5 },
|
||||
cardSize = 'md',
|
||||
}) => {
|
||||
const rowHeight = useMemo(() => {
|
||||
const base = CARD_HEIGHT_MAP[cardSize] ?? CARD_HEIGHT_MAP.md;
|
||||
return base + GRID_SPACING;
|
||||
}, [cardSize]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No media found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (groupByLetter) {
|
||||
const grouped = groupItemsByLetter(items);
|
||||
const sortedKeys = Array.from(grouped.keys()).sort();
|
||||
return (
|
||||
<Stack spacing="xl">
|
||||
{sortedKeys.map((letter) => {
|
||||
const refCallback = (el) => {
|
||||
if (letterRefs && el) {
|
||||
letterRefs.current[letter] = el;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Stack key={letter} spacing="md" ref={refCallback}>
|
||||
<Text fw={700} size="lg">
|
||||
{letter}
|
||||
</Text>
|
||||
<SimpleGrid cols={columns} spacing="lg">
|
||||
{grouped.get(letter).map((item) => (
|
||||
<MediaCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
size={cardSize}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '70vh',
|
||||
minHeight: 480,
|
||||
}}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
if (!width || !height) {
|
||||
return null;
|
||||
}
|
||||
const columnCount = getColumnCount(width, columns);
|
||||
const rowCount = Math.ceil(items.length / columnCount);
|
||||
const columnWidth = width / columnCount;
|
||||
return (
|
||||
<VirtualGrid
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
height={height}
|
||||
rowCount={rowCount}
|
||||
rowHeight={rowHeight}
|
||||
width={width}
|
||||
itemData={{
|
||||
items,
|
||||
columnCount,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
cardSize,
|
||||
}}
|
||||
>
|
||||
{VirtualizedCell}
|
||||
</VirtualGrid>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaGrid;
|
||||
757
frontend/src/pages/Library.jsx
Normal file
757
frontend/src/pages/Library.jsx
Normal file
|
|
@ -0,0 +1,757 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ActionIcon, Box, Button, Divider, Group, Paper, Portal, Select, Stack, Text, TextInput, Title, SegmentedControl } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { ListChecks, Play, RefreshCcw, Search, Trash2, XCircle } from 'lucide-react';
|
||||
|
||||
import useLibraryStore from '../store/library';
|
||||
import useMediaLibraryStore from '../store/mediaLibrary';
|
||||
import MediaDetailModal from '../components/library/MediaDetailModal';
|
||||
import LibraryScanDrawer from '../components/library/LibraryScanDrawer';
|
||||
import MediaCarousel from '../components/library/MediaCarousel';
|
||||
import MediaGrid from '../components/library/MediaGrid';
|
||||
import AlphabetSidebar from '../components/library/AlphabetSidebar';
|
||||
import API from '../api';
|
||||
|
||||
const TABS = [
|
||||
{ label: 'Recommended', value: 'recommended' },
|
||||
{ label: 'Library', value: 'library' },
|
||||
{ label: 'Categories', value: 'categories' },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ label: 'Default', value: 'default' },
|
||||
{ label: 'Name (A-Z)', value: 'alpha' },
|
||||
{ label: 'Release Year', value: 'year' },
|
||||
{ label: 'Recently Added', value: 'recent' },
|
||||
{ label: 'Genre', value: 'genre' },
|
||||
];
|
||||
|
||||
const parseDate = (value) => {
|
||||
if (!value) return 0;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
};
|
||||
|
||||
const LibraryPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { mediaType } = useParams();
|
||||
const normalizedMediaType =
|
||||
mediaType === 'shows' ? 'shows' : mediaType === 'movies' ? 'movies' : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedMediaType) {
|
||||
navigate('/library/movies', { replace: true });
|
||||
}
|
||||
}, [normalizedMediaType, navigate]);
|
||||
|
||||
const isMovies = normalizedMediaType !== 'shows';
|
||||
const itemTypeFilter = isMovies ? 'movie' : 'show';
|
||||
|
||||
const [scanDrawerOpen, setScanDrawerOpen] = useState(false);
|
||||
const [playbackModalOpen, setPlaybackModalOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('recommended');
|
||||
const [sortOption, setSortOption] = useState('default');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const contextMenuPosition = useMemo(() => {
|
||||
if (!contextMenu) return null;
|
||||
if (typeof window === 'undefined') {
|
||||
return { top: contextMenu.y, left: contextMenu.x };
|
||||
}
|
||||
return {
|
||||
top: Math.min(contextMenu.y, window.innerHeight - 220),
|
||||
left: Math.min(contextMenu.x, window.innerWidth - 260),
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(searchTerm, 350);
|
||||
|
||||
// Library store hooks
|
||||
const libraries = useLibraryStore((s) => s.libraries);
|
||||
const fetchLibraries = useLibraryStore((s) => s.fetchLibraries);
|
||||
const triggerScan = useLibraryStore((s) => s.triggerScan);
|
||||
const upsertScan = useLibraryStore((s) => s.upsertScan);
|
||||
const removeScan = useLibraryStore((s) => s.removeScan);
|
||||
|
||||
// Media store hooks
|
||||
const items = useMediaLibraryStore((s) => s.items);
|
||||
const itemsLoading = useMediaLibraryStore((s) => s.loading);
|
||||
const setItemFilters = useMediaLibraryStore((s) => s.setFilters);
|
||||
const fetchItems = useMediaLibraryStore((s) => s.fetchItems);
|
||||
const setSelectedMediaLibrary = useMediaLibraryStore((s) => s.setSelectedLibraryId);
|
||||
const openItem = useMediaLibraryStore((s) => s.openItem);
|
||||
const closeItem = useMediaLibraryStore((s) => s.closeItem);
|
||||
const removeItems = useMediaLibraryStore((s) => s.removeItems);
|
||||
const upsertItems = useMediaLibraryStore((s) => s.upsertItems);
|
||||
|
||||
// Fetch libraries on mount
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
}, [fetchLibraries]);
|
||||
|
||||
const relevantLibraryIds = useMemo(() => {
|
||||
if (!libraries || libraries.length === 0) return [];
|
||||
return libraries
|
||||
.filter((lib) =>
|
||||
isMovies
|
||||
? lib.library_type === 'movies'
|
||||
: lib.library_type === 'shows'
|
||||
)
|
||||
.map((lib) => lib.id);
|
||||
}, [libraries, isMovies]);
|
||||
|
||||
// Sync media filters with current type and search
|
||||
useEffect(() => {
|
||||
setItemFilters({
|
||||
type: itemTypeFilter,
|
||||
search: debouncedSearch,
|
||||
});
|
||||
}, [itemTypeFilter, debouncedSearch, setItemFilters]);
|
||||
|
||||
// Fetch items when library changes or filters update
|
||||
useEffect(() => {
|
||||
if (!libraries || libraries.length === 0) {
|
||||
setSelectedMediaLibrary(null);
|
||||
fetchItems([]);
|
||||
return;
|
||||
}
|
||||
const ids = relevantLibraryIds;
|
||||
setSelectedMediaLibrary(ids.length === 1 ? ids[0] : null);
|
||||
fetchItems(ids);
|
||||
}, [libraries, relevantLibraryIds, fetchItems, setSelectedMediaLibrary, debouncedSearch, itemTypeFilter]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const typeFiltered = items.filter((item) => item.item_type === itemTypeFilter);
|
||||
if (!debouncedSearch) return typeFiltered;
|
||||
const query = debouncedSearch.toLowerCase();
|
||||
return typeFiltered.filter((item) =>
|
||||
(item.title || '').toLowerCase().includes(query)
|
||||
);
|
||||
}, [items, itemTypeFilter, debouncedSearch]);
|
||||
|
||||
const continueWatching = useMemo(() => {
|
||||
return filteredItems
|
||||
.filter((item) => {
|
||||
if (item.item_type === 'show') {
|
||||
return item.watch_summary?.status === 'in_progress';
|
||||
}
|
||||
const progress = item.watch_progress;
|
||||
return progress && !progress.completed;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aTime = parseDate(a.watch_progress?.last_watched_at || a.updated_at);
|
||||
const bTime = parseDate(b.watch_progress?.last_watched_at || b.updated_at);
|
||||
return bTime - aTime;
|
||||
})
|
||||
.slice(0, 20);
|
||||
}, [filteredItems]);
|
||||
|
||||
const recentlyReleased = useMemo(() => {
|
||||
return [...filteredItems]
|
||||
.filter((item) => item.release_year)
|
||||
.sort((a, b) => (b.release_year || 0) - (a.release_year || 0))
|
||||
.slice(0, 30);
|
||||
}, [filteredItems]);
|
||||
|
||||
const recentlyAdded = useMemo(() => {
|
||||
return [...filteredItems]
|
||||
.sort((a, b) => parseDate(b.first_imported_at) - parseDate(a.first_imported_at))
|
||||
.slice(0, 30);
|
||||
}, [filteredItems]);
|
||||
|
||||
const genresMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
filteredItems.forEach((item) => {
|
||||
const genres = Array.isArray(item.genres) ? item.genres : [];
|
||||
if (genres.length === 0) return;
|
||||
const primary = genres[0];
|
||||
if (!map.has(primary)) {
|
||||
map.set(primary, []);
|
||||
}
|
||||
map.get(primary).push(item);
|
||||
});
|
||||
return map;
|
||||
}, [filteredItems]);
|
||||
|
||||
const genreCarousels = useMemo(() => {
|
||||
const entries = Array.from(genresMap.entries());
|
||||
return entries
|
||||
.map(([genre, genreItems]) => ({
|
||||
genre,
|
||||
items: genreItems
|
||||
.slice()
|
||||
.sort((a, b) => parseDate(b.first_imported_at) - parseDate(a.first_imported_at))
|
||||
.slice(0, 25),
|
||||
}))
|
||||
.filter((entry) => entry.items.length > 0)
|
||||
.slice(0, 12);
|
||||
}, [genresMap]);
|
||||
|
||||
const primaryLibraryId = useMemo(
|
||||
() => (relevantLibraryIds.length === 1 ? relevantLibraryIds[0] : null),
|
||||
[relevantLibraryIds]
|
||||
);
|
||||
|
||||
const hasLibraries = relevantLibraryIds.length > 0;
|
||||
|
||||
const aggregatedSubtitle = useMemo(() => {
|
||||
if (!libraries || libraries.length === 0) {
|
||||
return 'No libraries configured yet.';
|
||||
}
|
||||
if (!hasLibraries) {
|
||||
return isMovies
|
||||
? 'No movie libraries configured.'
|
||||
: 'No TV show libraries configured.';
|
||||
}
|
||||
const label = isMovies ? 'movie' : 'TV show';
|
||||
const count = relevantLibraryIds.length;
|
||||
return `Aggregating ${count} ${label} librar${count === 1 ? 'y' : 'ies'}.`;
|
||||
}, [libraries, hasLibraries, relevantLibraryIds, isMovies]);
|
||||
|
||||
const canManageSingleLibrary = Boolean(primaryLibraryId);
|
||||
|
||||
const sortedLibraryItems = useMemo(() => {
|
||||
switch (sortOption) {
|
||||
case 'alpha':
|
||||
return [...filteredItems].sort((a, b) => {
|
||||
const aTitle = (a.sort_title || a.title || '').toLowerCase();
|
||||
const bTitle = (b.sort_title || b.title || '').toLowerCase();
|
||||
return aTitle.localeCompare(bTitle);
|
||||
});
|
||||
case 'year':
|
||||
return [...filteredItems].sort((a, b) => (b.release_year || 0) - (a.release_year || 0));
|
||||
case 'recent':
|
||||
return [...filteredItems].sort((a, b) => parseDate(b.first_imported_at) - parseDate(a.first_imported_at));
|
||||
default:
|
||||
return filteredItems;
|
||||
}
|
||||
}, [filteredItems, sortOption]);
|
||||
|
||||
const availableLetters = useMemo(() => {
|
||||
if (sortOption !== 'alpha') return new Set();
|
||||
const letters = new Set();
|
||||
sortedLibraryItems.forEach((item) => {
|
||||
const name = item.sort_title || item.title || '';
|
||||
const firstChar = name.charAt(0).toUpperCase();
|
||||
const key = /[A-Z]/.test(firstChar) ? firstChar : '#';
|
||||
letters.add(key);
|
||||
});
|
||||
return letters;
|
||||
}, [sortedLibraryItems, sortOption]);
|
||||
|
||||
const letterRefs = useRef({});
|
||||
|
||||
const handleLetterSelect = (letter) => {
|
||||
const node = letterRefs.current[letter];
|
||||
if (node && node.scrollIntoView) {
|
||||
node.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenItem = (item) => {
|
||||
setPlaybackModalOpen(true);
|
||||
openItem(item.id).catch((error) => {
|
||||
console.error('Failed to open media item', error);
|
||||
setPlaybackModalOpen(false);
|
||||
notifications.show({
|
||||
title: 'Error loading media',
|
||||
message: 'Unable to open media details.',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const refreshItem = async (id) => {
|
||||
try {
|
||||
const data = await API.getMediaItem(id);
|
||||
upsertItems([data]);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh media item', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkWatched = async (item) => {
|
||||
try {
|
||||
if (item.item_type === 'show') {
|
||||
const response = await API.markSeriesWatched(item.id);
|
||||
if (response?.item) {
|
||||
upsertItems([response.item]);
|
||||
} else {
|
||||
await refreshItem(item.id);
|
||||
}
|
||||
notifications.show({
|
||||
title: 'Series updated',
|
||||
message: 'All episodes marked as watched.',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
await API.markMediaItemWatched(item.id);
|
||||
await refreshItem(item.id);
|
||||
notifications.show({
|
||||
title: 'Marked as watched',
|
||||
message: `${item.title} marked as watched.`,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark watched', error);
|
||||
notifications.show({
|
||||
title: 'Action failed',
|
||||
message: 'Unable to mark item as watched at this time.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkUnwatched = async (item) => {
|
||||
try {
|
||||
if (item.item_type === 'show') {
|
||||
const response = await API.markSeriesUnwatched(item.id);
|
||||
if (response?.item) {
|
||||
upsertItems([response.item]);
|
||||
} else {
|
||||
await refreshItem(item.id);
|
||||
}
|
||||
notifications.show({
|
||||
title: 'Series updated',
|
||||
message: 'Watch history cleared.',
|
||||
color: 'blue',
|
||||
});
|
||||
} else {
|
||||
await API.clearMediaItemProgress(item.id);
|
||||
await refreshItem(item.id);
|
||||
notifications.show({
|
||||
title: 'Progress cleared',
|
||||
message: `${item.title} marked as unwatched.`,
|
||||
color: 'blue',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark unwatched', error);
|
||||
notifications.show({
|
||||
title: 'Action failed',
|
||||
message: 'Unable to update watch state right now.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (item) => {
|
||||
const label = item.item_type === 'show' ? 'series and all episodes' : 'media item';
|
||||
if (!window.confirm(`Delete ${item.title}? This will remove the ${label} from your library.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await API.deleteMediaItem(item.id);
|
||||
removeItems(item.id);
|
||||
notifications.show({
|
||||
title: 'Deleted',
|
||||
message: `${item.title} removed from your library.`,
|
||||
color: 'red',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete media item', error);
|
||||
notifications.show({
|
||||
title: 'Delete failed',
|
||||
message: 'Unable to delete this item right now.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (event, item) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
item,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const handleOutsideClick = () => setContextMenu(null);
|
||||
document.addEventListener('click', handleOutsideClick);
|
||||
return () => document.removeEventListener('click', handleOutsideClick);
|
||||
}, [contextMenu]);
|
||||
|
||||
// --- SCAN CONTROLS ---
|
||||
|
||||
// Open the drawer only (do NOT start a scan)
|
||||
const handleOpenScanDrawer = () => {
|
||||
if (!hasLibraries) {
|
||||
notifications.show({
|
||||
title: 'No libraries configured',
|
||||
message: 'Add a library to manage scans.',
|
||||
color: 'yellow',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!canManageSingleLibrary) {
|
||||
notifications.show({
|
||||
title: 'Multiple libraries detected',
|
||||
message: 'Open the Libraries page to manage individual scans.',
|
||||
color: 'yellow',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setScanDrawerOpen(true);
|
||||
};
|
||||
|
||||
// Explicitly start a scan (quick or full)
|
||||
const handleStartScan = async (full = false) => {
|
||||
if (!hasLibraries) {
|
||||
notifications.show({
|
||||
title: 'Scan unavailable',
|
||||
message: 'Add a library before starting a scan.',
|
||||
color: 'yellow',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!primaryLibraryId) {
|
||||
notifications.show({
|
||||
title: 'Scan unavailable',
|
||||
message: 'Choose a specific library from the Libraries page to start a scan.',
|
||||
color: 'yellow',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await triggerScan(primaryLibraryId, { full });
|
||||
notifications.show({
|
||||
title: full ? 'Full scan started' : 'Scan started',
|
||||
message: full
|
||||
? 'A full library scan has been queued.'
|
||||
: 'Library scan has been queued.',
|
||||
color: 'blue',
|
||||
});
|
||||
setScanDrawerOpen(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to start scan', error);
|
||||
notifications.show({
|
||||
title: 'Scan failed',
|
||||
message: 'Unable to start scan at this time.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel a running scan by job id
|
||||
const handleCancelScanJob = async (jobId) => {
|
||||
try {
|
||||
const updated = await API.cancelLibraryScan(jobId);
|
||||
if (updated) {
|
||||
upsertScan(updated);
|
||||
}
|
||||
notifications.show({
|
||||
title: 'Scan canceled',
|
||||
message: 'The running scan has been stopped.',
|
||||
color: 'yellow',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
notifications.show({
|
||||
title: 'Cancel failed',
|
||||
message: 'Could not cancel this scan.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a queued scan by job id
|
||||
const handleDeleteQueuedScan = async (jobId) => {
|
||||
try {
|
||||
await API.deleteLibraryScan(jobId);
|
||||
removeScan(jobId);
|
||||
notifications.show({
|
||||
title: 'Removed from queue',
|
||||
message: 'The queued scan was removed.',
|
||||
color: 'green',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
notifications.show({
|
||||
title: 'Remove failed',
|
||||
message: 'Could not remove this queued scan.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const recommendedView = (
|
||||
<Stack spacing="xl">
|
||||
<MediaCarousel
|
||||
title="Continue Watching"
|
||||
items={continueWatching}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
emptyMessage="Start watching to see items here."
|
||||
/>
|
||||
<MediaCarousel
|
||||
title="Recently Released"
|
||||
items={recentlyReleased}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
<MediaCarousel
|
||||
title="Recently Added"
|
||||
items={recentlyAdded}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const libraryView = (() => {
|
||||
if (sortOption === 'alpha') {
|
||||
letterRefs.current = {};
|
||||
}
|
||||
return (
|
||||
<Box style={{ position: 'relative' }}>
|
||||
<Stack spacing="lg">
|
||||
<Group justify="space-between" align="center">
|
||||
<Select
|
||||
label="Sort by"
|
||||
data={SORT_OPTIONS}
|
||||
value={sortOption}
|
||||
onChange={(value) => setSortOption(value || 'default')}
|
||||
w={220}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<RefreshCcw size={16} />}
|
||||
onClick={() => fetchItems(relevantLibraryIds)}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
{sortOption === 'default' ? (
|
||||
recommendedView
|
||||
) : sortOption === 'genre' ? (
|
||||
<Stack spacing="xl">
|
||||
{genreCarousels.map(({ genre, items: genreItems }) => (
|
||||
<MediaCarousel
|
||||
key={genre}
|
||||
title={genre}
|
||||
items={genreItems}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{sortOption === 'alpha' && availableLetters.size > 0 && (
|
||||
<AlphabetSidebar available={availableLetters} onSelect={handleLetterSelect} />
|
||||
)}
|
||||
<MediaGrid
|
||||
items={sortedLibraryItems}
|
||||
loading={itemsLoading}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
groupByLetter={sortOption === 'alpha'}
|
||||
letterRefs={letterRefs}
|
||||
cardSize="md"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})();
|
||||
|
||||
const categoriesView = (
|
||||
<Stack spacing="xl">
|
||||
{genreCarousels.length === 0 ? (
|
||||
<Text c="dimmed">No categories available.</Text>
|
||||
) : (
|
||||
genreCarousels.map(({ genre, items: genreItems }) => (
|
||||
<MediaCarousel
|
||||
key={genre}
|
||||
title={genre}
|
||||
items={genreItems}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const contextItem = contextMenu?.item;
|
||||
const contextStatus = contextItem?.watch_summary?.status;
|
||||
|
||||
return (
|
||||
<Box p="lg">
|
||||
<Stack spacing="xl">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack spacing={4}>
|
||||
<Title order={2}>{isMovies ? 'Movies' : 'TV Shows'}</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
{aggregatedSubtitle}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group align="center" gap="sm">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={handleOpenScanDrawer}
|
||||
title="View recent scans"
|
||||
>
|
||||
<ListChecks size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={() => handleStartScan(false)}
|
||||
title="Start library scan"
|
||||
>
|
||||
<RefreshCcw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="center" wrap="wrap">
|
||||
<SegmentedControl value={activeTab} onChange={setActiveTab} data={TABS} />
|
||||
<Group align="center" gap="sm">
|
||||
<TextInput
|
||||
leftSection={<Search size={16} />}
|
||||
placeholder="Search library"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.currentTarget.value)}
|
||||
w={260}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{relevantLibraryIds.length > 0 ? (
|
||||
<div>
|
||||
{activeTab === 'recommended' && recommendedView}
|
||||
{activeTab === 'library' && libraryView}
|
||||
{activeTab === 'categories' && categoriesView}
|
||||
</div>
|
||||
) : (
|
||||
<Stack align="center" py="xl" spacing="md">
|
||||
<Text c="dimmed">Add a media library to begin importing content.</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<LibraryScanDrawer
|
||||
opened={scanDrawerOpen && canManageSingleLibrary}
|
||||
onClose={() => setScanDrawerOpen(false)}
|
||||
libraryId={primaryLibraryId}
|
||||
// NEW: enable controls inside the drawer
|
||||
onCancelJob={handleCancelScanJob}
|
||||
onDeleteQueuedJob={handleDeleteQueuedScan}
|
||||
onStartScan={() => handleStartScan(false)}
|
||||
onStartFullScan={() => handleStartScan(true)}
|
||||
/>
|
||||
|
||||
<MediaDetailModal
|
||||
opened={playbackModalOpen}
|
||||
onClose={() => {
|
||||
setPlaybackModalOpen(false);
|
||||
closeItem();
|
||||
}}
|
||||
/>
|
||||
|
||||
{contextMenu && contextItem && contextMenuPosition && (
|
||||
<Portal>
|
||||
<Paper
|
||||
shadow="md"
|
||||
p="xs"
|
||||
withBorder
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: contextMenuPosition.top,
|
||||
left: contextMenuPosition.left,
|
||||
zIndex: 1000,
|
||||
minWidth: 220,
|
||||
background: 'rgba(18, 21, 35, 0.97)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={4}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<Play size={16} />}
|
||||
onClick={() => {
|
||||
handleOpenItem(contextItem);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
{contextStatus === 'in_progress' ? 'Continue Watching' : 'Play'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<RefreshCcw size={16} />}
|
||||
onClick={async () => {
|
||||
await API.refreshMediaItemMetadata(contextItem.id);
|
||||
notifications.show({
|
||||
title: 'Metadata queued',
|
||||
message: 'Metadata refresh has been requested.',
|
||||
color: 'blue',
|
||||
});
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Refresh Metadata
|
||||
</Button>
|
||||
<Divider my="xs" />
|
||||
{contextStatus === 'watched' ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={async () => {
|
||||
await handleMarkUnwatched(contextItem);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Mark unwatched
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={async () => {
|
||||
await handleMarkWatched(contextItem);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Mark watched
|
||||
</Button>
|
||||
{contextStatus === 'in_progress' && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={async () => {
|
||||
await handleMarkUnwatched(contextItem);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Clear Watch Progress
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<Trash2 size={16} />}
|
||||
onClick={async () => {
|
||||
await handleDeleteItem(contextItem);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Portal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryPage;
|
||||
|
|
@ -11,13 +11,19 @@ import useUserAgentsStore from '../store/userAgents';
|
|||
import useStreamProfilesStore from '../store/streamProfiles';
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Loader,
|
||||
FileInput,
|
||||
List,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Stack,
|
||||
|
|
@ -25,6 +31,7 @@ import {
|
|||
Text,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
|
@ -40,6 +47,11 @@ import {
|
|||
} from '../constants';
|
||||
import ConfirmationDialog from '../components/ConfirmationDialog';
|
||||
import useWarningsStore from '../store/warnings';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import useLibraryStore from '../store/library';
|
||||
import LibraryFormModal from '../components/library/LibraryFormModal';
|
||||
import { Pencil, Plus, RefreshCcw, Trash2 } from 'lucide-react';
|
||||
import tmdbLogoUrl from '../assets/tmdb-logo-blue.svg?url';
|
||||
|
||||
const TIMEZONE_FALLBACKS = [
|
||||
'UTC',
|
||||
|
|
@ -183,6 +195,14 @@ const SettingsPage = () => {
|
|||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
|
||||
const mediaLibraries = useLibraryStore((state) => state.libraries);
|
||||
const librariesLoading = useLibraryStore((state) => state.loading);
|
||||
const fetchMediaLibraries = useLibraryStore((state) => state.fetchLibraries);
|
||||
const createMediaLibrary = useLibraryStore((state) => state.createLibrary);
|
||||
const updateMediaLibrary = useLibraryStore((state) => state.updateLibrary);
|
||||
const deleteMediaLibrary = useLibraryStore((state) => state.deleteLibrary);
|
||||
const triggerLibraryScan = useLibraryStore((state) => state.triggerScan);
|
||||
|
||||
const [accordianValue, setAccordianValue] = useState(null);
|
||||
const [networkAccessSaved, setNetworkAccessSaved] = useState(false);
|
||||
const [networkAccessError, setNetworkAccessError] = useState(null);
|
||||
|
|
@ -199,6 +219,22 @@ const SettingsPage = () => {
|
|||
// Add a new state to track the dialog type
|
||||
const [rehashDialogType, setRehashDialogType] = useState(null); // 'save' or 'rehash'
|
||||
|
||||
const [libraryModalOpen, setLibraryModalOpen] = useState(false);
|
||||
const [editingLibrarySettings, setEditingLibrarySettings] = useState(null);
|
||||
const [librarySubmitting, setLibrarySubmitting] = useState(false);
|
||||
const tmdbSetting = settings['tmdb-api-key'];
|
||||
const TMDB_REQUIREMENT_MESSAGE =
|
||||
'Metadata uses TMDB when available and falls back to Movie-DB when necessary.';
|
||||
const [tmdbKey, setTmdbKey] = useState('');
|
||||
const [metadataSourcesAvailable, setMetadataSourcesAvailable] = useState(false);
|
||||
const [tmdbValidating, setTmdbValidating] = useState(false);
|
||||
const [tmdbValidationState, setTmdbValidationState] = useState('info');
|
||||
const [tmdbValidationMessage, setTmdbValidationMessage] = useState(
|
||||
TMDB_REQUIREMENT_MESSAGE
|
||||
);
|
||||
const [activeMetadataSource, setActiveMetadataSource] = useState('unavailable');
|
||||
const [savingTmdbKey, setSavingTmdbKey] = useState(false);
|
||||
const [tmdbHintOpen, setTmdbHintOpen] = useState(false);
|
||||
// Store pending changed settings when showing the dialog
|
||||
const [pendingChangedSettings, setPendingChangedSettings] = useState(null);
|
||||
const [comskipFile, setComskipFile] = useState(null);
|
||||
|
|
@ -400,6 +436,12 @@ const SettingsPage = () => {
|
|||
loadComskipConfig();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authUser?.user_level === USER_LEVELS.ADMIN) {
|
||||
fetchMediaLibraries();
|
||||
}
|
||||
}, [authUser?.user_level, fetchMediaLibraries]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
const changedSettings = {};
|
||||
|
|
@ -449,6 +491,213 @@ const SettingsPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleLibrarySettingsSubmit = async (values) => {
|
||||
setLibrarySubmitting(true);
|
||||
try {
|
||||
if (editingLibrarySettings) {
|
||||
await updateMediaLibrary(editingLibrarySettings.id, values);
|
||||
notifications.show({
|
||||
title: 'Library updated',
|
||||
message: 'Changes saved.',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
await createMediaLibrary(values);
|
||||
notifications.show({
|
||||
title: 'Library created',
|
||||
message: 'New library added.',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
setLibraryModalOpen(false);
|
||||
setEditingLibrarySettings(null);
|
||||
fetchMediaLibraries();
|
||||
} catch (error) {
|
||||
console.error('Failed to save library', error);
|
||||
notifications.show({
|
||||
title: 'Library error',
|
||||
message: error?.body?.detail || 'Unable to save library changes.',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setLibrarySubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLibrarySettingsDelete = async (library) => {
|
||||
if (!window.confirm(`Delete library "${library.name}"?`)) return;
|
||||
try {
|
||||
await deleteMediaLibrary(library.id);
|
||||
notifications.show({
|
||||
title: 'Library deleted',
|
||||
message: 'Library removed successfully.',
|
||||
color: 'green',
|
||||
});
|
||||
fetchMediaLibraries();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete library', error);
|
||||
notifications.show({
|
||||
title: 'Library error',
|
||||
message: 'Unable to delete library.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLibrarySettingsScan = async (library) => {
|
||||
try {
|
||||
await triggerLibraryScan(library.id, { full: false });
|
||||
notifications.show({
|
||||
title: 'Scan started',
|
||||
message: `Library ${library.name} queued for scanning.`,
|
||||
color: 'blue',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger scan', error);
|
||||
notifications.show({
|
||||
title: 'Scan error',
|
||||
message: 'Unable to start scan.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateTmdbKeyValue = useCallback(
|
||||
async (value) => {
|
||||
const trimmed = (value || '').trim();
|
||||
setTmdbValidating(true);
|
||||
setTmdbValidationState('info');
|
||||
setTmdbValidationMessage('Checking metadata providers…');
|
||||
try {
|
||||
const result = await API.validateTmdbApiKey(trimmed);
|
||||
const overallValid = Boolean(result?.overall_valid);
|
||||
const provider = result?.provider || 'unavailable';
|
||||
const message =
|
||||
result?.message ||
|
||||
(overallValid
|
||||
? provider === 'tmdb'
|
||||
? 'TMDB key verified successfully. Metadata and artwork will load for your libraries.'
|
||||
: 'Using Movie-DB fallback for metadata.'
|
||||
: 'All metadata sources are unavailable.');
|
||||
|
||||
setMetadataSourcesAvailable(overallValid);
|
||||
setActiveMetadataSource(provider);
|
||||
|
||||
if (provider === 'tmdb') {
|
||||
setTmdbValidationState('valid');
|
||||
} else if (provider === 'movie-db' && overallValid) {
|
||||
setTmdbValidationState('fallback');
|
||||
} else {
|
||||
setTmdbValidationState('invalid');
|
||||
}
|
||||
|
||||
setTmdbValidationMessage(message);
|
||||
return result ?? { overall_valid: overallValid, provider, message };
|
||||
} catch (error) {
|
||||
setMetadataSourcesAvailable(false);
|
||||
setActiveMetadataSource('unavailable');
|
||||
setTmdbValidationState('error');
|
||||
setTmdbValidationMessage('Unable to reach metadata services right now.');
|
||||
throw error;
|
||||
} finally {
|
||||
setTmdbValidating(false);
|
||||
}
|
||||
},
|
||||
[TMDB_REQUIREMENT_MESSAGE]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentValue =
|
||||
tmdbSetting && tmdbSetting.value !== undefined ? tmdbSetting.value : '';
|
||||
setTmdbKey(currentValue);
|
||||
validateTmdbKeyValue(currentValue).catch((error) => {
|
||||
console.error('Failed to validate TMDB API key', error);
|
||||
});
|
||||
}, [tmdbSetting?.value, validateTmdbKeyValue]);
|
||||
|
||||
const handleSaveTmdbKey = async () => {
|
||||
const trimmedKey = tmdbKey.trim();
|
||||
setSavingTmdbKey(true);
|
||||
try {
|
||||
let validationResult = null;
|
||||
if (trimmedKey) {
|
||||
validationResult = await validateTmdbKeyValue(trimmedKey);
|
||||
if (!validationResult?.overall_valid) {
|
||||
notifications.show({
|
||||
title: 'Invalid TMDB key',
|
||||
message:
|
||||
validationResult?.message ||
|
||||
'Metadata providers are unavailable. TMDB rejected the API key and Movie-DB is unreachable.',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
validationResult = await validateTmdbKeyValue('');
|
||||
}
|
||||
|
||||
if (tmdbSetting && tmdbSetting.id) {
|
||||
await API.updateSetting({
|
||||
...tmdbSetting,
|
||||
value: trimmedKey,
|
||||
});
|
||||
} else {
|
||||
await API.createSetting({
|
||||
key: 'tmdb-api-key',
|
||||
name: 'TMDB API Key',
|
||||
value: trimmedKey,
|
||||
});
|
||||
}
|
||||
|
||||
const provider = validationResult?.provider || activeMetadataSource;
|
||||
const usingFallback = provider === 'movie-db';
|
||||
notifications.show({
|
||||
title:
|
||||
provider === 'tmdb'
|
||||
? 'TMDB key saved'
|
||||
: trimmedKey
|
||||
? 'Saved with fallback'
|
||||
: 'Using fallback metadata',
|
||||
message:
|
||||
provider === 'tmdb'
|
||||
? 'TMDB API key saved and verified.'
|
||||
: usingFallback
|
||||
? 'Movie-DB fallback will be used for metadata until TMDB becomes available.'
|
||||
: 'Metadata providers are currently unavailable. Libraries may fail to scan.',
|
||||
color:
|
||||
provider === 'tmdb'
|
||||
? 'green'
|
||||
: usingFallback
|
||||
? 'blue'
|
||||
: 'red',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save TMDB key', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Unable to update TMDB API key.',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setSavingTmdbKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const libraryActionsDisabled = tmdbValidating || !metadataSourcesAvailable;
|
||||
const tmdbMessageColor =
|
||||
tmdbValidationState === 'valid'
|
||||
? 'teal.6'
|
||||
: tmdbValidationState === 'fallback'
|
||||
? 'blue.4'
|
||||
: tmdbValidationState === 'error' || tmdbValidationState === 'invalid'
|
||||
? 'red.6'
|
||||
: 'orange.6';
|
||||
const addLibraryTooltipLabel = metadataSourcesAvailable
|
||||
? activeMetadataSource === 'tmdb'
|
||||
? 'TMDB metadata is available.'
|
||||
: 'Using Movie-DB fallback for metadata.'
|
||||
: 'Metadata sources are unavailable. Configure TMDB or try again later.';
|
||||
|
||||
const onNetworkAccessSubmit = async () => {
|
||||
setNetworkAccessSaved(false);
|
||||
setNetworkAccessError(null);
|
||||
|
|
@ -706,6 +955,174 @@ const SettingsPage = () => {
|
|||
|
||||
{authUser.user_level == USER_LEVELS.ADMIN && (
|
||||
<>
|
||||
<Accordion.Item value="media-libraries">
|
||||
<Accordion.Control>Media Libraries</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack spacing="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text c="dimmed" size="sm">
|
||||
Configure local media libraries used for scanning and playback.
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={addLibraryTooltipLabel}
|
||||
disabled={!libraryActionsDisabled}
|
||||
withArrow
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
disabled={libraryActionsDisabled}
|
||||
onClick={() => {
|
||||
setEditingLibrarySettings(null);
|
||||
setLibraryModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Add Library
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Stack spacing="xs">
|
||||
<TextInput
|
||||
label="TMDB API Key"
|
||||
placeholder="Enter TMDB API key"
|
||||
value={tmdbKey}
|
||||
onChange={(event) => setTmdbKey(event.currentTarget.value)}
|
||||
description="Used for metadata and artwork lookups."
|
||||
/>
|
||||
<Group justify="space-between" align="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color="gray"
|
||||
onClick={() => setTmdbHintOpen(true)}
|
||||
>
|
||||
Where do I get this?
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={handleSaveTmdbKey}
|
||||
loading={savingTmdbKey || tmdbValidating}
|
||||
>
|
||||
Save Metadata Settings
|
||||
</Button>
|
||||
</Group>
|
||||
{tmdbValidationMessage && (
|
||||
<Group gap="xs" align="center">
|
||||
{tmdbValidating && <Loader size="xs" />}
|
||||
<Text size="xs" c={tmdbMessageColor}>
|
||||
{tmdbValidationMessage}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{!metadataSourcesAvailable && !tmdbValidating && (
|
||||
<Alert color="yellow" variant="light" radius="md">
|
||||
All metadata sources are currently unavailable. Configure a working TMDB
|
||||
key or try again once Movie-DB fallback is reachable.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{librariesLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : mediaLibraries.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
No libraries configured yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack spacing="sm">
|
||||
{mediaLibraries.map((library) => (
|
||||
<Group
|
||||
key={library.id}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p="sm"
|
||||
style={{
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={4} style={{ flex: 1 }}>
|
||||
<Group gap="sm">
|
||||
<Text fw={600}>{library.name}</Text>
|
||||
<Badge color="violet" variant="light">
|
||||
{library.library_type}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={library.auto_scan_enabled ? 'green' : 'gray'}
|
||||
variant="outline"
|
||||
>
|
||||
{library.auto_scan_enabled ? 'Auto-scan' : 'Manual'}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Last scan:{' '}
|
||||
{library.last_scan_at
|
||||
? new Date(library.last_scan_at).toLocaleString()
|
||||
: 'Never'}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Tooltip label="Trigger scan">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={() => handleLibrarySettingsScan(library)}
|
||||
>
|
||||
<RefreshCcw size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
setEditingLibrarySettings(library);
|
||||
setLibraryModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => handleLibrarySettingsDelete(library)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Center py="md">
|
||||
<Anchor
|
||||
href="https://www.themoviedb.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={tmdbLogoUrl}
|
||||
alt="Powered by TMDB"
|
||||
style={{
|
||||
width: 180,
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Anchor>
|
||||
</Center>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="dvr-settings">
|
||||
<Accordion.Control>DVR</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
|
|
@ -1184,9 +1601,66 @@ const SettingsPage = () => {
|
|||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</>
|
||||
)}
|
||||
</Accordion>
|
||||
</Box>
|
||||
)}
|
||||
</Accordion>
|
||||
</Box>
|
||||
|
||||
<Modal
|
||||
opened={tmdbHintOpen}
|
||||
onClose={() => setTmdbHintOpen(false)}
|
||||
title="How to get a TMDB API key"
|
||||
size="lg"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 2 }}
|
||||
>
|
||||
<Stack spacing="sm">
|
||||
<Text size="sm">
|
||||
Dispatcharr uses TMDB (The Movie Database) for artwork and metadata. You can create
|
||||
a key in just a couple of minutes:
|
||||
</Text>
|
||||
<List size="sm" spacing="xs">
|
||||
<List.Item>
|
||||
Visit{' '}
|
||||
<Anchor
|
||||
href="https://www.themoviedb.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
themoviedb.org
|
||||
</Anchor>{' '}
|
||||
and sign in or create a free account.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
Open your{' '}
|
||||
<Anchor
|
||||
href="https://www.themoviedb.org/settings/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
TMDB account settings
|
||||
</Anchor>{' '}
|
||||
and choose <Text component="span" fw={500}>API</Text> from the sidebar.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
Complete the short API application and copy the generated v3 API key into the field
|
||||
above.
|
||||
</List.Item>
|
||||
</List>
|
||||
<Text size="sm" c="dimmed">
|
||||
TMDB issues separate v3 and v4 keys—Dispatcharr only needs the v3 key for metadata lookups.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<LibraryFormModal
|
||||
opened={libraryModalOpen}
|
||||
onClose={() => {
|
||||
setLibraryModalOpen(false);
|
||||
setEditingLibrarySettings(null);
|
||||
}}
|
||||
library={editingLibrarySettings}
|
||||
onSubmit={handleLibrarySettingsSubmit}
|
||||
submitting={librarySubmitting}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={rehashConfirmOpen}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ const formatDuration = (seconds) => {
|
|||
|
||||
const VODCard = ({ vod, onClick }) => {
|
||||
const isEpisode = vod.type === 'episode';
|
||||
const posterSrc =
|
||||
vod.movie_image || vod.logo?.cache_url || vod.logo?.url || null;
|
||||
const fallbackPoster =
|
||||
vod.custom_properties?.poster_url || vod.custom_properties?.cover || null;
|
||||
const imageSrc = posterSrc || fallbackPoster;
|
||||
|
||||
const getDisplayTitle = () => {
|
||||
if (isEpisode && vod.series) {
|
||||
|
|
@ -71,9 +76,9 @@ const VODCard = ({ vod, onClick }) => {
|
|||
>
|
||||
<Card.Section>
|
||||
<Box style={{ position: 'relative', height: 300 }}>
|
||||
{vod.logo?.url ? (
|
||||
{imageSrc ? (
|
||||
<Image
|
||||
src={vod.logo.url}
|
||||
src={imageSrc}
|
||||
height={300}
|
||||
alt={vod.name}
|
||||
fit="contain"
|
||||
|
|
@ -163,6 +168,12 @@ const VODCard = ({ vod, onClick }) => {
|
|||
};
|
||||
|
||||
const SeriesCard = ({ series, onClick }) => {
|
||||
const seriesCover =
|
||||
series.series_image ||
|
||||
series.logo?.cache_url ||
|
||||
series.logo?.url ||
|
||||
series.custom_properties?.poster_url ||
|
||||
null;
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
|
|
@ -174,9 +185,9 @@ const SeriesCard = ({ series, onClick }) => {
|
|||
>
|
||||
<Card.Section>
|
||||
<Box style={{ position: 'relative', height: 300 }}>
|
||||
{series.logo?.url ? (
|
||||
{seriesCover ? (
|
||||
<Image
|
||||
src={series.logo.url}
|
||||
src={seriesCover}
|
||||
height={300}
|
||||
alt={series.name}
|
||||
fit="contain"
|
||||
|
|
|
|||
349
frontend/src/store/library.jsx
Normal file
349
frontend/src/store/library.jsx
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
import { create } from 'zustand';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import API from '../api';
|
||||
import useMediaLibraryStore from './mediaLibrary';
|
||||
|
||||
const DEFAULT_STAGE = {
|
||||
status: 'pending',
|
||||
processed: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const toNumber = (value) => {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : 0;
|
||||
};
|
||||
|
||||
const normalizeStage = (stage = {}, fallback = {}) => {
|
||||
const source = {
|
||||
...DEFAULT_STAGE,
|
||||
...fallback,
|
||||
...stage,
|
||||
};
|
||||
return {
|
||||
status: source.status || 'pending',
|
||||
processed: toNumber(source.processed),
|
||||
total: toNumber(source.total),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeStages = (scan = {}) => {
|
||||
const stageFromFields = (prefix) => ({
|
||||
status: scan?.[`${prefix}_status`],
|
||||
processed: scan?.[`${prefix}_processed`],
|
||||
total: scan?.[`${prefix}_total`],
|
||||
});
|
||||
|
||||
const sourceStages = scan?.stages || {};
|
||||
return {
|
||||
discovery: normalizeStage(sourceStages.discovery, stageFromFields('discovery')),
|
||||
metadata: normalizeStage(sourceStages.metadata, stageFromFields('metadata')),
|
||||
artwork: normalizeStage(sourceStages.artwork, stageFromFields('artwork')),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeScanEntry = (scan) => {
|
||||
if (!scan) return scan;
|
||||
let processed = scan.processed_files ?? scan.processed;
|
||||
if (processed == null && scan.status === 'completed' && scan.total_files != null) {
|
||||
processed = scan.total_files;
|
||||
}
|
||||
if (processed == null) {
|
||||
processed = 0;
|
||||
}
|
||||
processed = toNumber(processed);
|
||||
const stages = normalizeStages(scan);
|
||||
return {
|
||||
...scan,
|
||||
processed,
|
||||
processed_files: processed,
|
||||
stages,
|
||||
discovery_status: stages.discovery.status,
|
||||
discovery_processed: stages.discovery.processed,
|
||||
discovery_total: stages.discovery.total,
|
||||
metadata_status: stages.metadata.status,
|
||||
metadata_processed: stages.metadata.processed,
|
||||
metadata_total: stages.metadata.total,
|
||||
artwork_status: stages.artwork.status,
|
||||
artwork_processed: stages.artwork.processed,
|
||||
artwork_total: stages.artwork.total,
|
||||
};
|
||||
};
|
||||
|
||||
const useLibraryStore = create(
|
||||
immer((set, get) => ({
|
||||
libraries: [],
|
||||
loading: false,
|
||||
scans: {},
|
||||
scansLoading: false,
|
||||
error: null,
|
||||
selectedLibraryId: null,
|
||||
filters: {
|
||||
search: '',
|
||||
type: 'all',
|
||||
autoScan: 'all',
|
||||
},
|
||||
|
||||
setSelectedLibrary: (id) =>
|
||||
set((state) => {
|
||||
state.selectedLibraryId = id;
|
||||
}),
|
||||
|
||||
setFilters: (filters) =>
|
||||
set((state) => {
|
||||
state.filters = { ...state.filters, ...filters };
|
||||
}),
|
||||
|
||||
fetchLibraries: async () => {
|
||||
set((state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
});
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
const { filters } = get();
|
||||
if (filters.type !== 'all') {
|
||||
params.append('library_type', filters.type);
|
||||
}
|
||||
if (filters.autoScan !== 'all') {
|
||||
params.append('auto_scan_enabled', filters.autoScan === 'enabled');
|
||||
}
|
||||
if (filters.search) {
|
||||
params.append('search', filters.search);
|
||||
}
|
||||
const data = await API.getMediaLibraries(params);
|
||||
set((state) => {
|
||||
state.libraries = Array.isArray(data) ? data : data.results || [];
|
||||
state.loading = false;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch libraries', error);
|
||||
set((state) => {
|
||||
state.error = 'Failed to load libraries';
|
||||
state.loading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createLibrary: async (payload) => {
|
||||
const response = await API.createMediaLibrary(payload);
|
||||
set((state) => {
|
||||
state.libraries.push(response);
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
updateLibrary: async (id, payload) => {
|
||||
const response = await API.updateMediaLibrary(id, payload);
|
||||
set((state) => {
|
||||
const index = state.libraries.findIndex((lib) => lib.id === id);
|
||||
if (index >= 0) {
|
||||
state.libraries[index] = { ...state.libraries[index], ...response };
|
||||
}
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
deleteLibrary: async (id) => {
|
||||
await API.deleteMediaLibrary(id);
|
||||
set((state) => {
|
||||
state.libraries = state.libraries.filter((lib) => lib.id !== id);
|
||||
if (state.selectedLibraryId === id) {
|
||||
state.selectedLibraryId = null;
|
||||
}
|
||||
delete state.scans[id];
|
||||
});
|
||||
},
|
||||
|
||||
purgeCompletedScans: async (options = {}) => {
|
||||
const response = await API.purgeLibraryScans(options);
|
||||
const statuses =
|
||||
Array.isArray(options.statuses) && options.statuses.length > 0
|
||||
? options.statuses
|
||||
: ['completed', 'failed', 'cancelled'];
|
||||
const libraryFilter =
|
||||
options.library !== undefined && options.library !== null
|
||||
? Number(options.library)
|
||||
: null;
|
||||
|
||||
set((state) => {
|
||||
const keysToUpdate = new Set(['all']);
|
||||
if (libraryFilter !== null) {
|
||||
keysToUpdate.add(libraryFilter);
|
||||
} else {
|
||||
Object.keys(state.scans).forEach((key) => keysToUpdate.add(key));
|
||||
}
|
||||
|
||||
keysToUpdate.forEach((key) => {
|
||||
const list = state.scans[key];
|
||||
if (!Array.isArray(list)) return;
|
||||
state.scans[key] = list.filter((scan) => {
|
||||
const statusMatch = statuses.includes(scan.status);
|
||||
const libraryMatch =
|
||||
libraryFilter === null || Number(scan.library) === libraryFilter;
|
||||
return !(statusMatch && libraryMatch);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
triggerScan: async (id, options = {}) => {
|
||||
const response = await API.triggerLibraryScan(id, options);
|
||||
set((state) => {
|
||||
if (!state.scans[id]) {
|
||||
state.scans[id] = [];
|
||||
}
|
||||
const normalized = normalizeScanEntry(response);
|
||||
state.scans[id] = [normalized, ...(state.scans[id] || [])];
|
||||
state.scans['all'] = [normalized, ...(state.scans['all'] || [])];
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
fetchScans: async (libraryId, options = {}) => {
|
||||
const { background = false } = options;
|
||||
if (!background) {
|
||||
set((state) => {
|
||||
state.scansLoading = true;
|
||||
});
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (libraryId) {
|
||||
params.append('library', libraryId);
|
||||
}
|
||||
const response = await API.getLibraryScans(params);
|
||||
set((state) => {
|
||||
const payload = Array.isArray(response)
|
||||
? response
|
||||
: response.results || [];
|
||||
state.scans[libraryId || 'all'] = payload.map((scan) =>
|
||||
normalizeScanEntry(scan)
|
||||
);
|
||||
if (!background) {
|
||||
state.scansLoading = false;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch scans', error);
|
||||
if (!background) {
|
||||
set((state) => {
|
||||
state.scansLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
applyScanUpdate: (event) =>
|
||||
set((state) => {
|
||||
if (!event?.scan_id) return;
|
||||
const scanId = event.scan_id;
|
||||
const libraryId = event.library_id || null;
|
||||
|
||||
if (event.media_item) {
|
||||
useMediaLibraryStore.getState().upsertItems([event.media_item]);
|
||||
}
|
||||
|
||||
const updateList = (list) => {
|
||||
const items = list ? [...list] : [];
|
||||
const index = items.findIndex((scan) => String(scan.id) === String(scanId));
|
||||
const existing = index >= 0 ? items[index] : undefined;
|
||||
const processedValue = toNumber(
|
||||
event.processed_files ??
|
||||
event.processed ??
|
||||
existing?.processed_files ??
|
||||
existing?.processed ??
|
||||
0
|
||||
);
|
||||
const stageSource = {
|
||||
...(existing || {}),
|
||||
...event,
|
||||
stages: event.stages ?? existing?.stages,
|
||||
};
|
||||
const stages = normalizeStages(stageSource);
|
||||
const updatedEntry = {
|
||||
id: scanId,
|
||||
library: libraryId ?? existing?.library ?? null,
|
||||
library_name: event.library_name || existing?.library_name || '',
|
||||
status: event.status || 'running',
|
||||
summary: event.summary || event.message || items[index]?.summary || '',
|
||||
matched_items: event.matched ?? items[index]?.matched_items ?? null,
|
||||
unmatched_files: event.unmatched ?? items[index]?.unmatched_files ?? null,
|
||||
total_files: event.total ?? event.files ?? items[index]?.total_files ?? null,
|
||||
new_files: event.new_files ?? items[index]?.new_files ?? null,
|
||||
updated_files: event.updated_files ?? items[index]?.updated_files ?? null,
|
||||
removed_files: event.removed_files ?? items[index]?.removed_files ?? null,
|
||||
processed: processedValue,
|
||||
processed_files: processedValue,
|
||||
stages,
|
||||
discovery_status: stages.discovery.status,
|
||||
discovery_processed: stages.discovery.processed,
|
||||
discovery_total: stages.discovery.total,
|
||||
metadata_status: stages.metadata.status,
|
||||
metadata_processed: stages.metadata.processed,
|
||||
metadata_total: stages.metadata.total,
|
||||
artwork_status: stages.artwork.status,
|
||||
artwork_processed: stages.artwork.processed,
|
||||
artwork_total: stages.artwork.total,
|
||||
created_at:
|
||||
(items[index]?.created_at || new Date().toISOString()),
|
||||
finished_at:
|
||||
['completed', 'failed', 'cancelled'].includes(event.status)
|
||||
? new Date().toISOString()
|
||||
: items[index]?.finished_at || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (index >= 0) {
|
||||
items[index] = normalizeScanEntry({ ...items[index], ...updatedEntry });
|
||||
} else {
|
||||
items.unshift(normalizeScanEntry(updatedEntry));
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const keysToUpdate = [libraryId || 'all'];
|
||||
if (libraryId && libraryId !== 'all') {
|
||||
keysToUpdate.push('all');
|
||||
}
|
||||
|
||||
keysToUpdate.forEach((key) => {
|
||||
state.scans[key] = updateList(state.scans[key]);
|
||||
});
|
||||
}),
|
||||
|
||||
upsertScan: (scan) =>
|
||||
set((state) => {
|
||||
if (!scan || !scan.id) return;
|
||||
const normalized = normalizeScanEntry(scan);
|
||||
const targetKeys = new Set(['all']);
|
||||
if (normalized.library) {
|
||||
targetKeys.add(normalized.library);
|
||||
}
|
||||
targetKeys.forEach((key) => {
|
||||
const items = state.scans[key] ? [...state.scans[key]] : [];
|
||||
const index = items.findIndex((entry) => String(entry.id) === String(normalized.id));
|
||||
if (index >= 0) {
|
||||
items[index] = { ...items[index], ...normalized };
|
||||
} else {
|
||||
items.unshift(normalized);
|
||||
}
|
||||
state.scans[key] = items;
|
||||
});
|
||||
}),
|
||||
|
||||
removeScan: (scanId) =>
|
||||
set((state) => {
|
||||
if (!scanId) return;
|
||||
Object.keys(state.scans).forEach((key) => {
|
||||
const list = state.scans[key];
|
||||
if (!Array.isArray(list)) return;
|
||||
state.scans[key] = list.filter((scan) => String(scan.id) !== String(scanId));
|
||||
});
|
||||
}),
|
||||
}))
|
||||
);
|
||||
|
||||
export default useLibraryStore;
|
||||
411
frontend/src/store/mediaLibrary.jsx
Normal file
411
frontend/src/store/mediaLibrary.jsx
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
import { create } from 'zustand';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import API from '../api';
|
||||
import useAuthStore from './auth';
|
||||
|
||||
const METADATA_REFRESH_COOLDOWN_MS = 60 * 1000;
|
||||
const metadataRequestCache = new Map();
|
||||
|
||||
const shouldRefreshMetadata = (item) => {
|
||||
if (!item || !item.id) return false;
|
||||
return !item.poster_url || !item.metadata_last_synced_at;
|
||||
};
|
||||
|
||||
const queueMetadataRefresh = (items, { force = false } = {}) => {
|
||||
if (!Array.isArray(items) || items.length === 0) return;
|
||||
|
||||
if (!force) {
|
||||
// Discovery pipeline now queues metadata/artwork on the backend.
|
||||
// Skip client-side batching to avoid flooding the API when large scans run.
|
||||
items.forEach((item) => {
|
||||
if (item?.id) {
|
||||
metadataRequestCache.delete(item.id);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
items.forEach((item) => {
|
||||
if (!shouldRefreshMetadata(item)) {
|
||||
if (item?.id) {
|
||||
metadataRequestCache.delete(item.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const lastRequested = metadataRequestCache.get(item.id) || 0;
|
||||
if (!force && now - lastRequested < METADATA_REFRESH_COOLDOWN_MS) return;
|
||||
metadataRequestCache.set(item.id, now);
|
||||
API.refreshMediaItemMetadata(item.id).catch((error) => {
|
||||
console.debug('Auto metadata refresh failed', error);
|
||||
metadataRequestCache.delete(item.id);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const initialFilters = {
|
||||
type: 'all',
|
||||
search: '',
|
||||
status: 'all',
|
||||
year: '',
|
||||
};
|
||||
|
||||
const getAuthSnapshot = () => {
|
||||
const authState = useAuthStore.getState();
|
||||
return {
|
||||
isAuthenticated: authState.isAuthenticated,
|
||||
userId: authState.user?.id ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const resetStateForUser = (state, userId) => {
|
||||
state.items = [];
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
state.total = 0;
|
||||
state.activeItem = null;
|
||||
state.activeItemError = null;
|
||||
state.activeProgress = null;
|
||||
state.activeItemLoading = false;
|
||||
state.resumePrompt = null;
|
||||
state.selectedLibraryId = null;
|
||||
state.activeLibraryIds = [];
|
||||
state.filters = { ...initialFilters };
|
||||
state.ownerUserId = userId ?? null;
|
||||
};
|
||||
|
||||
const useMediaLibraryStore = create(
|
||||
immer((set, get) => ({
|
||||
ownerUserId: null,
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
total: 0,
|
||||
activeItem: null,
|
||||
activeItemError: null,
|
||||
activeProgress: null,
|
||||
activeItemLoading: false,
|
||||
resumePrompt: null,
|
||||
selectedLibraryId: null,
|
||||
activeLibraryIds: [],
|
||||
filters: { ...initialFilters },
|
||||
|
||||
applyUserContext: (userId) =>
|
||||
set((state) => {
|
||||
const normalized = userId ?? null;
|
||||
if (state.ownerUserId === normalized) {
|
||||
return;
|
||||
}
|
||||
resetStateForUser(state, normalized);
|
||||
}),
|
||||
|
||||
setFilters: (updated) =>
|
||||
set((state) => {
|
||||
state.filters = { ...state.filters, ...updated };
|
||||
}),
|
||||
|
||||
setSelectedLibraryId: (libraryId) =>
|
||||
set((state) => {
|
||||
const { userId } = getAuthSnapshot();
|
||||
if (state.ownerUserId == null) {
|
||||
state.ownerUserId = userId ?? null;
|
||||
} else if (userId !== null && state.ownerUserId !== userId) {
|
||||
resetStateForUser(state, userId);
|
||||
}
|
||||
state.selectedLibraryId = libraryId;
|
||||
state.activeLibraryIds = libraryId != null ? [Number(libraryId)] : [];
|
||||
}),
|
||||
|
||||
resetFilters: () =>
|
||||
set((state) => {
|
||||
state.filters = { ...initialFilters };
|
||||
}),
|
||||
|
||||
upsertItems: (itemsToUpsert) =>
|
||||
set((state) => {
|
||||
const { userId } = getAuthSnapshot();
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
if (state.ownerUserId == null) {
|
||||
state.ownerUserId = userId;
|
||||
} else if (state.ownerUserId !== userId) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(itemsToUpsert) || itemsToUpsert.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeLibraryIds = get().activeLibraryIds || [];
|
||||
const filterByLibrary = activeLibraryIds.length > 0;
|
||||
|
||||
const byId = new Map();
|
||||
state.items.forEach((item) => {
|
||||
byId.set(item.id, item);
|
||||
});
|
||||
|
||||
itemsToUpsert.forEach((incoming) => {
|
||||
if (!incoming || typeof incoming !== 'object' || !incoming.id) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
filterByLibrary &&
|
||||
incoming.library &&
|
||||
!activeLibraryIds.includes(Number(incoming.library))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const existing = byId.get(incoming.id) || {};
|
||||
byId.set(incoming.id, { ...existing, ...incoming });
|
||||
});
|
||||
|
||||
const sorted = Array.from(byId.values()).sort((a, b) => {
|
||||
const aTitle = (a.sort_title || a.title || '').toLowerCase();
|
||||
const bTitle = (b.sort_title || b.title || '').toLowerCase();
|
||||
return aTitle.localeCompare(bTitle);
|
||||
});
|
||||
|
||||
state.items = sorted;
|
||||
state.total = sorted.length;
|
||||
}),
|
||||
|
||||
removeItems: (ids) =>
|
||||
set((state) => {
|
||||
const { userId } = getAuthSnapshot();
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
if (state.ownerUserId == null) {
|
||||
state.ownerUserId = userId;
|
||||
} else if (state.ownerUserId !== userId) {
|
||||
return;
|
||||
}
|
||||
const idSet = new Set(Array.isArray(ids) ? ids : [ids]);
|
||||
state.items = state.items.filter((item) => !idSet.has(item.id));
|
||||
state.total = state.items.length;
|
||||
}),
|
||||
|
||||
fetchItems: async (libraryIds) => {
|
||||
const { isAuthenticated, userId } = getAuthSnapshot();
|
||||
|
||||
if (!isAuthenticated || !userId) {
|
||||
set((state) => {
|
||||
if (state.ownerUserId == null) {
|
||||
state.ownerUserId = userId ?? null;
|
||||
} else if (userId !== null && state.ownerUserId !== userId) {
|
||||
resetStateForUser(state, userId);
|
||||
return;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedIds = Array.isArray(libraryIds)
|
||||
? Array.from(
|
||||
new Set(
|
||||
libraryIds
|
||||
.map((id) => Number(id))
|
||||
.filter((id) => Number.isFinite(id))
|
||||
)
|
||||
)
|
||||
: libraryIds != null
|
||||
? [Number(libraryIds)].filter((id) => Number.isFinite(id))
|
||||
: [];
|
||||
|
||||
set((state) => {
|
||||
if (state.ownerUserId == null) {
|
||||
state.ownerUserId = userId;
|
||||
} else if (state.ownerUserId !== userId) {
|
||||
resetStateForUser(state, userId);
|
||||
}
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
state.activeLibraryIds = normalizedIds;
|
||||
state.selectedLibraryId = normalizedIds.length > 0 ? normalizedIds[0] : null;
|
||||
});
|
||||
|
||||
try {
|
||||
const { filters } = get();
|
||||
const params = new URLSearchParams();
|
||||
normalizedIds.forEach((id) => params.append('library', id));
|
||||
if (filters.type !== 'all') {
|
||||
params.append('item_type', filters.type);
|
||||
}
|
||||
if (filters.status !== 'all') {
|
||||
params.append('status', filters.status);
|
||||
}
|
||||
if (filters.year) {
|
||||
params.append('release_year', filters.year);
|
||||
}
|
||||
if (filters.search) {
|
||||
params.append('search', filters.search);
|
||||
}
|
||||
const response = await API.getMediaItems(params);
|
||||
const results = response.results || response;
|
||||
const itemsArray = Array.isArray(results) ? results : [];
|
||||
set((state) => {
|
||||
if (state.ownerUserId !== userId) {
|
||||
return;
|
||||
}
|
||||
state.activeLibraryIds = normalizedIds;
|
||||
state.selectedLibraryId = normalizedIds.length > 0 ? normalizedIds[0] : null;
|
||||
state.items = itemsArray;
|
||||
state.total = response.count || itemsArray.length || 0;
|
||||
state.loading = false;
|
||||
});
|
||||
queueMetadataRefresh(itemsArray);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch media items', error);
|
||||
set((state) => {
|
||||
if (state.ownerUserId !== userId) {
|
||||
return;
|
||||
}
|
||||
state.error = 'Failed to load media items';
|
||||
state.loading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
openItem: async (id) => {
|
||||
const { isAuthenticated, userId } = getAuthSnapshot();
|
||||
if (!isAuthenticated || !userId) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const currentItems = get().items || [];
|
||||
const fallbackItem =
|
||||
currentItems.find((item) => item.id === id) || null;
|
||||
|
||||
set((state) => {
|
||||
if (state.ownerUserId == null) {
|
||||
state.ownerUserId = userId;
|
||||
} else if (state.ownerUserId !== userId) {
|
||||
resetStateForUser(state, userId);
|
||||
}
|
||||
state.activeItemLoading = true;
|
||||
state.resumePrompt = null;
|
||||
state.activeProgress = null;
|
||||
state.activeItemError = null;
|
||||
state.activeItem = fallbackItem ? { ...fallbackItem } : null;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await API.getMediaItem(id, {
|
||||
suppressErrorNotification: true,
|
||||
});
|
||||
const progress = response.watch_progress || null;
|
||||
set((state) => {
|
||||
if (state.ownerUserId !== userId) {
|
||||
return;
|
||||
}
|
||||
state.activeItem = response;
|
||||
state.activeItemLoading = false;
|
||||
state.activeProgress = progress;
|
||||
state.activeItemError = null;
|
||||
});
|
||||
get().upsertItems([response]);
|
||||
queueMetadataRefresh([response], { force: true });
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to load media item', error);
|
||||
if (fallbackItem) {
|
||||
let files = [];
|
||||
try {
|
||||
files = await API.getMediaItemFiles(id, {
|
||||
suppressErrorNotification: true,
|
||||
});
|
||||
} catch (fileError) {
|
||||
console.error('Failed to load media files for fallback item', fileError);
|
||||
}
|
||||
|
||||
const fallbackDetail = {
|
||||
...fallbackItem,
|
||||
files,
|
||||
metadataPending: true,
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
if (state.ownerUserId !== userId) {
|
||||
return;
|
||||
}
|
||||
state.activeItem = fallbackDetail;
|
||||
state.activeItemLoading = false;
|
||||
state.activeProgress = fallbackItem.watch_progress || null;
|
||||
state.activeItemError = 'metadata_pending';
|
||||
});
|
||||
queueMetadataRefresh([fallbackItem]);
|
||||
return fallbackDetail;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
if (state.ownerUserId === userId) {
|
||||
state.activeItemLoading = false;
|
||||
state.activeItemError = 'load_failed';
|
||||
}
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
closeItem: () =>
|
||||
set((state) => {
|
||||
state.activeItem = null;
|
||||
state.activeItemError = null;
|
||||
state.resumePrompt = null;
|
||||
state.activeProgress = null;
|
||||
}),
|
||||
|
||||
setActiveProgress: (progress) =>
|
||||
set((state) => {
|
||||
const { userId } = getAuthSnapshot();
|
||||
if (!userId || state.ownerUserId !== userId) {
|
||||
return;
|
||||
}
|
||||
if (state.activeItem) {
|
||||
state.activeItem = { ...state.activeItem, watch_progress: progress };
|
||||
}
|
||||
state.items = state.items.map((item) =>
|
||||
item.id === state.activeItem?.id
|
||||
? { ...item, watch_progress: progress }
|
||||
: item
|
||||
);
|
||||
state.activeProgress = progress;
|
||||
}),
|
||||
|
||||
requestResume: async (progressId) => {
|
||||
const { isAuthenticated, userId } = getAuthSnapshot();
|
||||
if (!isAuthenticated || !userId || !progressId) return null;
|
||||
try {
|
||||
const response = await API.resumeMediaProgress(progressId);
|
||||
set((state) => {
|
||||
if (state.ownerUserId !== userId) {
|
||||
return;
|
||||
}
|
||||
state.resumePrompt = response;
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to get resume info', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
clearResumePrompt: () =>
|
||||
set((state) => {
|
||||
state.resumePrompt = null;
|
||||
}),
|
||||
}))
|
||||
);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const initialUserId = getAuthSnapshot().userId;
|
||||
useMediaLibraryStore.getState().applyUserContext(initialUserId);
|
||||
useAuthStore.subscribe(
|
||||
(state) => state.user?.id ?? null,
|
||||
(userId) => {
|
||||
useMediaLibraryStore.getState().applyUserContext(userId);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default useMediaLibraryStore;
|
||||
|
|
@ -210,6 +210,17 @@ const useVODStore = create((set, get) => ({
|
|||
bitrate: response.bitrate || 0,
|
||||
video: response.video || {},
|
||||
audio: response.audio || {},
|
||||
library_sources:
|
||||
response.library_sources ||
|
||||
(response.library_item_id
|
||||
? [
|
||||
{
|
||||
library_id: response.m3u_account?.id || null,
|
||||
library_name: response.m3u_account?.name || 'Library',
|
||||
media_item_id: response.library_item_id,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
};
|
||||
|
||||
set({ loading: false }); // Only update loading state
|
||||
|
|
@ -332,6 +343,8 @@ const useVODStore = create((set, get) => ({
|
|||
tmdb_id: response.tmdb_id || '',
|
||||
imdb_id: response.imdb_id || '',
|
||||
episode_count: response.episode_count || 0,
|
||||
library_sources: response.library_sources || [],
|
||||
library_item_id: response.library_item_id || null,
|
||||
// Additional provider fields
|
||||
backdrop_path: response.custom_properties?.backdrop_path || [],
|
||||
release_date: response.release_date || '',
|
||||
|
|
@ -351,9 +364,9 @@ const useVODStore = create((set, get) => ({
|
|||
seasonEpisodes.forEach((episode) => {
|
||||
const episodeData = {
|
||||
id: episode.id,
|
||||
stream_id: episode.id,
|
||||
name: episode.title || '',
|
||||
description: episode.plot || '',
|
||||
stream_id: episode.stream_id || episode.id,
|
||||
name: episode.title || episode.name || '',
|
||||
description: episode.plot || episode.description || '',
|
||||
season_number: parseInt(seasonNumber) || 0,
|
||||
episode_number: episode.episode_number || 0,
|
||||
duration_secs: episode.duration_secs || null,
|
||||
|
|
@ -364,12 +377,15 @@ const useVODStore = create((set, get) => ({
|
|||
name: seriesInfo.name,
|
||||
},
|
||||
type: 'episode',
|
||||
uuid: episode.id, // Use the stream ID as UUID for playback
|
||||
uuid: episode.uuid || episode.id,
|
||||
logo: episode.movie_image ? { url: episode.movie_image } : null,
|
||||
air_date: episode.air_date || null,
|
||||
movie_image: episode.movie_image || null,
|
||||
tmdb_id: episode.tmdb_id || '',
|
||||
imdb_id: episode.imdb_id || '',
|
||||
library_media_item_ids:
|
||||
episode.library_media_item_ids || [],
|
||||
providers: episode.providers || [],
|
||||
};
|
||||
episodesData[episode.id] = episodeData;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,14 @@ const useVideoStore = create((set) => ({
|
|||
isVisible: true,
|
||||
streamUrl: url,
|
||||
contentType: type,
|
||||
metadata: metadata,
|
||||
metadata: metadata
|
||||
? {
|
||||
startOffsetMs: 0,
|
||||
requiresTranscode: false,
|
||||
transcodeStatus: null,
|
||||
...metadata,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
|
||||
hideVideo: () =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue