mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Media Server
Added media center capabilities
This commit is contained in:
parent
ee183a9f75
commit
ff90771a3f
39 changed files with 9616 additions and 14 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Media Library app with library management, scanning pipeline, watch progress, and metadata sync
|
||||
- Libraries management page and Media Library browsing page in the frontend, plus sidebar navigation
|
||||
- Media Library API endpoints for libraries, scans, directory browse, items, episodes, and stream URLs
|
||||
- VOD integration for Media Library items, including local-file playback through the VOD proxy
|
||||
|
||||
### Changed
|
||||
|
||||
- VOD proxy supports local file streaming and optional inclusion of inactive accounts for library playback
|
||||
|
||||
### Fixed
|
||||
|
||||
- XtreamCodes EPG `has_archive` field now returns integer `0` instead of string `"0"` for proper JSON type consistency
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ urlpatterns = [
|
|||
path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')),
|
||||
path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')),
|
||||
path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')),
|
||||
path('media-library/', include(('apps.media_library.api_urls', 'media_library'), namespace='media_library')),
|
||||
# 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')),
|
||||
|
|
|
|||
0
apps/media_library/__init__.py
Normal file
0
apps/media_library/__init__.py
Normal file
22
apps/media_library/admin.py
Normal file
22
apps/media_library/admin.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from apps.media_library.models import (
|
||||
ArtworkAsset,
|
||||
Library,
|
||||
LibraryLocation,
|
||||
LibraryScan,
|
||||
MediaFile,
|
||||
MediaItem,
|
||||
MediaItemVODLink,
|
||||
WatchProgress,
|
||||
)
|
||||
|
||||
|
||||
admin.site.register(Library)
|
||||
admin.site.register(LibraryLocation)
|
||||
admin.site.register(MediaItem)
|
||||
admin.site.register(MediaFile)
|
||||
admin.site.register(MediaItemVODLink)
|
||||
admin.site.register(ArtworkAsset)
|
||||
admin.site.register(WatchProgress)
|
||||
admin.site.register(LibraryScan)
|
||||
22
apps/media_library/api_urls.py
Normal file
22
apps/media_library/api_urls.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.media_library.api_views import (
|
||||
LibraryScanViewSet,
|
||||
LibraryViewSet,
|
||||
MediaItemViewSet,
|
||||
browse_library_path,
|
||||
)
|
||||
|
||||
app_name = "media_library"
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"libraries", LibraryViewSet, basename="library")
|
||||
router.register(r"scans", LibraryScanViewSet, basename="library-scan")
|
||||
router.register(r"items", MediaItemViewSet, basename="media-item")
|
||||
|
||||
urlpatterns = [
|
||||
path("browse/", browse_library_path, name="browse"),
|
||||
]
|
||||
|
||||
urlpatterns += router.urls
|
||||
455
apps/media_library/api_views.py
Normal file
455
apps/media_library/api_views.py
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
import os
|
||||
from django.utils import timezone
|
||||
from django.urls import reverse
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.accounts.permissions import Authenticated, IsAdmin, permission_classes_by_action
|
||||
from apps.media_library.models import Library, LibraryScan, MediaFile, MediaItem, WatchProgress
|
||||
from apps.media_library.serializers import (
|
||||
LibraryScanSerializer,
|
||||
LibrarySerializer,
|
||||
MediaItemDetailSerializer,
|
||||
MediaItemSerializer,
|
||||
MediaItemUpdateSerializer,
|
||||
)
|
||||
from apps.media_library.tasks import refresh_media_item_metadata, scan_library
|
||||
from apps.media_library.vod import sync_library_vod_account_state, sync_vod_for_media_item
|
||||
|
||||
|
||||
class MediaLibraryPagination(PageNumberPagination):
|
||||
page_size = 200
|
||||
page_size_query_param = "limit"
|
||||
max_page_size = 1000
|
||||
|
||||
def get_page_size(self, request):
|
||||
if "limit" not in request.query_params and "page" not in request.query_params:
|
||||
return None
|
||||
return super().get_page_size(request)
|
||||
|
||||
|
||||
class LibraryViewSet(viewsets.ModelViewSet):
|
||||
queryset = Library.objects.prefetch_related("locations")
|
||||
serializer_class = LibrarySerializer
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
library = serializer.save()
|
||||
sync_library_vod_account_state(library)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
library = serializer.save()
|
||||
sync_library_vod_account_state(library)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="scan")
|
||||
def start_scan(self, request, pk=None):
|
||||
library = self.get_object()
|
||||
full = bool(request.data.get("full", False))
|
||||
scan = LibraryScan.objects.create(
|
||||
library=library,
|
||||
scan_type=LibraryScan.SCAN_FULL if full else LibraryScan.SCAN_QUICK,
|
||||
status=LibraryScan.STATUS_QUEUED,
|
||||
summary="Full scan" if full else "Quick scan",
|
||||
stages={},
|
||||
)
|
||||
task = scan_library.delay(library.id, full=full, scan_id=scan.id)
|
||||
scan.task_id = task.id
|
||||
scan.save(update_fields=["task_id", "updated_at"])
|
||||
return Response(LibraryScanSerializer(scan).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class LibraryScanViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = LibraryScanSerializer
|
||||
pagination_class = MediaLibraryPagination
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = LibraryScan.objects.all()
|
||||
library_id = self.request.query_params.get("library")
|
||||
if library_id:
|
||||
queryset = queryset.filter(library_id=library_id)
|
||||
return queryset
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="cancel")
|
||||
def cancel_scan(self, request, pk=None):
|
||||
scan = self.get_object()
|
||||
if scan.status not in {
|
||||
LibraryScan.STATUS_PENDING,
|
||||
LibraryScan.STATUS_QUEUED,
|
||||
LibraryScan.STATUS_RUNNING,
|
||||
}:
|
||||
return Response(
|
||||
{"detail": "Scan is not running."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
scan.status = LibraryScan.STATUS_CANCELLED
|
||||
scan.save(update_fields=["status", "updated_at"])
|
||||
return Response(LibraryScanSerializer(scan).data)
|
||||
|
||||
@action(detail=False, methods=["delete"], url_path="purge")
|
||||
def purge_scans(self, request):
|
||||
library_id = request.query_params.get("library")
|
||||
queryset = LibraryScan.objects.filter(
|
||||
status__in=[
|
||||
LibraryScan.STATUS_COMPLETED,
|
||||
LibraryScan.STATUS_FAILED,
|
||||
LibraryScan.STATUS_CANCELLED,
|
||||
]
|
||||
)
|
||||
if library_id:
|
||||
queryset = queryset.filter(library_id=library_id)
|
||||
deleted, _ = queryset.delete()
|
||||
return Response({"deleted": deleted})
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
scan = self.get_object()
|
||||
if scan.status not in {LibraryScan.STATUS_PENDING, LibraryScan.STATUS_QUEUED}:
|
||||
return Response(
|
||||
{"detail": "Only queued scans can be deleted."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class MediaItemViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = MediaItemSerializer
|
||||
pagination_class = MediaLibraryPagination
|
||||
filter_backends = [SearchFilter, OrderingFilter]
|
||||
search_fields = ["title", "sort_title", "normalized_title"]
|
||||
ordering_fields = [
|
||||
"updated_at",
|
||||
"first_imported_at",
|
||||
"release_year",
|
||||
"title",
|
||||
"sort_title",
|
||||
]
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = MediaItem.objects.all()
|
||||
library_ids = self.request.query_params.getlist("library")
|
||||
if not library_ids:
|
||||
libraries = self.request.query_params.get("libraries") or self.request.query_params.get("library_ids")
|
||||
if libraries:
|
||||
library_ids = [entry for entry in libraries.split(",") if entry]
|
||||
if library_ids:
|
||||
queryset = queryset.filter(library_id__in=library_ids)
|
||||
item_type = self.request.query_params.get("type")
|
||||
if item_type:
|
||||
queryset = queryset.filter(item_type=item_type)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "retrieve":
|
||||
return MediaItemDetailSerializer
|
||||
if self.action in {"update", "partial_update"}:
|
||||
return MediaItemUpdateSerializer
|
||||
return MediaItemSerializer
|
||||
|
||||
def perform_update(self, serializer):
|
||||
media_item = serializer.save()
|
||||
try:
|
||||
sync_vod_for_media_item(media_item)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="episodes")
|
||||
def episodes(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
if item.item_type != MediaItem.TYPE_SHOW:
|
||||
return Response(
|
||||
{"detail": "Episodes are only available for series."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
episodes = (
|
||||
MediaItem.objects.filter(parent=item, item_type=MediaItem.TYPE_EPISODE)
|
||||
.order_by("season_number", "episode_number", "id")
|
||||
)
|
||||
serializer = MediaItemSerializer(episodes, many=True, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="refresh-metadata")
|
||||
def refresh_metadata(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
refresh_media_item_metadata.delay(item.id)
|
||||
return Response({"queued": True})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="stream")
|
||||
def stream(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
if item.item_type not in {MediaItem.TYPE_MOVIE, MediaItem.TYPE_EPISODE}:
|
||||
return Response(
|
||||
{"detail": "Streaming is only available for movies and episodes."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
file_id = request.data.get("fileId") or request.data.get("file_id")
|
||||
media_file = None
|
||||
if file_id:
|
||||
media_file = MediaFile.objects.filter(id=file_id, media_item=item).first()
|
||||
if not media_file:
|
||||
media_file = item.files.filter(is_primary=True).first() or item.files.first()
|
||||
if not media_file:
|
||||
return Response(
|
||||
{"detail": "No media file is linked to this item."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
sync_vod_for_media_item(item)
|
||||
item.refresh_from_db()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
link = getattr(item, "vod_link", None)
|
||||
vod_uuid = None
|
||||
vod_type = None
|
||||
if link:
|
||||
if item.item_type == MediaItem.TYPE_MOVIE and link.vod_movie_id:
|
||||
vod_uuid = link.vod_movie.uuid
|
||||
vod_type = "movie"
|
||||
elif item.item_type == MediaItem.TYPE_EPISODE and link.vod_episode_id:
|
||||
vod_uuid = link.vod_episode.uuid
|
||||
vod_type = "episode"
|
||||
|
||||
if not vod_uuid:
|
||||
return Response(
|
||||
{"detail": "Streaming endpoint is not ready yet."},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
params = []
|
||||
if item.library.vod_account_id:
|
||||
params.append(f"m3u_account_id={item.library.vod_account_id}")
|
||||
params.append("include_inactive=1")
|
||||
|
||||
query = f"?{'&'.join(params)}" if params else ""
|
||||
stream_path = reverse(
|
||||
"proxy:vod_proxy:vod_stream",
|
||||
kwargs={"content_type": vod_type, "content_id": vod_uuid},
|
||||
)
|
||||
stream_url = request.build_absolute_uri(f"{stream_path}{query}")
|
||||
return Response(
|
||||
{
|
||||
"url": stream_url,
|
||||
"stream_url": stream_url,
|
||||
"start_offset_ms": 0,
|
||||
"file_id": media_file.id,
|
||||
"duration_ms": media_file.duration_ms or item.runtime_ms,
|
||||
"requires_transcode": False,
|
||||
}
|
||||
)
|
||||
|
||||
def _get_duration_ms(self, media_item: MediaItem) -> int:
|
||||
if media_item.runtime_ms:
|
||||
return media_item.runtime_ms
|
||||
file = media_item.files.filter(is_primary=True).first() or media_item.files.first()
|
||||
if file and file.duration_ms:
|
||||
return file.duration_ms
|
||||
return 0
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="progress")
|
||||
def update_progress(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
if item.item_type not in {MediaItem.TYPE_MOVIE, MediaItem.TYPE_EPISODE}:
|
||||
return Response(
|
||||
{"detail": "Progress tracking is only available for movies and episodes."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def _parse_int(value):
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
position_ms = _parse_int(
|
||||
request.data.get("position_ms")
|
||||
or request.data.get("positionMs")
|
||||
or request.data.get("position")
|
||||
)
|
||||
duration_ms = _parse_int(
|
||||
request.data.get("duration_ms")
|
||||
or request.data.get("durationMs")
|
||||
or request.data.get("duration")
|
||||
)
|
||||
completed_raw = request.data.get("completed")
|
||||
completed = bool(completed_raw) if completed_raw is not None else False
|
||||
|
||||
if position_ms is None and not completed:
|
||||
return Response(
|
||||
{"detail": "position_ms is required to update progress."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if position_ms is None:
|
||||
position_ms = 0
|
||||
if position_ms < 0:
|
||||
position_ms = 0
|
||||
|
||||
file_id = request.data.get("file_id") or request.data.get("fileId")
|
||||
media_file = None
|
||||
if file_id:
|
||||
media_file = MediaFile.objects.filter(id=file_id, media_item=item).first()
|
||||
|
||||
if not duration_ms:
|
||||
if media_file and media_file.duration_ms:
|
||||
duration_ms = media_file.duration_ms
|
||||
else:
|
||||
duration_ms = self._get_duration_ms(item) or None
|
||||
|
||||
if duration_ms and position_ms > duration_ms:
|
||||
position_ms = duration_ms
|
||||
|
||||
if duration_ms and position_ms / max(duration_ms, 1) >= 0.95:
|
||||
completed = True
|
||||
|
||||
if completed and duration_ms:
|
||||
position_ms = duration_ms
|
||||
|
||||
progress, _ = WatchProgress.objects.get_or_create(
|
||||
user=request.user, media_item=item
|
||||
)
|
||||
progress.position_ms = position_ms
|
||||
if duration_ms:
|
||||
progress.duration_ms = duration_ms
|
||||
if media_file:
|
||||
progress.file = media_file
|
||||
progress.completed = completed
|
||||
progress.last_watched_at = timezone.now()
|
||||
progress.save()
|
||||
|
||||
serializer = MediaItemSerializer(item, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="mark-watched")
|
||||
def mark_watched(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
user = request.user
|
||||
duration_ms = self._get_duration_ms(item)
|
||||
progress, _ = WatchProgress.objects.get_or_create(user=user, media_item=item)
|
||||
progress.position_ms = duration_ms or progress.position_ms or 0
|
||||
progress.duration_ms = duration_ms or progress.duration_ms
|
||||
progress.completed = True
|
||||
progress.last_watched_at = timezone.now()
|
||||
progress.save()
|
||||
serializer = MediaItemSerializer(item, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="clear-progress")
|
||||
def clear_progress(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
WatchProgress.objects.filter(user=request.user, media_item=item).delete()
|
||||
serializer = MediaItemSerializer(item, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="series/mark-watched")
|
||||
def mark_series_watched(self, request, pk=None):
|
||||
series = self.get_object()
|
||||
if series.item_type != MediaItem.TYPE_SHOW:
|
||||
return Response(
|
||||
{"detail": "Series actions are only available for shows."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
episodes = MediaItem.objects.filter(parent=series, item_type=MediaItem.TYPE_EPISODE)
|
||||
existing = {
|
||||
entry.media_item_id: entry
|
||||
for entry in WatchProgress.objects.filter(user=request.user, media_item__in=episodes)
|
||||
}
|
||||
now = timezone.now()
|
||||
to_create = []
|
||||
to_update = []
|
||||
|
||||
for episode in episodes:
|
||||
duration_ms = self._get_duration_ms(episode)
|
||||
if episode.id in existing:
|
||||
progress = existing[episode.id]
|
||||
progress.position_ms = duration_ms or progress.position_ms or 0
|
||||
progress.duration_ms = duration_ms or progress.duration_ms
|
||||
progress.completed = True
|
||||
progress.last_watched_at = now
|
||||
to_update.append(progress)
|
||||
else:
|
||||
to_create.append(
|
||||
WatchProgress(
|
||||
user=request.user,
|
||||
media_item=episode,
|
||||
position_ms=duration_ms or 0,
|
||||
duration_ms=duration_ms or None,
|
||||
completed=True,
|
||||
last_watched_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
if to_create:
|
||||
WatchProgress.objects.bulk_create(to_create, ignore_conflicts=True)
|
||||
if to_update:
|
||||
WatchProgress.objects.bulk_update(
|
||||
to_update,
|
||||
["position_ms", "duration_ms", "completed", "last_watched_at", "updated_at"],
|
||||
)
|
||||
|
||||
serializer = MediaItemSerializer(series, context={"request": request})
|
||||
return Response({"item": serializer.data})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="series/clear-progress")
|
||||
def clear_series_progress(self, request, pk=None):
|
||||
series = self.get_object()
|
||||
if series.item_type != MediaItem.TYPE_SHOW:
|
||||
return Response(
|
||||
{"detail": "Series actions are only available for shows."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
episodes = MediaItem.objects.filter(parent=series, item_type=MediaItem.TYPE_EPISODE)
|
||||
WatchProgress.objects.filter(user=request.user, media_item__in=episodes).delete()
|
||||
serializer = MediaItemSerializer(series, context={"request": request})
|
||||
return Response({"item": serializer.data})
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAdmin])
|
||||
def browse_library_path(request):
|
||||
raw_path = request.query_params.get("path") or ""
|
||||
if not raw_path:
|
||||
path = os.path.abspath(os.sep)
|
||||
else:
|
||||
path = os.path.abspath(os.path.expanduser(raw_path))
|
||||
|
||||
if not os.path.exists(path) or not os.path.isdir(path):
|
||||
return Response({"detail": "Path not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
parent = os.path.dirname(path.rstrip(os.sep))
|
||||
if parent == path:
|
||||
parent = None
|
||||
|
||||
entries = []
|
||||
try:
|
||||
with os.scandir(path) as it:
|
||||
for entry in it:
|
||||
if entry.is_dir():
|
||||
entries.append({"name": entry.name, "path": entry.path})
|
||||
except PermissionError:
|
||||
return Response({"detail": "Permission denied."}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
entries.sort(key=lambda item: item["name"].lower())
|
||||
return Response({"path": path, "parent": parent, "entries": entries})
|
||||
11
apps/media_library/apps.py
Normal file
11
apps/media_library/apps.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MediaLibraryConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.media_library'
|
||||
verbose_name = 'Media Library'
|
||||
label = 'media_library'
|
||||
|
||||
def ready(self):
|
||||
import apps.media_library.signals
|
||||
332
apps/media_library/classification.py
Normal file
332
apps/media_library/classification.py
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from guessit import guessit
|
||||
|
||||
from apps.media_library.models import Library, MediaItem
|
||||
from apps.media_library.utils import ClassificationResult, _json_safe, normalize_title
|
||||
|
||||
SEASON_FOLDER_PATTERN = re.compile(
|
||||
r"^(?:season|series|s)[\s\._-]*([0-9]{1,3})$", re.IGNORECASE
|
||||
)
|
||||
EPISODE_PATTERN = re.compile(
|
||||
r"""
|
||||
(?:
|
||||
s(?P<season>\d{1,3})
|
||||
[\.\-_\s]*
|
||||
e(?P<episode>\d{1,4})
|
||||
(?:[\.\-_\s]*e?(?P<episode_end>\d{1,4}))?
|
||||
)
|
||||
|
|
||||
(?P<abs_episode>\d{1,4})
|
||||
""",
|
||||
re.IGNORECASE | re.VERBOSE,
|
||||
)
|
||||
YEAR_PATTERN = re.compile(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)")
|
||||
MOVIE_JUNK_TOKEN_PATTERN = re.compile(
|
||||
r"(?i)^(1080p|720p|2160p|480p|4k|web[- ]?dl|webrip|hdrip|hdtv|b[dr]rip|bluray|dvdrip|xvid|x264|x265|h264|h265|hevc|10bit|8bit|dts|ac3|aac|eac3|ddp|atmos|hdr10(?:plus)?|uhd|remux|proper|repack|unrated|extended|imax|readnfo|internal|limited|criterion|remastered|multi|dual(?:audio)?|subs?|yts|yify|rarbg|evo|fgt|psa|galaxy|amzn|nf|hmax|bd25|bd50)$"
|
||||
)
|
||||
|
||||
|
||||
def _safe_number(value):
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
for entry in value:
|
||||
normalized = _safe_number(entry)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
return None
|
||||
if value in (None, "", []):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _strip_extension(file_name: str) -> str:
|
||||
base, _ext = os.path.splitext(file_name)
|
||||
return base
|
||||
|
||||
|
||||
def _first_text(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
for entry in value:
|
||||
text = _first_text(entry)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
if isinstance(value, dict):
|
||||
for key in ("title", "name", "value"):
|
||||
if key in value:
|
||||
text = _first_text(value[key])
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def _ensure_series_name_from_path(
|
||||
relative_path: str, default: str | None = None
|
||||
) -> str:
|
||||
"""
|
||||
Best effort extraction of a series title from a relative path.
|
||||
Matches Jellyfin's approach of sanitizing folder names by replacing
|
||||
dots/underscores with spaces when they separate words.
|
||||
"""
|
||||
if not relative_path:
|
||||
return default or ""
|
||||
|
||||
segments = [segment for segment in relative_path.split(os.sep) if segment]
|
||||
if not segments:
|
||||
return default or ""
|
||||
|
||||
series_candidate = None
|
||||
for segment in segments:
|
||||
if SEASON_FOLDER_PATTERN.match(segment):
|
||||
continue
|
||||
series_candidate = segment
|
||||
break
|
||||
|
||||
if series_candidate is None:
|
||||
series_candidate = segments[0]
|
||||
|
||||
sanitized = re.sub(
|
||||
r"(([^._]{2,})[\._]*)|([\._]([^._]{2,}))", r"\2\4", series_candidate
|
||||
).replace("_", " ").replace(".", " ")
|
||||
sanitized = re.sub(r"\s+", " ", sanitized).strip()
|
||||
return sanitized or (default or "")
|
||||
|
||||
|
||||
def _season_from_segments(segments: list[str]) -> Optional[int]:
|
||||
for segment in reversed(segments):
|
||||
match = SEASON_FOLDER_PATTERN.match(segment)
|
||||
if match:
|
||||
try:
|
||||
return int(match.group(1))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _extract_year_from_text(text: str | None) -> Optional[int]:
|
||||
if not text:
|
||||
return None
|
||||
candidates = []
|
||||
for match in YEAR_PATTERN.finditer(text):
|
||||
try:
|
||||
year = int(match.group(1))
|
||||
candidates.append(year)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not candidates:
|
||||
return None
|
||||
return candidates[-1]
|
||||
|
||||
|
||||
def _tokenize_release_name(text: str) -> list[str]:
|
||||
"""
|
||||
Break a release name into tokens with punctuation stripped so we can
|
||||
drop common quality/source tags. Periods and underscores are treated
|
||||
as separators to better match scene-style names.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
cleaned = re.sub(r"[\[\]\(\)\{\}]", " ", text)
|
||||
cleaned = re.sub(r"[._]", " ", cleaned)
|
||||
cleaned = cleaned.replace("-", " ")
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||
return [token for token in cleaned.split(" ") if token]
|
||||
|
||||
|
||||
def _is_release_junk(token: str) -> bool:
|
||||
if not token:
|
||||
return True
|
||||
if MOVIE_JUNK_TOKEN_PATTERN.match(token):
|
||||
return True
|
||||
if re.match(r"^\d+\.\d+$", token):
|
||||
return True
|
||||
token_lower = token.lower()
|
||||
if token_lower in {"yts.mx", "yts.ag", "yts.lt", "yts.mx", "rarbg"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _sanitize_movie_title(
|
||||
title_source: str, base_name: str, year_hint: int | None = None
|
||||
) -> tuple[str, int | None]:
|
||||
"""
|
||||
Extract a clean movie title (and possibly year) from a release-style
|
||||
name. Removes quality/source tags and stops parsing once a year token
|
||||
is reached so entries like "6.Underground.2019.1080p..." become
|
||||
"6 Underground".
|
||||
"""
|
||||
year = (
|
||||
year_hint
|
||||
or _extract_year_from_text(title_source)
|
||||
or _extract_year_from_text(base_name)
|
||||
)
|
||||
tokens = _tokenize_release_name(title_source)
|
||||
cleaned_tokens: list[str] = []
|
||||
for token in tokens:
|
||||
token_stripped = token.strip(" -_.")
|
||||
if not token_stripped:
|
||||
continue
|
||||
if year and token_stripped.isdigit() and int(token_stripped) == year:
|
||||
break
|
||||
if _is_release_junk(token_stripped):
|
||||
continue
|
||||
cleaned_tokens.append(token_stripped)
|
||||
cleaned_title = " ".join(cleaned_tokens).strip()
|
||||
if not cleaned_title and title_source != base_name:
|
||||
tokens = _tokenize_release_name(base_name)
|
||||
cleaned_tokens = []
|
||||
for token in tokens:
|
||||
token_stripped = token.strip(" -_.")
|
||||
if not token_stripped:
|
||||
continue
|
||||
if year and token_stripped.isdigit() and int(token_stripped) == year:
|
||||
break
|
||||
if _is_release_junk(token_stripped):
|
||||
continue
|
||||
cleaned_tokens.append(token_stripped)
|
||||
cleaned_title = " ".join(cleaned_tokens).strip()
|
||||
if not cleaned_title:
|
||||
cleaned_title = re.sub(r"[._]", " ", base_name)
|
||||
cleaned_title = re.sub(r"\s+", " ", cleaned_title).strip()
|
||||
return cleaned_title, year
|
||||
|
||||
|
||||
def classify_media_entry(
|
||||
library: Library,
|
||||
*,
|
||||
relative_path: str,
|
||||
file_name: str,
|
||||
) -> ClassificationResult:
|
||||
"""
|
||||
Wrapper around guessit that injects folder hints similar to Jellyfin's resolver.
|
||||
"""
|
||||
base_name = _strip_extension(file_name)
|
||||
guess_data = {}
|
||||
guess_target = file_name
|
||||
if relative_path:
|
||||
guess_target = os.path.join(relative_path, file_name)
|
||||
guess_options = {}
|
||||
if library.library_type == Library.LIBRARY_TYPE_SHOWS:
|
||||
guess_options = {"type": "episode", "show_type": "series"}
|
||||
elif library.library_type == Library.LIBRARY_TYPE_MOVIES:
|
||||
guess_options = {"type": "movie"}
|
||||
guessed_title = ""
|
||||
|
||||
try:
|
||||
if guess_options:
|
||||
guess_data = guessit(guess_target, options=guess_options)
|
||||
else:
|
||||
guess_data = guessit(guess_target)
|
||||
guessed_title = _first_text(guess_data.get("title"))
|
||||
except Exception:
|
||||
guess_data = {}
|
||||
guessed_title = ""
|
||||
|
||||
segments = [segment for segment in relative_path.split(os.sep) if segment]
|
||||
series_name = _ensure_series_name_from_path(
|
||||
relative_path, default=guessed_title or base_name
|
||||
)
|
||||
|
||||
if library.library_type == Library.LIBRARY_TYPE_SHOWS:
|
||||
season_number = _safe_number(guess_data.get("season"))
|
||||
episode_number = _safe_number(guess_data.get("episode"))
|
||||
episode_title = _first_text(guess_data.get("episode_title"))
|
||||
|
||||
if season_number is None:
|
||||
season_number = _season_from_segments(segments)
|
||||
|
||||
if episode_number is None:
|
||||
episode_number = _safe_number(guess_data.get("episode_list"))
|
||||
|
||||
episode_list_raw = guess_data.get("episode_list") or []
|
||||
if not episode_list_raw and isinstance(
|
||||
guess_data.get("episode"), (list, tuple)
|
||||
):
|
||||
episode_list_raw = list(guess_data.get("episode") or [])
|
||||
episode_list = []
|
||||
for item in episode_list_raw:
|
||||
number = _safe_number(item)
|
||||
if number is not None and number not in episode_list:
|
||||
episode_list.append(number)
|
||||
|
||||
if episode_number is None:
|
||||
match = EPISODE_PATTERN.search(file_name)
|
||||
if match:
|
||||
episode_number = _safe_number(
|
||||
match.group("episode") or match.group("abs_episode")
|
||||
)
|
||||
if season_number is None and match.group("season"):
|
||||
try:
|
||||
season_number = int(match.group("season"))
|
||||
except (TypeError, ValueError):
|
||||
season_number = None
|
||||
if match.group("episode_end"):
|
||||
end_number = _safe_number(match.group("episode_end"))
|
||||
if end_number:
|
||||
if end_number not in episode_list:
|
||||
episode_list.append(end_number)
|
||||
if episode_number is not None and episode_number not in episode_list:
|
||||
episode_list.append(episode_number)
|
||||
|
||||
detected_type = (
|
||||
MediaItem.TYPE_EPISODE
|
||||
if season_number is not None and episode_number is not None
|
||||
else MediaItem.TYPE_SHOW
|
||||
)
|
||||
normalized_title = normalize_title(series_name or base_name)
|
||||
data = _json_safe(guess_data)
|
||||
if season_number is not None:
|
||||
data["season"] = season_number
|
||||
if episode_number is not None:
|
||||
data["episode"] = episode_number
|
||||
if episode_list:
|
||||
episode_list_sorted = sorted({n for n in episode_list if isinstance(n, int)})
|
||||
if episode_list_sorted:
|
||||
data["episode_list"] = episode_list_sorted
|
||||
if series_name:
|
||||
data["series"] = series_name
|
||||
|
||||
return ClassificationResult(
|
||||
detected_type=detected_type,
|
||||
title=series_name or base_name,
|
||||
year=guess_data.get("year"),
|
||||
season=season_number,
|
||||
episode=episode_number,
|
||||
episode_title=episode_title or None,
|
||||
data=data,
|
||||
)
|
||||
|
||||
detected_type = (
|
||||
MediaItem.TYPE_MOVIE
|
||||
if library.library_type == Library.LIBRARY_TYPE_MOVIES
|
||||
else MediaItem.TYPE_OTHER
|
||||
)
|
||||
data = _json_safe(guess_data)
|
||||
title_candidate = guessed_title or base_name
|
||||
title, parsed_year = _sanitize_movie_title(
|
||||
title_candidate, base_name, _safe_number(guess_data.get("year"))
|
||||
)
|
||||
year = _safe_number(guess_data.get("year")) or parsed_year
|
||||
if title and title != guessed_title:
|
||||
data["parsed_title"] = title
|
||||
if year is not None:
|
||||
data["parsed_year"] = year
|
||||
return ClassificationResult(
|
||||
detected_type=detected_type,
|
||||
title=title,
|
||||
year=year,
|
||||
data=data,
|
||||
)
|
||||
903
apps/media_library/metadata.py
Normal file
903
apps/media_library/metadata.py
Normal file
|
|
@ -0,0 +1,903 @@
|
|||
import logging
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
from dateutil import parser as date_parser
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.media_library.models import ArtworkAsset, MediaItem
|
||||
from apps.media_library.utils import normalize_title
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IMAGE_BASE_URL = "https://image.tmdb.org/t/p/original"
|
||||
MOVIEDB_SEARCH_URL = "https://movie-db.org/api.php"
|
||||
MOVIEDB_EPISODE_URL = "https://movie-db.org/episode.php"
|
||||
MOVIEDB_CREDITS_URL = "https://movie-db.org/credits.php"
|
||||
METADATA_CACHE_TIMEOUT = 60 * 60 * 6
|
||||
MOVIEDB_HEALTH_CACHE_KEY = "movie-db:health"
|
||||
MOVIEDB_HEALTH_SUCCESS_TTL = 60 * 15
|
||||
MOVIEDB_HEALTH_FAILURE_TTL = 60 * 2
|
||||
|
||||
_REQUESTS_SESSION = requests.Session()
|
||||
_REQUESTS_SESSION.mount(
|
||||
"https://",
|
||||
requests.adapters.HTTPAdapter(pool_connections=20, pool_maxsize=40, max_retries=3),
|
||||
)
|
||||
|
||||
_MOVIEDB_SEARCH_CACHE: dict[tuple[str, ...], tuple[dict | None, str | None]] = {}
|
||||
_MOVIEDB_CREDITS_CACHE: dict[tuple[str, str], tuple[dict | None, str | None]] = {}
|
||||
|
||||
GENRE_ID_MAP: dict[int, str] = {
|
||||
12: "Adventure",
|
||||
14: "Fantasy",
|
||||
16: "Animation",
|
||||
18: "Drama",
|
||||
27: "Horror",
|
||||
28: "Action",
|
||||
35: "Comedy",
|
||||
36: "History",
|
||||
37: "Western",
|
||||
53: "Thriller",
|
||||
80: "Crime",
|
||||
99: "Documentary",
|
||||
878: "Science Fiction",
|
||||
9648: "Mystery",
|
||||
10402: "Music",
|
||||
10749: "Romance",
|
||||
10751: "Family",
|
||||
10752: "War",
|
||||
10759: "Action & Adventure",
|
||||
10762: "Kids",
|
||||
10763: "News",
|
||||
10764: "Reality",
|
||||
10765: "Sci-Fi & Fantasy",
|
||||
10766: "Soap",
|
||||
10767: "Talk",
|
||||
10768: "War & Politics",
|
||||
10770: "TV Movie",
|
||||
}
|
||||
|
||||
|
||||
def _get_library_metadata_prefs(media_item: MediaItem) -> dict[str, Any]:
|
||||
prefs: dict[str, Any] = {"language": None, "region": None}
|
||||
library = getattr(media_item, "library", None)
|
||||
if not library:
|
||||
return prefs
|
||||
|
||||
prefs["language"] = (library.metadata_language or "").strip() or None
|
||||
prefs["region"] = (library.metadata_country or "").strip() or None
|
||||
|
||||
options = library.metadata_options or {}
|
||||
if isinstance(options, dict):
|
||||
if options.get("language"):
|
||||
prefs["language"] = str(options["language"]).strip() or prefs["language"]
|
||||
if options.get("region"):
|
||||
prefs["region"] = str(options["region"]).strip() or prefs["region"]
|
||||
|
||||
return prefs
|
||||
|
||||
|
||||
def _metadata_cache_key(media_item: MediaItem, prefs: dict[str, Any] | None = None) -> str:
|
||||
normalized = normalize_title(media_item.title) or media_item.normalized_title or ""
|
||||
key_parts = [
|
||||
"movie-db",
|
||||
str(media_item.item_type or ""),
|
||||
normalized,
|
||||
str(media_item.release_year or ""),
|
||||
]
|
||||
if media_item.movie_db_id:
|
||||
key_parts.append(f"id:{media_item.movie_db_id}")
|
||||
if media_item.item_type == MediaItem.TYPE_EPISODE:
|
||||
season = media_item.season_number or ""
|
||||
episode = media_item.episode_number or ""
|
||||
key_parts.append(f"s{season}e{episode}")
|
||||
parent = getattr(media_item, "parent", None)
|
||||
if parent and parent.movie_db_id:
|
||||
key_parts.append(f"parent:{parent.movie_db_id}")
|
||||
|
||||
if prefs:
|
||||
language = (prefs.get("language") or "").strip()
|
||||
region = (prefs.get("region") or "").strip()
|
||||
if language or region:
|
||||
key_parts.append(f"lang:{language}")
|
||||
key_parts.append(f"region:{region}")
|
||||
|
||||
return "media-metadata:" + ":".join(key_parts)
|
||||
|
||||
|
||||
def build_image_url(path: Optional[str]) -> Optional[str]:
|
||||
if not path:
|
||||
return None
|
||||
return f"{IMAGE_BASE_URL}{path}"
|
||||
|
||||
|
||||
def _to_serializable(obj):
|
||||
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]
|
||||
if isinstance(obj, (str, int, float, bool)) or obj is None:
|
||||
return obj
|
||||
return str(obj)
|
||||
|
||||
|
||||
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 _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 _php_value_from_string(value: str):
|
||||
lowered = value.lower()
|
||||
if lowered == "true":
|
||||
return True
|
||||
if lowered == "false":
|
||||
return False
|
||||
if lowered in {"null", ""}:
|
||||
return None
|
||||
try:
|
||||
if "." in value:
|
||||
return float(value)
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def _convert_numeric_container(value):
|
||||
if isinstance(value, dict):
|
||||
converted = {k: _convert_numeric_container(v) for k, v in value.items()}
|
||||
numeric = True
|
||||
indices: list[int] = []
|
||||
for key in converted.keys():
|
||||
try:
|
||||
indices.append(int(key))
|
||||
except (TypeError, ValueError):
|
||||
numeric = False
|
||||
break
|
||||
if numeric:
|
||||
return [converted[str(idx)] for idx in sorted(indices)]
|
||||
return converted
|
||||
if isinstance(value, list):
|
||||
return [_convert_numeric_container(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _parse_php_array_lines(lines: list[str], idx: int = 0):
|
||||
def parse_body(current_idx: int):
|
||||
result: dict[str, Any] = {}
|
||||
while current_idx < len(lines):
|
||||
token = lines[current_idx]
|
||||
if token == ")":
|
||||
return result, current_idx + 1
|
||||
if token.startswith("[") and "] =>" in token:
|
||||
key_part, value_part = token.split("] =>", 1)
|
||||
key = key_part[1:].strip()
|
||||
value = value_part.strip()
|
||||
if value == "Array":
|
||||
sub_value, next_idx = _parse_php_array_lines(lines, current_idx + 1)
|
||||
result[key] = sub_value
|
||||
current_idx = next_idx
|
||||
continue
|
||||
result[key] = _php_value_from_string(value)
|
||||
current_idx += 1
|
||||
return result, current_idx
|
||||
|
||||
if idx >= len(lines):
|
||||
return {}, idx
|
||||
|
||||
token = lines[idx]
|
||||
if token.lower() == "array":
|
||||
idx += 1
|
||||
if idx < len(lines) and lines[idx] == "(":
|
||||
idx += 1
|
||||
parsed, idx = parse_body(idx)
|
||||
return _convert_numeric_container(parsed), idx
|
||||
return {}, idx
|
||||
|
||||
|
||||
def _parse_movie_db_php_response(payload: str) -> Optional[dict | list]:
|
||||
if not payload:
|
||||
return None
|
||||
stripped = payload.replace("<pre>", "").replace("</pre>", "").strip()
|
||||
if not stripped.lower().startswith("array"):
|
||||
return None
|
||||
lines = [line.strip() for line in stripped.splitlines() if line.strip()]
|
||||
parsed, _ = _parse_php_array_lines(lines, 0)
|
||||
return parsed
|
||||
|
||||
|
||||
def _extract_imdb_id(payload: dict | None) -> Optional[str]:
|
||||
if not payload or not isinstance(payload, dict):
|
||||
return None
|
||||
for key in ("imdb_id", "imdbId", "imdbID"):
|
||||
value = payload.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
external = payload.get("external_ids") or {}
|
||||
if isinstance(external, dict):
|
||||
for key in ("imdb_id", "imdbId", "imdbID"):
|
||||
value = external.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
def _candidate_year(payload: dict) -> Optional[int]:
|
||||
release_date = (
|
||||
payload.get("release_date")
|
||||
or payload.get("first_air_date")
|
||||
or payload.get("air_date")
|
||||
)
|
||||
return _parse_release_year(release_date)
|
||||
|
||||
|
||||
def _movie_db_title(payload: dict) -> str:
|
||||
return (
|
||||
payload.get("title")
|
||||
or payload.get("name")
|
||||
or payload.get("original_title")
|
||||
or payload.get("original_name")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def _select_movie_db_candidate(
|
||||
results: list[dict],
|
||||
title: str,
|
||||
*,
|
||||
year: Optional[int] = None,
|
||||
prefs: dict[str, Any] | None = None,
|
||||
) -> Optional[dict]:
|
||||
if not results:
|
||||
return None
|
||||
normalized_query = normalize_title(title)
|
||||
language = (prefs or {}).get("language")
|
||||
region = (prefs or {}).get("region")
|
||||
best_score = None
|
||||
best_result = None
|
||||
for idx, result in enumerate(results):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
normalized_title = normalize_title(_movie_db_title(result))
|
||||
score = 0
|
||||
if normalized_query and normalized_title == normalized_query:
|
||||
score += 6
|
||||
elif normalized_query and (
|
||||
normalized_query in normalized_title or normalized_title in normalized_query
|
||||
):
|
||||
score += 3
|
||||
if year:
|
||||
candidate_year = _candidate_year(result)
|
||||
if candidate_year and candidate_year == year:
|
||||
score += 2
|
||||
if language and result.get("original_language") == language:
|
||||
score += 2
|
||||
if region:
|
||||
origin_country = result.get("origin_country") or []
|
||||
if isinstance(origin_country, str):
|
||||
origin_country = [origin_country]
|
||||
if region in origin_country:
|
||||
score += 2
|
||||
if result.get("poster_path"):
|
||||
score += 2
|
||||
elif result.get("backdrop_path"):
|
||||
score += 1
|
||||
# Keep earlier results when scores tie.
|
||||
score = (score, -idx)
|
||||
if best_score is None or score > best_score:
|
||||
best_score = score
|
||||
best_result = result
|
||||
return best_result or (results[0] if results else None)
|
||||
|
||||
|
||||
def _movie_db_search(
|
||||
media_type: str,
|
||||
title: str,
|
||||
*,
|
||||
year: Optional[int] = None,
|
||||
prefs: dict[str, Any] | None = None,
|
||||
) -> tuple[Optional[dict], Optional[str]]:
|
||||
language = (prefs or {}).get("language") or ""
|
||||
region = (prefs or {}).get("region") or ""
|
||||
normalized_key = (
|
||||
media_type,
|
||||
(title or "").strip().lower(),
|
||||
str(year or ""),
|
||||
str(language),
|
||||
str(region),
|
||||
)
|
||||
if normalized_key in _MOVIEDB_SEARCH_CACHE:
|
||||
return _MOVIEDB_SEARCH_CACHE[normalized_key]
|
||||
|
||||
try:
|
||||
response = _REQUESTS_SESSION.get(
|
||||
MOVIEDB_SEARCH_URL,
|
||||
params={"type": media_type, "query": title},
|
||||
timeout=6,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
message = f"Movie-DB search failed: {exc}"
|
||||
logger.warning(message)
|
||||
_MOVIEDB_SEARCH_CACHE[normalized_key] = (None, message)
|
||||
return None, message
|
||||
|
||||
if response.status_code != 200:
|
||||
message = f"Movie-DB search returned status {response.status_code}."
|
||||
logger.debug(message)
|
||||
_MOVIEDB_SEARCH_CACHE[normalized_key] = (None, message)
|
||||
return None, message
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
message = "Movie-DB search returned an unexpected response format."
|
||||
logger.debug(message)
|
||||
_MOVIEDB_SEARCH_CACHE[normalized_key] = (None, 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)
|
||||
_MOVIEDB_SEARCH_CACHE[normalized_key] = (None, message)
|
||||
return None, message
|
||||
|
||||
candidate = _select_movie_db_candidate(
|
||||
results, title, year=year, prefs=prefs
|
||||
)
|
||||
_MOVIEDB_SEARCH_CACHE[normalized_key] = (candidate, None)
|
||||
return candidate, None
|
||||
|
||||
|
||||
def _map_genre_ids(genre_ids: Any) -> list[str]:
|
||||
if not genre_ids or not isinstance(genre_ids, (list, tuple, set)):
|
||||
return []
|
||||
genres: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for entry in genre_ids:
|
||||
try:
|
||||
genre_id = int(entry)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
name = GENRE_ID_MAP.get(genre_id)
|
||||
if not name or name in seen:
|
||||
continue
|
||||
genres.append(name)
|
||||
seen.add(name)
|
||||
return genres
|
||||
|
||||
|
||||
def _movie_db_fetch_credits(
|
||||
media_type: str, content_id: Any
|
||||
) -> tuple[Optional[dict], Optional[str]]:
|
||||
if not content_id:
|
||||
return None, "Movie-DB credits require an id."
|
||||
normalized_key = (media_type, str(content_id))
|
||||
if normalized_key in _MOVIEDB_CREDITS_CACHE:
|
||||
return _MOVIEDB_CREDITS_CACHE[normalized_key]
|
||||
|
||||
params = {"id": content_id, "media_type": media_type}
|
||||
try:
|
||||
response = _REQUESTS_SESSION.get(
|
||||
MOVIEDB_CREDITS_URL,
|
||||
params=params,
|
||||
timeout=6,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
message = f"Movie-DB credits lookup failed: {exc}"
|
||||
logger.warning(message)
|
||||
_MOVIEDB_CREDITS_CACHE[normalized_key] = (None, message)
|
||||
return None, message
|
||||
|
||||
if response.status_code != 200 and media_type == "movie":
|
||||
try:
|
||||
response = _REQUESTS_SESSION.get(
|
||||
MOVIEDB_CREDITS_URL,
|
||||
params={"movie_id": content_id},
|
||||
timeout=6,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
message = f"Movie-DB credits lookup failed: {exc}"
|
||||
logger.warning(message)
|
||||
_MOVIEDB_CREDITS_CACHE[normalized_key] = (None, message)
|
||||
return None, message
|
||||
|
||||
if response.status_code != 200:
|
||||
message = f"Movie-DB credits returned status {response.status_code}."
|
||||
logger.debug(message)
|
||||
_MOVIEDB_CREDITS_CACHE[normalized_key] = (None, message)
|
||||
return None, message
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
message = "Movie-DB credits returned an unexpected response format."
|
||||
logger.debug(message)
|
||||
_MOVIEDB_CREDITS_CACHE[normalized_key] = (None, message)
|
||||
return None, message
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
message = "Movie-DB credits returned an unexpected payload."
|
||||
logger.debug(message)
|
||||
_MOVIEDB_CREDITS_CACHE[normalized_key] = (None, message)
|
||||
return None, message
|
||||
|
||||
_MOVIEDB_CREDITS_CACHE[normalized_key] = (payload, None)
|
||||
return payload, None
|
||||
|
||||
|
||||
def _normalize_credits(payload: dict | None) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
if not payload:
|
||||
return [], []
|
||||
cast_entries = payload.get("cast") or []
|
||||
crew_entries = payload.get("crew") or []
|
||||
|
||||
cast: list[dict[str, Any]] = []
|
||||
for member in cast_entries[:12]:
|
||||
if not isinstance(member, dict):
|
||||
continue
|
||||
name = member.get("name") or member.get("original_name")
|
||||
if not name:
|
||||
continue
|
||||
cast.append(
|
||||
{
|
||||
"name": name,
|
||||
"character": member.get("character"),
|
||||
"profile_url": build_image_url(member.get("profile_path")),
|
||||
}
|
||||
)
|
||||
|
||||
crew: list[dict[str, Any]] = []
|
||||
for member in crew_entries[:15]:
|
||||
if not isinstance(member, dict):
|
||||
continue
|
||||
name = member.get("name") or member.get("original_name")
|
||||
if not name:
|
||||
continue
|
||||
crew.append(
|
||||
{
|
||||
"name": name,
|
||||
"job": member.get("job"),
|
||||
"department": member.get("department"),
|
||||
"profile_url": build_image_url(member.get("profile_path")),
|
||||
}
|
||||
)
|
||||
|
||||
return cast, crew
|
||||
|
||||
|
||||
def _movie_db_fetch_episode_details(
|
||||
tv_id: Any, season: int, episode: int
|
||||
) -> tuple[Optional[dict], Optional[str]]:
|
||||
try:
|
||||
response = _REQUESTS_SESSION.get(
|
||||
MOVIEDB_EPISODE_URL,
|
||||
params={"tvId": tv_id, "season": season, "episode": episode},
|
||||
timeout=6,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
message = f"Movie-DB episode lookup failed: {exc}"
|
||||
logger.warning(message)
|
||||
return None, message
|
||||
|
||||
if response.status_code != 200:
|
||||
message = f"Movie-DB episode endpoint returned status {response.status_code}."
|
||||
logger.debug(message)
|
||||
return None, message
|
||||
|
||||
parsed = _parse_movie_db_php_response(response.text)
|
||||
if not parsed or not isinstance(parsed, dict):
|
||||
message = "Movie-DB episode endpoint returned an unexpected payload."
|
||||
logger.debug(message)
|
||||
return None, message
|
||||
|
||||
return _convert_numeric_container(parsed), None
|
||||
|
||||
|
||||
def fetch_movie_db_metadata(
|
||||
media_item: MediaItem,
|
||||
*,
|
||||
use_cache: bool = True,
|
||||
) -> 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 does not support this media type."
|
||||
|
||||
prefs = _get_library_metadata_prefs(media_item)
|
||||
cache_key = _metadata_cache_key(media_item, prefs)
|
||||
if use_cache:
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
return cached, None
|
||||
|
||||
if media_item.item_type == MediaItem.TYPE_MOVIE:
|
||||
candidate, message = _movie_db_search(
|
||||
"movie",
|
||||
media_item.title,
|
||||
year=media_item.release_year,
|
||||
prefs=prefs,
|
||||
)
|
||||
if not candidate:
|
||||
return None, message
|
||||
|
||||
movie_id = candidate.get("id")
|
||||
genres = _map_genre_ids(candidate.get("genre_ids"))
|
||||
credits_payload, _ = _movie_db_fetch_credits("movie", movie_id)
|
||||
cast, crew = _normalize_credits(credits_payload)
|
||||
imdb_id = _extract_imdb_id(candidate)
|
||||
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(
|
||||
{
|
||||
"movie_db_id": str(movie_id or media_item.movie_db_id or ""),
|
||||
"imdb_id": imdb_id,
|
||||
"title": candidate.get("title")
|
||||
or candidate.get("name")
|
||||
or media_item.title,
|
||||
"synopsis": candidate.get("overview") or media_item.synopsis,
|
||||
"tagline": candidate.get("tagline") or media_item.tagline,
|
||||
"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": genres,
|
||||
"cast": cast,
|
||||
"crew": crew,
|
||||
"source": "movie-db",
|
||||
"raw": candidate,
|
||||
}
|
||||
)
|
||||
if use_cache:
|
||||
cache.set(cache_key, metadata, METADATA_CACHE_TIMEOUT)
|
||||
return metadata, None
|
||||
|
||||
if media_item.item_type == MediaItem.TYPE_SHOW:
|
||||
candidate, message = _movie_db_search(
|
||||
"tv",
|
||||
media_item.title,
|
||||
year=media_item.release_year,
|
||||
prefs=prefs,
|
||||
)
|
||||
if not candidate:
|
||||
return None, message
|
||||
tv_id = candidate.get("id")
|
||||
genres = _map_genre_ids(candidate.get("genre_ids"))
|
||||
credits_payload, _ = _movie_db_fetch_credits("tv", tv_id)
|
||||
cast, crew = _normalize_credits(credits_payload)
|
||||
imdb_id = _extract_imdb_id(candidate)
|
||||
release_date = candidate.get("first_air_date") or candidate.get("release_date")
|
||||
release_year = _parse_release_year(release_date) or media_item.release_year
|
||||
metadata = _to_serializable(
|
||||
{
|
||||
"movie_db_id": str(candidate.get("id") or media_item.movie_db_id or ""),
|
||||
"imdb_id": imdb_id,
|
||||
"title": candidate.get("name")
|
||||
or candidate.get("title")
|
||||
or media_item.title,
|
||||
"synopsis": candidate.get("overview") or media_item.synopsis,
|
||||
"tagline": candidate.get("tagline") or media_item.tagline,
|
||||
"release_year": release_year,
|
||||
"poster": build_image_url(candidate.get("poster_path")),
|
||||
"backdrop": build_image_url(candidate.get("backdrop_path")),
|
||||
"runtime_minutes": candidate.get("episode_run_time"),
|
||||
"genres": genres,
|
||||
"cast": cast,
|
||||
"crew": crew,
|
||||
"movie_db_tv_id": str(tv_id) if tv_id is not None else None,
|
||||
"source": "movie-db",
|
||||
"raw": candidate,
|
||||
}
|
||||
)
|
||||
if use_cache:
|
||||
cache.set(cache_key, metadata, METADATA_CACHE_TIMEOUT)
|
||||
return metadata, None
|
||||
|
||||
parent_series = media_item.parent or MediaItem.objects.filter(
|
||||
pk=media_item.parent_id
|
||||
).first()
|
||||
if not parent_series:
|
||||
return None, "Unable to determine parent series for episode."
|
||||
|
||||
tv_id = parent_series.movie_db_id or None
|
||||
if not tv_id and isinstance(parent_series.metadata, dict):
|
||||
tv_id = parent_series.metadata.get("movie_db_tv_id")
|
||||
if tv_id and not parent_series.movie_db_id:
|
||||
parent_series.movie_db_id = str(tv_id)
|
||||
parent_series.save(update_fields=["movie_db_id", "updated_at"])
|
||||
|
||||
if not tv_id:
|
||||
series_candidate, message = _movie_db_search(
|
||||
"tv",
|
||||
parent_series.title,
|
||||
year=parent_series.release_year or media_item.release_year,
|
||||
prefs=prefs,
|
||||
)
|
||||
if not series_candidate:
|
||||
return None, message
|
||||
tv_id = series_candidate.get("id")
|
||||
if tv_id is not None:
|
||||
tv_id_str = str(tv_id)
|
||||
update_fields = []
|
||||
if not parent_series.movie_db_id:
|
||||
parent_series.movie_db_id = tv_id_str
|
||||
update_fields.append("movie_db_id")
|
||||
if isinstance(parent_series.metadata, dict):
|
||||
updated_metadata = dict(parent_series.metadata)
|
||||
else:
|
||||
updated_metadata = {}
|
||||
updated_metadata["movie_db_tv_id"] = tv_id_str
|
||||
parent_series.metadata = updated_metadata
|
||||
update_fields.append("metadata")
|
||||
update_fields.append("updated_at")
|
||||
parent_series.save(update_fields=update_fields)
|
||||
|
||||
if tv_id is None:
|
||||
return None, "Movie-DB did not return a tvId for this series."
|
||||
|
||||
if media_item.season_number is None or media_item.episode_number is None:
|
||||
return None, "Episode numbers are required for Movie-DB lookup."
|
||||
|
||||
try:
|
||||
season_number = int(media_item.season_number)
|
||||
episode_number = int(media_item.episode_number)
|
||||
except (TypeError, ValueError):
|
||||
return None, "Episode numbers are required for Movie-DB lookup."
|
||||
|
||||
episode_details, message = _movie_db_fetch_episode_details(
|
||||
tv_id, season_number, episode_number
|
||||
)
|
||||
if not episode_details:
|
||||
return None, message
|
||||
|
||||
release_year = (
|
||||
_parse_release_year(episode_details.get("air_date"))
|
||||
or media_item.release_year
|
||||
)
|
||||
guest_stars = episode_details.get("guest_stars") or []
|
||||
crew_entries = episode_details.get("crew") or []
|
||||
|
||||
cast: list[dict[str, Any]] = []
|
||||
for star in guest_stars:
|
||||
if not isinstance(star, dict):
|
||||
continue
|
||||
name = star.get("name") or star.get("original_name")
|
||||
if not name:
|
||||
continue
|
||||
cast.append(
|
||||
{
|
||||
"name": name,
|
||||
"character": star.get("character"),
|
||||
"profile_url": build_image_url(star.get("profile_path")),
|
||||
}
|
||||
)
|
||||
|
||||
crew: list[dict[str, Any]] = []
|
||||
for member in crew_entries:
|
||||
if not isinstance(member, dict):
|
||||
continue
|
||||
name = member.get("name") or member.get("original_name")
|
||||
if not name:
|
||||
continue
|
||||
crew.append(
|
||||
{
|
||||
"name": name,
|
||||
"job": member.get("job"),
|
||||
"department": member.get("department"),
|
||||
"profile_url": build_image_url(member.get("profile_path")),
|
||||
}
|
||||
)
|
||||
|
||||
metadata = _to_serializable(
|
||||
{
|
||||
"movie_db_id": str(episode_details.get("id") or media_item.movie_db_id or ""),
|
||||
"title": episode_details.get("name") or media_item.title,
|
||||
"synopsis": episode_details.get("overview") or media_item.synopsis,
|
||||
"release_year": release_year,
|
||||
"poster": build_image_url(episode_details.get("still_path")),
|
||||
"backdrop": build_image_url(episode_details.get("still_path")),
|
||||
"runtime_minutes": episode_details.get("runtime"),
|
||||
"genres": [],
|
||||
"cast": cast,
|
||||
"crew": crew,
|
||||
"movie_db_tv_id": str(tv_id) if tv_id is not None else None,
|
||||
"source": "movie-db",
|
||||
"raw": episode_details,
|
||||
}
|
||||
)
|
||||
|
||||
if use_cache:
|
||||
cache.set(cache_key, metadata, METADATA_CACHE_TIMEOUT)
|
||||
return metadata, None
|
||||
|
||||
|
||||
def apply_metadata(media_item: MediaItem, metadata: Dict[str, Any]) -> MediaItem:
|
||||
changed = False
|
||||
update_fields: list[str] = []
|
||||
|
||||
movie_db_id = metadata.get("movie_db_id")
|
||||
if movie_db_id and movie_db_id != media_item.movie_db_id:
|
||||
media_item.movie_db_id = movie_db_id
|
||||
update_fields.append("movie_db_id")
|
||||
changed = True
|
||||
|
||||
imdb_id = metadata.get("imdb_id")
|
||||
if imdb_id and imdb_id != media_item.imdb_id:
|
||||
media_item.imdb_id = 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):
|
||||
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:
|
||||
update_fields = list(dict.fromkeys(update_fields + ["updated_at"]))
|
||||
media_item.save(update_fields=update_fields)
|
||||
|
||||
if poster:
|
||||
ArtworkAsset.objects.update_or_create(
|
||||
media_item=media_item,
|
||||
asset_type=ArtworkAsset.TYPE_POSTER,
|
||||
source=metadata.get("source", "movie-db"),
|
||||
defaults={"external_url": poster},
|
||||
)
|
||||
if backdrop:
|
||||
ArtworkAsset.objects.update_or_create(
|
||||
media_item=media_item,
|
||||
asset_type=ArtworkAsset.TYPE_BACKDROP,
|
||||
source=metadata.get("source", "movie-db"),
|
||||
defaults={"external_url": backdrop},
|
||||
)
|
||||
|
||||
return media_item
|
||||
|
||||
|
||||
def sync_metadata(media_item: MediaItem, *, force: bool = False) -> Optional[MediaItem]:
|
||||
movie_db_ok, movie_db_message = check_movie_db_health()
|
||||
if not movie_db_ok:
|
||||
if movie_db_message:
|
||||
logger.debug("Metadata sync skipped for %s: %s", media_item, movie_db_message)
|
||||
return None
|
||||
|
||||
metadata, error = fetch_movie_db_metadata(media_item, use_cache=not force)
|
||||
if not metadata:
|
||||
if error:
|
||||
logger.debug("Metadata sync skipped for %s: %s", media_item, error)
|
||||
return None
|
||||
return apply_metadata(media_item, metadata)
|
||||
408
apps/media_library/migrations/0001_initial.py
Normal file
408
apps/media_library/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
# Generated by Django 5.2.4 on 2025-12-20 00:00
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("m3u", "0018_add_profile_custom_properties"),
|
||||
("vod", "0003_vodlogo_alter_movie_logo_alter_series_logo"),
|
||||
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)),
|
||||
("description", models.TextField(blank=True, default="")),
|
||||
(
|
||||
"library_type",
|
||||
models.CharField(
|
||||
choices=[("movies", "Movies"), ("shows", "TV Shows")],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("metadata_language", models.CharField(blank=True, default="", max_length=12)),
|
||||
("metadata_country", models.CharField(blank=True, default="", max_length=12)),
|
||||
("metadata_options", models.JSONField(blank=True, default=dict, null=True)),
|
||||
("scan_interval_minutes", models.PositiveIntegerField(default=1440)),
|
||||
("auto_scan_enabled", models.BooleanField(default=True)),
|
||||
("add_to_vod", models.BooleanField(default=False)),
|
||||
("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)),
|
||||
(
|
||||
"vod_account",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="media_libraries",
|
||||
to="m3u.m3uaccount",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="MediaItem",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"item_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("movie", "Movie"),
|
||||
("show", "Show"),
|
||||
("episode", "Episode"),
|
||||
("other", "Other"),
|
||||
],
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=512)),
|
||||
("sort_title", models.CharField(blank=True, default="", max_length=512)),
|
||||
("normalized_title", models.CharField(blank=True, default="", max_length=512)),
|
||||
("synopsis", models.TextField(blank=True, default="")),
|
||||
("tagline", models.TextField(blank=True, default="")),
|
||||
("release_year", models.IntegerField(blank=True, null=True)),
|
||||
("rating", models.CharField(blank=True, default="", max_length=10)),
|
||||
("runtime_ms", models.PositiveBigIntegerField(blank=True, null=True)),
|
||||
("season_number", models.IntegerField(blank=True, null=True)),
|
||||
("episode_number", models.IntegerField(blank=True, null=True)),
|
||||
("genres", models.JSONField(blank=True, default=list, null=True)),
|
||||
("tags", models.JSONField(blank=True, default=list, null=True)),
|
||||
("studios", models.JSONField(blank=True, default=list, null=True)),
|
||||
("cast", models.JSONField(blank=True, default=list, null=True)),
|
||||
("crew", models.JSONField(blank=True, default=list, null=True)),
|
||||
("poster_url", models.TextField(blank=True, default="")),
|
||||
("backdrop_url", models.TextField(blank=True, default="")),
|
||||
("movie_db_id", models.CharField(blank=True, default="", max_length=64)),
|
||||
("imdb_id", models.CharField(blank=True, default="", max_length=32)),
|
||||
("metadata", models.JSONField(blank=True, null=True)),
|
||||
("metadata_source", models.CharField(blank=True, default="", max_length=32)),
|
||||
("metadata_last_synced_at", models.DateTimeField(blank=True, null=True)),
|
||||
("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",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["sort_title", "title"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="LibraryLocation",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("path", models.CharField(max_length=1024)),
|
||||
("include_subdirectories", models.BooleanField(default=True)),
|
||||
("is_primary", models.BooleanField(default=False)),
|
||||
("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={
|
||||
"unique_together": {("library", "path")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="MediaFile",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("path", models.CharField(max_length=1024)),
|
||||
("relative_path", models.CharField(blank=True, default="", max_length=1024)),
|
||||
("file_name", models.CharField(max_length=512)),
|
||||
("size_bytes", models.BigIntegerField(blank=True, null=True)),
|
||||
("modified_at", models.DateTimeField(blank=True, null=True)),
|
||||
("duration_ms", models.PositiveBigIntegerField(blank=True, null=True)),
|
||||
("is_primary", models.BooleanField(default=False)),
|
||||
("last_seen_at", models.DateTimeField(blank=True, null=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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"media_item",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="files",
|
||||
to="media_library.mediaitem",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("library", "path")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="MediaItemVODLink",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"media_item",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="vod_link",
|
||||
to="media_library.mediaitem",
|
||||
),
|
||||
),
|
||||
(
|
||||
"vod_episode",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="media_library_links",
|
||||
to="vod.episode",
|
||||
),
|
||||
),
|
||||
(
|
||||
"vod_movie",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="media_library_links",
|
||||
to="vod.movie",
|
||||
),
|
||||
),
|
||||
(
|
||||
"vod_series",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="media_library_links",
|
||||
to="vod.series",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ArtworkAsset",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("asset_type", models.CharField(choices=[("poster", "Poster"), ("backdrop", "Backdrop")], max_length=16)),
|
||||
("source", models.CharField(blank=True, default="", max_length=32)),
|
||||
("external_url", models.TextField()),
|
||||
("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_assets",
|
||||
to="media_library.mediaitem",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WatchProgress",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("position_ms", models.PositiveBigIntegerField(default=0)),
|
||||
("duration_ms", models.PositiveBigIntegerField(blank=True, null=True)),
|
||||
("completed", models.BooleanField(default=False)),
|
||||
("last_watched_at", models.DateTimeField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"file",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="progress_entries",
|
||||
to="media_library.mediafile",
|
||||
),
|
||||
),
|
||||
(
|
||||
"media_item",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="progress_entries",
|
||||
to="media_library.mediaitem",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="media_progress",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("user", "media_item")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="LibraryScan",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"scan_type",
|
||||
models.CharField(
|
||||
choices=[("quick", "Quick"), ("full", "Full")],
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("queued", "Queued"),
|
||||
("running", "Running"),
|
||||
("completed", "Completed"),
|
||||
("failed", "Failed"),
|
||||
("cancelled", "Cancelled"),
|
||||
],
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("summary", models.CharField(blank=True, default="", max_length=255)),
|
||||
("stages", models.JSONField(blank=True, default=dict, null=True)),
|
||||
("processed_files", models.PositiveIntegerField(default=0)),
|
||||
("total_files", models.PositiveIntegerField(default=0)),
|
||||
("new_files", models.PositiveIntegerField(default=0)),
|
||||
("updated_files", models.PositiveIntegerField(default=0)),
|
||||
("removed_files", models.PositiveIntegerField(default=0)),
|
||||
("unmatched_files", models.PositiveIntegerField(default=0)),
|
||||
("log", models.TextField(blank=True, default="")),
|
||||
("extra", models.JSONField(blank=True, null=True)),
|
||||
("task_id", models.CharField(blank=True, default="", max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("started_at", models.DateTimeField(blank=True, null=True)),
|
||||
("finished_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"library",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="scans",
|
||||
to="media_library.library",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="librarylocation",
|
||||
index=models.Index(fields=["library", "is_primary"], name="media_libr_library_b13f17_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="mediaitem",
|
||||
index=models.Index(fields=["library", "item_type"], name="media_libr_library_a7ec3e_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="mediaitem",
|
||||
index=models.Index(fields=["library", "sort_title"], name="media_libr_library_2dc3a5_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="mediaitem",
|
||||
index=models.Index(fields=["library", "updated_at"], name="media_libr_library_0f7e9b_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="mediaitem",
|
||||
index=models.Index(fields=["parent", "season_number", "episode_number"], name="media_libr_parent__f58b2b_idx"),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="mediaitem",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(item_type="episode"),
|
||||
fields=("parent", "season_number", "episode_number"),
|
||||
name="unique_episode_per_season",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="mediafile",
|
||||
index=models.Index(fields=["media_item"], name="media_libr_media_i_4bf7b4_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="mediafile",
|
||||
index=models.Index(fields=["library", "is_primary"], name="media_libr_library_0b3ae5_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="mediafile",
|
||||
index=models.Index(fields=["library", "last_seen_at"], name="media_libr_library_31c4a1_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="mediaitemvodlink",
|
||||
index=models.Index(fields=["media_item"], name="media_libr_media_i_f9b327_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="mediaitemvodlink",
|
||||
index=models.Index(fields=["vod_movie"], name="media_libr_vod_mo_6d43ad_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="mediaitemvodlink",
|
||||
index=models.Index(fields=["vod_series"], name="media_libr_vod_se_05e51a_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="mediaitemvodlink",
|
||||
index=models.Index(fields=["vod_episode"], name="media_libr_vod_ep_c0b0c7_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="artworkasset",
|
||||
index=models.Index(fields=["media_item", "asset_type"], name="media_libr_media_i_6fe152_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="watchprogress",
|
||||
index=models.Index(fields=["user", "media_item"], name="media_libr_user_id_5f9dd5_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="libraryscan",
|
||||
index=models.Index(fields=["library", "created_at"], name="media_libr_library_1f1b2e_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="libraryscan",
|
||||
index=models.Index(fields=["library", "status"], name="media_libr_library_0f3a70_idx"),
|
||||
),
|
||||
]
|
||||
0
apps/media_library/migrations/__init__.py
Normal file
0
apps/media_library/migrations/__init__.py
Normal file
327
apps/media_library/models.py
Normal file
327
apps/media_library/models.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Library(models.Model):
|
||||
LIBRARY_TYPE_MOVIES = "movies"
|
||||
LIBRARY_TYPE_SHOWS = "shows"
|
||||
|
||||
LIBRARY_TYPE_CHOICES = [
|
||||
(LIBRARY_TYPE_MOVIES, "Movies"),
|
||||
(LIBRARY_TYPE_SHOWS, "TV Shows"),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, default="")
|
||||
library_type = models.CharField(max_length=20, choices=LIBRARY_TYPE_CHOICES)
|
||||
metadata_language = models.CharField(max_length=12, blank=True, default="")
|
||||
metadata_country = models.CharField(max_length=12, blank=True, default="")
|
||||
metadata_options = models.JSONField(default=dict, blank=True, null=True)
|
||||
scan_interval_minutes = models.PositiveIntegerField(default=1440)
|
||||
auto_scan_enabled = models.BooleanField(default=True)
|
||||
add_to_vod = models.BooleanField(default=False)
|
||||
vod_account = models.ForeignKey(
|
||||
"m3u.M3UAccount",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="media_libraries",
|
||||
)
|
||||
last_scan_at = models.DateTimeField(null=True, blank=True)
|
||||
last_successful_scan_at = models.DateTimeField(null=True, blank=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
|
||||
|
||||
|
||||
class LibraryLocation(models.Model):
|
||||
library = models.ForeignKey(
|
||||
Library, on_delete=models.CASCADE, related_name="locations"
|
||||
)
|
||||
path = models.CharField(max_length=1024)
|
||||
include_subdirectories = models.BooleanField(default=True)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("library", "path")]
|
||||
indexes = [
|
||||
models.Index(fields=["library", "is_primary"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.library.name}: {self.path}"
|
||||
|
||||
|
||||
class MediaItem(models.Model):
|
||||
TYPE_MOVIE = "movie"
|
||||
TYPE_SHOW = "show"
|
||||
TYPE_EPISODE = "episode"
|
||||
TYPE_OTHER = "other"
|
||||
|
||||
ITEM_TYPE_CHOICES = [
|
||||
(TYPE_MOVIE, "Movie"),
|
||||
(TYPE_SHOW, "Show"),
|
||||
(TYPE_EPISODE, "Episode"),
|
||||
(TYPE_OTHER, "Other"),
|
||||
]
|
||||
|
||||
library = models.ForeignKey(
|
||||
Library, on_delete=models.CASCADE, related_name="items"
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="children",
|
||||
)
|
||||
item_type = models.CharField(max_length=16, choices=ITEM_TYPE_CHOICES)
|
||||
title = models.CharField(max_length=512)
|
||||
sort_title = models.CharField(max_length=512, blank=True, default="")
|
||||
normalized_title = models.CharField(max_length=512, blank=True, default="")
|
||||
synopsis = models.TextField(blank=True, default="")
|
||||
tagline = models.TextField(blank=True, default="")
|
||||
release_year = models.IntegerField(null=True, blank=True)
|
||||
rating = models.CharField(max_length=10, blank=True, default="")
|
||||
runtime_ms = models.PositiveBigIntegerField(null=True, blank=True)
|
||||
season_number = models.IntegerField(null=True, blank=True)
|
||||
episode_number = models.IntegerField(null=True, blank=True)
|
||||
genres = models.JSONField(default=list, blank=True, null=True)
|
||||
tags = models.JSONField(default=list, blank=True, null=True)
|
||||
studios = models.JSONField(default=list, blank=True, null=True)
|
||||
cast = models.JSONField(default=list, blank=True, null=True)
|
||||
crew = models.JSONField(default=list, blank=True, null=True)
|
||||
poster_url = models.TextField(blank=True, default="")
|
||||
backdrop_url = models.TextField(blank=True, default="")
|
||||
movie_db_id = models.CharField(max_length=64, blank=True, default="")
|
||||
imdb_id = models.CharField(max_length=32, blank=True, default="")
|
||||
metadata = models.JSONField(blank=True, null=True)
|
||||
metadata_source = models.CharField(max_length=32, blank=True, default="")
|
||||
metadata_last_synced_at = models.DateTimeField(null=True, blank=True)
|
||||
first_imported_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["sort_title", "title"]
|
||||
indexes = [
|
||||
models.Index(fields=["library", "item_type"]),
|
||||
models.Index(fields=["library", "sort_title"]),
|
||||
models.Index(fields=["library", "updated_at"]),
|
||||
models.Index(fields=["parent", "season_number", "episode_number"]),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["parent", "season_number", "episode_number"],
|
||||
condition=models.Q(item_type="episode"),
|
||||
name="unique_episode_per_season",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def is_movie(self):
|
||||
return self.item_type == self.TYPE_MOVIE
|
||||
|
||||
@property
|
||||
def is_show(self):
|
||||
return self.item_type == self.TYPE_SHOW
|
||||
|
||||
@property
|
||||
def is_episode(self):
|
||||
return self.item_type == self.TYPE_EPISODE
|
||||
|
||||
|
||||
class MediaFile(models.Model):
|
||||
library = models.ForeignKey(
|
||||
Library, on_delete=models.CASCADE, related_name="files"
|
||||
)
|
||||
media_item = models.ForeignKey(
|
||||
MediaItem, on_delete=models.CASCADE, related_name="files"
|
||||
)
|
||||
path = models.CharField(max_length=1024)
|
||||
relative_path = models.CharField(max_length=1024, blank=True, default="")
|
||||
file_name = models.CharField(max_length=512)
|
||||
size_bytes = models.BigIntegerField(null=True, blank=True)
|
||||
modified_at = models.DateTimeField(null=True, blank=True)
|
||||
duration_ms = models.PositiveBigIntegerField(null=True, blank=True)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
last_seen_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("library", "path")]
|
||||
indexes = [
|
||||
models.Index(fields=["media_item"]),
|
||||
models.Index(fields=["library", "is_primary"]),
|
||||
models.Index(fields=["library", "last_seen_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.path
|
||||
|
||||
|
||||
class MediaItemVODLink(models.Model):
|
||||
media_item = models.OneToOneField(
|
||||
MediaItem, on_delete=models.CASCADE, related_name="vod_link"
|
||||
)
|
||||
vod_movie = models.ForeignKey(
|
||||
"vod.Movie",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="media_library_links",
|
||||
)
|
||||
vod_series = models.ForeignKey(
|
||||
"vod.Series",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="media_library_links",
|
||||
)
|
||||
vod_episode = models.ForeignKey(
|
||||
"vod.Episode",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="media_library_links",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["media_item"]),
|
||||
models.Index(fields=["vod_movie"]),
|
||||
models.Index(fields=["vod_series"]),
|
||||
models.Index(fields=["vod_episode"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.media_item.title} -> VOD"
|
||||
|
||||
|
||||
class ArtworkAsset(models.Model):
|
||||
TYPE_POSTER = "poster"
|
||||
TYPE_BACKDROP = "backdrop"
|
||||
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_POSTER, "Poster"),
|
||||
(TYPE_BACKDROP, "Backdrop"),
|
||||
]
|
||||
|
||||
media_item = models.ForeignKey(
|
||||
MediaItem, on_delete=models.CASCADE, related_name="artwork_assets"
|
||||
)
|
||||
asset_type = models.CharField(max_length=16, choices=TYPE_CHOICES)
|
||||
source = models.CharField(max_length=32, blank=True, default="")
|
||||
external_url = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["media_item", "asset_type"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.media_item.title}: {self.asset_type}"
|
||||
|
||||
|
||||
class WatchProgress(models.Model):
|
||||
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="progress_entries"
|
||||
)
|
||||
file = models.ForeignKey(
|
||||
MediaFile,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="progress_entries",
|
||||
)
|
||||
position_ms = models.PositiveBigIntegerField(default=0)
|
||||
duration_ms = models.PositiveBigIntegerField(null=True, blank=True)
|
||||
completed = models.BooleanField(default=False)
|
||||
last_watched_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("user", "media_item")]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "media_item"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - {self.media_item}"
|
||||
|
||||
|
||||
class LibraryScan(models.Model):
|
||||
SCAN_QUICK = "quick"
|
||||
SCAN_FULL = "full"
|
||||
|
||||
SCAN_TYPE_CHOICES = [
|
||||
(SCAN_QUICK, "Quick"),
|
||||
(SCAN_FULL, "Full"),
|
||||
]
|
||||
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_QUEUED = "queued"
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_COMPLETED = "completed"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_CANCELLED = "cancelled"
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_PENDING, "Pending"),
|
||||
(STATUS_QUEUED, "Queued"),
|
||||
(STATUS_RUNNING, "Running"),
|
||||
(STATUS_COMPLETED, "Completed"),
|
||||
(STATUS_FAILED, "Failed"),
|
||||
(STATUS_CANCELLED, "Cancelled"),
|
||||
]
|
||||
|
||||
library = models.ForeignKey(
|
||||
Library, on_delete=models.CASCADE, related_name="scans"
|
||||
)
|
||||
scan_type = models.CharField(max_length=16, choices=SCAN_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES)
|
||||
summary = models.CharField(max_length=255, blank=True, default="")
|
||||
stages = models.JSONField(default=dict, blank=True, null=True)
|
||||
processed_files = models.PositiveIntegerField(default=0)
|
||||
total_files = models.PositiveIntegerField(default=0)
|
||||
new_files = models.PositiveIntegerField(default=0)
|
||||
updated_files = models.PositiveIntegerField(default=0)
|
||||
removed_files = models.PositiveIntegerField(default=0)
|
||||
unmatched_files = models.PositiveIntegerField(default=0)
|
||||
log = models.TextField(blank=True, default="")
|
||||
extra = models.JSONField(blank=True, null=True)
|
||||
task_id = models.CharField(max_length=255, blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
finished_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["library", "created_at"]),
|
||||
models.Index(fields=["library", "status"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.library.name} ({self.scan_type})"
|
||||
373
apps/media_library/scanner.py
Normal file
373
apps/media_library/scanner.py
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
import os
|
||||
from datetime import timezone as dt_timezone
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Iterable, Optional
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.media_library.classification import classify_media_entry
|
||||
from apps.media_library.models import Library, MediaFile, MediaItem
|
||||
from apps.media_library.utils import normalize_title
|
||||
|
||||
VIDEO_EXTENSIONS = {
|
||||
".mkv",
|
||||
".mp4",
|
||||
".m4v",
|
||||
".avi",
|
||||
".mov",
|
||||
".wmv",
|
||||
".mpg",
|
||||
".mpeg",
|
||||
".ts",
|
||||
".m2ts",
|
||||
".webm",
|
||||
}
|
||||
|
||||
PROGRESS_UPDATE_INTERVAL = 50
|
||||
BULK_UPDATE_SIZE = 200
|
||||
BULK_CREATE_SIZE = 200
|
||||
|
||||
|
||||
class ScanCancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
processed_files: int = 0
|
||||
total_files: int = 0
|
||||
new_files: int = 0
|
||||
updated_files: int = 0
|
||||
removed_files: int = 0
|
||||
unmatched_files: int = 0
|
||||
unmatched_paths: list[str] = field(default_factory=list)
|
||||
metadata_item_ids: set[int] = field(default_factory=set)
|
||||
errors: list[dict[str, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _is_media_file(file_name: str) -> bool:
|
||||
_, ext = os.path.splitext(file_name)
|
||||
return ext.lower() in VIDEO_EXTENSIONS
|
||||
|
||||
|
||||
def _iter_location_files(base_path: str, include_subdirectories: bool) -> Iterable[str]:
|
||||
if include_subdirectories:
|
||||
for root, _dirs, files in os.walk(base_path):
|
||||
for file_name in files:
|
||||
yield os.path.join(root, file_name)
|
||||
else:
|
||||
for entry in os.scandir(base_path):
|
||||
if entry.is_file():
|
||||
yield entry.path
|
||||
|
||||
|
||||
def _resolve_relative_path(full_path: str, base_path: str) -> str:
|
||||
relative = os.path.relpath(full_path, base_path)
|
||||
if relative == ".":
|
||||
return ""
|
||||
parent = os.path.dirname(relative)
|
||||
return "" if parent == "." else parent
|
||||
|
||||
|
||||
def scan_library_files(
|
||||
library: Library,
|
||||
*,
|
||||
full: bool = False,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
cancel_check: Optional[Callable[[], bool]] = None,
|
||||
) -> ScanResult:
|
||||
result = ScanResult()
|
||||
scan_start = timezone.now()
|
||||
|
||||
existing_files = {
|
||||
row["path"]: row
|
||||
for row in MediaFile.objects.filter(library=library).values(
|
||||
"id",
|
||||
"path",
|
||||
"size_bytes",
|
||||
"modified_at",
|
||||
"media_item_id",
|
||||
"relative_path",
|
||||
"file_name",
|
||||
"duration_ms",
|
||||
"is_primary",
|
||||
)
|
||||
}
|
||||
|
||||
primary_item_ids = set(
|
||||
MediaFile.objects.filter(library=library, is_primary=True).values_list(
|
||||
"media_item_id", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
movie_cache: dict[tuple[str, int | None], MediaItem] = {}
|
||||
show_cache: dict[str, MediaItem] = {}
|
||||
episode_cache: dict[tuple[int, int | None, int | None, str], MediaItem] = {}
|
||||
|
||||
file_updates: list[MediaFile] = []
|
||||
file_creates: list[MediaFile] = []
|
||||
|
||||
def flush_updates():
|
||||
nonlocal file_updates, file_creates
|
||||
if file_updates:
|
||||
MediaFile.objects.bulk_update(
|
||||
file_updates,
|
||||
[
|
||||
"media_item",
|
||||
"relative_path",
|
||||
"file_name",
|
||||
"size_bytes",
|
||||
"modified_at",
|
||||
"duration_ms",
|
||||
"is_primary",
|
||||
"last_seen_at",
|
||||
],
|
||||
)
|
||||
file_updates = []
|
||||
if file_creates:
|
||||
MediaFile.objects.bulk_create(file_creates)
|
||||
file_creates = []
|
||||
|
||||
def get_or_create_movie(title: str, release_year: int | None) -> MediaItem:
|
||||
normalized = normalize_title(title)
|
||||
key = (normalized, release_year)
|
||||
if key in movie_cache:
|
||||
return movie_cache[key]
|
||||
query = MediaItem.objects.filter(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_MOVIE,
|
||||
normalized_title=normalized,
|
||||
)
|
||||
if release_year:
|
||||
query = query.filter(release_year=release_year)
|
||||
item = query.first()
|
||||
if not item:
|
||||
item = MediaItem.objects.create(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_MOVIE,
|
||||
title=title,
|
||||
sort_title=title,
|
||||
normalized_title=normalized,
|
||||
release_year=release_year,
|
||||
)
|
||||
movie_cache[key] = item
|
||||
return item
|
||||
|
||||
def get_or_create_show(title: str, release_year: int | None) -> MediaItem:
|
||||
normalized = normalize_title(title)
|
||||
if normalized in show_cache:
|
||||
return show_cache[normalized]
|
||||
query = MediaItem.objects.filter(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_SHOW,
|
||||
normalized_title=normalized,
|
||||
)
|
||||
if release_year:
|
||||
query = query.filter(release_year=release_year)
|
||||
item = query.first()
|
||||
if not item:
|
||||
item = MediaItem.objects.create(
|
||||
library=library,
|
||||
item_type=MediaItem.TYPE_SHOW,
|
||||
title=title,
|
||||
sort_title=title,
|
||||
normalized_title=normalized,
|
||||
release_year=release_year,
|
||||
)
|
||||
show_cache[normalized] = item
|
||||
return item
|
||||
|
||||
def get_or_create_episode(
|
||||
parent: MediaItem,
|
||||
title: str,
|
||||
season_number: int | None,
|
||||
episode_number: int | None,
|
||||
) -> MediaItem:
|
||||
cache_key = (parent.id, season_number, episode_number, title)
|
||||
if cache_key in episode_cache:
|
||||
return episode_cache[cache_key]
|
||||
query = MediaItem.objects.filter(
|
||||
library=library,
|
||||
parent=parent,
|
||||
item_type=MediaItem.TYPE_EPISODE,
|
||||
)
|
||||
if season_number is not None:
|
||||
query = query.filter(season_number=season_number)
|
||||
if episode_number is not None:
|
||||
query = query.filter(episode_number=episode_number)
|
||||
if season_number is None and episode_number is None:
|
||||
query = query.filter(title=title)
|
||||
item = query.first()
|
||||
if not item:
|
||||
item = MediaItem.objects.create(
|
||||
library=library,
|
||||
parent=parent,
|
||||
item_type=MediaItem.TYPE_EPISODE,
|
||||
title=title,
|
||||
sort_title=title,
|
||||
normalized_title=normalize_title(title),
|
||||
season_number=season_number,
|
||||
episode_number=episode_number,
|
||||
)
|
||||
episode_cache[cache_key] = item
|
||||
return item
|
||||
|
||||
processed_since_update = 0
|
||||
|
||||
for location in library.locations.all():
|
||||
base_path = os.path.expanduser(location.path)
|
||||
if not os.path.isdir(base_path):
|
||||
result.errors.append({"path": base_path, "error": "Path not found"})
|
||||
continue
|
||||
|
||||
try:
|
||||
file_iter = _iter_location_files(base_path, location.include_subdirectories)
|
||||
for full_path in file_iter:
|
||||
if cancel_check and cancel_check():
|
||||
raise ScanCancelled()
|
||||
|
||||
file_name = os.path.basename(full_path)
|
||||
if not _is_media_file(file_name):
|
||||
continue
|
||||
|
||||
result.total_files += 1
|
||||
result.processed_files += 1
|
||||
processed_since_update += 1
|
||||
|
||||
stat = os.stat(full_path)
|
||||
modified_at = timezone.datetime.fromtimestamp(
|
||||
stat.st_mtime, tz=dt_timezone.utc
|
||||
)
|
||||
size_bytes = stat.st_size
|
||||
relative_path = _resolve_relative_path(full_path, base_path)
|
||||
|
||||
existing = existing_files.get(full_path)
|
||||
changed = full or not existing
|
||||
if existing and not changed:
|
||||
if existing["size_bytes"] != size_bytes:
|
||||
changed = True
|
||||
elif existing["modified_at"] != modified_at:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
classification = classify_media_entry(
|
||||
library, relative_path=relative_path, file_name=file_name
|
||||
)
|
||||
item_type = classification.detected_type
|
||||
item_title = classification.title
|
||||
media_item: MediaItem
|
||||
|
||||
if library.library_type == Library.LIBRARY_TYPE_SHOWS:
|
||||
show_item = get_or_create_show(
|
||||
item_title, classification.year
|
||||
)
|
||||
result.metadata_item_ids.add(show_item.id)
|
||||
episode_title = classification.episode_title or os.path.splitext(file_name)[0]
|
||||
media_item = get_or_create_episode(
|
||||
show_item,
|
||||
episode_title,
|
||||
classification.season,
|
||||
classification.episode,
|
||||
)
|
||||
if media_item.normalized_title != normalize_title(media_item.title):
|
||||
media_item.normalized_title = normalize_title(media_item.title)
|
||||
media_item.save(update_fields=["normalized_title", "updated_at"])
|
||||
else:
|
||||
if item_type == MediaItem.TYPE_OTHER:
|
||||
result.unmatched_files += 1
|
||||
result.unmatched_paths.append(full_path)
|
||||
media_item = get_or_create_movie(
|
||||
item_title, classification.year
|
||||
)
|
||||
|
||||
result.metadata_item_ids.add(media_item.id)
|
||||
|
||||
if existing:
|
||||
file_updates.append(
|
||||
MediaFile(
|
||||
id=existing["id"],
|
||||
library_id=library.id,
|
||||
media_item_id=media_item.id,
|
||||
path=full_path,
|
||||
relative_path=relative_path,
|
||||
file_name=file_name,
|
||||
size_bytes=size_bytes,
|
||||
modified_at=modified_at,
|
||||
duration_ms=existing.get("duration_ms"),
|
||||
is_primary=existing.get("is_primary", False),
|
||||
last_seen_at=scan_start,
|
||||
)
|
||||
)
|
||||
result.updated_files += 1
|
||||
else:
|
||||
is_primary = False
|
||||
if media_item.id not in primary_item_ids:
|
||||
is_primary = True
|
||||
primary_item_ids.add(media_item.id)
|
||||
file_creates.append(
|
||||
MediaFile(
|
||||
library=library,
|
||||
media_item=media_item,
|
||||
path=full_path,
|
||||
relative_path=relative_path,
|
||||
file_name=file_name,
|
||||
size_bytes=size_bytes,
|
||||
modified_at=modified_at,
|
||||
is_primary=is_primary,
|
||||
last_seen_at=scan_start,
|
||||
)
|
||||
)
|
||||
result.new_files += 1
|
||||
else:
|
||||
file_updates.append(
|
||||
MediaFile(
|
||||
id=existing["id"],
|
||||
library_id=library.id,
|
||||
media_item_id=existing["media_item_id"],
|
||||
path=full_path,
|
||||
relative_path=existing.get("relative_path") or relative_path,
|
||||
file_name=existing.get("file_name") or file_name,
|
||||
size_bytes=size_bytes,
|
||||
modified_at=modified_at,
|
||||
duration_ms=existing.get("duration_ms"),
|
||||
is_primary=existing.get("is_primary", False),
|
||||
last_seen_at=scan_start,
|
||||
)
|
||||
)
|
||||
|
||||
if processed_since_update >= PROGRESS_UPDATE_INTERVAL:
|
||||
flush_updates()
|
||||
if progress_callback:
|
||||
progress_callback(result.processed_files, result.total_files)
|
||||
processed_since_update = 0
|
||||
|
||||
if len(file_updates) >= BULK_UPDATE_SIZE or len(file_creates) >= BULK_CREATE_SIZE:
|
||||
flush_updates()
|
||||
|
||||
except PermissionError as exc:
|
||||
result.errors.append({"path": base_path, "error": str(exc)})
|
||||
except OSError as exc:
|
||||
result.errors.append({"path": base_path, "error": str(exc)})
|
||||
|
||||
flush_updates()
|
||||
|
||||
stale_files = MediaFile.objects.filter(
|
||||
library=library, last_seen_at__lt=scan_start
|
||||
)
|
||||
result.removed_files = stale_files.count()
|
||||
stale_item_ids = set(stale_files.values_list("media_item_id", flat=True))
|
||||
stale_files.delete()
|
||||
|
||||
if stale_item_ids:
|
||||
MediaItem.objects.filter(
|
||||
library=library,
|
||||
id__in=stale_item_ids,
|
||||
).filter(
|
||||
files__isnull=True,
|
||||
children__isnull=True,
|
||||
).delete()
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(result.processed_files, result.total_files)
|
||||
|
||||
return result
|
||||
305
apps/media_library/serializers.py
Normal file
305
apps/media_library/serializers.py
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
from django.db.models import Count
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.media_library.models import (
|
||||
ArtworkAsset,
|
||||
Library,
|
||||
LibraryLocation,
|
||||
LibraryScan,
|
||||
MediaFile,
|
||||
MediaItem,
|
||||
WatchProgress,
|
||||
)
|
||||
|
||||
|
||||
class LibraryLocationSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = LibraryLocation
|
||||
fields = ["id", "path", "include_subdirectories", "is_primary"]
|
||||
|
||||
|
||||
class LibrarySerializer(serializers.ModelSerializer):
|
||||
locations = LibraryLocationSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Library
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"library_type",
|
||||
"metadata_language",
|
||||
"metadata_country",
|
||||
"metadata_options",
|
||||
"scan_interval_minutes",
|
||||
"auto_scan_enabled",
|
||||
"add_to_vod",
|
||||
"last_scan_at",
|
||||
"last_successful_scan_at",
|
||||
"locations",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
locations = validated_data.pop("locations", [])
|
||||
library = Library.objects.create(**validated_data)
|
||||
for index, location in enumerate(locations):
|
||||
LibraryLocation.objects.create(
|
||||
library=library,
|
||||
path=location["path"],
|
||||
include_subdirectories=location.get("include_subdirectories", True),
|
||||
is_primary=location.get("is_primary", index == 0),
|
||||
)
|
||||
return library
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
locations = validated_data.pop("locations", None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
|
||||
if locations is not None:
|
||||
existing_ids = {loc.id for loc in instance.locations.all()}
|
||||
seen_ids: set[int] = set()
|
||||
for index, location in enumerate(locations):
|
||||
location_id = location.get("id")
|
||||
if location_id and location_id in existing_ids:
|
||||
LibraryLocation.objects.filter(id=location_id).update(
|
||||
path=location.get("path", ""),
|
||||
include_subdirectories=location.get("include_subdirectories", True),
|
||||
is_primary=location.get("is_primary", index == 0),
|
||||
)
|
||||
seen_ids.add(location_id)
|
||||
else:
|
||||
created = LibraryLocation.objects.create(
|
||||
library=instance,
|
||||
path=location.get("path", ""),
|
||||
include_subdirectories=location.get("include_subdirectories", True),
|
||||
is_primary=location.get("is_primary", index == 0),
|
||||
)
|
||||
seen_ids.add(created.id)
|
||||
|
||||
missing_ids = existing_ids - seen_ids
|
||||
if missing_ids:
|
||||
LibraryLocation.objects.filter(id__in=missing_ids).delete()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class MediaFileSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MediaFile
|
||||
fields = [
|
||||
"id",
|
||||
"path",
|
||||
"relative_path",
|
||||
"file_name",
|
||||
"size_bytes",
|
||||
"modified_at",
|
||||
"duration_ms",
|
||||
]
|
||||
|
||||
|
||||
class MediaItemSerializer(serializers.ModelSerializer):
|
||||
watch_progress = serializers.SerializerMethodField()
|
||||
watch_summary = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MediaItem
|
||||
fields = [
|
||||
"id",
|
||||
"library",
|
||||
"parent",
|
||||
"item_type",
|
||||
"title",
|
||||
"sort_title",
|
||||
"normalized_title",
|
||||
"synopsis",
|
||||
"tagline",
|
||||
"release_year",
|
||||
"rating",
|
||||
"runtime_ms",
|
||||
"season_number",
|
||||
"episode_number",
|
||||
"genres",
|
||||
"tags",
|
||||
"studios",
|
||||
"cast",
|
||||
"crew",
|
||||
"poster_url",
|
||||
"backdrop_url",
|
||||
"movie_db_id",
|
||||
"imdb_id",
|
||||
"metadata_source",
|
||||
"metadata_last_synced_at",
|
||||
"first_imported_at",
|
||||
"updated_at",
|
||||
"watch_progress",
|
||||
"watch_summary",
|
||||
]
|
||||
|
||||
def _progress_payload(self, progress: WatchProgress | None, item: MediaItem):
|
||||
if not progress:
|
||||
return None
|
||||
duration = progress.duration_ms or item.runtime_ms
|
||||
percentage = None
|
||||
completed = progress.completed
|
||||
if duration:
|
||||
percentage = min(1, max(0, progress.position_ms / duration))
|
||||
if percentage >= 0.95:
|
||||
completed = True
|
||||
return {
|
||||
"id": progress.id,
|
||||
"position_ms": progress.position_ms,
|
||||
"duration_ms": duration,
|
||||
"percentage": percentage,
|
||||
"completed": completed,
|
||||
"last_watched_at": progress.last_watched_at,
|
||||
}
|
||||
|
||||
def _summary_for_progress(self, progress: WatchProgress | None, item: MediaItem):
|
||||
if not progress:
|
||||
return {"status": "unwatched"}
|
||||
duration = progress.duration_ms or item.runtime_ms or 0
|
||||
completed = progress.completed
|
||||
if duration and progress.position_ms / max(duration, 1) >= 0.95:
|
||||
completed = True
|
||||
if completed:
|
||||
status = "watched"
|
||||
elif progress.position_ms > 0:
|
||||
status = "in_progress"
|
||||
else:
|
||||
status = "unwatched"
|
||||
return {
|
||||
"status": status,
|
||||
"position_ms": progress.position_ms,
|
||||
"duration_ms": duration,
|
||||
"completed": completed,
|
||||
}
|
||||
|
||||
def _summary_for_show(self, item: MediaItem, user):
|
||||
episodes = MediaItem.objects.filter(
|
||||
parent=item, item_type=MediaItem.TYPE_EPISODE
|
||||
).order_by("season_number", "episode_number", "id")
|
||||
total = episodes.count()
|
||||
if total == 0:
|
||||
return {
|
||||
"status": "unwatched",
|
||||
"total_episodes": 0,
|
||||
"completed_episodes": 0,
|
||||
}
|
||||
|
||||
progress_map = {
|
||||
entry.media_item_id: entry
|
||||
for entry in WatchProgress.objects.filter(user=user, media_item__in=episodes)
|
||||
}
|
||||
|
||||
completed_episodes = 0
|
||||
resume_episode_id = None
|
||||
next_episode_id = None
|
||||
|
||||
for episode in episodes:
|
||||
progress = progress_map.get(episode.id)
|
||||
if progress:
|
||||
duration = progress.duration_ms or episode.runtime_ms or 0
|
||||
percent = progress.position_ms / max(duration, 1) if duration else 0
|
||||
completed = progress.completed or percent >= 0.95
|
||||
if completed:
|
||||
completed_episodes += 1
|
||||
elif progress.position_ms > 0 and resume_episode_id is None:
|
||||
resume_episode_id = episode.id
|
||||
if next_episode_id is None:
|
||||
if not progress:
|
||||
next_episode_id = episode.id
|
||||
else:
|
||||
duration = progress.duration_ms or episode.runtime_ms or 0
|
||||
percent = progress.position_ms / max(duration, 1) if duration else 0
|
||||
completed = progress.completed or percent >= 0.95
|
||||
if not completed:
|
||||
next_episode_id = episode.id
|
||||
|
||||
if completed_episodes == total:
|
||||
status = "watched"
|
||||
elif completed_episodes > 0 or resume_episode_id:
|
||||
status = "in_progress"
|
||||
else:
|
||||
status = "unwatched"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"total_episodes": total,
|
||||
"completed_episodes": completed_episodes,
|
||||
"resume_episode_id": resume_episode_id,
|
||||
"next_episode_id": next_episode_id,
|
||||
}
|
||||
|
||||
def get_watch_progress(self, obj: MediaItem):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return None
|
||||
progress = WatchProgress.objects.filter(user=user, media_item=obj).first()
|
||||
return self._progress_payload(progress, obj)
|
||||
|
||||
def get_watch_summary(self, obj: MediaItem):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return None
|
||||
if obj.item_type == MediaItem.TYPE_SHOW:
|
||||
return self._summary_for_show(obj, user)
|
||||
progress = WatchProgress.objects.filter(user=user, media_item=obj).first()
|
||||
return self._summary_for_progress(progress, obj)
|
||||
|
||||
|
||||
class MediaItemDetailSerializer(MediaItemSerializer):
|
||||
files = MediaFileSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta(MediaItemSerializer.Meta):
|
||||
fields = MediaItemSerializer.Meta.fields + ["files", "metadata"]
|
||||
|
||||
|
||||
class LibraryScanSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = LibraryScan
|
||||
fields = [
|
||||
"id",
|
||||
"library",
|
||||
"scan_type",
|
||||
"status",
|
||||
"summary",
|
||||
"stages",
|
||||
"processed_files",
|
||||
"total_files",
|
||||
"new_files",
|
||||
"updated_files",
|
||||
"removed_files",
|
||||
"unmatched_files",
|
||||
"log",
|
||||
"extra",
|
||||
"task_id",
|
||||
"created_at",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
]
|
||||
|
||||
|
||||
class MediaItemUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MediaItem
|
||||
fields = [
|
||||
"title",
|
||||
"synopsis",
|
||||
"release_year",
|
||||
"rating",
|
||||
"genres",
|
||||
"tags",
|
||||
"studios",
|
||||
"movie_db_id",
|
||||
"imdb_id",
|
||||
"poster_url",
|
||||
"backdrop_url",
|
||||
]
|
||||
86
apps/media_library/signals.py
Normal file
86
apps/media_library/signals.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from django.db.models.signals import post_delete, post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
||||
|
||||
from apps.media_library.models import Library, MediaItem
|
||||
from apps.media_library.vod import cleanup_library_vod, cleanup_media_item_vod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Library)
|
||||
def create_or_update_scan_task(sender, instance, **kwargs):
|
||||
interval_minutes = int(instance.scan_interval_minutes or 0)
|
||||
task_name = f"media-library-scan-{instance.id}"
|
||||
should_be_enabled = instance.auto_scan_enabled and interval_minutes > 0
|
||||
|
||||
if interval_minutes <= 0:
|
||||
if should_be_enabled:
|
||||
logger.warning("Scan interval invalid for library %s", instance.id)
|
||||
try:
|
||||
task = PeriodicTask.objects.get(name=task_name)
|
||||
if task.enabled:
|
||||
task.enabled = False
|
||||
task.save(update_fields=["enabled"])
|
||||
except PeriodicTask.DoesNotExist:
|
||||
pass
|
||||
return
|
||||
|
||||
interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=interval_minutes, period=IntervalSchedule.MINUTES
|
||||
)
|
||||
|
||||
kwargs_payload = json.dumps({"library_id": instance.id, "full": False})
|
||||
|
||||
try:
|
||||
task = PeriodicTask.objects.get(name=task_name)
|
||||
updated_fields = []
|
||||
|
||||
if task.enabled != should_be_enabled:
|
||||
task.enabled = should_be_enabled
|
||||
updated_fields.append("enabled")
|
||||
if task.interval != interval:
|
||||
task.interval = interval
|
||||
updated_fields.append("interval")
|
||||
if task.kwargs != kwargs_payload:
|
||||
task.kwargs = kwargs_payload
|
||||
updated_fields.append("kwargs")
|
||||
|
||||
if updated_fields:
|
||||
task.save(update_fields=updated_fields)
|
||||
except PeriodicTask.DoesNotExist:
|
||||
PeriodicTask.objects.create(
|
||||
name=task_name,
|
||||
interval=interval,
|
||||
task="apps.media_library.tasks.scan_library",
|
||||
kwargs=kwargs_payload,
|
||||
enabled=should_be_enabled,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Library)
|
||||
def delete_scan_task(sender, instance, **kwargs):
|
||||
task_name = f"media-library-scan-{instance.id}"
|
||||
try:
|
||||
PeriodicTask.objects.filter(name=task_name).delete()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to delete scan task for library %s: %s", instance.id, exc)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Library)
|
||||
def delete_library_vod(sender, instance, **kwargs):
|
||||
try:
|
||||
cleanup_library_vod(instance)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to cleanup VOD for library %s: %s", instance.id, exc)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=MediaItem)
|
||||
def delete_media_item_vod(sender, instance, **kwargs):
|
||||
try:
|
||||
cleanup_media_item_vod(instance)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to cleanup VOD for media item %s: %s", instance.id, exc)
|
||||
255
apps/media_library/tasks.py
Normal file
255
apps/media_library/tasks.py
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from celery import shared_task
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.media_library.metadata import METADATA_CACHE_TIMEOUT, sync_metadata
|
||||
from apps.media_library.models import Library, LibraryScan, MediaItem
|
||||
from apps.media_library.scanner import ScanCancelled, scan_library_files
|
||||
from apps.media_library.vod import sync_vod_for_media_item
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STAGE_DISCOVERY = "discovery"
|
||||
STAGE_METADATA = "metadata"
|
||||
STAGE_ARTWORK = "artwork"
|
||||
|
||||
|
||||
def _update_stage(scan: LibraryScan, stage_key: str, *, status=None, processed=None, total=None):
|
||||
stages = scan.stages or {}
|
||||
stage = stages.get(stage_key, {"status": "pending", "processed": 0, "total": 0})
|
||||
if status is not None:
|
||||
stage["status"] = status
|
||||
if processed is not None:
|
||||
stage["processed"] = processed
|
||||
if total is not None:
|
||||
stage["total"] = total
|
||||
stages[stage_key] = stage
|
||||
scan.stages = stages
|
||||
scan.save(update_fields=["stages", "updated_at"])
|
||||
|
||||
|
||||
def _filter_metadata_queryset(queryset, *, force: bool):
|
||||
if force or not METADATA_CACHE_TIMEOUT:
|
||||
return queryset
|
||||
cutoff = timezone.now() - timedelta(seconds=METADATA_CACHE_TIMEOUT)
|
||||
return queryset.filter(
|
||||
Q(metadata_last_synced_at__isnull=True)
|
||||
| Q(metadata_last_synced_at__lt=cutoff)
|
||||
| Q(poster_url__isnull=True)
|
||||
| Q(poster_url="")
|
||||
| Q(backdrop_url__isnull=True)
|
||||
| Q(backdrop_url="")
|
||||
)
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def scan_library(self, library_id: int, *, full: bool = False, scan_id: int | None = None):
|
||||
library = Library.objects.get(id=library_id)
|
||||
scan_type = LibraryScan.SCAN_FULL if full else LibraryScan.SCAN_QUICK
|
||||
|
||||
if scan_id:
|
||||
scan = LibraryScan.objects.get(id=scan_id, library=library)
|
||||
else:
|
||||
scan = LibraryScan.objects.create(
|
||||
library=library,
|
||||
scan_type=scan_type,
|
||||
status=LibraryScan.STATUS_QUEUED,
|
||||
summary="Full scan" if full else "Quick scan",
|
||||
stages={},
|
||||
)
|
||||
|
||||
scan.task_id = self.request.id
|
||||
scan.status = LibraryScan.STATUS_RUNNING
|
||||
scan.started_at = timezone.now()
|
||||
scan.save(update_fields=["task_id", "status", "started_at", "updated_at"])
|
||||
|
||||
library.last_scan_at = scan.started_at
|
||||
library.save(update_fields=["last_scan_at", "updated_at"])
|
||||
|
||||
_update_stage(scan, STAGE_DISCOVERY, status="running", processed=0, total=0)
|
||||
_update_stage(scan, STAGE_METADATA, status="pending", processed=0, total=0)
|
||||
_update_stage(scan, STAGE_ARTWORK, status="pending", processed=0, total=0)
|
||||
|
||||
def cancel_check():
|
||||
scan.refresh_from_db(fields=["status"])
|
||||
return scan.status == LibraryScan.STATUS_CANCELLED
|
||||
|
||||
def progress_callback(processed: int, total: int):
|
||||
scan.processed_files = processed
|
||||
scan.total_files = total
|
||||
scan.save(update_fields=["processed_files", "total_files", "updated_at"])
|
||||
_update_stage(
|
||||
scan,
|
||||
STAGE_DISCOVERY,
|
||||
status="running",
|
||||
processed=processed,
|
||||
total=total,
|
||||
)
|
||||
|
||||
try:
|
||||
result = scan_library_files(
|
||||
library,
|
||||
full=full,
|
||||
progress_callback=progress_callback,
|
||||
cancel_check=cancel_check,
|
||||
)
|
||||
except ScanCancelled:
|
||||
scan.status = LibraryScan.STATUS_CANCELLED
|
||||
scan.finished_at = timezone.now()
|
||||
scan.save(update_fields=["status", "finished_at", "updated_at"])
|
||||
_update_stage(scan, STAGE_DISCOVERY, status="cancelled")
|
||||
_update_stage(scan, STAGE_METADATA, status="skipped")
|
||||
_update_stage(scan, STAGE_ARTWORK, status="skipped")
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Library scan failed for %s", library.name)
|
||||
scan.status = LibraryScan.STATUS_FAILED
|
||||
scan.finished_at = timezone.now()
|
||||
scan.save(update_fields=["status", "finished_at", "updated_at"])
|
||||
_update_stage(scan, STAGE_DISCOVERY, status="failed")
|
||||
_update_stage(scan, STAGE_METADATA, status="skipped")
|
||||
_update_stage(scan, STAGE_ARTWORK, status="skipped")
|
||||
return
|
||||
|
||||
scan.processed_files = result.processed_files
|
||||
scan.total_files = result.total_files
|
||||
scan.new_files = result.new_files
|
||||
scan.updated_files = result.updated_files
|
||||
scan.removed_files = result.removed_files
|
||||
scan.unmatched_files = result.unmatched_files
|
||||
scan.extra = {
|
||||
"errors": result.errors,
|
||||
"unmatched_paths": result.unmatched_paths,
|
||||
}
|
||||
scan.save(
|
||||
update_fields=[
|
||||
"processed_files",
|
||||
"total_files",
|
||||
"new_files",
|
||||
"updated_files",
|
||||
"removed_files",
|
||||
"unmatched_files",
|
||||
"extra",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
_update_stage(
|
||||
scan,
|
||||
STAGE_DISCOVERY,
|
||||
status="completed",
|
||||
processed=result.processed_files,
|
||||
total=result.total_files,
|
||||
)
|
||||
|
||||
metadata_ids = list(result.metadata_item_ids)
|
||||
if not metadata_ids:
|
||||
_update_stage(scan, STAGE_METADATA, status="completed", processed=0, total=0)
|
||||
_update_stage(scan, STAGE_ARTWORK, status="completed", processed=0, total=0)
|
||||
scan.status = LibraryScan.STATUS_COMPLETED
|
||||
scan.finished_at = timezone.now()
|
||||
scan.save(update_fields=["status", "finished_at", "updated_at"])
|
||||
library.last_successful_scan_at = scan.finished_at
|
||||
library.save(update_fields=["last_successful_scan_at", "updated_at"])
|
||||
return
|
||||
|
||||
metadata_qs = MediaItem.objects.filter(id__in=metadata_ids)
|
||||
metadata_qs = _filter_metadata_queryset(metadata_qs, force=full)
|
||||
metadata_total = metadata_qs.count()
|
||||
if not metadata_total:
|
||||
_update_stage(scan, STAGE_METADATA, status="completed", processed=0, total=0)
|
||||
_update_stage(scan, STAGE_ARTWORK, status="completed", processed=0, total=0)
|
||||
scan.status = LibraryScan.STATUS_COMPLETED
|
||||
scan.finished_at = timezone.now()
|
||||
scan.save(update_fields=["status", "finished_at", "updated_at"])
|
||||
library.last_successful_scan_at = scan.finished_at
|
||||
library.save(update_fields=["last_successful_scan_at", "updated_at"])
|
||||
return
|
||||
|
||||
_update_stage(scan, STAGE_METADATA, status="running", processed=0, total=metadata_total)
|
||||
_update_stage(scan, STAGE_ARTWORK, status="running", processed=0, total=metadata_total)
|
||||
|
||||
processed = 0
|
||||
artwork_processed = 0
|
||||
|
||||
for media_item in metadata_qs.iterator():
|
||||
if cancel_check():
|
||||
scan.status = LibraryScan.STATUS_CANCELLED
|
||||
scan.finished_at = timezone.now()
|
||||
scan.save(update_fields=["status", "finished_at", "updated_at"])
|
||||
_update_stage(scan, STAGE_METADATA, status="cancelled")
|
||||
_update_stage(scan, STAGE_ARTWORK, status="cancelled")
|
||||
return
|
||||
|
||||
updated = sync_metadata(media_item, force=full)
|
||||
try:
|
||||
sync_vod_for_media_item(media_item)
|
||||
except Exception:
|
||||
logger.exception("Failed to sync VOD for media item %s", media_item.id)
|
||||
processed += 1
|
||||
if updated and (updated.poster_url or updated.backdrop_url):
|
||||
artwork_processed += 1
|
||||
|
||||
if processed % 25 == 0 or processed == metadata_total:
|
||||
_update_stage(
|
||||
scan,
|
||||
STAGE_METADATA,
|
||||
status="running",
|
||||
processed=processed,
|
||||
total=metadata_total,
|
||||
)
|
||||
_update_stage(
|
||||
scan,
|
||||
STAGE_ARTWORK,
|
||||
status="running",
|
||||
processed=artwork_processed,
|
||||
total=metadata_total,
|
||||
)
|
||||
|
||||
_update_stage(
|
||||
scan,
|
||||
STAGE_METADATA,
|
||||
status="completed",
|
||||
processed=processed,
|
||||
total=metadata_total,
|
||||
)
|
||||
_update_stage(
|
||||
scan,
|
||||
STAGE_ARTWORK,
|
||||
status="completed",
|
||||
processed=artwork_processed,
|
||||
total=metadata_total,
|
||||
)
|
||||
|
||||
scan.status = LibraryScan.STATUS_COMPLETED
|
||||
scan.finished_at = timezone.now()
|
||||
scan.save(update_fields=["status", "finished_at", "updated_at"])
|
||||
library.last_successful_scan_at = scan.finished_at
|
||||
library.save(update_fields=["last_successful_scan_at", "updated_at"])
|
||||
|
||||
|
||||
@shared_task
|
||||
def refresh_media_item_metadata(item_id: int):
|
||||
try:
|
||||
media_item = MediaItem.objects.get(id=item_id)
|
||||
except MediaItem.DoesNotExist:
|
||||
return
|
||||
sync_metadata(media_item, force=True)
|
||||
try:
|
||||
sync_vod_for_media_item(media_item)
|
||||
except Exception:
|
||||
logger.exception("Failed to sync VOD for media item %s", media_item.id)
|
||||
|
||||
|
||||
@shared_task
|
||||
def refresh_library_metadata(library_id: int):
|
||||
items = MediaItem.objects.filter(library_id=library_id).iterator()
|
||||
for item in items:
|
||||
sync_metadata(item, force=True)
|
||||
try:
|
||||
sync_vod_for_media_item(item)
|
||||
except Exception:
|
||||
logger.exception("Failed to sync VOD for media item %s", item.id)
|
||||
32
apps/media_library/utils.py
Normal file
32
apps/media_library/utils.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassificationResult:
|
||||
detected_type: str
|
||||
title: str
|
||||
year: Optional[int] = None
|
||||
season: Optional[int] = None
|
||||
episode: Optional[int] = None
|
||||
episode_title: Optional[str] = None
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def normalize_title(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
normalized = re.sub(r"[^A-Za-z0-9]+", " ", value)
|
||||
normalized = re.sub(r"\s+", " ", normalized).strip().lower()
|
||||
return normalized
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: _json_safe(val) for key, val in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_json_safe(val) for val in value]
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
return str(value)
|
||||
416
apps/media_library/vod.py
Normal file
416
apps/media_library/vod.py
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
import mimetypes
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from apps.media_library.models import Library, MediaFile, MediaItem, MediaItemVODLink
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.vod.models import (
|
||||
Episode,
|
||||
M3UEpisodeRelation,
|
||||
M3UMovieRelation,
|
||||
M3USeriesRelation,
|
||||
Movie,
|
||||
Series,
|
||||
VODCategory,
|
||||
VODLogo,
|
||||
)
|
||||
|
||||
LIBRARY_ACCOUNT_PREFIX = "Media Library"
|
||||
LOCAL_TMDB_PREFIX = "ml"
|
||||
UNCATEGORIZED_NAME = "Uncategorized"
|
||||
|
||||
|
||||
def _account_name(library: Library) -> str:
|
||||
return f"{LIBRARY_ACCOUNT_PREFIX} {library.id}: {library.name}"
|
||||
|
||||
|
||||
def _local_tmdb_id(media_item: MediaItem) -> str:
|
||||
return f"{LOCAL_TMDB_PREFIX}:{media_item.id}"
|
||||
|
||||
|
||||
def _ensure_uncategorized(category_type: str) -> VODCategory:
|
||||
category, _ = VODCategory.objects.get_or_create(
|
||||
name=UNCATEGORIZED_NAME, category_type=category_type
|
||||
)
|
||||
return category
|
||||
|
||||
|
||||
def _merge_custom_properties(existing: Optional[dict], updates: dict) -> dict:
|
||||
merged = dict(existing or {})
|
||||
for key, value in updates.items():
|
||||
if value in (None, "", [], {}):
|
||||
continue
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def _get_primary_file(media_item: MediaItem) -> Optional[MediaFile]:
|
||||
return (
|
||||
media_item.files.filter(is_primary=True).first()
|
||||
or media_item.files.first()
|
||||
)
|
||||
|
||||
|
||||
def ensure_library_vod_account(library: Library) -> M3UAccount:
|
||||
desired_name = _account_name(library)
|
||||
if library.vod_account_id:
|
||||
account = library.vod_account
|
||||
if account and account.name != desired_name:
|
||||
account.name = desired_name
|
||||
account.save(update_fields=["name"])
|
||||
return account
|
||||
|
||||
account = (
|
||||
M3UAccount.objects.filter(custom_properties__media_library_id=library.id).first()
|
||||
)
|
||||
if account:
|
||||
if account.name != desired_name:
|
||||
account.name = desired_name
|
||||
account.save(update_fields=["name"])
|
||||
library.vod_account = account
|
||||
library.save(update_fields=["vod_account"])
|
||||
return account
|
||||
|
||||
account = M3UAccount.objects.create(
|
||||
name=desired_name,
|
||||
account_type=M3UAccount.Types.STADNARD,
|
||||
is_active=library.add_to_vod,
|
||||
locked=True,
|
||||
priority=0,
|
||||
custom_properties={
|
||||
"media_library_id": library.id,
|
||||
"source": "media-library",
|
||||
},
|
||||
)
|
||||
library.vod_account = account
|
||||
library.save(update_fields=["vod_account"])
|
||||
return account
|
||||
|
||||
|
||||
def sync_library_vod_account_state(library: Library) -> M3UAccount:
|
||||
account = ensure_library_vod_account(library)
|
||||
if account.is_active != library.add_to_vod:
|
||||
account.is_active = library.add_to_vod
|
||||
account.save(update_fields=["is_active"])
|
||||
return account
|
||||
|
||||
|
||||
def _ensure_logo(media_item: MediaItem) -> Optional[VODLogo]:
|
||||
if not media_item.poster_url:
|
||||
return None
|
||||
logo, _ = VODLogo.objects.get_or_create(
|
||||
url=media_item.poster_url, defaults={"name": media_item.title}
|
||||
)
|
||||
return logo
|
||||
|
||||
|
||||
def _runtime_secs(media_item: MediaItem) -> Optional[int]:
|
||||
if media_item.runtime_ms:
|
||||
return int(media_item.runtime_ms / 1000)
|
||||
return None
|
||||
|
||||
|
||||
def _genre_string(media_item: MediaItem) -> str:
|
||||
if isinstance(media_item.genres, list) and media_item.genres:
|
||||
return ", ".join([str(entry) for entry in media_item.genres if entry])
|
||||
return ""
|
||||
|
||||
|
||||
def _file_container_extension(file: Optional[MediaFile]) -> Optional[str]:
|
||||
if not file or not file.file_name:
|
||||
return None
|
||||
_base, ext = os.path.splitext(file.file_name)
|
||||
return ext[1:].lower() if ext else None
|
||||
|
||||
|
||||
def _relation_payload(media_item: MediaItem, file: Optional[MediaFile]) -> dict:
|
||||
return _merge_custom_properties(
|
||||
{},
|
||||
{
|
||||
"source": "media-library",
|
||||
"library_id": media_item.library_id,
|
||||
"media_item_id": media_item.id,
|
||||
"file_id": file.id if file else None,
|
||||
"file_path": file.path if file else None,
|
||||
"file_name": file.file_name if file else None,
|
||||
"file_size_bytes": file.size_bytes if file else None,
|
||||
"file_mime": mimetypes.guess_type(file.path)[0] if file else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def sync_vod_for_media_item(media_item: MediaItem) -> None:
|
||||
library = media_item.library
|
||||
account = sync_library_vod_account_state(library)
|
||||
link, _ = MediaItemVODLink.objects.get_or_create(media_item=media_item)
|
||||
|
||||
if media_item.item_type == MediaItem.TYPE_MOVIE:
|
||||
_sync_movie(media_item, account, link)
|
||||
elif media_item.item_type == MediaItem.TYPE_SHOW:
|
||||
_sync_series(media_item, account, link)
|
||||
elif media_item.item_type == MediaItem.TYPE_EPISODE:
|
||||
_sync_episode(media_item, account, link)
|
||||
|
||||
|
||||
def _sync_movie(media_item: MediaItem, account: M3UAccount, link: MediaItemVODLink) -> None:
|
||||
logo = _ensure_logo(media_item)
|
||||
custom_properties = _merge_custom_properties(
|
||||
{},
|
||||
{
|
||||
"source": "media-library",
|
||||
"movie_db_id": media_item.movie_db_id,
|
||||
"imdb_id": media_item.imdb_id,
|
||||
"poster_url": media_item.poster_url,
|
||||
"backdrop_url": media_item.backdrop_url,
|
||||
"tags": media_item.tags,
|
||||
"studios": media_item.studios,
|
||||
},
|
||||
)
|
||||
|
||||
movie = link.vod_movie
|
||||
if not movie:
|
||||
movie = Movie.objects.filter(tmdb_id=_local_tmdb_id(media_item)).first()
|
||||
if not movie:
|
||||
movie = Movie.objects.create(
|
||||
name=media_item.title,
|
||||
description=media_item.synopsis or "",
|
||||
year=media_item.release_year,
|
||||
rating=media_item.rating or "",
|
||||
genre=_genre_string(media_item),
|
||||
duration_secs=_runtime_secs(media_item),
|
||||
tmdb_id=_local_tmdb_id(media_item),
|
||||
custom_properties=custom_properties,
|
||||
logo=logo,
|
||||
)
|
||||
else:
|
||||
movie.name = media_item.title
|
||||
movie.description = media_item.synopsis or ""
|
||||
movie.year = media_item.release_year
|
||||
movie.rating = media_item.rating or ""
|
||||
movie.genre = _genre_string(media_item)
|
||||
movie.duration_secs = _runtime_secs(media_item)
|
||||
movie.custom_properties = _merge_custom_properties(movie.custom_properties, custom_properties)
|
||||
if logo:
|
||||
movie.logo = logo
|
||||
movie.save()
|
||||
|
||||
link.vod_movie = movie
|
||||
link.vod_series = None
|
||||
link.vod_episode = None
|
||||
link.save(update_fields=["vod_movie", "vod_series", "vod_episode", "updated_at"])
|
||||
|
||||
file = _get_primary_file(media_item)
|
||||
if not file:
|
||||
return
|
||||
|
||||
category = _ensure_uncategorized("movie")
|
||||
M3UMovieRelation.objects.update_or_create(
|
||||
m3u_account=account,
|
||||
movie=movie,
|
||||
defaults={
|
||||
"category": category,
|
||||
"stream_id": str(media_item.id),
|
||||
"container_extension": _file_container_extension(file),
|
||||
"custom_properties": _relation_payload(media_item, file),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _sync_series(media_item: MediaItem, account: M3UAccount, link: MediaItemVODLink) -> None:
|
||||
logo = _ensure_logo(media_item)
|
||||
custom_properties = _merge_custom_properties(
|
||||
{},
|
||||
{
|
||||
"source": "media-library",
|
||||
"movie_db_id": media_item.movie_db_id,
|
||||
"imdb_id": media_item.imdb_id,
|
||||
"poster_url": media_item.poster_url,
|
||||
"backdrop_url": media_item.backdrop_url,
|
||||
"tags": media_item.tags,
|
||||
"studios": media_item.studios,
|
||||
},
|
||||
)
|
||||
|
||||
series = link.vod_series
|
||||
if not series:
|
||||
series = Series.objects.filter(tmdb_id=_local_tmdb_id(media_item)).first()
|
||||
if not series:
|
||||
series = Series.objects.create(
|
||||
name=media_item.title,
|
||||
description=media_item.synopsis or "",
|
||||
year=media_item.release_year,
|
||||
rating=media_item.rating or "",
|
||||
genre=_genre_string(media_item),
|
||||
tmdb_id=_local_tmdb_id(media_item),
|
||||
custom_properties=custom_properties,
|
||||
logo=logo,
|
||||
)
|
||||
else:
|
||||
series.name = media_item.title
|
||||
series.description = media_item.synopsis or ""
|
||||
series.year = media_item.release_year
|
||||
series.rating = media_item.rating or ""
|
||||
series.genre = _genre_string(media_item)
|
||||
series.custom_properties = _merge_custom_properties(series.custom_properties, custom_properties)
|
||||
if logo:
|
||||
series.logo = logo
|
||||
series.save()
|
||||
|
||||
link.vod_series = series
|
||||
link.vod_movie = None
|
||||
link.vod_episode = None
|
||||
link.save(update_fields=["vod_series", "vod_movie", "vod_episode", "updated_at"])
|
||||
|
||||
category = _ensure_uncategorized("series")
|
||||
M3USeriesRelation.objects.update_or_create(
|
||||
m3u_account=account,
|
||||
series=series,
|
||||
defaults={
|
||||
"category": category,
|
||||
"external_series_id": str(media_item.id),
|
||||
"custom_properties": _merge_custom_properties(
|
||||
{},
|
||||
{
|
||||
"source": "media-library",
|
||||
"library_id": media_item.library_id,
|
||||
"media_item_id": media_item.id,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _sync_episode(media_item: MediaItem, account: M3UAccount, link: MediaItemVODLink) -> None:
|
||||
if not media_item.parent_id:
|
||||
return
|
||||
parent = media_item.parent
|
||||
if not parent:
|
||||
return
|
||||
|
||||
parent_link, _ = MediaItemVODLink.objects.get_or_create(media_item=parent)
|
||||
if not parent_link.vod_series:
|
||||
_sync_series(parent, account, parent_link)
|
||||
parent_link.refresh_from_db(fields=["vod_series"])
|
||||
|
||||
if not parent_link.vod_series:
|
||||
return
|
||||
|
||||
logo = _ensure_logo(media_item) or _ensure_logo(parent)
|
||||
custom_properties = _merge_custom_properties(
|
||||
{},
|
||||
{
|
||||
"source": "media-library",
|
||||
"movie_db_id": media_item.movie_db_id,
|
||||
"imdb_id": media_item.imdb_id,
|
||||
"poster_url": media_item.poster_url,
|
||||
"backdrop_url": media_item.backdrop_url,
|
||||
"tags": media_item.tags,
|
||||
"studios": media_item.studios,
|
||||
},
|
||||
)
|
||||
|
||||
episode = link.vod_episode
|
||||
if not episode:
|
||||
episode = Episode.objects.filter(tmdb_id=_local_tmdb_id(media_item)).first()
|
||||
if not episode:
|
||||
episode = Episode.objects.create(
|
||||
name=media_item.title,
|
||||
description=media_item.synopsis or "",
|
||||
season_number=media_item.season_number,
|
||||
episode_number=media_item.episode_number,
|
||||
series=parent_link.vod_series,
|
||||
tmdb_id=_local_tmdb_id(media_item),
|
||||
custom_properties=custom_properties,
|
||||
duration_secs=_runtime_secs(media_item),
|
||||
)
|
||||
else:
|
||||
episode.name = media_item.title
|
||||
episode.description = media_item.synopsis or ""
|
||||
episode.season_number = media_item.season_number
|
||||
episode.episode_number = media_item.episode_number
|
||||
episode.duration_secs = _runtime_secs(media_item)
|
||||
episode.custom_properties = _merge_custom_properties(episode.custom_properties, custom_properties)
|
||||
episode.save()
|
||||
|
||||
link.vod_episode = episode
|
||||
link.vod_movie = None
|
||||
link.vod_series = None
|
||||
link.save(update_fields=["vod_episode", "vod_movie", "vod_series", "updated_at"])
|
||||
|
||||
file = _get_primary_file(media_item)
|
||||
if not file:
|
||||
return
|
||||
|
||||
M3UEpisodeRelation.objects.update_or_create(
|
||||
m3u_account=account,
|
||||
episode=episode,
|
||||
defaults={
|
||||
"stream_id": str(media_item.id),
|
||||
"container_extension": _file_container_extension(file),
|
||||
"custom_properties": _relation_payload(media_item, file),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def cleanup_library_vod(library: Library) -> None:
|
||||
if not library:
|
||||
return
|
||||
|
||||
link_qs = MediaItemVODLink.objects.filter(media_item__library=library)
|
||||
movie_ids = set(link_qs.exclude(vod_movie__isnull=True).values_list("vod_movie_id", flat=True))
|
||||
series_ids = set(link_qs.exclude(vod_series__isnull=True).values_list("vod_series_id", flat=True))
|
||||
episode_ids = set(link_qs.exclude(vod_episode__isnull=True).values_list("vod_episode_id", flat=True))
|
||||
|
||||
account = library.vod_account
|
||||
if account:
|
||||
account.delete()
|
||||
|
||||
if episode_ids:
|
||||
Episode.objects.filter(id__in=episode_ids, m3u_relations__isnull=True).delete()
|
||||
|
||||
if series_ids:
|
||||
Series.objects.filter(id__in=series_ids, m3u_relations__isnull=True, episodes__isnull=True).delete()
|
||||
|
||||
if movie_ids:
|
||||
Movie.objects.filter(id__in=movie_ids, m3u_relations__isnull=True).delete()
|
||||
|
||||
|
||||
def cleanup_media_item_vod(media_item: MediaItem) -> None:
|
||||
if not media_item:
|
||||
return
|
||||
link = getattr(media_item, "vod_link", None)
|
||||
if not link:
|
||||
return
|
||||
|
||||
account_id = None
|
||||
try:
|
||||
account_id = media_item.library.vod_account_id
|
||||
except Exception:
|
||||
account_id = None
|
||||
|
||||
movie_id = link.vod_movie_id
|
||||
series_id = link.vod_series_id
|
||||
episode_id = link.vod_episode_id
|
||||
|
||||
if account_id:
|
||||
if movie_id:
|
||||
M3UMovieRelation.objects.filter(
|
||||
m3u_account_id=account_id, movie_id=movie_id
|
||||
).delete()
|
||||
if series_id:
|
||||
M3USeriesRelation.objects.filter(
|
||||
m3u_account_id=account_id, series_id=series_id
|
||||
).delete()
|
||||
if episode_id:
|
||||
M3UEpisodeRelation.objects.filter(
|
||||
m3u_account_id=account_id, episode_id=episode_id
|
||||
).delete()
|
||||
|
||||
link.delete()
|
||||
|
||||
if episode_id:
|
||||
Episode.objects.filter(id=episode_id, m3u_relations__isnull=True).delete()
|
||||
if series_id:
|
||||
Series.objects.filter(id=series_id, m3u_relations__isnull=True, episodes__isnull=True).delete()
|
||||
if movie_id:
|
||||
Movie.objects.filter(id=movie_id, m3u_relations__isnull=True).delete()
|
||||
|
|
@ -6,6 +6,8 @@ Supports M3U profiles for authentication and URL transformation.
|
|||
import time
|
||||
import random
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import requests
|
||||
from django.http import StreamingHttpResponse, JsonResponse, Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
|
@ -145,6 +147,7 @@ class VODStreamView(View):
|
|||
# Extract preferred M3U account ID and stream ID from query parameters
|
||||
preferred_m3u_account_id = request.GET.get('m3u_account_id')
|
||||
preferred_stream_id = request.GET.get('stream_id')
|
||||
include_inactive = request.GET.get('include_inactive') in ('1', 'true', 'yes')
|
||||
|
||||
if preferred_m3u_account_id:
|
||||
try:
|
||||
|
|
@ -157,7 +160,13 @@ class VODStreamView(View):
|
|||
logger.info(f"[VOD-PARAM] Preferred stream ID: {preferred_stream_id}")
|
||||
|
||||
# Get the content object and its relation
|
||||
content_obj, relation = self._get_content_and_relation(content_type, content_id, preferred_m3u_account_id, preferred_stream_id)
|
||||
content_obj, relation = self._get_content_and_relation(
|
||||
content_type,
|
||||
content_id,
|
||||
preferred_m3u_account_id,
|
||||
preferred_stream_id,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
if not content_obj or not relation:
|
||||
logger.error(f"[VOD-ERROR] Content or relation not found: {content_type} {content_id}")
|
||||
raise Http404(f"Content not found: {content_type} {content_id}")
|
||||
|
|
@ -168,6 +177,10 @@ class VODStreamView(View):
|
|||
m3u_account = relation.m3u_account
|
||||
logger.info(f"[VOD-ACCOUNT] Using M3U account: {m3u_account.name}")
|
||||
|
||||
local_path = self._get_local_file_path(relation)
|
||||
if local_path:
|
||||
return self._stream_local_file(request, local_path, relation)
|
||||
|
||||
# Get stream URL from relation
|
||||
stream_url = self._get_stream_url_from_relation(relation)
|
||||
logger.info(f"[VOD-CONTENT] Content URL: {stream_url or 'No URL found'}")
|
||||
|
|
@ -268,11 +281,21 @@ class VODStreamView(View):
|
|||
logger.info(f"[VOD-HEAD] Preferred stream ID: {preferred_stream_id}")
|
||||
|
||||
# Get content and relation (same as GET)
|
||||
content_obj, relation = self._get_content_and_relation(content_type, content_id, preferred_m3u_account_id, preferred_stream_id)
|
||||
content_obj, relation = self._get_content_and_relation(
|
||||
content_type,
|
||||
content_id,
|
||||
preferred_m3u_account_id,
|
||||
preferred_stream_id,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
if not content_obj or not relation:
|
||||
logger.error(f"[VOD-HEAD] Content or relation not found: {content_type} {content_id}")
|
||||
return HttpResponse("Content not found", status=404)
|
||||
|
||||
local_path = self._get_local_file_path(relation)
|
||||
if local_path:
|
||||
return self._head_local_file(local_path, relation, session_url, session_id)
|
||||
|
||||
# Get M3U account and stream URL
|
||||
m3u_account = relation.m3u_account
|
||||
stream_url = self._get_stream_url_from_relation(relation)
|
||||
|
|
@ -385,7 +408,14 @@ class VODStreamView(View):
|
|||
logger.error(f"[VOD-HEAD] Error in HEAD request: {e}", exc_info=True)
|
||||
return HttpResponse(f"HEAD error: {str(e)}", status=500)
|
||||
|
||||
def _get_content_and_relation(self, content_type, content_id, preferred_m3u_account_id=None, preferred_stream_id=None):
|
||||
def _get_content_and_relation(
|
||||
self,
|
||||
content_type,
|
||||
content_id,
|
||||
preferred_m3u_account_id=None,
|
||||
preferred_stream_id=None,
|
||||
include_inactive=False,
|
||||
):
|
||||
"""Get the content object and its M3U relation"""
|
||||
try:
|
||||
logger.info(f"[CONTENT-LOOKUP] Looking up {content_type} with UUID {content_id}")
|
||||
|
|
@ -399,7 +429,9 @@ class VODStreamView(View):
|
|||
logger.info(f"[CONTENT-FOUND] Movie: {content_obj.name} (ID: {content_obj.id})")
|
||||
|
||||
# Filter by preferred stream ID first (most specific)
|
||||
relations_query = content_obj.m3u_relations.filter(m3u_account__is_active=True)
|
||||
relations_query = content_obj.m3u_relations.all()
|
||||
if not include_inactive:
|
||||
relations_query = relations_query.filter(m3u_account__is_active=True)
|
||||
if preferred_stream_id:
|
||||
specific_relation = relations_query.filter(stream_id=preferred_stream_id).first()
|
||||
if specific_relation:
|
||||
|
|
@ -430,7 +462,9 @@ class VODStreamView(View):
|
|||
logger.info(f"[CONTENT-FOUND] Episode: {content_obj.name} (ID: {content_obj.id}, Series: {content_obj.series.name})")
|
||||
|
||||
# Filter by preferred stream ID first (most specific)
|
||||
relations_query = content_obj.m3u_relations.filter(m3u_account__is_active=True)
|
||||
relations_query = content_obj.m3u_relations.all()
|
||||
if not include_inactive:
|
||||
relations_query = relations_query.filter(m3u_account__is_active=True)
|
||||
if preferred_stream_id:
|
||||
specific_relation = relations_query.filter(stream_id=preferred_stream_id).first()
|
||||
if specific_relation:
|
||||
|
|
@ -468,7 +502,9 @@ class VODStreamView(View):
|
|||
logger.info(f"[CONTENT-FOUND] First episode: {episode.name} (ID: {episode.id})")
|
||||
|
||||
# Filter by preferred stream ID first (most specific)
|
||||
relations_query = episode.m3u_relations.filter(m3u_account__is_active=True)
|
||||
relations_query = episode.m3u_relations.all()
|
||||
if not include_inactive:
|
||||
relations_query = relations_query.filter(m3u_account__is_active=True)
|
||||
if preferred_stream_id:
|
||||
specific_relation = relations_query.filter(stream_id=preferred_stream_id).first()
|
||||
if specific_relation:
|
||||
|
|
@ -524,6 +560,86 @@ class VODStreamView(View):
|
|||
logger.error(f"[VOD-URL] Error getting stream URL from relation: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def _get_local_file_path(self, relation):
|
||||
props = getattr(relation, "custom_properties", None)
|
||||
if not isinstance(props, dict):
|
||||
return None
|
||||
path = props.get("file_path")
|
||||
if not path:
|
||||
return None
|
||||
if not os.path.exists(path):
|
||||
logger.warning("[VOD-LOCAL] File not found at %s", path)
|
||||
return None
|
||||
return path
|
||||
|
||||
def _stream_local_file(self, request, file_path, relation):
|
||||
props = getattr(relation, "custom_properties", {}) if relation else {}
|
||||
file_name = None
|
||||
if isinstance(props, dict):
|
||||
file_name = props.get("file_name")
|
||||
file_name = file_name or os.path.basename(file_path)
|
||||
|
||||
content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
||||
file_size = os.path.getsize(file_path)
|
||||
range_header = request.META.get("HTTP_RANGE", "").strip()
|
||||
|
||||
def file_iterator(path, start=0, end=None, chunk_size=8192):
|
||||
with open(path, "rb") as handle:
|
||||
handle.seek(start)
|
||||
remaining = (end - start + 1) if end is not None else None
|
||||
while True:
|
||||
if remaining is not None and remaining <= 0:
|
||||
break
|
||||
bytes_to_read = min(chunk_size, remaining) if remaining is not None else chunk_size
|
||||
data = handle.read(bytes_to_read)
|
||||
if not data:
|
||||
break
|
||||
if remaining is not None:
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
|
||||
if range_header.startswith("bytes="):
|
||||
try:
|
||||
range_spec = range_header.split("=", 1)[1]
|
||||
start_str, end_str = range_spec.split("-", 1)
|
||||
start = int(start_str) if start_str else 0
|
||||
end = int(end_str) if end_str else file_size - 1
|
||||
start = max(0, start)
|
||||
end = min(file_size - 1, end)
|
||||
length = end - start + 1
|
||||
|
||||
resp = StreamingHttpResponse(
|
||||
file_iterator(file_path, start, end),
|
||||
status=206,
|
||||
content_type=content_type,
|
||||
)
|
||||
resp["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
resp["Content-Length"] = str(length)
|
||||
resp["Accept-Ranges"] = "bytes"
|
||||
resp["Content-Disposition"] = f'inline; filename="{file_name}"'
|
||||
return resp
|
||||
except Exception as exc:
|
||||
logger.warning("[VOD-LOCAL] Range parse failed: %s", exc)
|
||||
|
||||
response = FileResponse(open(file_path, "rb"), content_type=content_type)
|
||||
response["Content-Length"] = str(file_size)
|
||||
response["Accept-Ranges"] = "bytes"
|
||||
response["Content-Disposition"] = f'inline; filename="{file_name}"'
|
||||
return response
|
||||
|
||||
def _head_local_file(self, file_path, relation, session_url, session_id):
|
||||
if not os.path.exists(file_path):
|
||||
return HttpResponse("Content not found", status=404)
|
||||
content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
||||
file_size = os.path.getsize(file_path)
|
||||
response = HttpResponse()
|
||||
response["Content-Length"] = str(file_size)
|
||||
response["Content-Type"] = content_type
|
||||
response["Accept-Ranges"] = "bytes"
|
||||
response["X-Session-URL"] = session_url
|
||||
response["X-Dispatcharr-Session"] = session_id
|
||||
return response
|
||||
|
||||
def _get_m3u_profile(self, m3u_account, profile_id, session_id=None):
|
||||
"""Get appropriate M3U profile for streaming using Redis-based viewer counts
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ INSTALLED_APPS = [
|
|||
"apps.proxy.apps.ProxyConfig",
|
||||
"apps.proxy.ts_proxy",
|
||||
"apps.vod.apps.VODConfig",
|
||||
"apps.media_library.apps.MediaLibraryConfig",
|
||||
"core",
|
||||
"daphne",
|
||||
"drf_yasg",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -153,6 +154,7 @@ const App = () => {
|
|||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/logos" element={<LogosPage />} />
|
||||
<Route path="/vods" element={<VODsPage />} />
|
||||
<Route path="/library/:mediaType" element={<LibraryPage />} />
|
||||
</>
|
||||
) : (
|
||||
<Route path="/login" element={<Login needsSuperuser />} />
|
||||
|
|
|
|||
|
|
@ -2545,6 +2545,297 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
// Media Library Methods
|
||||
static async getLibraries() {
|
||||
try {
|
||||
const response = await request(`${host}/api/media-library/libraries/`);
|
||||
return response.results || response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve libraries', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async createLibrary(payload) {
|
||||
try {
|
||||
const response = await request(`${host}/api/media-library/libraries/`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to create library', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateLibrary(id, payload) {
|
||||
try {
|
||||
const response = await request(`${host}/api/media-library/libraries/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update library', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteLibrary(id) {
|
||||
try {
|
||||
await request(`${host}/api/media-library/libraries/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete library', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async browseLibraryPath(path = '') {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (path) {
|
||||
params.append('path', path);
|
||||
}
|
||||
const response = await request(
|
||||
`${host}/api/media-library/browse/?${params.toString()}`
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to browse library path', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getLibraryScans(libraryId, params = new URLSearchParams()) {
|
||||
try {
|
||||
if (libraryId) {
|
||||
params.append('library', libraryId);
|
||||
}
|
||||
const response = await request(
|
||||
`${host}/api/media-library/scans/?${params.toString()}`
|
||||
);
|
||||
return response.results || response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve library scans', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async triggerLibraryScan(libraryId, { full = false } = {}) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/media-library/libraries/${libraryId}/scan/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { full },
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to start library scan', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async cancelLibraryScan(scanId) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/media-library/scans/${scanId}/cancel/`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to cancel library scan', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteLibraryScan(scanId) {
|
||||
try {
|
||||
await request(`${host}/api/media-library/scans/${scanId}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete library scan', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async purgeLibraryScans(libraryId = null) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (libraryId) {
|
||||
params.append('library', libraryId);
|
||||
}
|
||||
const response = await request(
|
||||
`${host}/api/media-library/scans/purge/?${params.toString()}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to purge library scans', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getMediaItems(params = new URLSearchParams()) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/media-library/items/?${params.toString()}`
|
||||
);
|
||||
return response.results || response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve media items', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getMediaItem(id, options = {}) {
|
||||
try {
|
||||
const response = await request(`${host}/api/media-library/items/${id}/`);
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (!options.suppressErrorNotification) {
|
||||
errorNotification('Failed to retrieve media item', e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async updateMediaItem(id, payload) {
|
||||
try {
|
||||
const response = await request(`${host}/api/media-library/items/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update media item', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteMediaItem(id) {
|
||||
try {
|
||||
await request(`${host}/api/media-library/items/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete media item', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getMediaItemEpisodes(id) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/media-library/items/${id}/episodes/`
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve episodes', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async markMediaItemWatched(id) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/media-library/items/${id}/mark-watched/`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to mark item watched', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateMediaItemProgress(id, payload = {}, options = {}) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/media-library/items/${id}/progress/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (!options.suppressErrorNotification) {
|
||||
errorNotification('Failed to update media progress', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async clearMediaItemProgress(id) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/media-library/items/${id}/clear-progress/`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to clear media progress', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async markSeriesWatched(id) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/media-library/items/${id}/series/mark-watched/`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to mark series watched', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async markSeriesUnwatched(id) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/media-library/items/${id}/series/clear-progress/`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to clear series progress', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async refreshMediaItemMetadata(id) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/media-library/items/${id}/refresh-metadata/`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to refresh metadata', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async streamMediaItem(id, payload = {}) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/media-library/items/${id}/stream/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to start playback', e);
|
||||
}
|
||||
}
|
||||
|
||||
// VOD Methods
|
||||
static async getMovies(params = new URLSearchParams()) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import Draggable from 'react-draggable';
|
|||
import useVideoStore from '../store/useVideoStore';
|
||||
import mpegts from 'mpegts.js';
|
||||
import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core';
|
||||
import API from '../api';
|
||||
import useMediaLibraryStore from '../store/mediaLibrary';
|
||||
|
||||
export default function FloatingVideo() {
|
||||
const isVisible = useVideoStore((s) => s.isVisible);
|
||||
|
|
@ -14,6 +16,10 @@ export default function FloatingVideo() {
|
|||
const videoRef = useRef(null);
|
||||
const playerRef = useRef(null);
|
||||
const videoContainerRef = useRef(null);
|
||||
const playbackMetaRef = useRef({ contentType: null, metadata: null });
|
||||
const progressStateRef = useRef({ lastSentAt: 0, lastPositionMs: 0 });
|
||||
const progressInFlightRef = useRef(false);
|
||||
const resumeAppliedRef = useRef(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [showOverlay, setShowOverlay] = useState(true);
|
||||
|
|
@ -105,6 +111,82 @@ export default function FloatingVideo() {
|
|||
},
|
||||
},
|
||||
];
|
||||
const isVod = contentType === 'vod' || contentType === 'library';
|
||||
|
||||
useEffect(() => {
|
||||
playbackMetaRef.current = { contentType, metadata };
|
||||
}, [contentType, metadata]);
|
||||
|
||||
const reportLibraryProgress = useCallback(
|
||||
async ({ force = false, completed = false } = {}) => {
|
||||
const metaState = playbackMetaRef.current;
|
||||
if (!metaState || metaState.contentType !== 'library') {
|
||||
return;
|
||||
}
|
||||
const meta = metaState.metadata || {};
|
||||
const mediaItemId = meta.mediaItemId;
|
||||
if (!mediaItemId || !videoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const video = videoRef.current;
|
||||
const positionMs = Math.max(0, Math.floor((video.currentTime || 0) * 1000));
|
||||
const durationMs = meta.durationMs
|
||||
? Math.floor(meta.durationMs)
|
||||
: video.duration
|
||||
? Math.floor(video.duration * 1000)
|
||||
: null;
|
||||
|
||||
if (!force) {
|
||||
const now = Date.now();
|
||||
const last = progressStateRef.current;
|
||||
const timeDelta = now - last.lastSentAt;
|
||||
const positionDelta = Math.abs(positionMs - last.lastPositionMs);
|
||||
if (timeDelta < 10000 && positionDelta < 5000) {
|
||||
return;
|
||||
}
|
||||
if (positionMs <= 0 && !completed) {
|
||||
return;
|
||||
}
|
||||
progressStateRef.current = {
|
||||
lastSentAt: now,
|
||||
lastPositionMs: positionMs,
|
||||
};
|
||||
} else {
|
||||
progressStateRef.current = {
|
||||
lastSentAt: Date.now(),
|
||||
lastPositionMs: positionMs,
|
||||
};
|
||||
}
|
||||
|
||||
if (progressInFlightRef.current && !force) {
|
||||
return;
|
||||
}
|
||||
progressInFlightRef.current = true;
|
||||
try {
|
||||
const response = await API.updateMediaItemProgress(
|
||||
mediaItemId,
|
||||
{
|
||||
position_ms: positionMs,
|
||||
duration_ms: durationMs,
|
||||
completed,
|
||||
file_id: meta.fileId || null,
|
||||
},
|
||||
{ suppressErrorNotification: true }
|
||||
);
|
||||
if (response?.id) {
|
||||
const store = useMediaLibraryStore.getState();
|
||||
store.upsertItems([response]);
|
||||
store.setActiveProgress(response.watch_progress || null);
|
||||
}
|
||||
} catch (error) {
|
||||
// Progress updates should not interrupt playback.
|
||||
} finally {
|
||||
progressInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Safely destroy the mpegts player to prevent errors
|
||||
const safeDestroyPlayer = () => {
|
||||
|
|
@ -170,16 +252,48 @@ export default function FloatingVideo() {
|
|||
console.log('Initializing VOD player for:', streamUrl);
|
||||
|
||||
const video = videoRef.current;
|
||||
resumeAppliedRef.current = false;
|
||||
|
||||
// Enhanced video element configuration for VOD
|
||||
video.preload = 'metadata';
|
||||
video.crossOrigin = 'anonymous';
|
||||
|
||||
const applyResumeSeek = () => {
|
||||
if (resumeAppliedRef.current) return;
|
||||
const meta = playbackMetaRef.current?.metadata || {};
|
||||
const resumePositionMs = Number(meta.resumePositionMs || 0);
|
||||
const resumeHandledByServer = Boolean(meta.resumeHandledByServer);
|
||||
if (!resumePositionMs || resumeHandledByServer) {
|
||||
resumeAppliedRef.current = true;
|
||||
return;
|
||||
}
|
||||
const resumeSeconds = Math.max(0, resumePositionMs / 1000);
|
||||
if (!Number.isFinite(resumeSeconds) || resumeSeconds <= 0) {
|
||||
resumeAppliedRef.current = true;
|
||||
return;
|
||||
}
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : null;
|
||||
const safeSeconds =
|
||||
duration && duration > 1
|
||||
? Math.min(resumeSeconds, Math.max(0, duration - 1))
|
||||
: resumeSeconds;
|
||||
try {
|
||||
video.currentTime = safeSeconds;
|
||||
resumeAppliedRef.current = true;
|
||||
} catch (error) {
|
||||
// Keep false so we can retry on the next readiness event.
|
||||
}
|
||||
};
|
||||
|
||||
// Set up event listeners
|
||||
const handleLoadStart = () => setIsLoading(true);
|
||||
const handleLoadedMetadata = () => {
|
||||
applyResumeSeek();
|
||||
};
|
||||
const handleLoadedData = () => setIsLoading(false);
|
||||
const handleCanPlay = () => {
|
||||
setIsLoading(false);
|
||||
applyResumeSeek();
|
||||
// Auto-play for VOD content
|
||||
video.play().catch((e) => {
|
||||
console.log('Auto-play prevented:', e);
|
||||
|
|
@ -188,6 +302,15 @@ export default function FloatingVideo() {
|
|||
// Start overlay timer when video is ready
|
||||
startOverlayTimer();
|
||||
};
|
||||
const handleTimeUpdate = () => {
|
||||
reportLibraryProgress();
|
||||
};
|
||||
const handlePause = () => {
|
||||
reportLibraryProgress({ force: true });
|
||||
};
|
||||
const handleEnded = () => {
|
||||
reportLibraryProgress({ force: true, completed: true });
|
||||
};
|
||||
const handleError = (e) => {
|
||||
setIsLoading(false);
|
||||
const error = e.target.error;
|
||||
|
|
@ -229,10 +352,14 @@ export default function FloatingVideo() {
|
|||
|
||||
// Add event listeners
|
||||
video.addEventListener('loadstart', handleLoadStart);
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
video.addEventListener('canplay', handleCanPlay);
|
||||
video.addEventListener('error', handleError);
|
||||
video.addEventListener('progress', handleProgress);
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('ended', handleEnded);
|
||||
|
||||
// Set the source
|
||||
video.src = streamUrl;
|
||||
|
|
@ -242,10 +369,14 @@ export default function FloatingVideo() {
|
|||
playerRef.current = {
|
||||
destroy: () => {
|
||||
video.removeEventListener('loadstart', handleLoadStart);
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('loadeddata', handleLoadedData);
|
||||
video.removeEventListener('canplay', handleCanPlay);
|
||||
video.removeEventListener('error', handleError);
|
||||
video.removeEventListener('progress', handleProgress);
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('ended', handleEnded);
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
},
|
||||
|
|
@ -378,7 +509,7 @@ export default function FloatingVideo() {
|
|||
safeDestroyPlayer();
|
||||
|
||||
// Initialize the appropriate player based on content type
|
||||
if (contentType === 'vod') {
|
||||
if (isVod) {
|
||||
initializeVODPlayer();
|
||||
} else {
|
||||
initializeLivePlayer();
|
||||
|
|
@ -388,7 +519,7 @@ export default function FloatingVideo() {
|
|||
return () => {
|
||||
safeDestroyPlayer();
|
||||
};
|
||||
}, [isVisible, streamUrl, contentType]);
|
||||
}, [isVisible, streamUrl, contentType, isVod]);
|
||||
|
||||
// Modified hideVideo handler to clean up player first
|
||||
const handleClose = (e) => {
|
||||
|
|
@ -396,6 +527,7 @@ export default function FloatingVideo() {
|
|||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
reportLibraryProgress({ force: true });
|
||||
safeDestroyPlayer();
|
||||
setTimeout(() => {
|
||||
hideVideo();
|
||||
|
|
@ -743,7 +875,7 @@ export default function FloatingVideo() {
|
|||
<Box
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => {
|
||||
if (contentType === 'vod' && !isLoading) {
|
||||
if (isVod && !isLoading) {
|
||||
setShowOverlay(true);
|
||||
if (overlayTimeoutRef.current) {
|
||||
clearTimeout(overlayTimeoutRef.current);
|
||||
|
|
@ -751,7 +883,7 @@ export default function FloatingVideo() {
|
|||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (contentType === 'vod' && !isLoading) {
|
||||
if (isVod && !isLoading) {
|
||||
startOverlayTimer();
|
||||
}
|
||||
}}
|
||||
|
|
@ -767,19 +899,19 @@ export default function FloatingVideo() {
|
|||
backgroundColor: '#000',
|
||||
borderRadius: '0 0 8px 8px',
|
||||
// Better controls styling for VOD
|
||||
...(contentType === 'vod' && {
|
||||
...(isVod && {
|
||||
controlsList: 'nodownload',
|
||||
playsInline: true,
|
||||
}),
|
||||
}}
|
||||
// Add poster for VOD if available
|
||||
{...(contentType === 'vod' && {
|
||||
{...(isVod && {
|
||||
poster: metadata?.logo?.url, // Use VOD poster if available
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* VOD title overlay when not loading - auto-hides after 4 seconds */}
|
||||
{!isLoading && metadata && contentType === 'vod' && showOverlay && (
|
||||
{!isLoading && metadata && isVod && showOverlay && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import {
|
|||
ListOrdered,
|
||||
Play,
|
||||
Database,
|
||||
SlidersHorizontal,
|
||||
LayoutGrid,
|
||||
Settings as LucideSettings,
|
||||
Copy,
|
||||
ChartLine,
|
||||
Video,
|
||||
Library as LibraryIcon,
|
||||
PlugZap,
|
||||
LogOut,
|
||||
User,
|
||||
|
|
@ -104,6 +104,11 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
path: '/vods',
|
||||
icon: <Video size={20} />,
|
||||
},
|
||||
{
|
||||
label: 'Media Library',
|
||||
path: '/library/movies',
|
||||
icon: <LibraryIcon size={20} />,
|
||||
},
|
||||
{
|
||||
label: 'M3U & EPG Manager',
|
||||
icon: <Play size={20} />,
|
||||
|
|
@ -136,6 +141,11 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
path: '/channels',
|
||||
badge: `(${Object.keys(channels).length})`,
|
||||
},
|
||||
{
|
||||
label: 'Media Library',
|
||||
path: '/library/movies',
|
||||
icon: <LibraryIcon size={20} />,
|
||||
},
|
||||
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
|
||||
{
|
||||
label: 'Settings',
|
||||
|
|
|
|||
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;
|
||||
436
frontend/src/components/library/LibraryFormModal.jsx
Normal file
436
frontend/src/components/library/LibraryFormModal.jsx
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
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,
|
||||
add_to_vod: 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,
|
||||
add_to_vod: library.add_to_vod ?? 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="Expose this library in VOD"
|
||||
checked={form.values.add_to_vod}
|
||||
onChange={(event) =>
|
||||
form.setFieldValue('add_to_vod', 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;
|
||||
548
frontend/src/components/library/LibraryScanDrawer.jsx
Normal file
548
frontend/src/components/library/LibraryScanDrawer.jsx
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
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';
|
||||
const unmatchedPaths = Array.isArray(scan.extra?.unmatched_paths)
|
||||
? scan.extra.unmatched_paths.filter(Boolean)
|
||||
: [];
|
||||
const errorEntries = Array.isArray(scan.extra?.errors)
|
||||
? scan.extra.errors.filter(Boolean)
|
||||
: [];
|
||||
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>
|
||||
)}
|
||||
{unmatchedPaths.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={600}>
|
||||
Unmatched files
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={160}>
|
||||
<Stack gap={2}>
|
||||
{unmatchedPaths.map((path) => (
|
||||
<Text
|
||||
key={path}
|
||||
size="xs"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{path}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
{errorEntries.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={600} c="red.4">
|
||||
Errors
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={160}>
|
||||
<Stack gap={6}>
|
||||
{errorEntries.map((entry, index) => {
|
||||
const path =
|
||||
entry && typeof entry === 'object' ? entry.path || '' : '';
|
||||
const message =
|
||||
entry && typeof entry === 'object'
|
||||
? entry.error || ''
|
||||
: String(entry);
|
||||
const key = `${path}-${message}-${index}`;
|
||||
return (
|
||||
<Stack key={key} gap={2}>
|
||||
{path && (
|
||||
<Text
|
||||
size="xs"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{path}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="red.4">
|
||||
{message || 'Unknown error'}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryScanDrawer;
|
||||
374
frontend/src/components/library/MediaCard.jsx
Normal file
374
frontend/src/components/library/MediaCard.jsx
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import React, { memo, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
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 POSTER_ASPECT_RATIO = 2 / 3;
|
||||
const CARD_PADDING = 10;
|
||||
|
||||
const POSTER_HEIGHT = {
|
||||
sm: 180,
|
||||
md: 220,
|
||||
lg: 270,
|
||||
};
|
||||
|
||||
const TITLE_BAR_HEIGHT = {
|
||||
sm: 28,
|
||||
md: 30,
|
||||
lg: 32,
|
||||
};
|
||||
|
||||
const FOOTER_HEIGHT = {
|
||||
sm: 30,
|
||||
md: 32,
|
||||
lg: 34,
|
||||
};
|
||||
|
||||
export const getMediaCardDimensions = (size = 'md') => {
|
||||
const posterHeight = POSTER_HEIGHT[size] ?? POSTER_HEIGHT.md;
|
||||
const titleBarHeight = TITLE_BAR_HEIGHT[size] ?? TITLE_BAR_HEIGHT.md;
|
||||
const footerHeight = FOOTER_HEIGHT[size] ?? FOOTER_HEIGHT.md;
|
||||
const posterWidth = Math.round(posterHeight * POSTER_ASPECT_RATIO);
|
||||
const cardWidth = posterWidth + CARD_PADDING * 2;
|
||||
const cardHeight = posterHeight + footerHeight;
|
||||
return {
|
||||
posterHeight,
|
||||
titleBarHeight,
|
||||
footerHeight,
|
||||
posterWidth,
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
style = {},
|
||||
}) => {
|
||||
const [isTouch, setIsTouch] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mediaQuery = window.matchMedia('(hover: none)');
|
||||
const updateTouchState = () => {
|
||||
setIsTouch(mediaQuery.matches || navigator.maxTouchPoints > 0);
|
||||
};
|
||||
updateTouchState();
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', updateTouchState);
|
||||
return () => mediaQuery.removeEventListener('change', updateTouchState);
|
||||
}
|
||||
mediaQuery.addListener(updateTouchState);
|
||||
return () => mediaQuery.removeListener(updateTouchState);
|
||||
}, []);
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (isTouch && !isExpanded) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsExpanded(true);
|
||||
return;
|
||||
}
|
||||
setIsExpanded(false);
|
||||
onClick?.(item);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
if (!isTouch) return;
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovered(false);
|
||||
if (!isTouch) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (event) => {
|
||||
if (onContextMenu) {
|
||||
event.preventDefault();
|
||||
onContextMenu(event, item);
|
||||
}
|
||||
};
|
||||
|
||||
const { posterHeight, titleBarHeight, cardWidth, cardHeight } =
|
||||
getMediaCardDimensions(size);
|
||||
const minCardHeight = cardHeight;
|
||||
const progress = item.watch_progress;
|
||||
const watchSummary = item.watch_summary;
|
||||
const status = watchSummary?.status;
|
||||
const runtimeText = formatRuntime(item.runtime_ms);
|
||||
const hasGenres = Array.isArray(item.genres) && item.genres.length > 0;
|
||||
const hasPoster = Boolean(item.poster_url);
|
||||
const showEpisodeBadge =
|
||||
item.item_type === 'show' && watchSummary?.total_episodes;
|
||||
const isActive = isExpanded || (!isTouch && isHovered) || isFocused;
|
||||
const titleBarExpandedHeight = Math.round(posterHeight * 0.55);
|
||||
const metaText = useMemo(() => {
|
||||
const parts = [];
|
||||
if (hasGenres) {
|
||||
parts.push(item.genres[0]);
|
||||
}
|
||||
if (runtimeText) {
|
||||
parts.push(runtimeText);
|
||||
}
|
||||
if (showEpisodeBadge) {
|
||||
parts.push(
|
||||
`${watchSummary?.completed_episodes || 0}/${watchSummary?.total_episodes} eps`
|
||||
);
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
parts.push('In progress');
|
||||
} else if (status === 'watched') {
|
||||
parts.push('Watched');
|
||||
}
|
||||
if (showTypeBadge && item.item_type) {
|
||||
parts.push(item.item_type);
|
||||
}
|
||||
return parts.filter(Boolean).join(' | ');
|
||||
}, [
|
||||
hasGenres,
|
||||
item.genres,
|
||||
item.item_type,
|
||||
runtimeText,
|
||||
showEpisodeBadge,
|
||||
showTypeBadge,
|
||||
status,
|
||||
watchSummary,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding={CARD_PADDING}
|
||||
radius="md"
|
||||
withBorder
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: 'rgba(12, 15, 27, 0.75)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: minCardHeight,
|
||||
width: cardWidth,
|
||||
maxWidth: cardWidth,
|
||||
margin: '0 auto',
|
||||
outline: 'none',
|
||||
...style,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Stack spacing={8} style={{ flex: 1 }}>
|
||||
<Box
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
background: 'rgba(12, 17, 32, 0.75)',
|
||||
height: posterHeight,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(5, 7, 12, 0.2)',
|
||||
opacity: isActive ? 0.08 : 0,
|
||||
transition: 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{hasPoster ? (
|
||||
<Image
|
||||
src={item.poster_url}
|
||||
alt={item.title}
|
||||
height="100%"
|
||||
width="100%"
|
||||
fit="contain"
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
h="100%"
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
color: '#e2e8f0',
|
||||
textAlign: 'center',
|
||||
background:
|
||||
'linear-gradient(160deg, rgba(59, 130, 246, 0.3), rgba(15, 23, 42, 0.8))',
|
||||
}}
|
||||
>
|
||||
{typeIcon[item.item_type] || <LibraryIcon size={24} />}
|
||||
<Text size="sm" fw={600} ta="center" px="sm" lineClamp={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{progress && progress.percentage ? (
|
||||
<RingProgress
|
||||
style={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}
|
||||
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}
|
||||
{hasPoster ? (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 2,
|
||||
padding: '6px 10px 8px',
|
||||
background: isActive
|
||||
? 'rgba(6, 8, 12, 0.88)'
|
||||
: 'rgba(9, 11, 16, 0.78)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
height: isActive ? 'auto' : titleBarHeight,
|
||||
maxHeight: isActive ? titleBarExpandedHeight : titleBarHeight,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
overflow: 'hidden',
|
||||
transition:
|
||||
'max-height 200ms cubic-bezier(0.2, 0, 0, 1), background 200ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
}}
|
||||
>
|
||||
<Box style={{ position: 'relative' }}>
|
||||
<Text
|
||||
fw={600}
|
||||
lineClamp={isActive ? 4 : 1}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
color: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: '30%',
|
||||
opacity: isActive ? 0 : 1,
|
||||
transition: 'opacity 160ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
background:
|
||||
'linear-gradient(90deg, rgba(9, 11, 16, 0) 0%, rgba(9, 11, 16, 0.9) 65%, rgba(9, 11, 16, 1) 100%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{metaText ? (
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
lineClamp={2}
|
||||
style={{
|
||||
opacity: isActive ? 1 : 0,
|
||||
maxHeight: isActive ? 48 : 0,
|
||||
overflow: 'hidden',
|
||||
transition:
|
||||
'opacity 160ms cubic-bezier(0.2, 0, 0, 1), max-height 160ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
}}
|
||||
>
|
||||
{metaText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
minHeight: 22,
|
||||
}}
|
||||
>
|
||||
{item.release_year ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="xl"
|
||||
variant="outline"
|
||||
color="gray"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: 0.3,
|
||||
color: '#cbd5f5',
|
||||
borderColor: 'rgba(148, 163, 184, 0.6)',
|
||||
}}
|
||||
>
|
||||
{item.release_year}
|
||||
</Badge>
|
||||
) : (
|
||||
<Box style={{ height: 22 }} />
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MediaCard);
|
||||
105
frontend/src/components/library/MediaCarousel.jsx
Normal file
105
frontend/src/components/library/MediaCarousel.jsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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, { getMediaCardDimensions } from './MediaCard';
|
||||
|
||||
const SCROLL_STEP = 4;
|
||||
|
||||
const MediaCarousel = ({
|
||||
title,
|
||||
items,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
cardSize = 'sm',
|
||||
emptyMessage = null,
|
||||
}) => {
|
||||
const viewportRef = useRef(null);
|
||||
const cardWidth = useMemo(
|
||||
() => getMediaCardDimensions(cardSize).cardWidth,
|
||||
[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}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaCarousel;
|
||||
1300
frontend/src/components/library/MediaDetailModal.jsx
Normal file
1300
frontend/src/components/library/MediaDetailModal.jsx
Normal file
File diff suppressed because it is too large
Load diff
305
frontend/src/components/library/MediaEditModal.jsx
Normal file
305
frontend/src/components/library/MediaEditModal.jsx
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
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 { notifications } from '@mantine/notifications';
|
||||
|
||||
import API from '../../api';
|
||||
import useMediaLibraryStore from '../../store/mediaLibrary';
|
||||
|
||||
const emptyValues = {
|
||||
title: '',
|
||||
synopsis: '',
|
||||
release_year: null,
|
||||
rating: '',
|
||||
genres: '',
|
||||
tags: '',
|
||||
studios: '',
|
||||
movie_db_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 [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),
|
||||
movie_db_id: item.movie_db_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 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('movie_db_id', values.movie_db_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')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Movie-DB ID"
|
||||
placeholder="Enter Movie-DB ID"
|
||||
{...form.getInputProps('movie_db_id')}
|
||||
/>
|
||||
<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;
|
||||
164
frontend/src/components/library/MediaGrid.jsx
Normal file
164
frontend/src/components/library/MediaGrid.jsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
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, { getMediaCardDimensions } 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 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 { cardHeight, cardWidth } = useMemo(
|
||||
() => getMediaCardDimensions(cardSize),
|
||||
[cardSize]
|
||||
);
|
||||
const rowHeight = useMemo(() => {
|
||||
return cardHeight + GRID_SPACING;
|
||||
}, [cardHeight]);
|
||||
|
||||
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"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fit, minmax(${cardWidth}px, ${cardWidth}px))`,
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{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 columnWidth = cardWidth + GRID_SPACING;
|
||||
const columnCount = Math.max(1, Math.floor(width / columnWidth));
|
||||
const rowCount = Math.ceil(items.length / 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;
|
||||
203
frontend/src/pages/Libraries.jsx
Normal file
203
frontend/src/pages/Libraries.jsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Button, Group, SimpleGrid, Stack, Text, Title } from '@mantine/core';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import useLibraryStore from '../store/library';
|
||||
import LibraryCard from '../components/library/LibraryCard';
|
||||
import LibraryFormModal from '../components/library/LibraryFormModal';
|
||||
import LibraryScanDrawer from '../components/library/LibraryScanDrawer';
|
||||
|
||||
const LibrariesPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const libraries = useLibraryStore((s) => s.libraries);
|
||||
const fetchLibraries = useLibraryStore((s) => s.fetchLibraries);
|
||||
const createLibrary = useLibraryStore((s) => s.createLibrary);
|
||||
const updateLibrary = useLibraryStore((s) => s.updateLibrary);
|
||||
const deleteLibrary = useLibraryStore((s) => s.deleteLibrary);
|
||||
const triggerScan = useLibraryStore((s) => s.triggerScan);
|
||||
const fetchScans = useLibraryStore((s) => s.fetchScans);
|
||||
const upsertScan = useLibraryStore((s) => s.upsertScan);
|
||||
const removeScan = useLibraryStore((s) => s.removeScan);
|
||||
const cancelLibraryScan = useLibraryStore((s) => s.cancelLibraryScan);
|
||||
const deleteLibraryScan = useLibraryStore((s) => s.deleteLibraryScan);
|
||||
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingLibrary, setEditingLibrary] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [scanDrawerOpen, setScanDrawerOpen] = useState(false);
|
||||
const [scanLoadingId, setScanLoadingId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
}, [fetchLibraries]);
|
||||
|
||||
const selectedLibrary = useMemo(
|
||||
() => libraries.find((lib) => lib.id === selectedLibraryId) || null,
|
||||
[libraries, selectedLibraryId]
|
||||
);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingLibrary(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (library) => {
|
||||
setEditingLibrary(library);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (payload) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (editingLibrary) {
|
||||
const updated = await updateLibrary(editingLibrary.id, payload);
|
||||
if (updated) {
|
||||
notifications.show({
|
||||
title: 'Library updated',
|
||||
message: `${updated.name} saved successfully.`,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const created = await createLibrary(payload);
|
||||
if (created) {
|
||||
notifications.show({
|
||||
title: 'Library created',
|
||||
message: `${created.name} added.`,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
}
|
||||
setFormOpen(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (library) => {
|
||||
if (!window.confirm(`Delete ${library.name}? This will remove the library and related VOD items.`)) {
|
||||
return;
|
||||
}
|
||||
const success = await deleteLibrary(library.id);
|
||||
if (success) {
|
||||
notifications.show({
|
||||
title: 'Library deleted',
|
||||
message: `${library.name} removed.`,
|
||||
color: 'red',
|
||||
});
|
||||
if (selectedLibraryId === library.id) {
|
||||
setSelectedLibraryId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleScan = async (libraryId, full = false) => {
|
||||
setSelectedLibraryId(libraryId);
|
||||
setScanLoadingId(libraryId);
|
||||
try {
|
||||
const scan = await triggerScan(libraryId, { full });
|
||||
if (scan) {
|
||||
upsertScan(scan);
|
||||
setScanDrawerOpen(true);
|
||||
notifications.show({
|
||||
title: full ? 'Full scan started' : 'Scan started',
|
||||
message: 'The library scan has been queued.',
|
||||
color: 'blue',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setScanLoadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelScan = async (scanId) => {
|
||||
try {
|
||||
const updated = await cancelLibraryScan(scanId);
|
||||
if (updated) {
|
||||
upsertScan(updated);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteQueuedScan = async (scanId) => {
|
||||
try {
|
||||
const success = await deleteLibraryScan(scanId);
|
||||
if (success) {
|
||||
removeScan(scanId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowse = (library) => {
|
||||
const target = library.library_type === 'shows' ? 'shows' : 'movies';
|
||||
navigate(`/library/${target}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box p="lg">
|
||||
<Stack spacing="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack spacing={4}>
|
||||
<Title order={2}>Libraries</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Manage your movie and TV show libraries.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button leftSection={<Plus size={16} />} onClick={openCreateModal}>
|
||||
Add Library
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{libraries.length === 0 ? (
|
||||
<Text c="dimmed">No libraries configured yet.</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing="lg">
|
||||
{libraries.map((library) => (
|
||||
<LibraryCard
|
||||
key={library.id}
|
||||
library={library}
|
||||
selected={selectedLibraryId === library.id}
|
||||
onSelect={() => handleBrowse(library)}
|
||||
onEdit={openEditModal}
|
||||
onDelete={handleDelete}
|
||||
onScan={(id) => handleScan(id, false)}
|
||||
loadingScan={scanLoadingId === library.id}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<LibraryFormModal
|
||||
opened={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
library={editingLibrary}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
/>
|
||||
|
||||
<LibraryScanDrawer
|
||||
opened={scanDrawerOpen && Boolean(selectedLibraryId)}
|
||||
onClose={() => setScanDrawerOpen(false)}
|
||||
libraryId={selectedLibraryId}
|
||||
onCancelJob={handleCancelScan}
|
||||
onDeleteQueuedJob={handleDeleteQueuedScan}
|
||||
onStartScan={() => handleScan(selectedLibraryId, false)}
|
||||
onStartFullScan={() => handleScan(selectedLibraryId, true)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibrariesPage;
|
||||
806
frontend/src/pages/Library.jsx
Normal file
806
frontend/src/pages/Library.jsx
Normal file
|
|
@ -0,0 +1,806 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ActionIcon, Box, Button, Divider, Group, Loader, 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: 'Name (A-Z)', value: 'alpha' },
|
||||
{ label: 'Release Year', value: 'year' },
|
||||
{ label: 'Recently Added', value: 'recent' },
|
||||
{ label: 'Genre', value: 'genre' },
|
||||
];
|
||||
|
||||
const INITIAL_LIBRARY_LIMIT = 60;
|
||||
|
||||
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 handleMediaTypeChange = (value) => {
|
||||
if (!value || value === normalizedMediaType) return;
|
||||
navigate(`/library/${value}`);
|
||||
};
|
||||
|
||||
const [scanDrawerOpen, setScanDrawerOpen] = useState(false);
|
||||
const [playbackModalOpen, setPlaybackModalOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('recommended');
|
||||
const [sortOption, setSortOption] = useState('alpha');
|
||||
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 itemsBackgroundLoading = useMediaLibraryStore((s) => s.backgroundLoading);
|
||||
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);
|
||||
const pollItem = useMediaLibraryStore((s) => s.pollItem);
|
||||
|
||||
// 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(() => {
|
||||
const ids = relevantLibraryIds;
|
||||
|
||||
if (!libraries || libraries.length === 0) {
|
||||
setSelectedMediaLibrary(null);
|
||||
fetchItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedMediaLibrary(ids.length === 1 ? ids[0] : null);
|
||||
|
||||
if (ids.length === 0) {
|
||||
fetchItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadItems = async () => {
|
||||
if (debouncedSearch) {
|
||||
await fetchItems(ids, { background: false });
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchItems(ids, { limit: INITIAL_LIBRARY_LIMIT, ordering: '-updated_at' });
|
||||
if (cancelled) return;
|
||||
await fetchItems(ids, { background: true });
|
||||
};
|
||||
|
||||
loadItems();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [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.';
|
||||
}
|
||||
return '';
|
||||
}, [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">
|
||||
<Group align="flex-end" gap="sm">
|
||||
<Select
|
||||
label="Sort by"
|
||||
data={SORT_OPTIONS}
|
||||
value={sortOption}
|
||||
onChange={(value) => setSortOption(value || 'alpha')}
|
||||
w={220}
|
||||
/>
|
||||
{itemsBackgroundLoading && <Loader size="xs" />}
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<RefreshCcw size={16} />}
|
||||
onClick={() => fetchItems(relevantLibraryIds)}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
{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>
|
||||
{aggregatedSubtitle && (
|
||||
<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">
|
||||
<Group align="center" gap="sm">
|
||||
<SegmentedControl
|
||||
value={normalizedMediaType || 'movies'}
|
||||
onChange={handleMediaTypeChange}
|
||||
data={[
|
||||
{ label: 'Movies', value: 'movies' },
|
||||
{ label: 'TV Shows', value: 'shows' },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
data={TABS}
|
||||
/>
|
||||
</Group>
|
||||
<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);
|
||||
pollItem(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;
|
||||
|
|
@ -5,6 +5,7 @@ import React, {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import API from '../api';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import useUserAgentsStore from '../store/userAgents';
|
||||
|
|
@ -19,20 +20,24 @@ import {
|
|||
Group,
|
||||
FileInput,
|
||||
MultiSelect,
|
||||
SimpleGrid,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { Plus } from 'lucide-react';
|
||||
import UserAgentsTable from '../components/tables/UserAgentsTable';
|
||||
import StreamProfilesTable from '../components/tables/StreamProfilesTable';
|
||||
import BackupManager from '../components/backups/BackupManager';
|
||||
import useLocalStorage from '../hooks/useLocalStorage';
|
||||
import useAuthStore from '../store/auth';
|
||||
import useLibraryStore from '../store/library';
|
||||
import {
|
||||
USER_LEVELS,
|
||||
NETWORK_ACCESS_OPTIONS,
|
||||
|
|
@ -41,6 +46,9 @@ import {
|
|||
} from '../constants';
|
||||
import ConfirmationDialog from '../components/ConfirmationDialog';
|
||||
import useWarningsStore from '../store/warnings';
|
||||
import LibraryCard from '../components/library/LibraryCard';
|
||||
import LibraryFormModal from '../components/library/LibraryFormModal';
|
||||
import LibraryScanDrawer from '../components/library/LibraryScanDrawer';
|
||||
|
||||
const TIMEZONE_FALLBACKS = [
|
||||
'UTC',
|
||||
|
|
@ -183,6 +191,17 @@ const SettingsPage = () => {
|
|||
const authUser = useAuthStore((s) => s.user);
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
const navigate = useNavigate();
|
||||
const libraries = useLibraryStore((s) => s.libraries);
|
||||
const fetchLibraries = useLibraryStore((s) => s.fetchLibraries);
|
||||
const createLibrary = useLibraryStore((s) => s.createLibrary);
|
||||
const updateLibrary = useLibraryStore((s) => s.updateLibrary);
|
||||
const deleteLibrary = useLibraryStore((s) => s.deleteLibrary);
|
||||
const triggerScan = useLibraryStore((s) => s.triggerScan);
|
||||
const upsertScan = useLibraryStore((s) => s.upsertScan);
|
||||
const removeScan = useLibraryStore((s) => s.removeScan);
|
||||
const cancelLibraryScan = useLibraryStore((s) => s.cancelLibraryScan);
|
||||
const deleteLibraryScan = useLibraryStore((s) => s.deleteLibraryScan);
|
||||
|
||||
const [accordianValue, setAccordianValue] = useState(null);
|
||||
const [networkAccessSaved, setNetworkAccessSaved] = useState(false);
|
||||
|
|
@ -209,6 +228,12 @@ const SettingsPage = () => {
|
|||
path: '',
|
||||
exists: false,
|
||||
});
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState(null);
|
||||
const [libraryFormOpen, setLibraryFormOpen] = useState(false);
|
||||
const [editingLibrary, setEditingLibrary] = useState(null);
|
||||
const [librarySubmitting, setLibrarySubmitting] = useState(false);
|
||||
const [scanDrawerOpen, setScanDrawerOpen] = useState(false);
|
||||
const [scanLoadingId, setScanLoadingId] = useState(null);
|
||||
|
||||
// UI / local storage settings
|
||||
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
|
||||
|
|
@ -406,6 +431,12 @@ const SettingsPage = () => {
|
|||
loadComskipConfig();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authUser?.user_level == USER_LEVELS.ADMIN) {
|
||||
fetchLibraries();
|
||||
}
|
||||
}, [authUser, fetchLibraries]);
|
||||
|
||||
// Clear success states when switching accordion panels
|
||||
useEffect(() => {
|
||||
setGeneralSettingsSaved(false);
|
||||
|
|
@ -698,6 +729,116 @@ const SettingsPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const openCreateLibraryModal = () => {
|
||||
setEditingLibrary(null);
|
||||
setLibraryFormOpen(true);
|
||||
};
|
||||
|
||||
const openEditLibraryModal = (library) => {
|
||||
setEditingLibrary(library);
|
||||
setLibraryFormOpen(true);
|
||||
};
|
||||
|
||||
const handleLibrarySubmit = async (payload) => {
|
||||
setLibrarySubmitting(true);
|
||||
try {
|
||||
if (editingLibrary) {
|
||||
const updated = await updateLibrary(editingLibrary.id, payload);
|
||||
if (updated) {
|
||||
notifications.show({
|
||||
title: 'Library updated',
|
||||
message: `${updated.name} saved successfully.`,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const created = await createLibrary(payload);
|
||||
if (created) {
|
||||
notifications.show({
|
||||
title: 'Library created',
|
||||
message: `${created.name} added.`,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
}
|
||||
setLibraryFormOpen(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLibrarySubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLibraryDelete = async (library) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete ${library.name}? This will remove the library and related VOD items.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const success = await deleteLibrary(library.id);
|
||||
if (success) {
|
||||
notifications.show({
|
||||
title: 'Library deleted',
|
||||
message: `${library.name} removed.`,
|
||||
color: 'red',
|
||||
});
|
||||
if (selectedLibraryId === library.id) {
|
||||
setSelectedLibraryId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLibraryScan = async (libraryId, full = false) => {
|
||||
setSelectedLibraryId(libraryId);
|
||||
setScanLoadingId(libraryId);
|
||||
try {
|
||||
const scan = await triggerScan(libraryId, { full });
|
||||
if (scan) {
|
||||
upsertScan(scan);
|
||||
setScanDrawerOpen(true);
|
||||
notifications.show({
|
||||
title: full ? 'Full scan started' : 'Scan started',
|
||||
message: 'The library scan has been queued.',
|
||||
color: 'blue',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setScanLoadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelLibraryScan = async (scanId) => {
|
||||
try {
|
||||
const updated = await cancelLibraryScan(scanId);
|
||||
if (updated) {
|
||||
upsertScan(updated);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteQueuedLibraryScan = async (scanId) => {
|
||||
try {
|
||||
const success = await deleteLibraryScan(scanId);
|
||||
if (success) {
|
||||
removeScan(scanId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseLibrary = (library) => {
|
||||
const target = library.library_type === 'shows' ? 'shows' : 'movies';
|
||||
setSelectedLibraryId(library.id);
|
||||
navigate(`/library/${target}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Center
|
||||
style={{
|
||||
|
|
@ -776,6 +917,69 @@ const SettingsPage = () => {
|
|||
|
||||
{authUser.user_level == USER_LEVELS.ADMIN && (
|
||||
<>
|
||||
<Accordion.Item value="media-library">
|
||||
<Accordion.Control>Media Library</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack spacing={4}>
|
||||
<Title order={4}>Libraries</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Manage your movie and TV show libraries.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={openCreateLibraryModal}
|
||||
>
|
||||
Add Library
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{libraries.length === 0 ? (
|
||||
<Text c="dimmed">No libraries configured yet.</Text>
|
||||
) : (
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, md: 2, lg: 3 }}
|
||||
spacing="lg"
|
||||
>
|
||||
{libraries.map((library) => (
|
||||
<LibraryCard
|
||||
key={library.id}
|
||||
library={library}
|
||||
selected={selectedLibraryId === library.id}
|
||||
onSelect={() => handleBrowseLibrary(library)}
|
||||
onEdit={openEditLibraryModal}
|
||||
onDelete={handleLibraryDelete}
|
||||
onScan={(id) => handleLibraryScan(id, false)}
|
||||
loadingScan={scanLoadingId === library.id}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<LibraryFormModal
|
||||
opened={libraryFormOpen}
|
||||
onClose={() => setLibraryFormOpen(false)}
|
||||
library={editingLibrary}
|
||||
onSubmit={handleLibrarySubmit}
|
||||
submitting={librarySubmitting}
|
||||
/>
|
||||
|
||||
<LibraryScanDrawer
|
||||
opened={scanDrawerOpen && Boolean(selectedLibraryId)}
|
||||
onClose={() => setScanDrawerOpen(false)}
|
||||
libraryId={selectedLibraryId}
|
||||
onCancelJob={handleCancelLibraryScan}
|
||||
onDeleteQueuedJob={handleDeleteQueuedLibraryScan}
|
||||
onStartScan={() => handleLibraryScan(selectedLibraryId, false)}
|
||||
onStartFullScan={() =>
|
||||
handleLibraryScan(selectedLibraryId, true)
|
||||
}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="dvr-settings">
|
||||
<Accordion.Control>DVR</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
|
|
|
|||
132
frontend/src/store/library.jsx
Normal file
132
frontend/src/store/library.jsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { create } from 'zustand';
|
||||
import API from '../api';
|
||||
|
||||
const useLibraryStore = create((set, get) => ({
|
||||
libraries: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
scans: {},
|
||||
scansLoading: false,
|
||||
|
||||
fetchLibraries: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const libraries = await API.getLibraries();
|
||||
set({ libraries: Array.isArray(libraries) ? libraries : [], loading: false });
|
||||
} catch (error) {
|
||||
set({ error: error.message || 'Failed to load libraries.', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createLibrary: async (payload) => {
|
||||
const response = await API.createLibrary(payload);
|
||||
if (!response) return null;
|
||||
set((state) => ({
|
||||
libraries: [...state.libraries, response],
|
||||
}));
|
||||
return response;
|
||||
},
|
||||
|
||||
updateLibrary: async (id, payload) => {
|
||||
const response = await API.updateLibrary(id, payload);
|
||||
if (!response) return null;
|
||||
set((state) => ({
|
||||
libraries: state.libraries.map((lib) => (lib.id === id ? response : lib)),
|
||||
}));
|
||||
return response;
|
||||
},
|
||||
|
||||
deleteLibrary: async (id) => {
|
||||
const success = await API.deleteLibrary(id);
|
||||
if (!success) return false;
|
||||
set((state) => ({
|
||||
libraries: state.libraries.filter((lib) => lib.id !== id),
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
|
||||
fetchScans: async (libraryId = null, { background = false } = {}) => {
|
||||
if (!background) {
|
||||
set({ scansLoading: true });
|
||||
}
|
||||
try {
|
||||
const scans = await API.getLibraryScans(libraryId);
|
||||
const key = libraryId || 'all';
|
||||
set((state) => ({
|
||||
scans: {
|
||||
...state.scans,
|
||||
[key]: Array.isArray(scans) ? scans : [],
|
||||
},
|
||||
scansLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!background) {
|
||||
set({ scansLoading: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
triggerScan: async (libraryId, { full = false } = {}) => {
|
||||
const scan = await API.triggerLibraryScan(libraryId, { full });
|
||||
if (!scan) return null;
|
||||
get().upsertScan(scan);
|
||||
return scan;
|
||||
},
|
||||
|
||||
cancelLibraryScan: async (scanId) => {
|
||||
const updated = await API.cancelLibraryScan(scanId);
|
||||
if (updated) {
|
||||
get().upsertScan(updated);
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
|
||||
deleteLibraryScan: async (scanId) => {
|
||||
const success = await API.deleteLibraryScan(scanId);
|
||||
if (success) {
|
||||
get().removeScan(scanId);
|
||||
}
|
||||
return success;
|
||||
},
|
||||
|
||||
upsertScan: (scan) => {
|
||||
if (!scan) return;
|
||||
set((state) => {
|
||||
const updated = { ...state.scans };
|
||||
const keys = [scan.library, 'all'];
|
||||
keys.forEach((key) => {
|
||||
if (!key) return;
|
||||
const list = Array.isArray(updated[key]) ? [...updated[key]] : [];
|
||||
const idx = list.findIndex((entry) => entry.id === scan.id);
|
||||
if (idx >= 0) {
|
||||
list[idx] = scan;
|
||||
} else {
|
||||
list.unshift(scan);
|
||||
}
|
||||
updated[key] = list;
|
||||
});
|
||||
return { scans: updated };
|
||||
});
|
||||
},
|
||||
|
||||
removeScan: (scanId) => {
|
||||
set((state) => {
|
||||
const updated = {};
|
||||
Object.entries(state.scans).forEach(([key, list]) => {
|
||||
updated[key] = Array.isArray(list)
|
||||
? list.filter((scan) => scan.id !== scanId)
|
||||
: list;
|
||||
});
|
||||
return { scans: updated };
|
||||
});
|
||||
},
|
||||
|
||||
purgeCompletedScans: async ({ library } = {}) => {
|
||||
const response = await API.purgeLibraryScans(library);
|
||||
if (!response) return null;
|
||||
await get().fetchScans(library, { background: true });
|
||||
return response;
|
||||
},
|
||||
}));
|
||||
|
||||
export default useLibraryStore;
|
||||
231
frontend/src/store/mediaLibrary.jsx
Normal file
231
frontend/src/store/mediaLibrary.jsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { create } from 'zustand';
|
||||
import API from '../api';
|
||||
|
||||
const defaultFilters = {
|
||||
type: 'movie',
|
||||
search: '',
|
||||
};
|
||||
|
||||
const pollHandles = new Map();
|
||||
|
||||
const schedulePoll = (itemId, callback, delayMs) => {
|
||||
const handle = setTimeout(callback, delayMs);
|
||||
pollHandles.set(itemId, handle);
|
||||
};
|
||||
|
||||
const stopPolling = (itemId) => {
|
||||
const handle = pollHandles.get(itemId);
|
||||
if (handle) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
pollHandles.delete(itemId);
|
||||
};
|
||||
|
||||
const useMediaLibraryStore = create((set, get) => ({
|
||||
items: [],
|
||||
itemsById: {},
|
||||
loading: false,
|
||||
backgroundLoading: false,
|
||||
filters: defaultFilters,
|
||||
activeLibraryIds: [],
|
||||
selectedLibraryId: null,
|
||||
activeItem: null,
|
||||
activeItemLoading: false,
|
||||
activeItemError: null,
|
||||
activeProgress: null,
|
||||
resumePrompt: null,
|
||||
|
||||
setFilters: (filters) =>
|
||||
set((state) => ({ filters: { ...state.filters, ...filters } })),
|
||||
|
||||
setSelectedLibraryId: (id) => set({ selectedLibraryId: id }),
|
||||
|
||||
fetchItems: async (libraryIds = [], { background = false, limit, ordering } = {}) => {
|
||||
if (background) {
|
||||
set({ backgroundLoading: true });
|
||||
} else {
|
||||
set({ loading: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
libraryIds.forEach((id) => params.append('library', id));
|
||||
if (get().filters.type) {
|
||||
params.append('type', get().filters.type);
|
||||
}
|
||||
if (get().filters.search) {
|
||||
params.append('search', get().filters.search);
|
||||
}
|
||||
if (ordering) {
|
||||
params.append('ordering', ordering);
|
||||
}
|
||||
if (limit) {
|
||||
params.append('limit', limit);
|
||||
}
|
||||
|
||||
const response = await API.getMediaItems(params);
|
||||
const items = Array.isArray(response) ? response : response?.results || [];
|
||||
const itemsById = items.reduce((acc, item) => {
|
||||
acc[item.id] = item;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
set({
|
||||
items,
|
||||
itemsById,
|
||||
loading: false,
|
||||
backgroundLoading: false,
|
||||
activeLibraryIds: libraryIds,
|
||||
});
|
||||
|
||||
return items;
|
||||
} catch (error) {
|
||||
set({ loading: false, backgroundLoading: false });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
upsertItems: (items) => {
|
||||
if (!Array.isArray(items)) return;
|
||||
set((state) => {
|
||||
const itemsById = { ...state.itemsById };
|
||||
const merged = [...state.items];
|
||||
|
||||
items.forEach((item) => {
|
||||
if (!item) return;
|
||||
itemsById[item.id] = item;
|
||||
const index = merged.findIndex((entry) => entry.id === item.id);
|
||||
if (index >= 0) {
|
||||
merged[index] = item;
|
||||
} else {
|
||||
merged.unshift(item);
|
||||
}
|
||||
});
|
||||
|
||||
return { items: merged, itemsById };
|
||||
});
|
||||
},
|
||||
|
||||
removeItems: (itemId) => {
|
||||
set((state) => {
|
||||
const items = state.items.filter((item) => item.id !== itemId);
|
||||
const itemsById = { ...state.itemsById };
|
||||
delete itemsById[itemId];
|
||||
return { items, itemsById };
|
||||
});
|
||||
},
|
||||
|
||||
openItem: async (itemId) => {
|
||||
if (!itemId) return null;
|
||||
set({ activeItemLoading: true, activeItemError: null });
|
||||
try {
|
||||
const item = await API.getMediaItem(itemId, { suppressErrorNotification: true });
|
||||
set((state) => {
|
||||
const itemsById = { ...state.itemsById, [item.id]: item };
|
||||
const existingIndex = state.items.findIndex((entry) => entry.id === item.id);
|
||||
let items = state.items;
|
||||
if (existingIndex >= 0) {
|
||||
items = [...state.items];
|
||||
items[existingIndex] = item;
|
||||
} else {
|
||||
items = [item, ...state.items];
|
||||
}
|
||||
return {
|
||||
activeItem: item,
|
||||
activeItemLoading: false,
|
||||
activeItemError: null,
|
||||
activeProgress: item?.watch_progress || null,
|
||||
items,
|
||||
itemsById,
|
||||
};
|
||||
});
|
||||
return item;
|
||||
} catch (error) {
|
||||
set({ activeItemLoading: false, activeItemError: error?.message || 'Failed to load item.' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
refreshItem: async (itemId, options = {}) => {
|
||||
if (!itemId) return null;
|
||||
try {
|
||||
const item = await API.getMediaItem(itemId, {
|
||||
suppressErrorNotification: true,
|
||||
...options,
|
||||
});
|
||||
if (!item) return null;
|
||||
set((state) => {
|
||||
const itemsById = { ...state.itemsById, [item.id]: item };
|
||||
const existingIndex = state.items.findIndex((entry) => entry.id === item.id);
|
||||
let items = state.items;
|
||||
if (existingIndex >= 0) {
|
||||
items = [...state.items];
|
||||
items[existingIndex] = item;
|
||||
} else {
|
||||
items = [item, ...state.items];
|
||||
}
|
||||
const nextState = { items, itemsById };
|
||||
if (state.activeItem?.id === item.id) {
|
||||
nextState.activeItem = item;
|
||||
nextState.activeProgress = item?.watch_progress || null;
|
||||
}
|
||||
return nextState;
|
||||
});
|
||||
return item;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
pollItem: (itemId, { intervalMs = 4000, timeoutMs = 90000 } = {}) => {
|
||||
if (!itemId) return;
|
||||
if (pollHandles.has(itemId)) return;
|
||||
const baseline = get().itemsById[itemId] || {};
|
||||
const baselineSyncedAt = baseline.metadata_last_synced_at || null;
|
||||
const baselinePoster = baseline.poster_url || '';
|
||||
const baselineBackdrop = baseline.backdrop_url || '';
|
||||
const startedAt = Date.now();
|
||||
|
||||
const tick = async () => {
|
||||
const item = await get().refreshItem(itemId);
|
||||
const timedOut = Date.now() - startedAt > timeoutMs;
|
||||
const updated =
|
||||
item &&
|
||||
((item.metadata_last_synced_at &&
|
||||
item.metadata_last_synced_at !== baselineSyncedAt) ||
|
||||
(!baselineSyncedAt && item.metadata_last_synced_at) ||
|
||||
item.poster_url !== baselinePoster ||
|
||||
item.backdrop_url !== baselineBackdrop);
|
||||
if (updated || timedOut || !item) {
|
||||
stopPolling(itemId);
|
||||
return;
|
||||
}
|
||||
schedulePoll(itemId, tick, intervalMs);
|
||||
};
|
||||
|
||||
schedulePoll(itemId, tick, 0);
|
||||
},
|
||||
|
||||
stopPollItem: (itemId) => stopPolling(itemId),
|
||||
|
||||
closeItem: () =>
|
||||
set({
|
||||
activeItem: null,
|
||||
activeItemError: null,
|
||||
activeProgress: null,
|
||||
resumePrompt: null,
|
||||
}),
|
||||
|
||||
setActiveProgress: (progress) => set({ activeProgress: progress }),
|
||||
|
||||
requestResume: (progressId) => {
|
||||
const progress = get().activeProgress;
|
||||
if (progress && progress.id === progressId) {
|
||||
set({ resumePrompt: progress });
|
||||
}
|
||||
},
|
||||
|
||||
clearResumePrompt: () => set({ resumePrompt: null }),
|
||||
}));
|
||||
|
||||
export default useMediaLibraryStore;
|
||||
|
|
@ -18,6 +18,8 @@ m3u8
|
|||
rapidfuzz==3.14.3
|
||||
regex # Required by transformers but also used for advanced regex features
|
||||
tzlocal
|
||||
guessit==3.8.0
|
||||
python-dateutil==2.9.0.post0
|
||||
|
||||
# PyTorch dependencies (CPU only)
|
||||
--extra-index-url https://download.pytorch.org/whl/cpu/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue