mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Dispatcharr Media Library Updates
Added TMDB for metadata Added nfo scanning Added background poster to modal Removed idle fade out on AlphabetSidebar Added scaling on hover on AlphabetSidebar Optimized library page loading Added Show/Movie count Moved TV show descriptions into their own stack that spans the modal.
This commit is contained in:
parent
dd0ad5b012
commit
2014a0f850
15 changed files with 1786 additions and 226 deletions
|
|
@ -7,6 +7,7 @@ from apps.media_library.api_views import (
|
|||
MediaItemViewSet,
|
||||
browse_library_path,
|
||||
)
|
||||
from apps.media_library.artwork import artwork_backdrop, artwork_poster
|
||||
|
||||
app_name = "media_library"
|
||||
|
||||
|
|
@ -16,6 +17,16 @@ router.register(r"scans", LibraryScanViewSet, basename="library-scan")
|
|||
router.register(r"items", MediaItemViewSet, basename="media-item")
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"items/<int:pk>/artwork/poster/",
|
||||
artwork_poster,
|
||||
name="media-item-artwork-poster",
|
||||
),
|
||||
path(
|
||||
"items/<int:pk>/artwork/backdrop/",
|
||||
artwork_backdrop,
|
||||
name="media-item-artwork-backdrop",
|
||||
),
|
||||
path("browse/", browse_library_path, name="browse"),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.urls import reverse
|
||||
from django.db.models import Count, Q
|
||||
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.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.accounts.permissions import Authenticated, IsAdmin, permission_classes_by_action
|
||||
|
|
@ -16,9 +23,59 @@ from apps.media_library.serializers import (
|
|||
MediaItemSerializer,
|
||||
MediaItemUpdateSerializer,
|
||||
)
|
||||
from apps.media_library.metadata import find_local_artwork_path
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serve_artwork_response(request, item: MediaItem, asset_type: str):
|
||||
logger.debug(
|
||||
"Artwork request path=%s item_id=%s asset_type=%s",
|
||||
request.path,
|
||||
item.id,
|
||||
asset_type,
|
||||
)
|
||||
path = find_local_artwork_path(item, asset_type)
|
||||
if not path:
|
||||
file = item.files.filter(is_primary=True).first() or item.files.first()
|
||||
logger.debug(
|
||||
"Artwork not found item_id=%s asset_type=%s library_id=%s file_path=%s",
|
||||
item.id,
|
||||
asset_type,
|
||||
item.library_id,
|
||||
file.path if file else None,
|
||||
)
|
||||
response = Response(
|
||||
{"detail": "Artwork not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
if settings.DEBUG:
|
||||
response["X-Dispatcharr-Artwork-Status"] = "not-found"
|
||||
return response
|
||||
try:
|
||||
content_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
||||
response = FileResponse(open(path, "rb"), content_type=content_type)
|
||||
except OSError:
|
||||
logger.debug(
|
||||
"Artwork file unavailable item_id=%s asset_type=%s path=%s",
|
||||
item.id,
|
||||
asset_type,
|
||||
path,
|
||||
)
|
||||
response = Response(
|
||||
{"detail": "Artwork not available."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
if settings.DEBUG:
|
||||
response["X-Dispatcharr-Artwork-Status"] = "unavailable"
|
||||
return response
|
||||
response["Cache-Control"] = "private, max-age=3600"
|
||||
if settings.DEBUG:
|
||||
response["X-Dispatcharr-Artwork-Path"] = path
|
||||
return response
|
||||
|
||||
|
||||
class MediaLibraryPagination(PageNumberPagination):
|
||||
page_size = 200
|
||||
|
|
@ -32,9 +89,25 @@ class MediaLibraryPagination(PageNumberPagination):
|
|||
|
||||
|
||||
class LibraryViewSet(viewsets.ModelViewSet):
|
||||
queryset = Library.objects.prefetch_related("locations")
|
||||
serializer_class = LibrarySerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Library.objects.prefetch_related("locations")
|
||||
.annotate(
|
||||
movie_count=Count(
|
||||
"items",
|
||||
filter=Q(items__item_type=MediaItem.TYPE_MOVIE),
|
||||
distinct=True,
|
||||
),
|
||||
show_count=Count(
|
||||
"items",
|
||||
filter=Q(items__item_type=MediaItem.TYPE_SHOW),
|
||||
distinct=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
|
|
@ -153,6 +226,9 @@ class MediaItemViewSet(viewsets.ModelViewSet):
|
|||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
action = getattr(self, self.action, None)
|
||||
if action and hasattr(action, "permission_classes"):
|
||||
return [perm() for perm in action.permission_classes]
|
||||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
|
|
@ -270,6 +346,28 @@ class MediaItemViewSet(viewsets.ModelViewSet):
|
|||
}
|
||||
)
|
||||
|
||||
def _serve_artwork(self, request, asset_type: str):
|
||||
item = self.get_object()
|
||||
return _serve_artwork_response(request, item, asset_type)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
url_path="artwork/poster",
|
||||
permission_classes=[AllowAny],
|
||||
)
|
||||
def artwork_poster(self, request, pk=None):
|
||||
return self._serve_artwork(request, "poster")
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
url_path="artwork/backdrop",
|
||||
permission_classes=[AllowAny],
|
||||
)
|
||||
def artwork_backdrop(self, request, pk=None):
|
||||
return self._serve_artwork(request, "backdrop")
|
||||
|
||||
def _get_duration_ms(self, media_item: MediaItem) -> int:
|
||||
if media_item.runtime_ms:
|
||||
return media_item.runtime_ms
|
||||
|
|
@ -437,6 +535,20 @@ class MediaItemViewSet(viewsets.ModelViewSet):
|
|||
return Response({"item": serializer.data})
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([AllowAny])
|
||||
def artwork_poster(request, pk: int):
|
||||
item = get_object_or_404(MediaItem, pk=pk)
|
||||
return _serve_artwork_response(request, item, "poster")
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([AllowAny])
|
||||
def artwork_backdrop(request, pk: int):
|
||||
item = get_object_or_404(MediaItem, pk=pk)
|
||||
return _serve_artwork_response(request, item, "backdrop")
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAdmin])
|
||||
def browse_library_path(request):
|
||||
|
|
|
|||
76
apps/media_library/artwork.py
Normal file
76
apps/media_library/artwork.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import logging
|
||||
import mimetypes
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.media_library.metadata import find_local_artwork_path
|
||||
from apps.media_library.models import MediaItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def serve_artwork_response(request, item: MediaItem, asset_type: str):
|
||||
logger.debug(
|
||||
"Artwork request path=%s item_id=%s asset_type=%s",
|
||||
request.path,
|
||||
item.id,
|
||||
asset_type,
|
||||
)
|
||||
path = find_local_artwork_path(item, asset_type)
|
||||
if not path:
|
||||
file = item.files.filter(is_primary=True).first() or item.files.first()
|
||||
logger.debug(
|
||||
"Artwork not found item_id=%s asset_type=%s library_id=%s file_path=%s",
|
||||
item.id,
|
||||
asset_type,
|
||||
item.library_id,
|
||||
file.path if file else None,
|
||||
)
|
||||
response = Response(
|
||||
{"detail": "Artwork not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
if settings.DEBUG:
|
||||
response["X-Dispatcharr-Artwork-Status"] = "not-found"
|
||||
return response
|
||||
try:
|
||||
content_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
||||
response = FileResponse(open(path, "rb"), content_type=content_type)
|
||||
except OSError:
|
||||
logger.debug(
|
||||
"Artwork file unavailable item_id=%s asset_type=%s path=%s",
|
||||
item.id,
|
||||
asset_type,
|
||||
path,
|
||||
)
|
||||
response = Response(
|
||||
{"detail": "Artwork not available."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
if settings.DEBUG:
|
||||
response["X-Dispatcharr-Artwork-Status"] = "unavailable"
|
||||
return response
|
||||
response["Cache-Control"] = "private, max-age=3600"
|
||||
if settings.DEBUG:
|
||||
response["X-Dispatcharr-Artwork-Path"] = path
|
||||
return response
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([AllowAny])
|
||||
def artwork_poster(request, pk: int):
|
||||
item = get_object_or_404(MediaItem, pk=pk)
|
||||
return serve_artwork_response(request, item, "poster")
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([AllowAny])
|
||||
def artwork_backdrop(request, pk: int):
|
||||
item = get_object_or_404(MediaItem, pk=pk)
|
||||
return serve_artwork_response(request, item, "backdrop")
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,8 @@ class LibraryLocationSerializer(serializers.ModelSerializer):
|
|||
|
||||
class LibrarySerializer(serializers.ModelSerializer):
|
||||
locations = LibraryLocationSerializer(many=True)
|
||||
movie_count = serializers.IntegerField(read_only=True)
|
||||
show_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Library
|
||||
|
|
@ -38,6 +40,8 @@ class LibrarySerializer(serializers.ModelSerializer):
|
|||
"add_to_vod",
|
||||
"last_scan_at",
|
||||
"last_successful_scan_at",
|
||||
"movie_count",
|
||||
"show_count",
|
||||
"locations",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
|
|
|
|||
|
|
@ -162,6 +162,8 @@ DVR_COMSKIP_ENABLED_KEY = slugify("DVR Comskip Enabled")
|
|||
DVR_COMSKIP_CUSTOM_PATH_KEY = slugify("DVR Comskip Custom Path")
|
||||
DVR_PRE_OFFSET_MINUTES_KEY = slugify("DVR Pre-Offset Minutes")
|
||||
DVR_POST_OFFSET_MINUTES_KEY = slugify("DVR Post-Offset Minutes")
|
||||
TMDB_API_KEY = slugify("TMDB API Key")
|
||||
PREFER_LOCAL_METADATA_KEY = slugify("Prefer Local Metadata")
|
||||
SYSTEM_TIME_ZONE_KEY = slugify("System Time Zone")
|
||||
|
||||
|
||||
|
|
@ -350,6 +352,27 @@ class CoreSettings(models.Model):
|
|||
obj.save(update_fields=["value"])
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def get_tmdb_api_key(cls):
|
||||
"""Return configured TMDB API key or None when unset."""
|
||||
try:
|
||||
value = cls.objects.get(key=TMDB_API_KEY).value
|
||||
except cls.DoesNotExist:
|
||||
return None
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
@classmethod
|
||||
def get_prefer_local_metadata(cls):
|
||||
"""Return True when local NFO metadata is preferred."""
|
||||
try:
|
||||
value = cls.objects.get(key=PREFER_LOCAL_METADATA_KEY).value
|
||||
except cls.DoesNotExist:
|
||||
return False
|
||||
return str(value).lower() in ("1", "true", "yes", "on")
|
||||
|
||||
@classmethod
|
||||
def get_dvr_series_rules(cls):
|
||||
"""Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}"""
|
||||
|
|
|
|||
1
frontend/src/assets/tmdb-logo-blue.svg
Normal file
1
frontend/src/assets/tmdb-logo-blue.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 273.42 35.52"><defs><linearGradient id="a" y1="17.76" x2="273.42" y2="17.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset=".56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><g data-name="Layer 2"><path d="M191.85 35.37h63.9a17.67 17.67 0 0017.67-17.67A17.67 17.67 0 00255.75 0h-63.9a17.67 17.67 0 00-17.67 17.7 17.67 17.67 0 0017.67 17.67zm-181.75.05h7.8V6.92H28V0H0v6.9h10.1zm28.1 0H46V8.25h.1l8.95 27.15h6L70.3 8.25h.1V35.4h7.8V0H66.45l-8.2 23.1h-.1L50 0H38.2zM89.14.12h11.7a33.56 33.56 0 018.08 1 18.52 18.52 0 016.67 3.08 15.09 15.09 0 014.53 5.52 18.5 18.5 0 011.67 8.25 16.91 16.91 0 01-1.62 7.58 16.3 16.3 0 01-4.38 5.5 19.24 19.24 0 01-6.35 3.37 24.53 24.53 0 01-7.55 1.15H89.14zm7.8 28.2h4a21.66 21.66 0 005-.55A10.58 10.58 0 00110 26a8.73 8.73 0 002.68-3.35 11.9 11.9 0 001-5.08 9.87 9.87 0 00-1-4.52 9.17 9.17 0 00-2.63-3.18A11.61 11.61 0 00106.22 8a17.06 17.06 0 00-4.68-.63h-4.6zM133.09.12h13.2a32.87 32.87 0 014.63.33 12.66 12.66 0 014.17 1.3 7.94 7.94 0 013 2.72 8.34 8.34 0 011.15 4.65 7.48 7.48 0 01-1.67 5 9.13 9.13 0 01-4.43 2.82V17a10.28 10.28 0 013.18 1 8.51 8.51 0 012.45 1.85 7.79 7.79 0 011.57 2.62 9.16 9.16 0 01.55 3.2 8.52 8.52 0 01-1.2 4.68 9.32 9.32 0 01-3.1 3 13.38 13.38 0 01-4.27 1.65 22.5 22.5 0 01-4.73.5h-14.5zm7.8 14.15h5.65a7.65 7.65 0 001.78-.2 4.78 4.78 0 001.57-.65 3.43 3.43 0 001.13-1.2 3.63 3.63 0 00.42-1.8A3.3 3.3 0 00151 8.6a3.42 3.42 0 00-1.23-1.13A6.07 6.07 0 00148 6.9a9.9 9.9 0 00-1.85-.18h-5.3zm0 14.65h7a8.27 8.27 0 001.83-.2 4.67 4.67 0 001.67-.7 3.93 3.93 0 001.23-1.3 3.8 3.8 0 00.47-1.95 3.16 3.16 0 00-.62-2 4 4 0 00-1.58-1.18 8.23 8.23 0 00-2-.55 15.12 15.12 0 00-2.05-.15h-5.9z" fill="url(#a)" data-name="Layer 1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -1,10 +1,10 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { 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 HOTZONE_BOTTOM_OFFSET = 24; // px: keep a little margin from the bottom
|
||||
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
|
||||
|
|
@ -17,32 +17,20 @@ export default function AlphabetSidebar({
|
|||
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 [hoveredLetter, setHoveredLetter] = useState(null);
|
||||
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 hotZoneHeight = `calc(100vh - ${top}px - ${HOTZONE_BOTTOM_OFFSET}px)`;
|
||||
|
||||
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.)
|
||||
|
|
@ -53,7 +41,7 @@ export default function AlphabetSidebar({
|
|||
}, []);
|
||||
|
||||
// Visible when hovering the hot zone or the sidebar, unless idling.
|
||||
const visible = isHot && !isIdle;
|
||||
const visible = isHot;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -66,7 +54,7 @@ export default function AlphabetSidebar({
|
|||
top,
|
||||
right: 0,
|
||||
width: HOTZONE_WIDTH,
|
||||
height: '70vh',
|
||||
height: hotZoneHeight,
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
|
|
@ -104,11 +92,18 @@ export default function AlphabetSidebar({
|
|||
const boost = Math.exp(-(d * d) / (2 * SIGMA_PX * SIGMA_PX)); // 0..1
|
||||
scale = 1 + MAX_SCALE_BOOST * boost;
|
||||
}
|
||||
if (hoveredLetter === letter) {
|
||||
scale = Math.max(scale, 1.5);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={letter}
|
||||
onClick={() => isEnabled && onSelect?.(letter)}
|
||||
onMouseEnter={() => setHoveredLetter(letter)}
|
||||
onMouseLeave={() => setHoveredLetter(null)}
|
||||
onFocus={() => setHoveredLetter(letter)}
|
||||
onBlur={() => setHoveredLetter(null)}
|
||||
style={{
|
||||
opacity: isEnabled ? 1 : 0.3,
|
||||
cursor: isEnabled ? 'pointer' : 'default',
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ const LibraryCard = ({
|
|||
onScan,
|
||||
loadingScan = false,
|
||||
}) => {
|
||||
const isShowLibrary = library.library_type === 'shows';
|
||||
const countValue = isShowLibrary ? library.show_count : library.movie_count;
|
||||
const count = Number.isFinite(Number(countValue)) ? Number(countValue) : 0;
|
||||
const countLabel = isShowLibrary ? 'Series' : 'Movies';
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow={selected ? 'lg' : 'sm'}
|
||||
|
|
@ -76,6 +81,10 @@ const LibraryCard = ({
|
|||
</Text>
|
||||
)}
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{count} {countLabel}
|
||||
</Text>
|
||||
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Last scan">
|
||||
<Group gap={4} align="center">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Text,
|
||||
} from '@mantine/core';
|
||||
import { Film, Library as LibraryIcon, Tv2 } from 'lucide-react';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
|
||||
const typeIcon = {
|
||||
movie: <Film size={18} />,
|
||||
|
|
@ -63,6 +64,14 @@ const formatRuntime = (runtimeMs) => {
|
|||
return `${hours}h ${minutes}m`;
|
||||
};
|
||||
|
||||
const resolveArtworkUrl = (url, envMode) => {
|
||||
if (!url) return url;
|
||||
if (envMode === 'dev' && url.startsWith('/')) {
|
||||
return `${window.location.protocol}//${window.location.hostname}:5656${url}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const MediaCard = ({
|
||||
item,
|
||||
onClick,
|
||||
|
|
@ -71,6 +80,7 @@ const MediaCard = ({
|
|||
showTypeBadge = true,
|
||||
style = {},
|
||||
}) => {
|
||||
const envMode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const [isTouch, setIsTouch] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
|
@ -136,8 +146,12 @@ const MediaCard = ({
|
|||
const watchSummary = item.watch_summary;
|
||||
const status = watchSummary?.status;
|
||||
const runtimeText = formatRuntime(item.runtime_ms);
|
||||
const posterUrl = useMemo(
|
||||
() => resolveArtworkUrl(item.poster_url, envMode),
|
||||
[item.poster_url, envMode]
|
||||
);
|
||||
const hasGenres = Array.isArray(item.genres) && item.genres.length > 0;
|
||||
const hasPoster = Boolean(item.poster_url);
|
||||
const hasPoster = Boolean(posterUrl);
|
||||
const showEpisodeBadge =
|
||||
item.item_type === 'show' && watchSummary?.total_episodes;
|
||||
const isActive = isExpanded || (!isTouch && isHovered) || isFocused;
|
||||
|
|
@ -226,11 +240,13 @@ const MediaCard = ({
|
|||
/>
|
||||
{hasPoster ? (
|
||||
<Image
|
||||
src={item.poster_url}
|
||||
src={posterUrl}
|
||||
alt={item.title}
|
||||
height="100%"
|
||||
width="100%"
|
||||
fit="contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import API from '../../api';
|
|||
import useMediaLibraryStore from '../../store/mediaLibrary';
|
||||
import useVideoStore from '../../store/useVideoStore';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import { USER_LEVELS } from '../../constants';
|
||||
import MediaEditModal from './MediaEditModal';
|
||||
|
||||
|
|
@ -49,7 +50,6 @@ const CAST_TILE_SPACING = 1;
|
|||
const STACK_TIGHT = 4; // was 6
|
||||
const SECTION_STACK = 12; // was larger in some places
|
||||
const DETAIL_SCROLL_HEIGHT = '82vh';
|
||||
const DETAIL_PANEL_MIN_HEIGHT = '72vh';
|
||||
const DETAIL_POSTER_MAX_HEIGHT = '72vh';
|
||||
// ----------------------------
|
||||
|
||||
|
|
@ -62,6 +62,14 @@ const runtimeLabel = (runtimeMs) => {
|
|||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
const resolveArtworkUrl = (url, envMode) => {
|
||||
if (!url) return url;
|
||||
if (envMode === 'dev' && url.startsWith('/')) {
|
||||
return `${window.location.protocol}//${window.location.hostname}:5656${url}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const MediaDetailModal = ({ opened, onClose }) => {
|
||||
const activeItem = useMediaLibraryStore((s) => s.activeItem);
|
||||
const activeItemLoading = useMediaLibraryStore((s) => s.activeItemLoading);
|
||||
|
|
@ -74,6 +82,7 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
const pollItem = useMediaLibraryStore((s) => s.pollItem);
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const userLevel = useAuthStore((s) => s.user?.user_level ?? 0);
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const canEditMetadata = userLevel >= USER_LEVELS.ADMIN;
|
||||
|
||||
const [startingPlayback, setStartingPlayback] = useState(false);
|
||||
|
|
@ -332,8 +341,6 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
.filter(Boolean);
|
||||
}, [activeItem]);
|
||||
const hasCredits = castPeople.length > 0 || crewPeople.length > 0;
|
||||
const detailPanelMinHeight =
|
||||
activeItem?.item_type === 'show' ? DETAIL_PANEL_MIN_HEIGHT : 'auto';
|
||||
|
||||
const handleStartPlayback = async (mode = 'start') => {
|
||||
if (!activeItem) return;
|
||||
|
|
@ -379,7 +386,7 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
mediaTitle: activeItem.title,
|
||||
name: activeItem.title,
|
||||
year: activeItem.release_year,
|
||||
logo: activeItem.poster_url ? { url: activeItem.poster_url } : undefined,
|
||||
logo: posterUrl ? { url: posterUrl } : undefined,
|
||||
progressId: activeProgress?.id,
|
||||
resumePositionMs,
|
||||
resumeHandledByServer,
|
||||
|
|
@ -532,6 +539,7 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
episodeDetail.runtime_ms ??
|
||||
episodeDetail.files?.[0]?.duration_ms ??
|
||||
null;
|
||||
const episodePosterUrl = resolveArtworkUrl(episodeDetail.poster_url, env_mode);
|
||||
|
||||
showVideo(playbackUrl, 'library', {
|
||||
mediaItemId: episodeDetail.id,
|
||||
|
|
@ -541,12 +549,12 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
name: episodeDetail.title,
|
||||
year: episodeDetail.release_year,
|
||||
logo:
|
||||
episodeDetail.poster_url
|
||||
? { url: episodeDetail.poster_url }
|
||||
: activeItem?.poster_url
|
||||
? { url: activeItem.poster_url }
|
||||
episodePosterUrl
|
||||
? { url: episodePosterUrl }
|
||||
: posterUrl
|
||||
? { url: posterUrl }
|
||||
: undefined,
|
||||
showPoster: activeItem?.poster_url,
|
||||
showPoster: posterUrl,
|
||||
progressId: episodeProgress?.id,
|
||||
resumePositionMs,
|
||||
resumeHandledByServer,
|
||||
|
|
@ -686,6 +694,27 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
);
|
||||
|
||||
const files = activeItem?.files || [];
|
||||
const posterUrl = useMemo(
|
||||
() => resolveArtworkUrl(activeItem?.poster_url, env_mode),
|
||||
[activeItem?.poster_url, env_mode]
|
||||
);
|
||||
const backdropUrl = useMemo(() => {
|
||||
const fallback = activeItem?.id
|
||||
? `/api/media-library/items/${activeItem.id}/artwork/backdrop/`
|
||||
: '';
|
||||
return resolveArtworkUrl(activeItem?.backdrop_url || fallback, env_mode);
|
||||
}, [activeItem?.backdrop_url, activeItem?.id, env_mode]);
|
||||
const modalBackgroundStyle = useMemo(() => {
|
||||
if (!backdropUrl) {
|
||||
return { backgroundColor: '#1f1f1f' };
|
||||
}
|
||||
return {
|
||||
backgroundImage: `linear-gradient(180deg, rgba(8, 10, 12, 0.92), rgba(8, 10, 12, 0.86) 45%, rgba(8, 10, 12, 0.92)), url('${backdropUrl}')`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
};
|
||||
}, [backdropUrl]);
|
||||
|
||||
const seasonOptions = useMemo(
|
||||
() =>
|
||||
|
|
@ -742,6 +771,17 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
size="xl"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
|
||||
padding="md"
|
||||
styles={{
|
||||
content: {
|
||||
...modalBackgroundStyle,
|
||||
},
|
||||
header: {
|
||||
background: 'transparent',
|
||||
},
|
||||
body: {
|
||||
background: 'transparent',
|
||||
},
|
||||
}}
|
||||
title={
|
||||
<Group justify="space-between" align="center" gap="xs">
|
||||
<Text fw={600} truncate>
|
||||
|
|
@ -770,7 +810,7 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
<ScrollArea h={DETAIL_SCROLL_HEIGHT} offsetScrollbars>
|
||||
<Stack spacing="xl">
|
||||
<Group align="flex-start" gap="xl" wrap="wrap">
|
||||
{activeItem.poster_url ? (
|
||||
{posterUrl ? (
|
||||
<Box w={{ base: '100%', sm: 240 }} style={{ flexShrink: 0, maxWidth: 260 }}>
|
||||
<Box
|
||||
style={{
|
||||
|
|
@ -784,7 +824,7 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
}}
|
||||
>
|
||||
<Image
|
||||
src={activeItem.poster_url}
|
||||
src={posterUrl}
|
||||
alt={activeItem.title}
|
||||
width="100%"
|
||||
height="100%"
|
||||
|
|
@ -796,7 +836,7 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
|
||||
<Stack
|
||||
spacing={SECTION_STACK}
|
||||
style={{ flex: 1, minWidth: 0, minHeight: detailPanelMinHeight }}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{metadataPending && (
|
||||
<Alert
|
||||
|
|
@ -938,74 +978,75 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{activeItem.item_type === 'show' && (
|
||||
<>
|
||||
<Divider label="Episodes" labelPosition="center" />
|
||||
{episodesLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
{activeItem.item_type === 'show' && (
|
||||
<Stack spacing={STACK_TIGHT} style={{ width: '100%' }}>
|
||||
<Divider label="Episodes" labelPosition="center" />
|
||||
{episodesLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : sortedSeasons.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No episodes discovered yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack spacing="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Select
|
||||
label="Season"
|
||||
data={seasonOptions}
|
||||
value={
|
||||
selectedSeason != null ? String(selectedSeason) : null
|
||||
}
|
||||
onChange={(value) => {
|
||||
setSeasonManuallySelected(true);
|
||||
setSelectedSeason(value ? Number(value) : null);
|
||||
}}
|
||||
placeholder="Select season"
|
||||
allowDeselect={false}
|
||||
w={220}
|
||||
/>
|
||||
<Badge variant="outline" size="xs">
|
||||
{visibleEpisodes.length} episode
|
||||
{visibleEpisodes.length === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
</Group>
|
||||
) : sortedSeasons.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No episodes discovered yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack spacing="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Select
|
||||
label="Season"
|
||||
data={seasonOptions}
|
||||
value={
|
||||
selectedSeason != null ? String(selectedSeason) : null
|
||||
}
|
||||
onChange={(value) => {
|
||||
setSeasonManuallySelected(true);
|
||||
setSelectedSeason(value ? Number(value) : null);
|
||||
}}
|
||||
placeholder="Select season"
|
||||
allowDeselect={false}
|
||||
w={220}
|
||||
/>
|
||||
<Badge variant="outline" size="xs">
|
||||
{visibleEpisodes.length} episode
|
||||
{visibleEpisodes.length === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
</Group>
|
||||
{visibleEpisodes.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No episodes available for this season.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack spacing={STACK_TIGHT}>
|
||||
{visibleEpisodes.map((episode) => {
|
||||
const episodeProgress = episode.watch_progress;
|
||||
const episodeStatus = episode.watch_summary?.status;
|
||||
const progressPercent = episodeProgress?.percentage
|
||||
? Math.round(episodeProgress.percentage * 100)
|
||||
: null;
|
||||
const isWatched = episodeStatus === 'watched';
|
||||
const isInProgress = episodeStatus === 'in_progress';
|
||||
const episodeLoading = episodeActionLoading[episode.id];
|
||||
const isExpanded = expandedEpisodeIds.has(episode.id);
|
||||
const synopsisText = episode.synopsis?.trim();
|
||||
return (
|
||||
<Group
|
||||
key={episode.id}
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
gap="md"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => handleEpisodeCardClick(episode.id, event)}
|
||||
onKeyDown={(event) => handleEpisodeCardKeyDown(episode.id, event)}
|
||||
style={{
|
||||
border: '1px solid rgba(148, 163, 184, 0.15)',
|
||||
borderRadius: 12,
|
||||
padding: '10px 12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{visibleEpisodes.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No episodes available for this season.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack spacing={STACK_TIGHT}>
|
||||
{visibleEpisodes.map((episode) => {
|
||||
const episodeProgress = episode.watch_progress;
|
||||
const episodeStatus = episode.watch_summary?.status;
|
||||
const progressPercent = episodeProgress?.percentage
|
||||
? Math.round(episodeProgress.percentage * 100)
|
||||
: null;
|
||||
const isWatched = episodeStatus === 'watched';
|
||||
const isInProgress = episodeStatus === 'in_progress';
|
||||
const episodeLoading = episodeActionLoading[episode.id];
|
||||
const isExpanded = expandedEpisodeIds.has(episode.id);
|
||||
const synopsisText = episode.synopsis?.trim();
|
||||
return (
|
||||
<Stack
|
||||
key={episode.id}
|
||||
spacing={STACK_TIGHT}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => handleEpisodeCardClick(episode.id, event)}
|
||||
onKeyDown={(event) => handleEpisodeCardKeyDown(episode.id, event)}
|
||||
style={{
|
||||
border: '1px solid rgba(148, 163, 184, 0.15)',
|
||||
borderRadius: 12,
|
||||
padding: '10px 12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
|
||||
<Stack spacing={STACK_TIGHT} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm" lineClamp={1}>
|
||||
|
|
@ -1016,7 +1057,7 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
<Group gap={6}>
|
||||
{isWatched && (
|
||||
<Badge size="xs" color="green">
|
||||
Watched
|
||||
Watched
|
||||
</Badge>
|
||||
)}
|
||||
{isInProgress && (
|
||||
|
|
@ -1045,86 +1086,66 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{synopsisText ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={isExpanded ? undefined : 2}>
|
||||
{synopsisText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Stack spacing={STACK_TIGHT} align="flex-end">
|
||||
<Group gap={6}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<PlayCircle size={16} />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handlePlayEpisode(episode, {
|
||||
sequence: playbackPlan?.sorted ?? orderedEpisodes,
|
||||
});
|
||||
}}
|
||||
loading={episodePlayLoadingId === episode.id}
|
||||
>
|
||||
Play
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={
|
||||
isWatched ? <Undo2 size={14} /> : <CheckCircle2 size={14} />
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
isWatched
|
||||
? handleEpisodeMarkUnwatched(episode)
|
||||
: handleEpisodeMarkWatched(episode);
|
||||
}}
|
||||
loading={episodeLoading === 'watch' || episodeLoading === 'unwatch'}
|
||||
>
|
||||
{isWatched ? 'Unwatch' : 'Mark watched'}
|
||||
</Button>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleEpisodeDelete(episode);
|
||||
}}
|
||||
loading={episodeLoading === 'delete'}
|
||||
title="Delete episode"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Group gap={6}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<PlayCircle size={16} />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handlePlayEpisode(episode, {
|
||||
sequence: playbackPlan?.sorted ?? orderedEpisodes,
|
||||
});
|
||||
}}
|
||||
loading={episodePlayLoadingId === episode.id}
|
||||
>
|
||||
Play
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={
|
||||
isWatched ? <Undo2 size={14} /> : <CheckCircle2 size={14} />
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
isWatched
|
||||
? handleEpisodeMarkUnwatched(episode)
|
||||
: handleEpisodeMarkWatched(episode);
|
||||
}}
|
||||
loading={episodeLoading === 'watch' || episodeLoading === 'unwatch'}
|
||||
>
|
||||
{isWatched ? 'Unwatch' : 'Mark watched'}
|
||||
</Button>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleEpisodeDelete(episode);
|
||||
}}
|
||||
loading={episodeLoading === 'delete'}
|
||||
title="Delete episode"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider label="Metadata" labelPosition="center" />
|
||||
|
||||
<Stack spacing={STACK_TIGHT}>
|
||||
<Group gap="xs">
|
||||
{activeItem.imdb_id && (
|
||||
<Badge
|
||||
component="a"
|
||||
href={`https://www.imdb.com/title/${activeItem.imdb_id}`}
|
||||
target="_blank"
|
||||
leftSection={<Info size={14} />}
|
||||
>
|
||||
IMDB {activeItem.imdb_id}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{synopsisText ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={isExpanded ? undefined : 2}>
|
||||
{synopsisText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{hasCredits ? (
|
||||
<Stack spacing={STACK_TIGHT} style={{ width: '100%' }}>
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ const LibraryPage = () => {
|
|||
const itemsBackgroundLoading = useMediaLibraryStore((s) => s.backgroundLoading);
|
||||
const setItemFilters = useMediaLibraryStore((s) => s.setFilters);
|
||||
const fetchItems = useMediaLibraryStore((s) => s.fetchItems);
|
||||
const clearItems = useMediaLibraryStore((s) => s.clearItems);
|
||||
const setSelectedMediaLibrary = useMediaLibraryStore((s) => s.setSelectedLibraryId);
|
||||
const openItem = useMediaLibraryStore((s) => s.openItem);
|
||||
const closeItem = useMediaLibraryStore((s) => s.closeItem);
|
||||
|
|
@ -124,14 +125,14 @@ const LibraryPage = () => {
|
|||
|
||||
if (!libraries || libraries.length === 0) {
|
||||
setSelectedMediaLibrary(null);
|
||||
fetchItems([]);
|
||||
clearItems();
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedMediaLibrary(ids.length === 1 ? ids[0] : null);
|
||||
|
||||
if (ids.length === 0) {
|
||||
fetchItems([]);
|
||||
clearItems();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +154,15 @@ const LibraryPage = () => {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [libraries, relevantLibraryIds, fetchItems, setSelectedMediaLibrary, debouncedSearch, itemTypeFilter]);
|
||||
}, [
|
||||
libraries,
|
||||
relevantLibraryIds,
|
||||
fetchItems,
|
||||
clearItems,
|
||||
setSelectedMediaLibrary,
|
||||
debouncedSearch,
|
||||
itemTypeFilter,
|
||||
]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const typeFiltered = items.filter((item) => item.item_type === itemTypeFilter);
|
||||
|
|
|
|||
|
|
@ -12,13 +12,17 @@ import useUserAgentsStore from '../store/userAgents';
|
|||
import useStreamProfilesStore from '../store/streamProfiles';
|
||||
import {
|
||||
Accordion,
|
||||
Anchor,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
FileInput,
|
||||
List,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
SimpleGrid,
|
||||
Select,
|
||||
|
|
@ -49,6 +53,7 @@ import useWarningsStore from '../store/warnings';
|
|||
import LibraryCard from '../components/library/LibraryCard';
|
||||
import LibraryFormModal from '../components/library/LibraryFormModal';
|
||||
import LibraryScanDrawer from '../components/library/LibraryScanDrawer';
|
||||
import tmdbLogoUrl from '../assets/tmdb-logo-blue.svg?url';
|
||||
|
||||
const TIMEZONE_FALLBACKS = [
|
||||
'UTC',
|
||||
|
|
@ -202,6 +207,8 @@ const SettingsPage = () => {
|
|||
const removeScan = useLibraryStore((s) => s.removeScan);
|
||||
const cancelLibraryScan = useLibraryStore((s) => s.cancelLibraryScan);
|
||||
const deleteLibraryScan = useLibraryStore((s) => s.deleteLibraryScan);
|
||||
const tmdbSetting = settings['tmdb-api-key'];
|
||||
const preferLocalSetting = settings['prefer-local-metadata'];
|
||||
|
||||
const [accordianValue, setAccordianValue] = useState(null);
|
||||
const [networkAccessSaved, setNetworkAccessSaved] = useState(false);
|
||||
|
|
@ -234,6 +241,10 @@ const SettingsPage = () => {
|
|||
const [librarySubmitting, setLibrarySubmitting] = useState(false);
|
||||
const [scanDrawerOpen, setScanDrawerOpen] = useState(false);
|
||||
const [scanLoadingId, setScanLoadingId] = useState(null);
|
||||
const [tmdbKey, setTmdbKey] = useState('');
|
||||
const [preferLocalMetadata, setPreferLocalMetadata] = useState(false);
|
||||
const [savingMetadataSettings, setSavingMetadataSettings] = useState(false);
|
||||
const [tmdbHelpOpen, setTmdbHelpOpen] = useState(false);
|
||||
|
||||
// UI / local storage settings
|
||||
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
|
||||
|
|
@ -431,6 +442,16 @@ const SettingsPage = () => {
|
|||
loadComskipConfig();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const currentKey = tmdbSetting?.value ?? '';
|
||||
setTmdbKey(currentKey);
|
||||
const preferValue = preferLocalSetting?.value;
|
||||
const normalized = String(preferValue ?? '').toLowerCase();
|
||||
setPreferLocalMetadata(
|
||||
['1', 'true', 'yes', 'on'].includes(normalized)
|
||||
);
|
||||
}, [tmdbSetting?.value, preferLocalSetting?.value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authUser?.user_level == USER_LEVELS.ADMIN) {
|
||||
fetchLibraries();
|
||||
|
|
@ -650,6 +671,56 @@ const SettingsPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSaveMetadataSettings = async () => {
|
||||
setSavingMetadataSettings(true);
|
||||
try {
|
||||
const tasks = [];
|
||||
const preferValue = preferLocalMetadata ? 'true' : 'false';
|
||||
if (preferLocalSetting?.id) {
|
||||
tasks.push(
|
||||
API.updateSetting({ ...preferLocalSetting, value: preferValue })
|
||||
);
|
||||
} else {
|
||||
tasks.push(
|
||||
API.createSetting({
|
||||
key: 'prefer-local-metadata',
|
||||
name: 'Prefer Local Metadata',
|
||||
value: preferValue,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedKey = (tmdbKey || '').trim();
|
||||
if (tmdbSetting?.id) {
|
||||
tasks.push(API.updateSetting({ ...tmdbSetting, value: trimmedKey }));
|
||||
} else if (trimmedKey) {
|
||||
tasks.push(
|
||||
API.createSetting({
|
||||
key: 'tmdb-api-key',
|
||||
name: 'TMDB API Key',
|
||||
value: trimmedKey,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
notifications.show({
|
||||
title: 'Metadata settings saved',
|
||||
message: 'Metadata preferences updated successfully.',
|
||||
color: 'green',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save metadata settings', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Unable to save metadata settings.',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setSavingMetadataSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const executeSettingsSaveAndRehash = async () => {
|
||||
setRehashConfirmOpen(false);
|
||||
setGeneralSettingsSaved(false);
|
||||
|
|
@ -921,6 +992,71 @@ const SettingsPage = () => {
|
|||
<Accordion.Control>Media Library</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xl">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack spacing={4}>
|
||||
<Title order={4}>Metadata Sources</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Prefer local NFO metadata, then fill missing fields
|
||||
from TMDB.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Switch
|
||||
label="Prefer local metadata (.nfo files)"
|
||||
description="Use NFO data first and fill missing fields from TMDB."
|
||||
checked={preferLocalMetadata}
|
||||
onChange={(event) =>
|
||||
setPreferLocalMetadata(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="TMDB API Key"
|
||||
placeholder="Enter TMDB API key"
|
||||
value={tmdbKey}
|
||||
onChange={(event) =>
|
||||
setTmdbKey(event.currentTarget.value)
|
||||
}
|
||||
description="Used for metadata and artwork lookups."
|
||||
/>
|
||||
<Group justify="space-between" align="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setTmdbHelpOpen(true)}
|
||||
>
|
||||
Where do I get this?
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={handleSaveMetadataSettings}
|
||||
loading={savingMetadataSettings}
|
||||
>
|
||||
Save Metadata Settings
|
||||
</Button>
|
||||
</Group>
|
||||
<Center>
|
||||
<Anchor
|
||||
href="https://www.themoviedb.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={tmdbLogoUrl}
|
||||
alt="TMDB logo"
|
||||
style={{
|
||||
width: 140,
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Anchor>
|
||||
</Center>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack spacing={4}>
|
||||
<Title order={4}>Libraries</Title>
|
||||
|
|
@ -1527,6 +1663,53 @@ const SettingsPage = () => {
|
|||
</Accordion>
|
||||
</Box>
|
||||
|
||||
<Modal
|
||||
opened={tmdbHelpOpen}
|
||||
onClose={() => setTmdbHelpOpen(false)}
|
||||
title="How to get a TMDB API key"
|
||||
size="lg"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 2 }}
|
||||
>
|
||||
<Stack spacing="sm">
|
||||
<Text size="sm">
|
||||
Dispatcharr uses TMDB (The Movie Database) for artwork and
|
||||
metadata. You can create a key in a few minutes:
|
||||
</Text>
|
||||
<List size="sm" spacing="xs">
|
||||
<List.Item>
|
||||
Visit{' '}
|
||||
<Anchor
|
||||
href="https://www.themoviedb.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
themoviedb.org
|
||||
</Anchor>{' '}
|
||||
and sign in or create a free account.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
Open your{' '}
|
||||
<Anchor
|
||||
href="https://www.themoviedb.org/settings/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
TMDB account settings
|
||||
</Anchor>{' '}
|
||||
and choose <Text component="span" fw={500}>API</Text>.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
Complete the short API application and copy the v3 API key into
|
||||
the field above.
|
||||
</List.Item>
|
||||
</List>
|
||||
<Text size="sm" c="dimmed">
|
||||
TMDB issues separate v3 and v4 keys. Dispatcharr only needs the v3
|
||||
API key for metadata lookups.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={rehashConfirmOpen}
|
||||
onClose={() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { create } from 'zustand';
|
||||
import API from '../api';
|
||||
|
||||
let librariesInFlight = null;
|
||||
|
||||
const useLibraryStore = create((set, get) => ({
|
||||
libraries: [],
|
||||
loading: false,
|
||||
|
|
@ -9,13 +11,23 @@ const useLibraryStore = create((set, get) => ({
|
|||
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 });
|
||||
if (librariesInFlight) {
|
||||
return librariesInFlight;
|
||||
}
|
||||
set({ loading: true, error: null });
|
||||
librariesInFlight = (async () => {
|
||||
try {
|
||||
const libraries = await API.getLibraries();
|
||||
set({ libraries: Array.isArray(libraries) ? libraries : [], loading: false });
|
||||
return libraries;
|
||||
} catch (error) {
|
||||
set({ error: error.message || 'Failed to load libraries.', loading: false });
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return librariesInFlight.finally(() => {
|
||||
librariesInFlight = null;
|
||||
});
|
||||
},
|
||||
|
||||
createLibrary: async (payload) => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const defaultFilters = {
|
|||
};
|
||||
|
||||
const pollHandles = new Map();
|
||||
const inFlightRequests = new Map();
|
||||
|
||||
const schedulePoll = (itemId, callback, delayMs) => {
|
||||
const handle = setTimeout(callback, delayMs);
|
||||
|
|
@ -41,6 +42,23 @@ const useMediaLibraryStore = create((set, get) => ({
|
|||
setSelectedLibraryId: (id) => set({ selectedLibraryId: id }),
|
||||
|
||||
fetchItems: async (libraryIds = [], { background = false, limit, ordering } = {}) => {
|
||||
const resolvedLibraryIds =
|
||||
libraryIds === undefined ? get().activeLibraryIds || [] : libraryIds;
|
||||
const explicitEmpty = Array.isArray(libraryIds) && libraryIds.length === 0;
|
||||
|
||||
if (!resolvedLibraryIds || resolvedLibraryIds.length === 0) {
|
||||
if (explicitEmpty) {
|
||||
set({
|
||||
items: [],
|
||||
itemsById: {},
|
||||
loading: false,
|
||||
backgroundLoading: false,
|
||||
activeLibraryIds: [],
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (background) {
|
||||
set({ backgroundLoading: true });
|
||||
} else {
|
||||
|
|
@ -49,7 +67,7 @@ const useMediaLibraryStore = create((set, get) => ({
|
|||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
libraryIds.forEach((id) => params.append('library', id));
|
||||
resolvedLibraryIds.forEach((id) => params.append('library', id));
|
||||
if (get().filters.type) {
|
||||
params.append('type', get().filters.type);
|
||||
}
|
||||
|
|
@ -63,28 +81,49 @@ const useMediaLibraryStore = create((set, get) => ({
|
|||
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;
|
||||
}, {});
|
||||
const requestKey = `${params.toString()}|bg:${background ? '1' : '0'}`;
|
||||
if (inFlightRequests.has(requestKey)) {
|
||||
return await inFlightRequests.get(requestKey);
|
||||
}
|
||||
|
||||
set({
|
||||
items,
|
||||
itemsById,
|
||||
loading: false,
|
||||
backgroundLoading: false,
|
||||
activeLibraryIds: libraryIds,
|
||||
const requestPromise = (async () => {
|
||||
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: resolvedLibraryIds,
|
||||
});
|
||||
|
||||
return items;
|
||||
})();
|
||||
|
||||
inFlightRequests.set(requestKey, requestPromise);
|
||||
return await requestPromise.finally(() => {
|
||||
inFlightRequests.delete(requestKey);
|
||||
});
|
||||
|
||||
return items;
|
||||
} catch (error) {
|
||||
set({ loading: false, backgroundLoading: false });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
clearItems: () =>
|
||||
set({
|
||||
items: [],
|
||||
itemsById: {},
|
||||
loading: false,
|
||||
backgroundLoading: false,
|
||||
activeLibraryIds: [],
|
||||
}),
|
||||
|
||||
upsertItems: (items) => {
|
||||
if (!Array.isArray(items)) return;
|
||||
set((state) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue