feat: Dynamic card sizes everywhere, reworked content requesting for EpisodePage
This commit is contained in:
@@ -256,7 +256,9 @@ export class SonarrApi implements ApiAsync<paths> {
|
||||
params: {
|
||||
query: {
|
||||
includeEpisode: true,
|
||||
includeSeries: true
|
||||
includeSeries: true,
|
||||
// @ts-ignore
|
||||
pageSize: 1000
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<any>) | null = null;
|
||||
|
||||
let actionIsFetching = false;
|
||||
$: _disabled = disabled || actionIsFetching;
|
||||
|
||||
let armed = false;
|
||||
let hasFocus: Readable<boolean>;
|
||||
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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
|
||||
})}
|
||||
>
|
||||
<div class="flex-1 text-center text-nowrap flex items-center justify-center">
|
||||
|
||||
@@ -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<boolean>;
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:resize={(e) => (dimensions = getDimensions(e.currentTarget.innerWidth))} />
|
||||
|
||||
<AnimatedSelection hasFocus={$hasFocus}>
|
||||
<Container
|
||||
{disabled}
|
||||
@@ -50,12 +70,13 @@
|
||||
'h-32 w-56': size === 'sm' && orientation === 'landscape',
|
||||
'w-44 h-64': size === 'md' && orientation === 'portrait',
|
||||
'h-44 w-80': size === 'md' && orientation === 'landscape',
|
||||
'w-60 h-96': size === 'lg' && orientation === 'portrait',
|
||||
// 'w-60 h-96': size === 'lg' && orientation === 'portrait',
|
||||
'h-60 w-96': size === 'lg' && orientation === 'landscape',
|
||||
'w-full h-96': size === 'dynamic',
|
||||
'shadow-lg': shadow
|
||||
}
|
||||
)}
|
||||
style={`width: ${dimensions.width}px; height: ${dimensions.height}px;`}
|
||||
bind:hasFocus
|
||||
>
|
||||
<LazyImg
|
||||
|
||||
@@ -43,14 +43,20 @@
|
||||
>
|
||||
<IconButton
|
||||
on:click={() => {
|
||||
carousel?.scrollTo({ left: scrollX - carousel?.clientWidth * 0.8, behavior: 'smooth' });
|
||||
carousel?.scrollTo({
|
||||
left: scrollX - (carousel?.clientWidth - 2 * 128 + 32),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
on:click={() => {
|
||||
carousel?.scrollTo({ left: scrollX + carousel?.clientWidth * 0.8, behavior: 'smooth' });
|
||||
carousel?.scrollTo({
|
||||
left: scrollX + (carousel?.clientWidth - 2 * 128) + 32,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
import Dialog from './Dialog.svelte';
|
||||
|
||||
type ActionFn = (() => Promise<any>) | (() => any);
|
||||
|
||||
export let modalId: symbol;
|
||||
|
||||
type ActionFn = (() => Promise<any>) | (() => any);
|
||||
export let header: string;
|
||||
export let body: string;
|
||||
export let confirm: ActionFn;
|
||||
export let cancel: ActionFn = () => {};
|
||||
|
||||
@@ -28,10 +31,10 @@
|
||||
|
||||
<Dialog>
|
||||
<div class="header2 mb-4">
|
||||
<slot name="header" />
|
||||
{header}
|
||||
</div>
|
||||
<div class="font-medium text-secondary-300 mb-8">
|
||||
<slot />
|
||||
{body}
|
||||
</div>
|
||||
<Container class="flex flex-col space-y-4">
|
||||
<Button type="secondary" disabled={fetching} on:clickOrSelect={() => handleAction(confirm)}>
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import classNames from 'classnames';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
|
||||
export let size: 'sm' | 'full' = 'sm';
|
||||
|
||||
function handleClose() {
|
||||
modalStack.closeTopmost();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal on:back>
|
||||
<div
|
||||
class="h-full flex items-center justify-center bg-primary-900/75 py-20 px-32"
|
||||
transition:fade={{ duration: 100 }}
|
||||
on:click|self={() => handleClose()}
|
||||
>
|
||||
<div
|
||||
class={classNames(
|
||||
|
||||
@@ -7,11 +7,8 @@
|
||||
import {
|
||||
sonarrApi,
|
||||
type SonarrMonitorOptions,
|
||||
sonarrMonitorOptions,
|
||||
type SonarrQualityProfile,
|
||||
type SonarrRootFolder
|
||||
sonarrMonitorOptions
|
||||
} from '../../apis/sonarr/sonarr-api';
|
||||
import type { TmdbSeries2 } from '../../apis/tmdb/tmdb-api';
|
||||
import { TMDB_BACKDROP_SMALL } from '../../constants';
|
||||
import classNames from 'classnames';
|
||||
import { type BackEvent, scrollIntoView, Selectable } from '../../selectable';
|
||||
@@ -27,10 +24,13 @@
|
||||
monitorOptions: SonarrMonitorOptions | null;
|
||||
};
|
||||
|
||||
export let backdropUri: string;
|
||||
export let tmdbId: number;
|
||||
export let title: string;
|
||||
|
||||
export let modalId: symbol;
|
||||
export let series: TmdbSeries2;
|
||||
export let onComplete: () => void = () => {};
|
||||
$: backgroundUrl = TMDB_BACKDROP_SMALL + series.backdrop_path;
|
||||
$: backgroundUrl = TMDB_BACKDROP_SMALL + backdropUri;
|
||||
|
||||
let tab: 'add-to-sonarr' | 'root-folders' | 'quality-profiles' | 'monitor-settings' =
|
||||
'add-to-sonarr';
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
function handleAddToSonarr() {
|
||||
return sonarrApi
|
||||
.addToSonarr(series.id as number, {
|
||||
.addToSonarr(tmdbId as number, {
|
||||
rootFolderPath: $addOptionsStore.rootFolderPath || undefined,
|
||||
monitorOptions: $addOptionsStore.monitorOptions || undefined,
|
||||
qualityProfileId: $addOptionsStore.qualityProfileId || undefined
|
||||
@@ -123,7 +123,7 @@
|
||||
>
|
||||
<div class="z-10 mb-8">
|
||||
<div class="h-24" />
|
||||
<h1 class="header2">Add {series?.name} to Sonarr?</h1>
|
||||
<h1 class="header2">Add {title} to Sonarr?</h1>
|
||||
<div class="font-medium text-secondary-300 mb-8">
|
||||
Before you can fetch episodes, you need to add this series to Sonarr.
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { sonarrApi, type SonarrRelease, type SonarrSeries } from '../../apis/sonarr/sonarr-api';
|
||||
import MMMainLayout from './MMMainLayout.svelte';
|
||||
import { sonarrApi, type SonarrEpisode, type SonarrSeries } from '../../apis/sonarr/sonarr-api';
|
||||
import MMReleasesTab from './Releases/MMReleasesTab.svelte';
|
||||
import type { GrabReleaseFn } from './MediaManagerModal';
|
||||
import { onDestroy } from 'svelte';
|
||||
@@ -8,13 +7,13 @@
|
||||
import type { Release } from '../../apis/combined-types';
|
||||
import MMSeasonSelectTab from './MMSeasonSelectTab.svelte';
|
||||
|
||||
export let season: number | null = null;
|
||||
export let sonarrItem: SonarrSeries;
|
||||
export let season: number | undefined = undefined;
|
||||
export let sonarrItem: SonarrSeries | SonarrEpisode;
|
||||
export let modalId: symbol;
|
||||
export let hidden: boolean;
|
||||
export let onGrabRelease: (release: Release) => void = () => {};
|
||||
|
||||
$: releases = season !== null ? getReleases(season) : null;
|
||||
$: releases = getReleases(season);
|
||||
|
||||
let refreshDownloadsTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
@@ -24,14 +23,9 @@
|
||||
return r;
|
||||
});
|
||||
|
||||
function getReleases(season: number) {
|
||||
return sonarrApi.getSeasonReleases(sonarrItem.id || -1, season);
|
||||
}
|
||||
|
||||
function getDownloads(season: number) {
|
||||
return sonarrApi
|
||||
.getDownloadsBySeriesId(sonarrItem.id || -1)
|
||||
.then((ds) => ds.filter((d) => d.episode?.seasonNumber === season));
|
||||
function getReleases(season?: number) {
|
||||
if (season) return sonarrApi.getSeasonReleases(sonarrItem.id || -1, season);
|
||||
else return sonarrApi.getEpisodeReleases(sonarrItem.id || -1);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -40,12 +34,18 @@
|
||||
</script>
|
||||
|
||||
<Dialog size="full" {modalId} {hidden}>
|
||||
{#if !season}
|
||||
{#if 'seasons' in sonarrItem && !season}
|
||||
<MMSeasonSelectTab />
|
||||
{:else if releases}
|
||||
{:else}
|
||||
<MMReleasesTab {releases} {grabRelease}>
|
||||
<h1 slot="title">{sonarrItem?.title}</h1>
|
||||
<h2 slot="subtitle">Season {season} Releases</h2>
|
||||
<h2 slot="subtitle">
|
||||
{#if season}
|
||||
Season {season} Releases
|
||||
{:else if 'episodeNumber' in sonarrItem}
|
||||
Episode {sonarrItem.episodeNumber} Releases
|
||||
{/if}
|
||||
</h2>
|
||||
</MMReleasesTab>
|
||||
{/if}
|
||||
</Dialog>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { type EpisodeFileResource, sonarrApi } from '../../apis/sonarr/sonarr-api';
|
||||
import ConfirmDialog from '../Dialog/ConfirmDialog.svelte';
|
||||
|
||||
export let modalId: symbol;
|
||||
|
||||
export let files: EpisodeFileResource[];
|
||||
export let onComplete: () => void = () => {};
|
||||
|
||||
function handleDeleteSeason() {
|
||||
return sonarrApi.deleteSonarrEpisodes(files.map((f) => f.id || -1)).then(() => onComplete());
|
||||
}
|
||||
</script>
|
||||
|
||||
<ConfirmDialog {modalId} confirm={handleDeleteSeason}>
|
||||
<h1 slot="header">Delete Season Files?</h1>
|
||||
<div>
|
||||
Are you sure you want to delete all {files.length} file(s) from season {files[0]?.seasonNumber}?
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
66
src/lib/components/SeriesPage/DownloadDetailsDialog.svelte
Normal file
66
src/lib/components/SeriesPage/DownloadDetailsDialog.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import Dialog from '../Dialog/Dialog.svelte';
|
||||
import {
|
||||
type EpisodeDownload,
|
||||
sonarrApi,
|
||||
type SonarrEpisode
|
||||
} from '../../apis/sonarr/sonarr-api';
|
||||
import Button from '../Button.svelte';
|
||||
import Container from '../../../Container.svelte';
|
||||
import { formatSize } from '../../utils';
|
||||
import { Cross1 } from 'radix-icons-svelte';
|
||||
import { capitalize } from '../../utils.js';
|
||||
|
||||
export let download: EpisodeDownload;
|
||||
export let episode: SonarrEpisode | undefined;
|
||||
export let onCancel: () => void;
|
||||
$: backgroundUrl = episode?.images?.[0]?.remoteUrl;
|
||||
console.log(download);
|
||||
|
||||
function handleCancelDownload() {
|
||||
return sonarrApi.cancelDownload(download.id || -1).then(() => onCancel());
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog class="flex flex-col relative">
|
||||
{#if backgroundUrl}
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center h-52"
|
||||
style="background-image: url({backgroundUrl}); -webkit-mask-image: radial-gradient(at 90% 10%, hsla(0,0%,0%,1) 0px, transparent 70%);"
|
||||
/>
|
||||
{/if}
|
||||
<div class="z-10">
|
||||
{#if backgroundUrl}
|
||||
<div class="h-24" />
|
||||
{/if}
|
||||
<h1 class="header2">{episode?.title}</h1>
|
||||
<h2 class="header1 mb-4">Season {episode?.seasonNumber} Episode {episode?.episodeNumber}</h2>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_max-content] font-medium mb-16
|
||||
[&>*:nth-child(odd)]:text-secondary-300 [&>*:nth-child(even)]:text-right [&>*:nth-child(even)]:text-secondary-100 *:py-1"
|
||||
>
|
||||
<span class="border-b border-secondary-600">Status</span>
|
||||
<span class="border-b border-secondary-600">{capitalize(download.status || '')}</span>
|
||||
|
||||
<span class="border-b border-secondary-600">Progress</span>
|
||||
<span class="border-b border-secondary-600"
|
||||
>{formatSize((download?.size || 0) - (download?.sizeleft || 1))} / {formatSize(
|
||||
download?.size || 0
|
||||
)}</span
|
||||
>
|
||||
<span class="border-b border-secondary-600">Estimated Time Left</span>
|
||||
<span class="border-b border-secondary-600">{download.timeleft}</span>
|
||||
<span class="border-b border-secondary-600">Source</span>
|
||||
<span class="border-b border-secondary-600">{download.indexer}</span>
|
||||
<span>Quality</span>
|
||||
<span>{download.quality?.quality?.name}</span>
|
||||
</div>
|
||||
|
||||
<Container class="flex flex-col space-y-4">
|
||||
<Button type="secondary" confirmDanger action={handleCancelDownload}>
|
||||
<Cross1 size={19} slot="icon" />
|
||||
Cancel Downloads
|
||||
</Button>
|
||||
</Container>
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import Dialog from '../Dialog/Dialog.svelte';
|
||||
import type { EpisodeFileResource, SonarrEpisode } from '../../apis/sonarr/sonarr-api';
|
||||
import {
|
||||
type EpisodeFileResource,
|
||||
sonarrApi,
|
||||
type SonarrEpisode
|
||||
} from '../../apis/sonarr/sonarr-api';
|
||||
import Button from '../Button.svelte';
|
||||
import Container from '../../../Container.svelte';
|
||||
import { formatSize } from '../../utils';
|
||||
@@ -8,7 +12,12 @@
|
||||
|
||||
export let file: EpisodeFileResource;
|
||||
export let episode: SonarrEpisode | undefined;
|
||||
export let onDelete: () => void;
|
||||
$: backgroundUrl = episode?.images?.[0]?.remoteUrl;
|
||||
|
||||
function handleDeleteFile() {
|
||||
return sonarrApi.deleteSonarrEpisode(file.id || -1).then(() => onDelete());
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog class="flex flex-col relative">
|
||||
@@ -25,7 +34,7 @@
|
||||
<h1 class="header2">{episode?.title}</h1>
|
||||
<h2 class="header1 mb-4">Season {episode?.seasonNumber} Episode {episode?.episodeNumber}</h2>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_min-content] font-medium mb-16
|
||||
class="grid grid-cols-[1fr_max-content] font-medium mb-16
|
||||
[&>*:nth-child(odd)]:text-secondary-300 [&>*:nth-child(even)]:text-right [&>*:nth-child(even)]:text-secondary-100 *:py-1"
|
||||
>
|
||||
<span class="border-b border-secondary-600">Runtime</span>
|
||||
@@ -34,14 +43,12 @@
|
||||
<span class="border-b border-secondary-600">{formatSize(file.size || 0)}</span>
|
||||
<span>Quality</span>
|
||||
<span>{file.quality?.quality?.name}</span>
|
||||
<!-- <span>Asd</span>-->
|
||||
<!-- <span>Asd</span>-->
|
||||
</div>
|
||||
|
||||
<Container class="flex flex-col space-y-4">
|
||||
<Button type="secondary">
|
||||
<Button type="secondary" confirmDanger action={handleDeleteFile}>
|
||||
<Trash size={19} slot="icon" />
|
||||
Delete
|
||||
Delete File
|
||||
</Button>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
@@ -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)))
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<DetachedPage let:handleGoBack let:registrar>
|
||||
@@ -273,49 +305,34 @@
|
||||
</div>
|
||||
</Container>
|
||||
{/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}
|
||||
<Container
|
||||
class="flex-1 bg-secondary-950 pt-8 pb-16 px-32 flex flex-col"
|
||||
on:enter={scrollIntoView({ top: 0 })}
|
||||
on:enter={scrollIntoView({ top: 32 })}
|
||||
>
|
||||
<!-- <h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">Local Files</h1>-->
|
||||
<div class="space-y-16">
|
||||
{#each seasons as season}
|
||||
{@const files = localFiles.filter((f) => f.seasonNumber === season)}
|
||||
<div class="">
|
||||
{@const seasonEpisodes = episodes.filter((e) => e.seasonNumber === season)}
|
||||
{@const seasonFiles = files.filter((f) => f.seasonNumber === season)}
|
||||
{@const seasonDownloads = downloads.filter((d) => d.seasonNumber === season)}
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between">
|
||||
<h2 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">
|
||||
Season {season} Files
|
||||
</h2>
|
||||
<!-- <Checkbox-->
|
||||
<!-- checked={Object.keys(selectedFiles).length-->
|
||||
<!-- ? Object.values(selectedFiles).every(({ file, selected }) => {-->
|
||||
<!-- return file.seasonNumber === season ? selected : true;-->
|
||||
<!-- })-->
|
||||
<!-- : false}-->
|
||||
<!-- on:change={({ detail }) => {-->
|
||||
<!-- selectedFiles = Object.fromEntries(-->
|
||||
<!-- Object.entries(selectedFiles).map(([key, value]) => {-->
|
||||
<!-- if (value.file.seasonNumber === season) {-->
|
||||
<!-- value.selected = detail;-->
|
||||
<!-- }-->
|
||||
<!-- return [key, value];-->
|
||||
<!-- })-->
|
||||
<!-- );-->
|
||||
<!-- }}-->
|
||||
<!-- />-->
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-8">
|
||||
{#each files as file}
|
||||
{@const episode = episodes.find(
|
||||
(e) => e.episodeFileId !== undefined && e.episodeFileId === file.id
|
||||
)}
|
||||
<Container direction="grid" gridCols={2} class="grid grid-cols-2 gap-8">
|
||||
{#each seasonEpisodes as episode}
|
||||
{@const file = seasonFiles.find((f) => f.id === episode.episodeFileId)}
|
||||
{@const download = seasonDownloads.find((d) => d.episodeId === episode.id)}
|
||||
|
||||
<Container
|
||||
class={classNames(
|
||||
'flex space-x-8 items-center text-zinc-300 font-medium',
|
||||
'flex space-x-8 items-center text-zinc-300 font-medium relative overflow-hidden',
|
||||
'px-8 py-4 border-2 border-transparent rounded-xl',
|
||||
{
|
||||
'bg-secondary-800 focus-within:bg-primary-700 focus-within:border-primary-500': true,
|
||||
@@ -324,38 +341,76 @@
|
||||
// 'bg-secondary-800 focus-within:border-zinc-300': !selected
|
||||
}
|
||||
)}
|
||||
on:clickOrSelect={() =>
|
||||
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}
|
||||
<div
|
||||
class="absolute inset-0 bg-secondary-50/10"
|
||||
style={`width: ${
|
||||
(((download.size || 0) - (download.sizeleft || 0)) /
|
||||
(download.size || 1)) *
|
||||
100
|
||||
}%`}
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<h1 class="text-lg">
|
||||
{episode?.episodeNumber}. {episode?.title}
|
||||
</h1>
|
||||
</div>
|
||||
{#if file}
|
||||
<div>
|
||||
{file.mediaInfo?.runTime}
|
||||
{file?.mediaInfo?.runTime}
|
||||
</div>
|
||||
<div>
|
||||
{formatSize(file.size || 0)}
|
||||
{formatSize(file?.size || 0)}
|
||||
</div>
|
||||
<div>
|
||||
{file.quality?.quality?.name}
|
||||
{file?.quality?.quality?.name}
|
||||
</div>
|
||||
{:else if download}
|
||||
<div>
|
||||
{formatSize((download?.size || 0) - (download?.sizeleft || 1))} / {formatSize(
|
||||
download?.size || 0
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{download?.quality?.quality?.name}
|
||||
</div>
|
||||
{/if}
|
||||
</Container>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
<Container direction="horizontal" class="flex mt-8">
|
||||
<Button
|
||||
on:clickOrSelect={() =>
|
||||
createModal(ConfirmDeleteSeasonDialog, {
|
||||
files: files,
|
||||
onComplete: () => (localFilesP = getLocalFiles())
|
||||
})}
|
||||
>
|
||||
{#if seasonFiles?.length}
|
||||
<Button on:clickOrSelect={() => createConfirmDeleteSeasonDialog(seasonFiles)}>
|
||||
<Trash size={19} slot="icon" />
|
||||
Delete Season Files
|
||||
</Button>
|
||||
{/if}
|
||||
{#if seasonDownloads?.length}
|
||||
<Button
|
||||
on:clickOrSelect={() => createConfirmCancelDownloadsDialog(seasonDownloads)}
|
||||
>
|
||||
<Cross1 size={19} slot="icon" />
|
||||
Cancel Season Downloads
|
||||
</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -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<SonarrSeries | undefined>) {
|
||||
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<SonarrSeries | undefined>,
|
||||
sonarrEpisode: Promise<SonarrEpisode | undefined>
|
||||
) {
|
||||
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));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<DetachedPage let:handleGoBack let:registrar>
|
||||
@@ -103,19 +178,21 @@
|
||||
<div class="text-stone-300 font-medium line-clamp-3 opacity-75 max-w-4xl mt-4">
|
||||
{tmdbEpisode?.overview}
|
||||
</div>
|
||||
<Container direction="horizontal" class="flex mt-8">
|
||||
{#await jellyfinEpisode}
|
||||
<Container direction="horizontal" class="flex mt-8 space-x-4">
|
||||
{#await Promise.all([jellyfinEpisode, sonarrEpisode])}
|
||||
<ButtonGhost>Play</ButtonGhost>
|
||||
<ButtonGhost>Play</ButtonGhost>
|
||||
{:then jEpisode}
|
||||
<ButtonGhost>Manage Media</ButtonGhost>
|
||||
<ButtonGhost>Delete Files</ButtonGhost>
|
||||
{:then [jellyfinEpisode]}
|
||||
{#if jellyfinEpisode?.MediaSources?.length}
|
||||
<Button
|
||||
class="mr-4"
|
||||
on:clickOrSelect={() => jEpisode?.Id && playerState.streamJellyfinId(jEpisode.Id)}
|
||||
on:clickOrSelect={() =>
|
||||
jellyfinEpisode?.Id && playerState.streamJellyfinId(jellyfinEpisode.Id)}
|
||||
>
|
||||
Play
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
<Button class="mr-4" disabled={$markAsLoading} on:clickOrSelect={toggleMarkAs}>
|
||||
<Button disabled={$markAsLoading} on:clickOrSelect={toggleMarkAs}>
|
||||
{#if isWatched}
|
||||
Mark as Unwatched
|
||||
{:else}
|
||||
@@ -123,14 +200,36 @@
|
||||
{/if}
|
||||
<Check slot="icon" size={19} />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button action={handleRequestEpisode}>
|
||||
Request
|
||||
<Plus size={19} slot="icon" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/await}
|
||||
<Button
|
||||
class="mr-4"
|
||||
on:clickOrSelect={() =>
|
||||
openEpisodeMediaManager(Number(id), Number(season), Number(episode))}
|
||||
>Manage Media <File slot="icon" size={19} /></Button
|
||||
<!-- <Button-->
|
||||
<!-- on:clickOrSelect={() =>-->
|
||||
<!-- openEpisodeMediaManager(Number(id), Number(season), Number(episode))}-->
|
||||
<!-- >Manage Media <File slot="icon" size={19} /></Button-->
|
||||
<!-- >-->
|
||||
{#await sonarrFiles then files}
|
||||
{#if files?.length}
|
||||
<Button on:clickOrSelect={() => createConfirmDeleteFiles(files)}
|
||||
>Delete Files <Trash slot="icon" size={19} /></Button
|
||||
>
|
||||
<Button class="mr-4">Delete Files <Trash slot="icon" size={19} /></Button>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
{#if PLATFORM_WEB}
|
||||
<Button>
|
||||
Open In TMDB
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
<Button>
|
||||
Open In Jellyfin
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
</Container>
|
||||
{/await}
|
||||
|
||||
Reference in New Issue
Block a user