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:
Dispatcharr 2025-12-26 11:21:33 -06:00
parent dd0ad5b012
commit 2014a0f850
15 changed files with 1786 additions and 226 deletions

View file

@ -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"),
]

View file

@ -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):

View 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

View file

@ -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",

View file

@ -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'}"""

View 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

View file

@ -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',

View file

@ -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">

View file

@ -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 }}
/>
) : (

View file

@ -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%' }}>

View file

@ -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);

View file

@ -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={() => {

View file

@ -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) => {

View file

@ -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) => {