feat: Managing movie files, requesting movies
This commit is contained in:
@@ -8,17 +8,26 @@ import { log } from '../../utils';
|
||||
import { appState } from '../../stores/app-state.store';
|
||||
import type { Api } from '../api.interface';
|
||||
|
||||
export const movieAvailabilities = [
|
||||
// 'tba',
|
||||
'announced',
|
||||
'inCinemas',
|
||||
'released'
|
||||
// 'deleted'
|
||||
] as const;
|
||||
|
||||
export type RadarrMovie = components['schemas']['MovieResource'];
|
||||
export type MovieFileResource = components['schemas']['MovieFileResource'];
|
||||
export type RadarrRelease = components['schemas']['ReleaseResource'];
|
||||
export type MovieDownload = components['schemas']['QueueResource'] & { movie: RadarrMovie };
|
||||
export type DiskSpaceInfo = components['schemas']['DiskSpaceResource'];
|
||||
export type MovieHistoryResource = components['schemas']['HistoryResource'];
|
||||
export type MovieAvailability = components['schemas']['MovieStatusType'];
|
||||
|
||||
export interface RadarrMovieOptions {
|
||||
title: string;
|
||||
qualityProfileId: number;
|
||||
minimumAvailability: 'announced' | 'inCinemas' | 'released';
|
||||
minimumAvailability: MovieAvailability;
|
||||
tags: number[];
|
||||
profileId: number;
|
||||
year: number;
|
||||
@@ -68,19 +77,26 @@ export class RadarrApi implements Api<paths> {
|
||||
})
|
||||
.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 radarrMovie = await this.lookupRadarrMovieByTmdbId(tmdbId);
|
||||
|
||||
if (radarrMovie?.id) throw new Error('Movie already exists');
|
||||
// const radarrMovie = await this.lookupRadarrMovieByTmdbId(tmdbId);
|
||||
//
|
||||
// if (radarrMovie?.id) throw new Error('Movie already exists');
|
||||
|
||||
if (!tmdbMovie) throw new Error('Movie not found');
|
||||
|
||||
const options: RadarrMovieOptions = {
|
||||
qualityProfileId: get(appState).user?.settings.radarr.qualityProfileId || 0,
|
||||
profileId: get(appState).user?.settings.radarr?.qualityProfileId || 0,
|
||||
rootFolderPath: get(appState).user?.settings.radarr.rootFolderPath || '',
|
||||
minimumAvailability: 'announced',
|
||||
qualityProfileId: _options.qualityProfileId || this.getSettings()?.qualityProfileId || 0,
|
||||
profileId: _options.qualityProfileId || this.getSettings()?.qualityProfileId || 0,
|
||||
rootFolderPath: _options.rootFolderPath || this.getSettings()?.rootFolderPath || '',
|
||||
minimumAvailability: _options.minimumAvailability || 'released',
|
||||
title: tmdbMovie.title || tmdbMovie.original_title || '',
|
||||
tmdbId: tmdbMovie.id || 0,
|
||||
year: Number(tmdbMovie.release_date?.slice(0, 4)),
|
||||
@@ -117,6 +133,15 @@ export class RadarrApi implements Api<paths> {
|
||||
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[]> =>
|
||||
this.getClient()
|
||||
?.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);
|
||||
|
||||
deleteFiles = (ids: number[]) =>
|
||||
this.getClient()
|
||||
?.DELETE('/api/v3/moviefile/bulk', {
|
||||
body: {
|
||||
movieFileIds: ids
|
||||
}
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
|
||||
getRadarrDownloads = (): Promise<MovieDownload[]> =>
|
||||
this.getClient()
|
||||
?.GET('/api/v3/queue', {
|
||||
|
||||
279
src/lib/components/MediaManagerModal/MMAddToRadarrDialog.svelte
Normal file
279
src/lib/components/MediaManagerModal/MMAddToRadarrDialog.svelte
Normal 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>
|
||||
@@ -5,34 +5,52 @@
|
||||
import ReleaseList from './Releases/MMReleasesTab.svelte';
|
||||
import DownloadList from '../MediaManager/DownloadList.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 hidden: boolean;
|
||||
|
||||
const radarrItem = radarrApi.getMovieByTmdbId(id);
|
||||
const downloads = radarrItem.then((i) => radarrApi.getDownloadsById(i?.id || -1));
|
||||
const files = radarrItem.then((i) => radarrApi.getFilesByMovieId(i?.id || -1));
|
||||
$: releases = radarrApi.getReleases(radarrItem.id || -1);
|
||||
|
||||
const getReleases = () => radarrItem.then((si) => radarrApi.getReleases(si?.id || -1));
|
||||
const selectRelease = () => {};
|
||||
|
||||
const cancelDownload = radarrApi.cancelDownloadRadarrMovie;
|
||||
const handleSelectFile = () => {};
|
||||
const grabRelease: GrabReleaseFn = (release) =>
|
||||
radarrApi.downloadMovie(release.guid || '', release.indexerId || -1).then((r) => {
|
||||
onGrabRelease(release);
|
||||
return r;
|
||||
});
|
||||
</script>
|
||||
|
||||
<MMModal {modalId} {hidden}>
|
||||
{#await radarrItem then movie}
|
||||
{#if !movie}
|
||||
<!-- <MMAddToSonarr />-->
|
||||
{: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>
|
||||
<Dialog size="full" {modalId} {hidden}>
|
||||
<MMReleasesTab {releases} {grabRelease}>
|
||||
<h1 slot="title">{radarrItem?.title}</h1>
|
||||
<h2 slot="subtitle">
|
||||
Releases
|
||||
<!--{#if season}-->
|
||||
<!-- Season {season} Releases-->
|
||||
<!--{:else if 'episodeNumber' in sonarrItem}-->
|
||||
<!-- Episode {sonarrItem.episodeNumber} Releases-->
|
||||
<!--{/if}-->
|
||||
</h2>
|
||||
</MMReleasesTab>
|
||||
</Dialog>
|
||||
|
||||
<!--<MMModal {modalId} {hidden}>-->
|
||||
<!-- {#await radarrItem then movie}-->
|
||||
<!-- {#if !movie}-->
|
||||
<!-- <!– <MMAddToSonarr />–>-->
|
||||
<!-- {: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>-->
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
|
||||
export let season: number | undefined = undefined;
|
||||
export let sonarrItem: SonarrSeries | SonarrEpisode;
|
||||
export let onGrabRelease: (release: Release) => void = () => {};
|
||||
|
||||
export let modalId: symbol;
|
||||
export let hidden: boolean;
|
||||
export let onGrabRelease: (release: Release) => void = () => {};
|
||||
|
||||
$: releases = getReleases(season);
|
||||
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
import { formatSize } from '../../utils';
|
||||
import { Cross1 } from 'radix-icons-svelte';
|
||||
import { capitalize } from '../../utils.js';
|
||||
import type { Download } from '../../apis/combined-types';
|
||||
|
||||
export let download: EpisodeDownload;
|
||||
export let episode: SonarrEpisode | undefined;
|
||||
export let download: Download;
|
||||
export let title: string;
|
||||
export let subtitle: string;
|
||||
export let backgroundUrl: string;
|
||||
export let onCancel: () => void;
|
||||
$: backgroundUrl = episode?.images?.[0]?.remoteUrl;
|
||||
console.log(download);
|
||||
|
||||
function handleCancelDownload() {
|
||||
return sonarrApi.cancelDownload(download.id || -1).then(() => onCancel());
|
||||
@@ -33,8 +34,8 @@
|
||||
{#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>
|
||||
<h1 class="header2">{title}</h1>
|
||||
<h2 class="header1 mb-4">{subtitle}</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"
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<script lang="ts">
|
||||
import Dialog from '../Dialog/Dialog.svelte';
|
||||
import {
|
||||
type EpisodeFileResource,
|
||||
sonarrApi,
|
||||
type SonarrEpisode
|
||||
} from '../../apis/sonarr/sonarr-api';
|
||||
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
|
||||
import Button from '../Button.svelte';
|
||||
import Container from '../../../Container.svelte';
|
||||
import { formatSize } from '../../utils';
|
||||
import { Trash } from 'radix-icons-svelte';
|
||||
import type { FileResource } from '../../apis/combined-types';
|
||||
|
||||
export let file: EpisodeFileResource;
|
||||
export let episode: SonarrEpisode | undefined;
|
||||
export let file: FileResource;
|
||||
export let title = '';
|
||||
export let subtitle = '';
|
||||
export let backgroundUrl: string;
|
||||
export let onDelete: () => void;
|
||||
$: backgroundUrl = episode?.images?.[0]?.remoteUrl;
|
||||
|
||||
function handleDeleteFile() {
|
||||
return sonarrApi.deleteSonarrEpisode(file.id || -1).then(() => onDelete());
|
||||
@@ -31,8 +29,8 @@
|
||||
{#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>
|
||||
<h1 class="header2">{title}</h1>
|
||||
<h2 class="header1 mb-4">{subtitle}</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"
|
||||
|
||||
@@ -347,13 +347,17 @@
|
||||
if (file)
|
||||
modalStack.create(FileDetailsDialog, {
|
||||
file,
|
||||
episode,
|
||||
title: episode?.title || '',
|
||||
subtitle: `Season ${episode?.seasonNumber} Episode ${episode?.episodeNumber}`,
|
||||
backgroundUrl: episode?.images?.[0]?.remoteUrl || '',
|
||||
onDelete: () => (sonarrFiles = getFiles(sonarrItem))
|
||||
});
|
||||
else if (download)
|
||||
modalStack.create(DownloadDetailsDialog, {
|
||||
download,
|
||||
episode,
|
||||
title: episode?.title || '',
|
||||
subtitle: `Season ${episode?.seasonNumber} Episode ${episode?.episodeNumber}`,
|
||||
backgroundUrl: episode?.images?.[0]?.remoteUrl || '',
|
||||
onCancel: () => (sonarrDownloads = getDownloads(sonarrItem))
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -4,36 +4,112 @@
|
||||
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 } from 'radix-icons-svelte';
|
||||
import {
|
||||
Cross1,
|
||||
DotFilled,
|
||||
Download,
|
||||
ExternalLink,
|
||||
File,
|
||||
Play,
|
||||
Plus,
|
||||
Trash
|
||||
} from 'radix-icons-svelte';
|
||||
import Button from '../components/Button.svelte';
|
||||
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 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 { scrollIntoView } from '../selectable';
|
||||
import Carousel from '../components/Carousel/Carousel.svelte';
|
||||
import TmdbPersonCard from '../components/PersonCard/TmdbPersonCard.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;
|
||||
const tmdbId = Number(id);
|
||||
|
||||
const { promise: movieDataP } = useRequest(tmdbApi.getTmdbMovie, Number(id));
|
||||
$: recommendations = tmdbApi.getMovieRecommendations(Number(id));
|
||||
const tmdbMovie = tmdbApi.getTmdbMovie(tmdbId);
|
||||
$: recommendations = tmdbApi.getMovieRecommendations(tmdbId);
|
||||
const { promise: jellyfinItemP } = useRequest(
|
||||
(id: string) => jellyfinApi.getLibraryItemFromTmdbId(id),
|
||||
id
|
||||
);
|
||||
const { promise: radarrItemP, send: refreshRadarrItem } = useRequest(
|
||||
radarrApi.getMovieByTmdbId,
|
||||
Number(id)
|
||||
tmdbId
|
||||
);
|
||||
|
||||
let radarrItem = radarrApi.getMovieByTmdbId(tmdbId);
|
||||
$: radarrDownloads = getDownloads(radarrItem);
|
||||
$: radarrFiles = getFiles(radarrItem);
|
||||
|
||||
const { requests, isFetching, data } = useActionRequests({
|
||||
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>
|
||||
|
||||
<DetachedPage let:handleGoBack let:registrar>
|
||||
@@ -43,7 +119,7 @@
|
||||
on:enter={scrollIntoView({ top: 999 })}
|
||||
>
|
||||
<HeroCarousel
|
||||
urls={$movieDataP.then(
|
||||
urls={tmdbMovie.then(
|
||||
(movie) =>
|
||||
movie?.images.backdrops
|
||||
?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))
|
||||
@@ -53,7 +129,7 @@
|
||||
>
|
||||
<Container />
|
||||
<div class="h-full flex-1 flex flex-col justify-end">
|
||||
{#await $movieDataP then movie}
|
||||
{#await tmdbMovie then movie}
|
||||
{#if movie}
|
||||
<div
|
||||
class={classNames(
|
||||
@@ -104,25 +180,29 @@
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if radarrItem}
|
||||
<Button class="mr-4" on:clickOrSelect={() => openMovieMediaManager(Number(id))}>
|
||||
{#if jellyfinItem}
|
||||
Manage Media
|
||||
{:else}
|
||||
Request
|
||||
{/if}
|
||||
<svelte:component this={jellyfinItem ? File : Download} size={19} slot="icon" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
class="mr-4"
|
||||
on:clickOrSelect={() => requests.handleAddToRadarr(Number(id))}
|
||||
disabled={$isFetching.handleAddToRadarr}
|
||||
>
|
||||
Add to Radarr
|
||||
<Plus slot="icon" size={19} />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button class="mr-4" action={handleRequest}>
|
||||
Request
|
||||
<Plus size={19} slot="icon" />
|
||||
</Button>
|
||||
<!--{#if radarrItem}-->
|
||||
<!-- <Button class="mr-4" on:clickOrSelect={() => openMovieMediaManager(Number(id))}>-->
|
||||
<!-- {#if jellyfinItem}-->
|
||||
<!-- Manage Media-->
|
||||
<!-- {:else}-->
|
||||
<!-- Request-->
|
||||
<!-- {/if}-->
|
||||
<!-- <svelte:component this={jellyfinItem ? File : Download} size={19} slot="icon" />-->
|
||||
<!-- </Button>-->
|
||||
<!--{:else}-->
|
||||
<!-- <Button-->
|
||||
<!-- class="mr-4"-->
|
||||
<!-- on:clickOrSelect={() => requests.handleAddToRadarr(Number(id))}-->
|
||||
<!-- disabled={$isFetching.handleAddToRadarr}-->
|
||||
<!-- >-->
|
||||
<!-- Add to Radarr-->
|
||||
<!-- <Plus slot="icon" size={19} />-->
|
||||
<!-- </Button>-->
|
||||
<!--{/if}-->
|
||||
{#if PLATFORM_WEB}
|
||||
<Button class="mr-4">
|
||||
Open In TMDB
|
||||
@@ -139,7 +219,7 @@
|
||||
</HeroCarousel>
|
||||
</Container>
|
||||
<Container on:enter={scrollIntoView({ top: 0 })} class="">
|
||||
{#await $movieDataP then movie}
|
||||
{#await tmdbMovie then movie}
|
||||
<Carousel scrollClass="px-32" class="mb-8">
|
||||
<div slot="header">Show Cast</div>
|
||||
{#each movie?.credits?.cast?.slice(0, 15) || [] as credit}
|
||||
@@ -156,7 +236,7 @@
|
||||
</Carousel>
|
||||
{/await}
|
||||
</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 })}>
|
||||
<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">
|
||||
@@ -201,5 +281,115 @@
|
||||
</div>
|
||||
</Container>
|
||||
{/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>
|
||||
</DetachedPage>
|
||||
|
||||
Reference in New Issue
Block a user