diff --git a/apps/media_library/migrations/0001_initial.py b/apps/media_library/migrations/0001_initial.py index 4da07607..814440c5 100644 --- a/apps/media_library/migrations/0001_initial.py +++ b/apps/media_library/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.14 on 2025-09-16 18:34 +# Generated by Django 5.2.4 on 2025-09-18 01:25 import django.core.validators import django.db.models.deletion @@ -63,6 +63,7 @@ class Migration(migrations.Migration): ('started_at', models.DateTimeField(blank=True, null=True)), ('finished_at', models.DateTimeField(blank=True, null=True)), ('total_files', models.PositiveIntegerField(default=0)), + ('processed_files', models.PositiveIntegerField(default=0)), ('new_files', models.PositiveIntegerField(default=0)), ('updated_files', models.PositiveIntegerField(default=0)), ('removed_files', models.PositiveIntegerField(default=0)), diff --git a/apps/media_library/migrations/0002_libraryscan_processed_files.py b/apps/media_library/migrations/0002_libraryscan_processed_files.py deleted file mode 100644 index bfd67383..00000000 --- a/apps/media_library/migrations/0002_libraryscan_processed_files.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.14 on 2025-09-17 15:54 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('media_library', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='libraryscan', - name='processed_files', - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/docker/docker-compose.debug.yml b/docker/docker-compose.debug.yml index 163ebf6a..7de8d8ff 100644 --- a/docker/docker-compose.debug.yml +++ b/docker/docker-compose.debug.yml @@ -12,6 +12,8 @@ services: - 5678:5678 # Debugging port volumes: - ../:/app + - "D:/Server Backup/Movies:/library/Movies" + - "D:/Server Backup/TV Shows:/library/TVShows" environment: - DISPATCHARR_ENV=dev - DISPATCHARR_DEBUG=true diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5325ff6c..b3332a13 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "dayjs": "^1.11.13", "formik": "^2.4.6", "hls.js": "^1.5.20", + "immer": "^10.1.1", "lucide-react": "^0.511.0", "mpegts.js": "^1.8.0", "react": "^19.0.0", @@ -2177,6 +2178,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", diff --git a/frontend/src/components/library/MediaDetailModal.jsx b/frontend/src/components/library/MediaDetailModal.jsx index 4a7b1193..57921ec8 100644 --- a/frontend/src/components/library/MediaDetailModal.jsx +++ b/frontend/src/components/library/MediaDetailModal.jsx @@ -14,6 +14,7 @@ import { Stack, Text, Title, + rem, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { @@ -31,18 +32,26 @@ import API from '../../api'; import useMediaLibraryStore from '../../store/mediaLibrary'; import useVideoStore from '../../store/useVideoStore'; +// ---- quick tuning knobs ---- +const CAST_TILE_WIDTH = 96; // was 116 +const CAST_TILE_HEIGHT = 148; // was 160 +const CAST_AVATAR_SIZE = 72; // was 80 +const CAST_GAP = 8; // was "md" (~16) +const STACK_TIGHT = 4; // was 6 +const SECTION_STACK = 12; // was larger in some places +// ---------------------------- + const runtimeLabel = (runtimeMs) => { if (!runtimeMs) return null; const totalSeconds = Math.round(runtimeMs / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); - if (hours) { - return `${hours}h ${minutes}m`; - } + if (hours) return `${hours}h ${minutes}m`; return `${minutes}m`; }; const MediaDetailModal = ({ opened, onClose }) => { +<<<<<<< Updated upstream const activeItem = useMediaLibraryStore((state) => state.activeItem); const activeItemLoading = useMediaLibraryStore((state) => state.activeItemLoading); const activeProgress = useMediaLibraryStore((state) => state.activeProgress); @@ -59,6 +68,26 @@ const MediaDetailModal = ({ opened, onClose }) => { const [episodePlayLoadingId, setEpisodePlayLoadingId] = useState(null); const [episodeActionLoading, setEpisodeActionLoading] = useState({}); +======= + const activeItem = useMediaLibraryStore((s) => s.activeItem); + const activeItemLoading = useMediaLibraryStore((s) => s.activeItemLoading); + const activeProgress = useMediaLibraryStore((s) => s.activeProgress); + const resumePrompt = useMediaLibraryStore((s) => s.resumePrompt); + const requestResume = useMediaLibraryStore((s) => s.requestResume); + const clearResumePrompt = useMediaLibraryStore((s) => s.clearResumePrompt); + const setActiveProgress = useMediaLibraryStore((s) => s.setActiveProgress); + const showVideo = useVideoStore((s) => s.showVideo); + + const [startingPlayback, setStartingPlayback] = useState(false); + const [resumeModalOpen, setResumeModalOpen] = useState(false); + const [resumeMode, setResumeMode] = useState('start'); + + const [episodes, setEpisodes] = useState([]); + const [episodesLoading, setEpisodesLoading] = useState(false); + const [episodePlayLoadingId, setEpisodePlayLoadingId] = useState(null); + const [episodeActionLoading, setEpisodeActionLoading] = useState({}); + +>>>>>>> Stashed changes const setEpisodeLoading = useCallback((episodeId, action) => { setEpisodeActionLoading((prev) => ({ ...prev, [episodeId]: action })); }, []); @@ -100,6 +129,7 @@ const MediaDetailModal = ({ opened, onClose }) => { }, [activeItem]); const orderedEpisodes = useMemo(() => { +<<<<<<< Updated upstream if (!episodes || episodes.length === 0) { return []; } @@ -114,6 +144,16 @@ const MediaDetailModal = ({ opened, onClose }) => { if (episodeA !== episodeB) { return episodeA - episodeB; } +======= + if (!episodes || episodes.length === 0) return []; + return [...episodes].sort((a, b) => { + const seasonA = a.season_number ?? 0; + const seasonB = b.season_number ?? 0; + if (seasonA !== seasonB) return seasonA - seasonB; + const episodeA = a.episode_number ?? 0; + const episodeB = b.episode_number ?? 0; + if (episodeA !== episodeB) return episodeA - episodeB; +>>>>>>> Stashed changes return (a.sort_title || '').localeCompare(b.sort_title || ''); }); }, [episodes]); @@ -121,6 +161,7 @@ const MediaDetailModal = ({ opened, onClose }) => { const showWatchSummary = activeItem?.watch_summary || null; const playbackPlan = useMemo(() => { +<<<<<<< Updated upstream if (!activeItem || activeItem.item_type !== 'show') { return null; } @@ -163,7 +204,37 @@ const MediaDetailModal = ({ opened, onClose }) => { const canResume = useMemo(() => { if (!activeProgress || !activeProgress.position_ms || !activeProgress.duration_ms) { return false; +======= + if (!activeItem || activeItem.item_type !== 'show') return null; + + const sorted = orderedEpisodes; + if (!sorted || sorted.length === 0) { + return { sorted: [], resumeEpisode: null, nextEpisode: null }; +>>>>>>> Stashed changes } + + const resumeId = showWatchSummary?.resume_episode_id; + const nextId = showWatchSummary?.next_episode_id; + let resumeEpisode = sorted.find((ep) => ep.id === resumeId) || null; + let nextEpisode = sorted.find((ep) => ep.id === nextId) || null; + + if (!resumeEpisode) { + resumeEpisode = sorted.find((ep) => ep.watch_summary?.status === 'in_progress') || null; + } + if (!resumeEpisode) { + resumeEpisode = sorted.find((ep) => ep.watch_summary?.status !== 'watched') || sorted[0]; + } + if (!nextEpisode && resumeEpisode) { + const currentIndex = sorted.findIndex((ep) => ep.id === resumeEpisode.id); + if (currentIndex >= 0 && currentIndex + 1 < sorted.length) { + nextEpisode = sorted[currentIndex + 1]; + } + } + return { sorted, resumeEpisode, nextEpisode }; + }, [activeItem, orderedEpisodes, showWatchSummary]); + + const canResume = useMemo(() => { + if (!activeProgress || !activeProgress.position_ms || !activeProgress.duration_ms) return false; const remaining = activeProgress.duration_ms - activeProgress.position_ms; return remaining > activeProgress.duration_ms * 0.04; }, [activeProgress]); @@ -191,6 +262,7 @@ const MediaDetailModal = ({ opened, onClose }) => { }, [opened, activeItem?.id, activeItem?.item_type, loadEpisodes]); const castPeople = useMemo(() => { +<<<<<<< Updated upstream if (!activeItem?.cast) { return []; } @@ -211,6 +283,17 @@ const MediaDetailModal = ({ opened, onClose }) => { if (!name) { return null; } +======= + if (!activeItem?.cast) return []; + return activeItem.cast + .map((entry, index) => { + if (!entry) return null; + if (typeof entry === 'string') { + return { key: `${entry}-${index}`, name: entry, role: null, profile: null }; + } + const name = entry.name || entry.character || entry.job; + if (!name) return null; +>>>>>>> Stashed changes return { key: `${name}-${index}`, name, @@ -222,6 +305,7 @@ const MediaDetailModal = ({ opened, onClose }) => { }, [activeItem]); const crewPeople = useMemo(() => { +<<<<<<< Updated upstream if (!activeItem?.crew) { return []; } @@ -242,6 +326,17 @@ const MediaDetailModal = ({ opened, onClose }) => { if (!name) { return null; } +======= + if (!activeItem?.crew) return []; + return activeItem.crew + .map((entry, index) => { + if (!entry) return null; + if (typeof entry === 'string') { + return { key: `${entry}-${index}`, name: entry, role: null, profile: null }; + } + const name = entry.name || entry.job || entry.department; + if (!name) return null; +>>>>>>> Stashed changes return { key: `${name}-${index}`, name, @@ -266,9 +361,7 @@ const MediaDetailModal = ({ opened, onClose }) => { setResumeMode(mode); setStartingPlayback(true); try { - const streamInfo = await API.streamMediaItem(activeItem.id, { - fileId, - }); + const streamInfo = await API.streamMediaItem(activeItem.id, { fileId }); const playbackUrl = streamInfo?.url || streamInfo?.stream_url; if (!playbackUrl) { notifications.show({ @@ -330,13 +423,31 @@ const MediaDetailModal = ({ opened, onClose }) => { return 'Play'; }, [activeItem, showWatchSummary, canResume, resumePrompt, activeProgress]); +<<<<<<< Updated upstream +======= + const primaryButtonLabel = useMemo(() => { + if (!activeItem) return 'Play'; + if (activeItem.item_type === 'show') { + if (showWatchSummary?.status === 'in_progress') return 'Continue Watching'; + if (showWatchSummary?.status === 'watched') return 'Watch Again'; + return 'Play'; + } + if (canResume && (resumePrompt || activeProgress)) return 'Continue Watching'; + return 'Play'; + }, [activeItem, showWatchSummary, canResume, resumePrompt, activeProgress]); + +>>>>>>> Stashed changes const handlePrimaryAction = () => { if (!activeItem) return; if (activeItem.item_type === 'show') { const targetEpisode = +<<<<<<< Updated upstream playbackPlan?.resumeEpisode || playbackPlan?.nextEpisode || playbackPlan?.sorted?.[0]; +======= + playbackPlan?.resumeEpisode || playbackPlan?.nextEpisode || playbackPlan?.sorted?.[0]; +>>>>>>> Stashed changes if (!targetEpisode) { notifications.show({ title: 'No episodes available', @@ -345,6 +456,7 @@ const MediaDetailModal = ({ opened, onClose }) => { }); return; } +<<<<<<< Updated upstream const startIndex = playbackPlan.sorted.findIndex( (ep) => ep.id === targetEpisode.id ); @@ -352,6 +464,10 @@ const MediaDetailModal = ({ opened, onClose }) => { sequence: playbackPlan.sorted, startIndex, }); +======= + const startIndex = playbackPlan.sorted.findIndex((ep) => ep.id === targetEpisode.id); + void handlePlayEpisode(targetEpisode, { sequence: playbackPlan.sorted, startIndex }); +>>>>>>> Stashed changes return; } if (canResume && (resumePrompt || activeProgress)) { @@ -380,9 +496,13 @@ const MediaDetailModal = ({ opened, onClose }) => { return; } +<<<<<<< Updated upstream const streamInfo = await API.streamMediaItem(episodeDetail.id, { fileId: episodeFileId, }); +======= + const streamInfo = await API.streamMediaItem(episodeDetail.id, { fileId: episodeFileId }); +>>>>>>> Stashed changes const playbackUrl = streamInfo?.url || streamInfo?.stream_url; if (!playbackUrl) { notifications.show({ @@ -400,10 +520,14 @@ const MediaDetailModal = ({ opened, onClose }) => { const computedIndex = startIndex ?? episodeIds.findIndex((id) => id === episode.id); const playbackSequence = episodeIds.length +<<<<<<< Updated upstream ? { episodeIds, currentIndex: computedIndex >= 0 ? computedIndex : 0, } +======= + ? { episodeIds, currentIndex: computedIndex >= 0 ? computedIndex : 0 } +>>>>>>> Stashed changes : null; const episodeProgress = episodeDetail.watch_progress; @@ -452,21 +576,33 @@ const MediaDetailModal = ({ opened, onClose }) => { const grouped = new Map(); orderedEpisodes.forEach((episode) => { const season = episode.season_number || 1; +<<<<<<< Updated upstream if (!grouped.has(season)) { grouped.set(season, []); } +======= + if (!grouped.has(season)) grouped.set(season, []); +>>>>>>> Stashed changes grouped.get(season).push(episode); }); return grouped; }, [orderedEpisodes]); +<<<<<<< Updated upstream const sortedSeasons = useMemo(() => { return Array.from(episodesBySeason.keys()).sort((a, b) => a - b); }, [episodesBySeason]); +======= + const sortedSeasons = useMemo( + () => Array.from(episodesBySeason.keys()).sort((a, b) => a - b), + [episodesBySeason] + ); +>>>>>>> Stashed changes const formatEpisodeCode = (episode) => { const season = episode.season_number || 0; const ep = episode.episode_number || 0; +<<<<<<< Updated upstream if (!season && !ep) { return ''; } @@ -476,6 +612,11 @@ const MediaDetailModal = ({ opened, onClose }) => { if (!ep) { return `S${season.toString().padStart(2, '0')}`; } +======= + if (!season && !ep) return ''; + if (!season) return `E${ep.toString().padStart(2, '0')}`; + if (!ep) return `S${season.toString().padStart(2, '0')}`; +>>>>>>> Stashed changes return `S${season.toString().padStart(2, '0')}E${ep.toString().padStart(2, '0')}`; }; @@ -563,10 +704,14 @@ const MediaDetailModal = ({ opened, onClose }) => { {activeItem.poster_url ? ( +<<<<<<< Updated upstream +======= + +>>>>>>> Stashed changes { ) : null} +<<<<<<< Updated upstream +======= + + +>>>>>>> Stashed changes {activeItem.title} @@ -625,15 +775,11 @@ const MediaDetailModal = ({ opened, onClose }) => { )} - {runtimeLabel( - activeItem.runtime_ms || files[0]?.duration_ms - ) && ( + {runtimeLabel(activeItem.runtime_ms || files[0]?.duration_ms) && ( - {runtimeLabel( - activeItem.runtime_ms || files[0]?.duration_ms - )} + {runtimeLabel(activeItem.runtime_ms || files[0]?.duration_ms)} )} @@ -648,6 +794,7 @@ const MediaDetailModal = ({ opened, onClose }) => { )} +<<<<<<< Updated upstream {activeItem.item_type === 'show' && showWatchSummary?.total_episodes ? ( @@ -655,6 +802,14 @@ const MediaDetailModal = ({ opened, onClose }) => { {showWatchSummary.total_episodes} episodes watched ) : null} +======= + {activeItem.item_type === 'show' && showWatchSummary?.total_episodes ? ( + + {showWatchSummary.completed_episodes || 0} of{' '} + {showWatchSummary.total_episodes} episodes watched + + ) : null} +>>>>>>> Stashed changes