diff --git a/src/lib/apis/radarr/radarr-api.ts b/src/lib/apis/radarr/radarr-api.ts index be8310b..36c0552 100644 --- a/src/lib/apis/radarr/radarr-api.ts +++ b/src/lib/apis/radarr/radarr-api.ts @@ -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 { }) .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 { 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 => this.getClient() ?.GET('/api/v3/release', { params: { query: { movieId: movieId } } }) @@ -165,6 +190,15 @@ export class RadarrApi implements Api { }) .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 => this.getClient() ?.GET('/api/v3/queue', { diff --git a/src/lib/components/MediaManagerModal/MMAddToRadarrDialog.svelte b/src/lib/components/MediaManagerModal/MMAddToRadarrDialog.svelte new file mode 100644 index 0000000..9baf4b7 --- /dev/null +++ b/src/lib/components/MediaManagerModal/MMAddToRadarrDialog.svelte @@ -0,0 +1,279 @@ + + + + {#if backgroundUrl && tab === 'add-to-radarr'} +
+ {/if} + + {#await sonarrOptions then { qualityProfiles, rootFolders }} + {@const selectedRootFolder = rootFolders.find( + (f) => f.path === $addOptionsStore.rootFolderPath + )} + {@const selectedQualityProfile = qualityProfiles.find( + (f) => f.id === $addOptionsStore.qualityProfileId + )} + + +
+
+

Add {title} to Sonarr?

+
+ Before you can fetch episodes, you need to add this series to Sonarr. +
+ (tab = 'root-folders')} + let:hasFocus + > +
+

Root Folder

+ {selectedRootFolder?.path} + ({formatSize(selectedRootFolder?.freeSpace || 0)} left) +
+ +
+ + (tab = 'quality-profiles')} + let:hasFocus + > +
+

+ Quality Profile +

+ + {selectedQualityProfile?.name} + +
+ +
+ + (tab = 'monitor-settings')} + let:hasFocus + > +
+

+ Minimum Availability +

+ + {capitalize($addOptionsStore.minimumAvailability || 'released')} + +
+ +
+ + + + + + + + + +
+ + + + + + + +

Root Folder

+
+ {#each rootFolders as rootFolder} + + addOptionsStore.update((prev) => ({ ...prev, rootFolderId: rootFolder.id || 0 }))} + focusOnClick + focusOnMount={$addOptionsStore.rootFolderPath === rootFolder.path} + > +
+ {rootFolder.path} ({formatSize(rootFolder.freeSpace || 0)} left) +
+ {#if selectedRootFolder?.id === rootFolder.id} + + {/if} +
+ {/each} +
+
+ + +

Quality Profile

+
+ {#each qualityProfiles as qualityProfile} + + addOptionsStore.update((prev) => ({ + ...prev, + qualityProfileId: qualityProfile.id || 0 + }))} + focusOnClick + focusOnMount={$addOptionsStore.qualityProfileId === qualityProfile.id} + > +
{qualityProfile.name}
+ {#if selectedQualityProfile?.id === qualityProfile.id} + + {/if} +
+ {/each} +
+
+ + +

Monitor Episodes

+
+ {#each movieAvailabilities as availibility} + + addOptionsStore.update((prev) => ({ ...prev, monitorOptions: availibility }))} + focusOnClick + focusOnMount={$addOptionsStore.minimumAvailability === availibility} + > +
{capitalize(availibility)}
+ {#if $addOptionsStore.minimumAvailability === availibility} + + {/if} +
+ {/each} +
+
+ + {/await} +
diff --git a/src/lib/components/MediaManagerModal/MovieMediaManagerModal.svelte b/src/lib/components/MediaManagerModal/MovieMediaManagerModal.svelte index 32cb75d..e80cc8e 100644 --- a/src/lib/components/MediaManagerModal/MovieMediaManagerModal.svelte +++ b/src/lib/components/MediaManagerModal/MovieMediaManagerModal.svelte @@ -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; + }); - - {#await radarrItem then movie} - {#if !movie} - - {:else} - -

{movie?.title}

- - - -
- {/if} - {/await} -
+ + +

{radarrItem?.title}

+

+ Releases + + + + + +

+
+
+ + + + + + + + + + + + + + + diff --git a/src/lib/components/MediaManagerModal/SeasonMediaManagerModal.svelte b/src/lib/components/MediaManagerModal/SeasonMediaManagerModal.svelte index 3f9c640..cf49c81 100644 --- a/src/lib/components/MediaManagerModal/SeasonMediaManagerModal.svelte +++ b/src/lib/components/MediaManagerModal/SeasonMediaManagerModal.svelte @@ -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); diff --git a/src/lib/components/SeriesPage/DownloadDetailsDialog.svelte b/src/lib/components/SeriesPage/DownloadDetailsDialog.svelte index a521476..86b6b24 100644 --- a/src/lib/components/SeriesPage/DownloadDetailsDialog.svelte +++ b/src/lib/components/SeriesPage/DownloadDetailsDialog.svelte @@ -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}
{/if} -

{episode?.title}

-

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

+

{title}

+

{subtitle}

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}
{/if} -

{episode?.title}

-

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

+

{title}

+

{subtitle}

(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)) }); }} diff --git a/src/lib/pages/MoviePage.svelte b/src/lib/pages/MoviePage.svelte index f12217f..0633398 100644 --- a/src/lib/pages/MoviePage.svelte +++ b/src/lib/pages/MoviePage.svelte @@ -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))) + }); + } @@ -43,7 +119,7 @@ on:enter={scrollIntoView({ top: 999 })} > movie?.images.backdrops ?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0)) @@ -53,7 +129,7 @@ >
- {#await $movieDataP then movie} + {#await tmdbMovie then movie} {#if movie}
{/if} - {#if radarrItem} - - {:else} - - {/if} + + + + + + + + + + + + + + + + + + + + {#if PLATFORM_WEB} + {/if} + {#if downloads?.length} + + {/if} + +
+ + {/if} + {/await}