feat: Managing movie files, requesting movies

This commit is contained in:
Aleksi Lassila
2024-05-31 12:51:07 +03:00
parent 8f561bfa7f
commit 8d779d4d7b
8 changed files with 607 additions and 82 deletions

View File

@@ -8,17 +8,26 @@ import { log } from '../../utils';
import { appState } from '../../stores/app-state.store'; import { appState } from '../../stores/app-state.store';
import type { Api } from '../api.interface'; import type { Api } from '../api.interface';
export const movieAvailabilities = [
// 'tba',
'announced',
'inCinemas',
'released'
// 'deleted'
] as const;
export type RadarrMovie = components['schemas']['MovieResource']; export type RadarrMovie = components['schemas']['MovieResource'];
export type MovieFileResource = components['schemas']['MovieFileResource']; export type MovieFileResource = components['schemas']['MovieFileResource'];
export type RadarrRelease = components['schemas']['ReleaseResource']; export type RadarrRelease = components['schemas']['ReleaseResource'];
export type MovieDownload = components['schemas']['QueueResource'] & { movie: RadarrMovie }; export type MovieDownload = components['schemas']['QueueResource'] & { movie: RadarrMovie };
export type DiskSpaceInfo = components['schemas']['DiskSpaceResource']; export type DiskSpaceInfo = components['schemas']['DiskSpaceResource'];
export type MovieHistoryResource = components['schemas']['HistoryResource']; export type MovieHistoryResource = components['schemas']['HistoryResource'];
export type MovieAvailability = components['schemas']['MovieStatusType'];
export interface RadarrMovieOptions { export interface RadarrMovieOptions {
title: string; title: string;
qualityProfileId: number; qualityProfileId: number;
minimumAvailability: 'announced' | 'inCinemas' | 'released'; minimumAvailability: MovieAvailability;
tags: number[]; tags: number[];
profileId: number; profileId: number;
year: number; year: number;
@@ -68,19 +77,26 @@ export class RadarrApi implements Api<paths> {
}) })
.then((r) => r.data || []) || Promise.resolve([]); .then((r) => r.data || []) || Promise.resolve([]);
addMovieToRadarr = async (tmdbId: number) => { addMovieToRadarr = async (
tmdbId: number,
_options: {
qualityProfileId?: number;
rootFolderPath?: string;
minimumAvailability?: MovieAvailability;
} = {}
) => {
const tmdbMovie = await getTmdbMovie(tmdbId); const tmdbMovie = await getTmdbMovie(tmdbId);
const radarrMovie = await this.lookupRadarrMovieByTmdbId(tmdbId); // const radarrMovie = await this.lookupRadarrMovieByTmdbId(tmdbId);
//
if (radarrMovie?.id) throw new Error('Movie already exists'); // if (radarrMovie?.id) throw new Error('Movie already exists');
if (!tmdbMovie) throw new Error('Movie not found'); if (!tmdbMovie) throw new Error('Movie not found');
const options: RadarrMovieOptions = { const options: RadarrMovieOptions = {
qualityProfileId: get(appState).user?.settings.radarr.qualityProfileId || 0, qualityProfileId: _options.qualityProfileId || this.getSettings()?.qualityProfileId || 0,
profileId: get(appState).user?.settings.radarr?.qualityProfileId || 0, profileId: _options.qualityProfileId || this.getSettings()?.qualityProfileId || 0,
rootFolderPath: get(appState).user?.settings.radarr.rootFolderPath || '', rootFolderPath: _options.rootFolderPath || this.getSettings()?.rootFolderPath || '',
minimumAvailability: 'announced', minimumAvailability: _options.minimumAvailability || 'released',
title: tmdbMovie.title || tmdbMovie.original_title || '', title: tmdbMovie.title || tmdbMovie.original_title || '',
tmdbId: tmdbMovie.id || 0, tmdbId: tmdbMovie.id || 0,
year: Number(tmdbMovie.release_date?.slice(0, 4)), year: Number(tmdbMovie.release_date?.slice(0, 4)),
@@ -117,6 +133,15 @@ export class RadarrApi implements Api<paths> {
return !!deleteResponse?.response.ok; return !!deleteResponse?.response.ok;
}; };
cancelDownloads = async (downloadIds: number[]) =>
this.getClient()
?.DELETE('/api/v3/queue/bulk', {
body: {
ids: downloadIds
}
})
.then((r) => r.response.ok) || Promise.resolve(false);
getReleases = (movieId: number): Promise<RadarrRelease[]> => getReleases = (movieId: number): Promise<RadarrRelease[]> =>
this.getClient() this.getClient()
?.GET('/api/v3/release', { params: { query: { movieId: movieId } } }) ?.GET('/api/v3/release', { params: { query: { movieId: movieId } } })
@@ -165,6 +190,15 @@ export class RadarrApi implements Api<paths> {
}) })
.then((res) => res.response.ok) || Promise.resolve(false); .then((res) => res.response.ok) || Promise.resolve(false);
deleteFiles = (ids: number[]) =>
this.getClient()
?.DELETE('/api/v3/moviefile/bulk', {
body: {
movieFileIds: ids
}
})
.then((res) => res.response.ok) || Promise.resolve(false);
getRadarrDownloads = (): Promise<MovieDownload[]> => getRadarrDownloads = (): Promise<MovieDownload[]> =>
this.getClient() this.getClient()
?.GET('/api/v3/queue', { ?.GET('/api/v3/queue', {

View File

@@ -0,0 +1,279 @@
<script lang="ts">
import Dialog from '../Dialog/Dialog.svelte';
import { TMDB_BACKDROP_SMALL } from '../../constants';
import { type BackEvent, scrollIntoView, type Selectable } from '../../selectable';
import { createLocalStorageStore } from '../../stores/localstorage.store';
import {
movieAvailabilities,
type MovieAvailability,
radarrApi
} from '../../apis/radarr/radarr-api';
import { modalStack } from '../Modal/modal.store';
import classNames from 'classnames';
import { fade } from 'svelte/transition';
import Container from '../../../Container.svelte';
import { capitalize, formatSize } from '../../utils';
import { ArrowRight, Check, Plus } from 'radix-icons-svelte';
import Button from '../Button.svelte';
type AddOptionsStore = {
rootFolderPath: string | null;
qualityProfileId: number | null;
minimumAvailability: MovieAvailability | null;
};
export let backdropUri: string;
export let tmdbId: number;
export let title: string;
export let onComplete: () => void = () => {};
export let modalId: symbol;
$: backgroundUrl = TMDB_BACKDROP_SMALL + backdropUri;
let tab: 'add-to-radarr' | 'root-folders' | 'quality-profiles' | 'monitor-settings' =
'add-to-radarr';
let addToSonarrTab: Selectable;
let rootFoldersTab: Selectable;
let qualityProfilesTab: Selectable;
let monitorSettingsTab: Selectable;
$: {
if (tab === 'add-to-radarr' && addToSonarrTab) addToSonarrTab.focus();
if (tab === 'root-folders' && rootFoldersTab) rootFoldersTab.focus();
if (tab === 'quality-profiles' && qualityProfilesTab) qualityProfilesTab.focus();
if (tab === 'monitor-settings' && monitorSettingsTab) monitorSettingsTab.focus();
}
const addOptionsStore = createLocalStorageStore<AddOptionsStore>('add-to-radarr-options', {
rootFolderPath: null,
qualityProfileId: null,
minimumAvailability: null
});
const sonarrOptions = Promise.all([
radarrApi.getRootFolders(),
radarrApi.getQualityProfiles()
]).then(([rootFolders, qualityProfiles]) => ({ rootFolders, qualityProfiles }));
sonarrOptions.then((s) => {
addOptionsStore.update((prev) => ({
rootFolderPath: prev.rootFolderPath || s.rootFolders[0]?.path || null,
qualityProfileId: prev.qualityProfileId || s.qualityProfiles[0]?.id || null,
minimumAvailability: prev.minimumAvailability || 'released'
}));
});
addOptionsStore.subscribe(() => (tab = 'add-to-radarr'));
function handleAddToSonarr() {
return radarrApi
.addMovieToRadarr(tmdbId, {
rootFolderPath: $addOptionsStore.rootFolderPath || undefined,
qualityProfileId: $addOptionsStore.qualityProfileId || undefined,
minimumAvailability: $addOptionsStore.minimumAvailability || undefined
})
.then((success) => {
if (success) {
modalStack.close(modalId);
onComplete();
}
});
}
function handleBack(e: BackEvent) {
if (tab !== 'add-to-radarr') {
tab = 'add-to-radarr';
e.detail.stopPropagation();
}
}
const tabClasses = (active: boolean, secondary: boolean = false) =>
classNames('flex flex-col transition-all', {
'opacity-0 pointer-events-none': !active,
'-translate-x-10': !active && !secondary,
'translate-x-10': !active && secondary,
'absolute inset-0': secondary
});
const listItemClass = `flex items-center justify-between bg-primary-900 rounded-xl px-6 py-2.5 mb-4 font-medium
border-2 border-transparent focus:border-primary-500 hover:border-primary-500 cursor-pointer group`;
const scaledArrowClas = (hasFocus: boolean) =>
classNames('transition-transform', {
'text-primary-500 translate-x-0.5 scale-110': hasFocus,
'group-hover:text-primary-500 group-hover:translate-x-0.5 group-hover:scale-110': true
});
</script>
<Dialog>
{#if backgroundUrl && tab === 'add-to-radarr'}
<div
transition:fade={{ duration: 200 }}
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}
{#await sonarrOptions then { qualityProfiles, rootFolders }}
{@const selectedRootFolder = rootFolders.find(
(f) => f.path === $addOptionsStore.rootFolderPath
)}
{@const selectedQualityProfile = qualityProfiles.find(
(f) => f.id === $addOptionsStore.qualityProfileId
)}
<Container on:back={handleBack} class="relative">
<Container
trapFocus
bind:selectable={addToSonarrTab}
class={tabClasses(tab === 'add-to-radarr')}
>
<div class="z-10 mb-8">
<div class="h-24" />
<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>
<Container
class={listItemClass}
on:clickOrSelect={() => (tab = 'root-folders')}
let:hasFocus
>
<div>
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">Root Folder</h1>
{selectedRootFolder?.path}
({formatSize(selectedRootFolder?.freeSpace || 0)} left)
</div>
<ArrowRight class={scaledArrowClas(hasFocus)} size={24} />
</Container>
<Container
class={listItemClass}
on:clickOrSelect={() => (tab = 'quality-profiles')}
let:hasFocus
>
<div>
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">
Quality Profile
</h1>
<span>
{selectedQualityProfile?.name}
</span>
</div>
<ArrowRight class={scaledArrowClas(hasFocus)} size={24} />
</Container>
<Container
class={listItemClass}
on:clickOrSelect={() => (tab = 'monitor-settings')}
let:hasFocus
>
<div>
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">
Minimum Availability
</h1>
<span>
{capitalize($addOptionsStore.minimumAvailability || 'released')}
</span>
</div>
<ArrowRight class={scaledArrowClas(hasFocus)} size={24} />
</Container>
<!-- <Container class="flex items-center" on:clickOrSelect={() => (tab = 'quality-profiles')}>-->
<!-- {qualityProfile?.name}-->
<!-- <ArrowRight size={19} />-->
<!-- </Container>-->
<!-- <Container class="flex items-center" on:clickOrSelect={() => (tab = 'monitor-settings')}>-->
<!-- Monitor {$addOptionsStore.monitorSettings}-->
<!-- <ArrowRight size={19} />-->
<!-- </Container>-->
</div>
<Container class="flex flex-col space-y-4">
<Button type="primary-dark" action={handleAddToSonarr} focusOnMount>
<Plus size={19} slot="icon" />
Add to Radarr
</Button>
<Button type="primary-dark" on:clickOrSelect={() => modalStack.close(modalId)}>
Cancel
</Button>
</Container>
</Container>
<Container
trapFocus
class={tabClasses(tab === 'root-folders', true)}
bind:selectable={rootFoldersTab}
>
<h1 class="text-xl text-secondary-100 font-medium mb-4">Root Folder</h1>
<div class="min-h-0 overflow-y-auto scrollbar-hide">
{#each rootFolders as rootFolder}
<Container
class={listItemClass}
on:enter={scrollIntoView({ vertical: 64 })}
on:clickOrSelect={() =>
addOptionsStore.update((prev) => ({ ...prev, rootFolderId: rootFolder.id || 0 }))}
focusOnClick
focusOnMount={$addOptionsStore.rootFolderPath === rootFolder.path}
>
<div>
{rootFolder.path} ({formatSize(rootFolder.freeSpace || 0)} left)
</div>
{#if selectedRootFolder?.id === rootFolder.id}
<Check size={24} />
{/if}
</Container>
{/each}
</div>
</Container>
<Container
trapFocus
class={tabClasses(tab === 'quality-profiles', true)}
bind:selectable={qualityProfilesTab}
>
<h1 class="text-xl text-secondary-100 font-medium mb-4">Quality Profile</h1>
<div class="min-h-0 overflow-y-auto scrollbar-hide">
{#each qualityProfiles as qualityProfile}
<Container
class={listItemClass}
on:enter={scrollIntoView({ vertical: 64 })}
on:clickOrSelect={() =>
addOptionsStore.update((prev) => ({
...prev,
qualityProfileId: qualityProfile.id || 0
}))}
focusOnClick
focusOnMount={$addOptionsStore.qualityProfileId === qualityProfile.id}
>
<div>{qualityProfile.name}</div>
{#if selectedQualityProfile?.id === qualityProfile.id}
<Check size={24} />
{/if}
</Container>
{/each}
</div>
</Container>
<Container
trapFocus
class={tabClasses(tab === 'monitor-settings', true)}
bind:selectable={monitorSettingsTab}
>
<h1 class="text-xl text-secondary-100 font-medium mb-4">Monitor Episodes</h1>
<div class="min-h-0 overflow-y-auto scrollbar-hide">
{#each movieAvailabilities as availibility}
<Container
class={listItemClass}
on:enter={scrollIntoView({ vertical: 64 })}
on:clickOrSelect={() =>
addOptionsStore.update((prev) => ({ ...prev, monitorOptions: availibility }))}
focusOnClick
focusOnMount={$addOptionsStore.minimumAvailability === availibility}
>
<div>{capitalize(availibility)}</div>
{#if $addOptionsStore.minimumAvailability === availibility}
<Check size={24} />
{/if}
</Container>
{/each}
</div>
</Container>
</Container>
{/await}
</Dialog>

View File

@@ -5,34 +5,52 @@
import ReleaseList from './Releases/MMReleasesTab.svelte'; import ReleaseList from './Releases/MMReleasesTab.svelte';
import DownloadList from '../MediaManager/DownloadList.svelte'; import DownloadList from '../MediaManager/DownloadList.svelte';
import FileList from './LocalFiles/MMLocalFilesTab.svelte'; import FileList from './LocalFiles/MMLocalFilesTab.svelte';
import { radarrApi } from '../../apis/radarr/radarr-api'; import { radarrApi, type RadarrMovie } from '../../apis/radarr/radarr-api';
import type { GrabReleaseFn } from './MediaManagerModal';
import type { Release } from '../../apis/combined-types';
import Dialog from '../Dialog/Dialog.svelte';
import MMReleasesTab from './Releases/MMReleasesTab.svelte';
export let radarrItem: RadarrMovie;
export let onGrabRelease: (release: Release) => void = () => {};
export let id: number; // Tmdb ID
export let modalId: symbol; export let modalId: symbol;
export let hidden: boolean; export let hidden: boolean;
const radarrItem = radarrApi.getMovieByTmdbId(id); $: releases = radarrApi.getReleases(radarrItem.id || -1);
const downloads = radarrItem.then((i) => radarrApi.getDownloadsById(i?.id || -1));
const files = radarrItem.then((i) => radarrApi.getFilesByMovieId(i?.id || -1));
const getReleases = () => radarrItem.then((si) => radarrApi.getReleases(si?.id || -1)); const grabRelease: GrabReleaseFn = (release) =>
const selectRelease = () => {}; radarrApi.downloadMovie(release.guid || '', release.indexerId || -1).then((r) => {
onGrabRelease(release);
const cancelDownload = radarrApi.cancelDownloadRadarrMovie; return r;
const handleSelectFile = () => {}; });
</script> </script>
<MMModal {modalId} {hidden}> <Dialog size="full" {modalId} {hidden}>
{#await radarrItem then movie} <MMReleasesTab {releases} {grabRelease}>
{#if !movie} <h1 slot="title">{radarrItem?.title}</h1>
<!-- <MMAddToSonarr />--> <h2 slot="subtitle">
{:else} Releases
<MMMainLayout> <!--{#if season}-->
<h1 slot="title">{movie?.title}</h1> <!-- Season {season} Releases-->
<ReleaseList slot="releases" {getReleases} {selectRelease} /> <!--{:else if 'episodeNumber' in sonarrItem}-->
<DownloadList slot="downloads" {downloads} {cancelDownload} /> <!-- Episode {sonarrItem.episodeNumber} Releases-->
<FileList slot="local-files" {files} {handleSelectFile} /> <!--{/if}-->
</MMMainLayout> </h2>
{/if} </MMReleasesTab>
{/await} </Dialog>
</MMModal>
<!--<MMModal {modalId} {hidden}>-->
<!-- {#await radarrItem then movie}-->
<!-- {#if !movie}-->
<!-- &lt;!&ndash; <MMAddToSonarr />&ndash;&gt;-->
<!-- {:else}-->
<!-- <MMMainLayout>-->
<!-- <h1 slot="title">{movie?.title}</h1>-->
<!-- <ReleaseList slot="releases" {getReleases} {selectRelease} />-->
<!-- <DownloadList slot="downloads" {downloads} {cancelDownload} />-->
<!-- <FileList slot="local-files" {files} {handleSelectFile} />-->
<!-- </MMMainLayout>-->
<!-- {/if}-->
<!-- {/await}-->
<!--</MMModal>-->

View File

@@ -9,9 +9,10 @@
export let season: number | undefined = undefined; export let season: number | undefined = undefined;
export let sonarrItem: SonarrSeries | SonarrEpisode; export let sonarrItem: SonarrSeries | SonarrEpisode;
export let onGrabRelease: (release: Release) => void = () => {};
export let modalId: symbol; export let modalId: symbol;
export let hidden: boolean; export let hidden: boolean;
export let onGrabRelease: (release: Release) => void = () => {};
$: releases = getReleases(season); $: releases = getReleases(season);

View File

@@ -10,12 +10,13 @@
import { formatSize } from '../../utils'; import { formatSize } from '../../utils';
import { Cross1 } from 'radix-icons-svelte'; import { Cross1 } from 'radix-icons-svelte';
import { capitalize } from '../../utils.js'; import { capitalize } from '../../utils.js';
import type { Download } from '../../apis/combined-types';
export let download: EpisodeDownload; export let download: Download;
export let episode: SonarrEpisode | undefined; export let title: string;
export let subtitle: string;
export let backgroundUrl: string;
export let onCancel: () => void; export let onCancel: () => void;
$: backgroundUrl = episode?.images?.[0]?.remoteUrl;
console.log(download);
function handleCancelDownload() { function handleCancelDownload() {
return sonarrApi.cancelDownload(download.id || -1).then(() => onCancel()); return sonarrApi.cancelDownload(download.id || -1).then(() => onCancel());
@@ -33,8 +34,8 @@
{#if backgroundUrl} {#if backgroundUrl}
<div class="h-24" /> <div class="h-24" />
{/if} {/if}
<h1 class="header2">{episode?.title}</h1> <h1 class="header2">{title}</h1>
<h2 class="header1 mb-4">Season {episode?.seasonNumber} Episode {episode?.episodeNumber}</h2> <h2 class="header1 mb-4">{subtitle}</h2>
<div <div
class="grid grid-cols-[1fr_max-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" [&>*:nth-child(odd)]:text-secondary-300 [&>*:nth-child(even)]:text-right [&>*:nth-child(even)]:text-secondary-100 *:py-1"

View File

@@ -1,19 +1,17 @@
<script lang="ts"> <script lang="ts">
import Dialog from '../Dialog/Dialog.svelte'; import Dialog from '../Dialog/Dialog.svelte';
import { import { sonarrApi } from '../../apis/sonarr/sonarr-api';
type EpisodeFileResource,
sonarrApi,
type SonarrEpisode
} from '../../apis/sonarr/sonarr-api';
import Button from '../Button.svelte'; import Button from '../Button.svelte';
import Container from '../../../Container.svelte'; import Container from '../../../Container.svelte';
import { formatSize } from '../../utils'; import { formatSize } from '../../utils';
import { Trash } from 'radix-icons-svelte'; import { Trash } from 'radix-icons-svelte';
import type { FileResource } from '../../apis/combined-types';
export let file: EpisodeFileResource; export let file: FileResource;
export let episode: SonarrEpisode | undefined; export let title = '';
export let subtitle = '';
export let backgroundUrl: string;
export let onDelete: () => void; export let onDelete: () => void;
$: backgroundUrl = episode?.images?.[0]?.remoteUrl;
function handleDeleteFile() { function handleDeleteFile() {
return sonarrApi.deleteSonarrEpisode(file.id || -1).then(() => onDelete()); return sonarrApi.deleteSonarrEpisode(file.id || -1).then(() => onDelete());
@@ -31,8 +29,8 @@
{#if backgroundUrl} {#if backgroundUrl}
<div class="h-24" /> <div class="h-24" />
{/if} {/if}
<h1 class="header2">{episode?.title}</h1> <h1 class="header2">{title}</h1>
<h2 class="header1 mb-4">Season {episode?.seasonNumber} Episode {episode?.episodeNumber}</h2> <h2 class="header1 mb-4">{subtitle}</h2>
<div <div
class="grid grid-cols-[1fr_max-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" [&>*:nth-child(odd)]:text-secondary-300 [&>*:nth-child(even)]:text-right [&>*:nth-child(even)]:text-secondary-100 *:py-1"

View File

@@ -347,13 +347,17 @@
if (file) if (file)
modalStack.create(FileDetailsDialog, { modalStack.create(FileDetailsDialog, {
file, file,
episode, title: episode?.title || '',
subtitle: `Season ${episode?.seasonNumber} Episode ${episode?.episodeNumber}`,
backgroundUrl: episode?.images?.[0]?.remoteUrl || '',
onDelete: () => (sonarrFiles = getFiles(sonarrItem)) onDelete: () => (sonarrFiles = getFiles(sonarrItem))
}); });
else if (download) else if (download)
modalStack.create(DownloadDetailsDialog, { modalStack.create(DownloadDetailsDialog, {
download, download,
episode, title: episode?.title || '',
subtitle: `Season ${episode?.seasonNumber} Episode ${episode?.episodeNumber}`,
backgroundUrl: episode?.images?.[0]?.remoteUrl || '',
onCancel: () => (sonarrDownloads = getDownloads(sonarrItem)) onCancel: () => (sonarrDownloads = getDownloads(sonarrItem))
}); });
}} }}

View File

@@ -4,36 +4,112 @@
import { tmdbApi } from '../apis/tmdb/tmdb-api'; import { tmdbApi } from '../apis/tmdb/tmdb-api';
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../constants'; import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../constants';
import classNames from 'classnames'; import classNames from 'classnames';
import { DotFilled, Download, ExternalLink, File, Play, Plus } from 'radix-icons-svelte'; import {
Cross1,
DotFilled,
Download,
ExternalLink,
File,
Play,
Plus,
Trash
} from 'radix-icons-svelte';
import Button from '../components/Button.svelte'; import Button from '../components/Button.svelte';
import { jellyfinApi } from '../apis/jellyfin/jellyfin-api'; import { jellyfinApi } from '../apis/jellyfin/jellyfin-api';
import { radarrApi } from '../apis/radarr/radarr-api'; import { type MovieDownload, type MovieFileResource, radarrApi } from '../apis/radarr/radarr-api';
import { useActionRequests, useRequest } from '../stores/data.store'; import { useActionRequests, useRequest } from '../stores/data.store';
import DetachedPage from '../components/DetachedPage/DetachedPage.svelte'; import DetachedPage from '../components/DetachedPage/DetachedPage.svelte';
import { openMovieMediaManager } from '../components/Modal/modal.store'; import { createModal, modalStack, openMovieMediaManager } from '../components/Modal/modal.store';
import { playerState } from '../components/VideoPlayer/VideoPlayer'; import { playerState } from '../components/VideoPlayer/VideoPlayer';
import { scrollIntoView } from '../selectable'; import { scrollIntoView } from '../selectable';
import Carousel from '../components/Carousel/Carousel.svelte'; import Carousel from '../components/Carousel/Carousel.svelte';
import TmdbPersonCard from '../components/PersonCard/TmdbPersonCard.svelte'; import TmdbPersonCard from '../components/PersonCard/TmdbPersonCard.svelte';
import TmdbCard from '../components/Card/TmdbCard.svelte'; import TmdbCard from '../components/Card/TmdbCard.svelte';
import MovieMediaManagerModal from '../components/MediaManagerModal/MovieMediaManagerModal.svelte';
import MMAddToRadarrDialog from '../components/MediaManagerModal/MMAddToRadarrDialog.svelte';
import FileDetailsDialog from '../components/SeriesPage/FileDetailsDialog.svelte';
import DownloadDetailsDialog from '../components/SeriesPage/DownloadDetailsDialog.svelte';
import { capitalize, formatSize } from '../utils';
import ConfirmDialog from '../components/Dialog/ConfirmDialog.svelte';
import { TMDB_BACKDROP_SMALL } from '../constants.js';
export let id: string; export let id: string;
const tmdbId = Number(id);
const { promise: movieDataP } = useRequest(tmdbApi.getTmdbMovie, Number(id)); const tmdbMovie = tmdbApi.getTmdbMovie(tmdbId);
$: recommendations = tmdbApi.getMovieRecommendations(Number(id)); $: recommendations = tmdbApi.getMovieRecommendations(tmdbId);
const { promise: jellyfinItemP } = useRequest( const { promise: jellyfinItemP } = useRequest(
(id: string) => jellyfinApi.getLibraryItemFromTmdbId(id), (id: string) => jellyfinApi.getLibraryItemFromTmdbId(id),
id id
); );
const { promise: radarrItemP, send: refreshRadarrItem } = useRequest( const { promise: radarrItemP, send: refreshRadarrItem } = useRequest(
radarrApi.getMovieByTmdbId, radarrApi.getMovieByTmdbId,
Number(id) tmdbId
); );
let radarrItem = radarrApi.getMovieByTmdbId(tmdbId);
$: radarrDownloads = getDownloads(radarrItem);
$: radarrFiles = getFiles(radarrItem);
const { requests, isFetching, data } = useActionRequests({ const { requests, isFetching, data } = useActionRequests({
handleAddToRadarr: (id: number) => handleAddToRadarr: (id: number) =>
radarrApi.addMovieToRadarr(id).finally(() => refreshRadarrItem(Number(id))) radarrApi.addMovieToRadarr(id).finally(() => refreshRadarrItem(tmdbId))
}); });
async function getFiles(item: typeof radarrItem) {
return item.then((item) => (item ? radarrApi.getFilesByMovieId(item?.id || -1) : []));
}
async function getDownloads(item: typeof radarrItem) {
return item.then((item) => (item ? radarrApi.getDownloadsById(item?.id || -1) : []));
}
function handleAddedToRadarr() {
radarrItem = radarrApi.getMovieByTmdbId(tmdbId);
radarrItem.then(
(radarrItem) =>
radarrItem && createModal(MovieMediaManagerModal, { radarrItem, onGrabRelease })
);
}
const onGrabRelease = () => setTimeout(() => (radarrDownloads = getDownloads(radarrItem)), 8000);
async function handleRequest() {
return radarrItem.then((radarrItem) => {
if (radarrItem) createModal(MovieMediaManagerModal, { radarrItem, onGrabRelease });
else
return tmdbMovie.then((tmdbMovie) => {
createModal(MMAddToRadarrDialog, {
title: tmdbMovie?.title || '',
tmdbId,
backdropUri: tmdbMovie?.backdrop_path || '',
onComplete: handleAddedToRadarr
});
});
});
}
function createConfirmDeleteSeasonDialog(files: MovieFileResource[]) {
createModal(ConfirmDialog, {
header: 'Delete Season Files?',
body: `Are you sure you want to delete all ${files.length} file(s)?`, // TODO: These messages could be better, for series too
confirm: () =>
radarrApi
.deleteFiles(files.map((f) => f.id || -1))
.then(() => (radarrFiles = getFiles(radarrItem)))
});
}
function createConfirmCancelDownloadsDialog(downloads: MovieDownload[]) {
createModal(ConfirmDialog, {
header: 'Cancel Season Downloads?',
body: `Are you sure you want to cancel all ${downloads.length} download(s)?`, // TODO: These messages could be better, for series too
confirm: () =>
radarrApi
.cancelDownloads(downloads.map((f) => f.id || -1))
.then(() => (radarrDownloads = getDownloads(radarrItem)))
});
}
</script> </script>
<DetachedPage let:handleGoBack let:registrar> <DetachedPage let:handleGoBack let:registrar>
@@ -43,7 +119,7 @@
on:enter={scrollIntoView({ top: 999 })} on:enter={scrollIntoView({ top: 999 })}
> >
<HeroCarousel <HeroCarousel
urls={$movieDataP.then( urls={tmdbMovie.then(
(movie) => (movie) =>
movie?.images.backdrops movie?.images.backdrops
?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0)) ?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))
@@ -53,7 +129,7 @@
> >
<Container /> <Container />
<div class="h-full flex-1 flex flex-col justify-end"> <div class="h-full flex-1 flex flex-col justify-end">
{#await $movieDataP then movie} {#await tmdbMovie then movie}
{#if movie} {#if movie}
<div <div
class={classNames( class={classNames(
@@ -104,25 +180,29 @@
<Play size={19} slot="icon" /> <Play size={19} slot="icon" />
</Button> </Button>
{/if} {/if}
{#if radarrItem} <Button class="mr-4" action={handleRequest}>
<Button class="mr-4" on:clickOrSelect={() => openMovieMediaManager(Number(id))}> Request
{#if jellyfinItem} <Plus size={19} slot="icon" />
Manage Media </Button>
{:else} <!--{#if radarrItem}-->
Request <!-- <Button class="mr-4" on:clickOrSelect={() => openMovieMediaManager(Number(id))}>-->
{/if} <!-- {#if jellyfinItem}-->
<svelte:component this={jellyfinItem ? File : Download} size={19} slot="icon" /> <!-- Manage Media-->
</Button> <!-- {:else}-->
{:else} <!-- Request-->
<Button <!-- {/if}-->
class="mr-4" <!-- <svelte:component this={jellyfinItem ? File : Download} size={19} slot="icon" />-->
on:clickOrSelect={() => requests.handleAddToRadarr(Number(id))} <!-- </Button>-->
disabled={$isFetching.handleAddToRadarr} <!--{:else}-->
> <!-- <Button-->
Add to Radarr <!-- class="mr-4"-->
<Plus slot="icon" size={19} /> <!-- on:clickOrSelect={() => requests.handleAddToRadarr(Number(id))}-->
</Button> <!-- disabled={$isFetching.handleAddToRadarr}-->
{/if} <!-- >-->
<!-- Add to Radarr-->
<!-- <Plus slot="icon" size={19} />-->
<!-- </Button>-->
<!--{/if}-->
{#if PLATFORM_WEB} {#if PLATFORM_WEB}
<Button class="mr-4"> <Button class="mr-4">
Open In TMDB Open In TMDB
@@ -139,7 +219,7 @@
</HeroCarousel> </HeroCarousel>
</Container> </Container>
<Container on:enter={scrollIntoView({ top: 0 })} class=""> <Container on:enter={scrollIntoView({ top: 0 })} class="">
{#await $movieDataP then movie} {#await tmdbMovie then movie}
<Carousel scrollClass="px-32" class="mb-8"> <Carousel scrollClass="px-32" class="mb-8">
<div slot="header">Show Cast</div> <div slot="header">Show Cast</div>
{#each movie?.credits?.cast?.slice(0, 15) || [] as credit} {#each movie?.credits?.cast?.slice(0, 15) || [] as credit}
@@ -156,7 +236,7 @@
</Carousel> </Carousel>
{/await} {/await}
</Container> </Container>
{#await $movieDataP then movie} {#await tmdbMovie then movie}
<Container class="flex-1 bg-secondary-950 pt-8 px-32" on:enter={scrollIntoView({ top: 0 })}> <Container class="flex-1 bg-secondary-950 pt-8 px-32" on:enter={scrollIntoView({ top: 0 })}>
<h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">More Information</h1> <h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">More Information</h1>
<div class="text-zinc-300 font-medium text-lg flex flex-wrap"> <div class="text-zinc-300 font-medium text-lg flex flex-wrap">
@@ -201,5 +281,115 @@
</div> </div>
</Container> </Container>
{/await} {/await}
{#await Promise.all([tmdbMovie, radarrFiles, radarrDownloads]) then [movie, files, downloads]}
{#if files?.length || downloads?.length}
<Container
class="flex-1 bg-secondary-950 pt-8 pb-16 px-32 flex flex-col"
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-8">
<Container direction="grid" gridCols={2} class="grid grid-cols-2 gap-8">
{#each downloads as download}
<Container
class={classNames(
'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,
'hover:bg-primary-700 hover:border-primary-500 cursor-pointer': true
// 'bg-primary-700 focus-within:border-primary-500': selected,
// 'bg-secondary-800 focus-within:border-zinc-300': !selected
}
)}
on:clickOrSelect={() =>
modalStack.create(DownloadDetailsDialog, {
download,
title: movie?.title || '',
subtitle: download.title || '',
backgroundUrl: TMDB_BACKDROP_SMALL + movie?.backdrop_path || '',
onCancel: () => (radarrDownloads = getDownloads(radarrItem))
})}
on:enter={scrollIntoView({ vertical: 128 })}
focusOnClick
>
<div
class="absolute inset-0 bg-secondary-50/10"
style={`width: ${
(((download.size || 0) - (download.sizeleft || 0)) / (download.size || 1)) *
100
}%`}
/>
<div class="flex-1">
<h1 class="text-lg">
{capitalize(download.status || movie?.title || '')}
</h1>
</div>
<div>
{formatSize((download?.size || 0) - (download?.sizeleft || 1))} / {formatSize(
download?.size || 0
)}
</div>
<div>
{download?.quality?.quality?.name}
</div>
</Container>
{/each}
{#each files as file}
<Container
class={classNames(
'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,
'hover:bg-primary-700 hover:border-primary-500 cursor-pointer': true
// 'bg-primary-700 focus-within:border-primary-500': selected,
// 'bg-secondary-800 focus-within:border-zinc-300': !selected
}
)}
on:clickOrSelect={() =>
modalStack.create(FileDetailsDialog, {
file,
title: movie?.title || '',
subtitle: file.relativePath || '',
backgroundUrl: TMDB_BACKDROP_SMALL + movie?.backdrop_path || '',
onDelete: () => (radarrFiles = getFiles(radarrItem))
})}
on:enter={scrollIntoView({ vertical: 128 })}
focusOnClick
>
<div class="flex-1">
<h1 class="text-lg">
{file?.quality?.quality?.name}
</h1>
</div>
<div>
{file?.mediaInfo?.runTime}
</div>
<div>
{formatSize(file?.size || 0)}
</div>
</Container>
{/each}
</Container>
<Container direction="horizontal" class="flex mt-0">
{#if files?.length}
<Button on:clickOrSelect={() => createConfirmDeleteSeasonDialog(files)}>
<Trash size={19} slot="icon" />
Delete All Files
</Button>
{/if}
{#if downloads?.length}
<Button on:clickOrSelect={() => createConfirmCancelDownloadsDialog(downloads)}>
<Cross1 size={19} slot="icon" />
Cancel All Downloads
</Button>
{/if}
</Container>
</div>
</Container>
{/if}
{/await}
</div> </div>
</DetachedPage> </DetachedPage>