diff --git a/src/lib/apis/sonarr/sonarr-api.ts b/src/lib/apis/sonarr/sonarr-api.ts index 01cf1d5..f7554eb 100644 --- a/src/lib/apis/sonarr/sonarr-api.ts +++ b/src/lib/apis/sonarr/sonarr-api.ts @@ -256,7 +256,9 @@ export class SonarrApi implements ApiAsync { params: { query: { includeEpisode: true, - includeSeries: true + includeSeries: true, + // @ts-ignore + pageSize: 1000 } } }) diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte index 3d46924..ee06b7a 100644 --- a/src/lib/components/Button.svelte +++ b/src/lib/components/Button.svelte @@ -5,25 +5,33 @@ import AnimatedSelection from './AnimateScale.svelte'; import { createEventDispatcher } from 'svelte'; + const dispatch = createEventDispatcher<{ clickOrSelect: null }>(); + export let disabled: boolean = false; export let focusOnMount: boolean = false; export let type: 'primary' | 'secondary' | 'primary-dark' = 'primary'; - + export let confirmDanger = false; export let action: (() => Promise) | null = null; + let actionIsFetching = false; $: _disabled = disabled || actionIsFetching; - + let armed = false; let hasFocus: Readable; - - const dispatch = createEventDispatcher<{ clickOrSelect: null }>(); + $: if (!$hasFocus && armed) armed = false; function handleClickOrSelect() { + if (confirmDanger && !armed) { + armed = true; + return; + } + if (action) { actionIsFetching = true; action().then(() => (actionIsFetching = false)); } dispatch('clickOrSelect'); + armed = false; } @@ -38,6 +46,7 @@ 'selectable px-6': type === 'primary' || type === 'primary-dark', 'border-2 p-1 hover:border-primary-500': type === 'secondary', 'border-primary-500': type === 'secondary' && $hasFocus, + '!border-red-500': confirmDanger && armed, 'cursor-pointer': !_disabled, 'cursor-not-allowed pointer-events-none opacity-40': _disabled }, @@ -55,7 +64,8 @@ 'border-2 border-transparent h-full w-full rounded-md flex items-center px-6': type === 'secondary', 'bg-primary-500 text-secondary-950': type === 'secondary' && $hasFocus, - 'group-hover:bg-primary-500 group-hover:text-secondary-950': type === 'secondary' + 'group-hover:bg-primary-500 group-hover:text-secondary-950': type === 'secondary', + '!bg-red-500': confirmDanger && armed })} >
diff --git a/src/lib/components/Card/Card.svelte b/src/lib/components/Card/Card.svelte index 1fe6720..6131125 100644 --- a/src/lib/components/Card/Card.svelte +++ b/src/lib/components/Card/Card.svelte @@ -3,10 +3,8 @@ import PlayButton from '../PlayButton.svelte'; import ProgressBar from '../ProgressBar.svelte'; import LazyImg from '../LazyImg.svelte'; - import { Star } from 'radix-icons-svelte'; import type { TitleType } from '../../types'; import Container from '../../../Container.svelte'; - import { useNavigate } from 'svelte-navigator'; import type { Readable } from 'svelte/store'; import AnimatedSelection from '../AnimateScale.svelte'; import { navigate } from '../StackRouter/StackRouter'; @@ -28,8 +26,30 @@ export let orientation: 'portrait' | 'landscape' = 'landscape'; let hasFocus: Readable; + + let dimensions = getDimensions(window.innerWidth); + + function getDimensions(viewportWidth: number) { + const minWidth = 240; + + const margin = 128; + const gap = 32; + + const cols = Math.floor((gap - 2 * margin + viewportWidth) / (minWidth + gap)); + const scale = -(gap * (cols - 1) + 2 * margin - viewportWidth) / (cols * minWidth); + + const newWidth = minWidth * scale; + const newHeight = (3 / 2) * newWidth; + + return { + width: newWidth, + height: newHeight + }; + } + (dimensions = getDimensions(e.currentTarget.innerWidth))} /> + { - carousel?.scrollTo({ left: scrollX - carousel?.clientWidth * 0.8, behavior: 'smooth' }); + carousel?.scrollTo({ + left: scrollX - (carousel?.clientWidth - 2 * 128 + 32), + behavior: 'smooth' + }); }} > { - carousel?.scrollTo({ left: scrollX + carousel?.clientWidth * 0.8, behavior: 'smooth' }); + carousel?.scrollTo({ + left: scrollX + (carousel?.clientWidth - 2 * 128) + 32, + behavior: 'smooth' + }); }} > diff --git a/src/lib/components/Dialog/ConfirmDialog.svelte b/src/lib/components/Dialog/ConfirmDialog.svelte index 28fabb9..8e6fa8b 100644 --- a/src/lib/components/Dialog/ConfirmDialog.svelte +++ b/src/lib/components/Dialog/ConfirmDialog.svelte @@ -4,9 +4,12 @@ import { modalStack } from '../Modal/modal.store'; import Dialog from './Dialog.svelte'; + type ActionFn = (() => Promise) | (() => any); + export let modalId: symbol; - type ActionFn = (() => Promise) | (() => any); + export let header: string; + export let body: string; export let confirm: ActionFn; export let cancel: ActionFn = () => {}; @@ -28,10 +31,10 @@
- + {header}
- + {body}
+ +
+ diff --git a/src/lib/components/SeriesPage/FileDetailsDialog.svelte b/src/lib/components/SeriesPage/FileDetailsDialog.svelte index a75f1cb..1523818 100644 --- a/src/lib/components/SeriesPage/FileDetailsDialog.svelte +++ b/src/lib/components/SeriesPage/FileDetailsDialog.svelte @@ -1,6 +1,10 @@ @@ -25,7 +34,7 @@

{episode?.title}

Season {episode?.seasonNumber} Episode {episode?.episodeNumber}

*:nth-child(odd)]:text-secondary-300 [&>*:nth-child(even)]:text-right [&>*:nth-child(even)]:text-secondary-100 *:py-1" > Runtime @@ -34,14 +43,12 @@ {formatSize(file.size || 0)} Quality {file.quality?.quality?.name} - -
- diff --git a/src/lib/components/SeriesPage/SeriesPage.svelte b/src/lib/components/SeriesPage/SeriesPage.svelte index 60c5dd0..e0e534d 100644 --- a/src/lib/components/SeriesPage/SeriesPage.svelte +++ b/src/lib/components/SeriesPage/SeriesPage.svelte @@ -2,17 +2,21 @@ import Container from '../../../Container.svelte'; import HeroCarousel from '../HeroCarousel/HeroCarousel.svelte'; import DetachedPage from '../DetachedPage/DetachedPage.svelte'; - import { useActionRequest, useDependantRequest, useRequest } from '../../stores/data.store'; - import { tmdbApi, type TmdbSeasonEpisode } from '../../apis/tmdb/tmdb-api'; + import { useRequest } from '../../stores/data.store'; + import { tmdbApi } from '../../apis/tmdb/tmdb-api'; import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants'; import classNames from 'classnames'; - import { DotFilled, Download, ExternalLink, File, Play, Plus, Trash } from 'radix-icons-svelte'; + import { Cross1, DotFilled, ExternalLink, Play, Plus, Trash } from 'radix-icons-svelte'; import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api'; - import { type EpisodeFileResource, sonarrApi } from '../../apis/sonarr/sonarr-api'; + import { + type EpisodeDownload, + type EpisodeFileResource, + sonarrApi + } from '../../apis/sonarr/sonarr-api'; import Button from '../Button.svelte'; import { playerState } from '../VideoPlayer/VideoPlayer'; import { createModal, modalStack } from '../Modal/modal.store'; - import { derived, get, writable } from 'svelte/store'; + import { get } from 'svelte/store'; import { scrollIntoView, useRegistrar } from '../../selectable'; import ScrollHelper from '../ScrollHelper.svelte'; import Carousel from '../Carousel/Carousel.svelte'; @@ -21,9 +25,10 @@ import EpisodeGrid from './EpisodeGrid.svelte'; import { formatSize } from '../../utils'; import FileDetailsDialog from './FileDetailsDialog.svelte'; - import ConfirmDeleteSeasonDialog from './ConfirmDeleteSeasonDialog.svelte'; import SeasonMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte'; import MMAddToSonarrDialog from '../MediaManagerModal/MMAddToSonarrDialog.svelte'; + import ConfirmDialog from '../Dialog/ConfirmDialog.svelte'; + import DownloadDetailsDialog from './DownloadDetailsDialog.svelte'; export let id: string; @@ -34,20 +39,19 @@ let sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id)); const { promise: recommendations } = useRequest(tmdbApi.getSeriesRecommendations, Number(id)); - // @ts-ignore - $: localFilesP = sonarrItem && getLocalFiles(); - $: localFileSeasons = localFilesP.then((files) => [ - ...new Set(files.map((item) => item.seasonNumber || -1)) - ]); - $: sonarrEpisodes = Promise.all([sonarrItem, localFileSeasons]) + $: sonarrDownloads = getDownloads(sonarrItem); + $: sonarrFiles = getFiles(sonarrItem); + $: sonarrSeasonNumbers = Promise.all([sonarrFiles, sonarrDownloads]).then( + ([files, downloads]) => [ + ...new Set(files.map((item) => item.seasonNumber || -1)), + ...new Set(downloads.map((item) => item.seasonNumber || -1)) + ] + ); + $: sonarrEpisodes = Promise.all([sonarrItem, sonarrSeasonNumbers]) .then(([item, seasons]) => Promise.all(seasons.map((s) => sonarrApi.getEpisodes(item?.id || -1, s))) ) .then((items) => items.flat()); - $: localFilesP.then(console.log); - $: sonarrEpisodes.then(console.log); - $: sonarrItem.then(console.log); - $: localFileSeasons.then(console.log); const jellyfinSeries = getJellyfinSeries(id); @@ -71,11 +75,7 @@ return jellyfinApi.getLibraryItemFromTmdbId(id); } - function getLocalFiles() { - return sonarrItem.then((item) => - item ? sonarrApi.getFilesBySeriesId(item?.id || -1) : Promise.resolve([]) - ); - } + const onGrabRelease = () => setTimeout(() => (sonarrDownloads = getDownloads(sonarrItem)), 8000); function handleAddedToSonarr() { sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id)); @@ -84,7 +84,8 @@ sonarrItem && createModal(SeasonMediaManagerModal, { season: 1, - sonarrItem + sonarrItem, + onGrabRelease }) ); } @@ -95,7 +96,8 @@ if (sonarrItem) { createModal(SeasonMediaManagerModal, { season, - sonarrItem + sonarrItem, + onGrabRelease }); } else if (tmdbSeries) { createModal(MMAddToSonarrDialog, { @@ -107,6 +109,36 @@ } }); } + + async function getFiles(item: typeof sonarrItem) { + return item.then((item) => (item ? sonarrApi.getFilesBySeriesId(item?.id || -1) : [])); + } + + async function getDownloads(item: typeof sonarrItem) { + return item.then((item) => (item ? sonarrApi.getDownloadsBySeriesId(item?.id || -1) : [])); + } + + function createConfirmDeleteSeasonDialog(files: EpisodeFileResource[]) { + createModal(ConfirmDialog, { + header: 'Delete Season Files?', + body: `Are you sure you want to delete all ${files.length} file(s) from season ${files[0]?.seasonNumber}?`, + confirm: () => + sonarrApi + .deleteSonarrEpisodes(files.map((f) => f.id || -1)) + .then(() => (sonarrFiles = getFiles(sonarrItem))) + }); + } + + function createConfirmCancelDownloadsDialog(downloads: EpisodeDownload[]) { + createModal(ConfirmDialog, { + header: 'Cancel Season Downloads?', + body: `Are you sure you want to cancel all ${downloads.length} download(s) from season ${downloads[0]?.seasonNumber}?`, + confirm: () => + sonarrApi + .cancelDownloads(downloads.map((f) => f.id || -1)) + .then(() => (sonarrDownloads = getDownloads(sonarrItem))) + }); + } @@ -273,49 +305,34 @@ {/await} - {#await Promise.all( [localFilesP, localFileSeasons, sonarrEpisodes] ) then [localFiles, seasons, episodes]} - {#if localFiles?.length} + {#await Promise.all( [sonarrSeasonNumbers, sonarrFiles, sonarrEpisodes, sonarrDownloads] ) then [seasons, files, episodes, downloads]} + {#if files?.length}
{#each seasons as season} - {@const files = localFiles.filter((f) => f.seasonNumber === season)} -
+ {@const seasonEpisodes = episodes.filter((e) => e.seasonNumber === season)} + {@const seasonFiles = files.filter((f) => f.seasonNumber === season)} + {@const seasonDownloads = downloads.filter((d) => d.seasonNumber === season)} + +

Season {season} Files

- - - - - - - - - - - - - - - - -
-
- {#each files as file} - {@const episode = episodes.find( - (e) => e.episodeFileId !== undefined && e.episodeFileId === file.id - )} + + {#each seasonEpisodes as episode} + {@const file = seasonFiles.find((f) => f.id === episode.episodeFileId)} + {@const download = seasonDownloads.find((d) => d.episodeId === episode.id)} - modalStack.create(FileDetailsDialog, { file, episode })} + on:clickOrSelect={() => { + if (file) + modalStack.create(FileDetailsDialog, { + file, + episode, + onDelete: () => (sonarrFiles = getFiles(sonarrItem)) + }); + else if (download) + modalStack.create(DownloadDetailsDialog, { + download, + episode, + onCancel: () => (sonarrDownloads = getDownloads(sonarrItem)) + }); + }} + on:enter={scrollIntoView({ vertical: 128 })} focusOnClick > + {#if download} +
+ {/if}

{episode?.episodeNumber}. {episode?.title}

-
- {file.mediaInfo?.runTime} -
-
- {formatSize(file.size || 0)} -
-
- {file.quality?.quality?.name} -
+ {#if file} +
+ {file?.mediaInfo?.runTime} +
+
+ {formatSize(file?.size || 0)} +
+
+ {file?.quality?.quality?.name} +
+ {:else if download} +
+ {formatSize((download?.size || 0) - (download?.sizeleft || 1))} / {formatSize( + download?.size || 0 + )} +
+
+ {download?.quality?.quality?.name} +
+ {/if} {/each} -
+
- + {#if seasonFiles?.length} + + {/if} + {#if seasonDownloads?.length} + + {/if}
{/each} diff --git a/src/lib/pages/EpisodePage.svelte b/src/lib/pages/EpisodePage.svelte index 06ec531..f3d562d 100644 --- a/src/lib/pages/EpisodePage.svelte +++ b/src/lib/pages/EpisodePage.svelte @@ -3,17 +3,35 @@ import { tmdbApi } from '../apis/tmdb/tmdb-api'; import DetachedPage from '../components/DetachedPage/DetachedPage.svelte'; import { useActionRequest, useDependantRequest, useRequest } from '../stores/data.store'; - import { TMDB_IMAGES_ORIGINAL } from '../constants'; + import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../constants'; import classNames from 'classnames'; - import { Check, DotFilled, Download, File, Play, Trash } from 'radix-icons-svelte'; + import { + Check, + DotFilled, + Download, + ExternalLink, + File, + Play, + Plus, + Trash + } from 'radix-icons-svelte'; import HeroInfoTitle from '../components/HeroInfo/HeroInfoTitle.svelte'; import Button from '../components/Button.svelte'; import { jellyfinApi } from '../apis/jellyfin/jellyfin-api'; import { playerState } from '../components/VideoPlayer/VideoPlayer'; import { formatSize, timeout } from '../utils'; import { tick } from 'svelte'; - import { openEpisodeMediaManager } from '../components/Modal/modal.store'; + import { createModal, openEpisodeMediaManager } from '../components/Modal/modal.store'; import ButtonGhost from '../components/Ghosts/ButtonGhost.svelte'; + import { + type EpisodeFileResource, + sonarrApi, + type SonarrEpisode, + type SonarrSeries + } from '../apis/sonarr/sonarr-api'; + import MMAddToSonarrDialog from '../components/MediaManagerModal/MMAddToSonarrDialog.svelte'; + import SeasonMediaManagerModal from '../components/MediaManagerModal/SeasonMediaManagerModal.svelte'; + import ConfirmDialog from '../components/Dialog/ConfirmDialog.svelte'; export let id: string; // Series ID export let season: string; @@ -22,6 +40,10 @@ let isWatched = false; const tmdbEpisode = tmdbApi.getEpisode(Number(id), Number(season), Number(episode)); + let sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id)); + $: sonarrEpisode = getSonarrEpisode(sonarrItem); + let sonarrFiles = getFiles(sonarrItem, sonarrEpisode); + const jellyfinSeries = jellyfinApi.getLibraryItemFromTmdbId(id); let jellyfinEpisode = jellyfinSeries.then((series) => jellyfinApi.getEpisode(series?.Id || '', Number(season), Number(episode)) @@ -43,6 +65,59 @@ jellyfinEpisode.then((e) => { isWatched = e?.UserData?.Played || false; }); + + async function getSonarrEpisode(sonarrItem: Promise) { + return sonarrItem.then((sonarrItem) => { + if (!sonarrItem?.id) return; + + return sonarrApi + .getEpisodes(sonarrItem.id, Number(season)) + .then((episodes) => episodes.find((e) => e.episodeNumber === Number(episode))); + }); + } + + function handleRequestEpisode() { + return Promise.all([sonarrEpisode, tmdbEpisode]).then(([sonarrEpisode, tmdbEpisode]) => { + if (sonarrEpisode) { + createModal(SeasonMediaManagerModal, { + sonarrItem: sonarrEpisode, + onGrabRelease: () => {} // TODO + }); + } else if (tmdbEpisode) { + createModal(MMAddToSonarrDialog, { + tmdbId: Number(id), + backdropUri: tmdbEpisode.still_path || '', + title: tmdbEpisode.name || '', + onComplete: () => (sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id))) + }); + } else { + console.error('No series found'); + } + }); + } + + function createConfirmDeleteFiles(files: EpisodeFileResource[]) { + createModal(ConfirmDialog, { + header: 'Delete Season Files?', + body: `Are you sure you want to delete all ${files.length} file(s)?`, + confirm: () => + sonarrApi + .deleteSonarrEpisodes(files.map((f) => f.id || -1)) + .then(() => (sonarrFiles = getFiles(sonarrItem, sonarrEpisode))) + }); + } + + function getFiles( + sonarrItem: Promise, + sonarrEpisode: Promise + ) { + return Promise.all([sonarrItem, sonarrEpisode]).then(([sonarrItem, sonarrEpisode]) => { + if (!sonarrItem?.id) return []; + return sonarrApi + .getFilesBySeriesId(sonarrItem.id) + .then((files) => files.filter((f) => sonarrEpisode?.episodeFileId === f.id)); + }); + } @@ -103,34 +178,58 @@
{tmdbEpisode?.overview}
- - {#await jellyfinEpisode} + + {#await Promise.all([jellyfinEpisode, sonarrEpisode])} Play - Play - {:then jEpisode} - - + Manage Media + Delete Files + {:then [jellyfinEpisode]} + {#if jellyfinEpisode?.MediaSources?.length} + + + {:else} + + {/if} {/await} - - + + + + + + {#await sonarrFiles then files} + {#if files?.length} + + {/if} + {/await} + + {#if PLATFORM_WEB} + + + {/if} {/await}