diff --git a/src/app.css b/src/app.css index bd6213e..c9ae48c 100644 --- a/src/app.css +++ b/src/app.css @@ -1,3 +1,7 @@ @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; + +a { + @apply hover:text-amber-200; +} \ No newline at end of file diff --git a/src/lib/radarr.d.ts b/src/lib/radarr/radarr-api.d.ts similarity index 100% rename from src/lib/radarr.d.ts rename to src/lib/radarr/radarr-api.d.ts diff --git a/src/lib/radarr/radarr.ts b/src/lib/radarr/radarr.ts new file mode 100644 index 0000000..1f338e3 --- /dev/null +++ b/src/lib/radarr/radarr.ts @@ -0,0 +1,113 @@ +import createClient from 'openapi-fetch'; +import { PUBLIC_RADARR_API_KEY } from '$env/static/public'; +import { request } from '$lib/utils'; +import type { paths } from '$lib/radarr/radarr-api'; +import type { components } from '$lib/radarr/radarr-api'; +import type { TmdbMovie, TmdbMovieFull } from '$lib/tmdb-api'; +import { fetchTmdbMovie, TmdbApi } from '$lib/tmdb-api'; + +export type MovieResource = components['schemas']['MovieResource']; +export type MovieFileResource = components['schemas']['MovieFileResource']; +export type ReleaseResource = components['schemas']['ReleaseResource']; + +export interface RadarrMovieOptions { + title: string; + qualityProfileId: number; + minimumAvailability: 'announced' | 'inCinemas' | 'released'; + tags: number[]; + profileId: number; + year: number; + rootFolderPath: string; + tmdbId: number; + monitored?: boolean; + searchNow?: boolean; +} + +export const RadarrApi = createClient({ + baseUrl: 'http://radarr.home', + headers: { + 'X-Api-Key': PUBLIC_RADARR_API_KEY + } +}); + +export const getRadarrMovie = () => + request((tmdbId: string) => + RadarrApi.get('/api/v3/movie', { + params: { + query: { + tmdbId: Number(tmdbId) + } + } + }).then((r) => r.data?.find((m) => (m.tmdbId as any) == tmdbId)) + ); + +export const addRadarrMovie = () => + request(async (tmdbId: string) => { + const tmdbMovie = await fetchTmdbMovie(tmdbId); + const radarrMovie = await getMovieByTmdbIdByTmdbId(tmdbId); + console.log('fetched movies', tmdbMovie, radarrMovie); + + if (radarrMovie?.id) throw new Error('Movie already exists'); + + if (!tmdbMovie) throw new Error('Movie not found'); + + const qualityProfile = 4; + const options: RadarrMovieOptions = { + qualityProfileId: qualityProfile, + profileId: qualityProfile, + rootFolderPath: '/movies', + minimumAvailability: 'announced', + title: tmdbMovie.title, + tmdbId: tmdbMovie.id, + year: Number((await tmdbMovie).release_date.slice(0, 4)), + monitored: false, + tags: [], + searchNow: false + }; + + return RadarrApi.post('/api/v3/movie', { + params: {}, + body: options + }).then((r) => r.data); + }); + +export const getReleases = () => + request((movieId: string) => + RadarrApi.get('/api/v3/release', { params: { query: { movieId: Number(movieId) } } }).then( + (r) => r.data + ) + ); + +export const queueRelease = () => + request((guid: string) => + RadarrApi.post('/api/v3/release', { + params: {}, + body: { + indexerId: 2, + guid + } + }) + ); + +export const getQueuedById = () => + request((id: string) => + getQueue().then((queue) => queue?.records?.filter((r) => (r?.movie?.id as any) == id)) + ); + +const getQueue = () => + RadarrApi.get('/api/v3/queue', { + params: { + query: { + includeMovie: true + } + } + }).then((r) => r.data); + +const getMovieByTmdbIdByTmdbId = (tmdbId: string) => + RadarrApi.get('/api/v3/movie/lookup/tmdb', { + params: { + query: { + tmdbId: Number(tmdbId) + } + } + }).then((r) => r.data as any as MovieResource); diff --git a/src/lib/servarr-api.ts b/src/lib/servarr-api.ts deleted file mode 100644 index bbc2edd..0000000 --- a/src/lib/servarr-api.ts +++ /dev/null @@ -1,18 +0,0 @@ -import createClient from 'openapi-fetch'; -import type { paths as radarrPaths } from '$lib/radarr'; -import type { paths as sonarrPaths } from '$lib/sonarr'; -import { PUBLIC_RADARR_API_KEY, PUBLIC_SONARR_API_KEY } from '$env/static/public'; - -export const radarrApi = createClient({ - baseUrl: 'http://radarr.home', - headers: { - 'X-Api-Key': PUBLIC_RADARR_API_KEY - } -}); - -export const sonarrApi = createClient({ - baseUrl: 'http://sonarr.home', - headers: { - 'X-Api-Key': PUBLIC_SONARR_API_KEY - } -}); diff --git a/src/lib/sonarr.d.ts b/src/lib/sonarr/sonarr-api.d.ts similarity index 100% rename from src/lib/sonarr.d.ts rename to src/lib/sonarr/sonarr-api.d.ts diff --git a/src/lib/sonarr/sonarr.ts b/src/lib/sonarr/sonarr.ts new file mode 100644 index 0000000..a2e691b --- /dev/null +++ b/src/lib/sonarr/sonarr.ts @@ -0,0 +1,10 @@ +import createClient from 'openapi-fetch'; +import type { paths as sonarrPaths } from '$lib/sonarr/sonarr'; +import { PUBLIC_SONARR_API_KEY } from '$env/static/public'; + +export const SonarrApi = createClient({ + baseUrl: 'http://sonarr.home', + headers: { + 'X-Api-Key': PUBLIC_SONARR_API_KEY + } +}); diff --git a/src/lib/tmdb-api.ts b/src/lib/tmdb-api.ts index 670730e..396d870 100644 --- a/src/lib/tmdb-api.ts +++ b/src/lib/tmdb-api.ts @@ -8,25 +8,26 @@ export const TmdbApi = axios.create({ } }); -export async function fetchMovieDetails(imdbId: string | number): Promise { +export async function fetchFullMovieDetails(tmdbId: string): Promise { return { - ...(await TmdbApi.get('/movie/' + imdbId).then((res) => res.data)), - videos: await TmdbApi.get('/movie/' + imdbId + '/videos').then( - (res) => res.data.results - ), - images: await TmdbApi.get('/movie/' + imdbId + '/images').then((res) => { - return { - backdrops: res.data.backdrops, - logos: res.data.logos, - posters: res.data.posters - }; - }), - credits: await TmdbApi.get('/movie/' + imdbId + '/credits').then( + ...(await fetchTmdbMovie(tmdbId)), + videos: await fetchTmdbMovieVideos(tmdbId), + images: await fetchTmdbMovieImages(tmdbId), + credits: await TmdbApi.get('/movie/' + tmdbId + '/credits').then( (res) => res.data.cast ) }; } +export const fetchTmdbMovie = async (tmdbId: string) => + await TmdbApi.get('/movie/' + tmdbId).then((r) => r.data); + +export const fetchTmdbMovieVideos = async (tmdbId: string) => + await TmdbApi.get('/movie/' + tmdbId + '/videos').then((res) => res.data.results); + +export const fetchTmdbMovieImages = async (tmdbId: string) => + await TmdbApi.get('/movie/' + tmdbId + '/images').then((res) => res.data); + export interface TmdbMovieFull extends TmdbMovie { videos: Video[]; images: { diff --git a/src/lib/types.ts b/src/lib/types.ts index 9858073..cd19998 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,3 @@ -import type { components as radarrComponents } from '$lib/radarr'; -import type { components as sonarrComponents } from '$lib/sonarr'; - -export type MovieResource = radarrComponents['schemas']['MovieResource']; +import type { components as sonarrComponents } from '$lib/sonarr/sonarr-api'; export type SeriesResource = sonarrComponents['schemas']['SeriesResource']; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2f3c77f..612d6be 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,7 @@ import type { Genre } from '$lib/tmdb-api'; +import { writable } from 'svelte/store'; -export function getRuntime(minutes: number) { +export function formatMinutes(minutes: number) { const hours = Math.floor(minutes / 60); const mins = Math.floor(minutes % 60); @@ -10,3 +11,49 @@ export function getRuntime(minutes: number) { export function formatGenres(genres: Genre[]) { return genres.map((genre) => genre.name.charAt(0).toUpperCase() + genre.name.slice(1)).join(', '); } + +export function formatSize(size: number) { + const gbs = size / 1024 / 1024 / 1024; + const mbs = size / 1024 / 1024; + + if (gbs >= 1) { + return `${gbs.toFixed(2)} GB`; + } else { + return `${mbs.toFixed(2)} MB`; + } +} + +export function request(fetcher: (arg: A) => Promise, args: A | undefined = undefined) { + const loading = writable(args !== undefined); + const error = writable(null); + const data = writable(null); + const didLoad = writable(false); + + async function load(arg: A) { + loading.set(true); + error.set(null); + + fetcher(arg) + .then((d) => { + console.log('got data', d); + data.set(d); + }) + .catch((e) => error.set(e)) + .finally(() => { + loading.set(false); + didLoad.set(true); + }); + } + + if (args) { + load(args); + } + + return { + loading, + error, + data, + didLoad, + load + }; +} diff --git a/src/routes/+page.ts b/src/routes/+page.ts index 52bce0e..0348c85 100644 --- a/src/routes/+page.ts +++ b/src/routes/+page.ts @@ -1,10 +1,10 @@ import type { PageLoad } from './$types'; -import { radarrApi } from '$lib/servarr-api'; -import { fetchMovieDetails, TmdbApi } from '$lib/tmdb-api'; +import { fetchFullMovieDetails, TmdbApi } from '$lib/tmdb-api'; +import { RadarrApi } from '$lib/radarr/radarr'; export const load = (async () => { const movies = await TmdbApi.get('/movie/popular').then((res) => res.data.results.slice(0, 5)); - const showcases = await Promise.all(movies.map((m: any) => fetchMovieDetails(m.id))); + const showcases = await Promise.all(movies.map((m: any) => fetchFullMovieDetails(m.id))); return { showcases }; }) satisfies PageLoad; diff --git a/src/routes/components/Modal/Modal.svelte b/src/routes/components/Modal/Modal.svelte index fcffa47..ba40def 100644 --- a/src/routes/components/Modal/Modal.svelte +++ b/src/routes/components/Modal/Modal.svelte @@ -11,7 +11,7 @@
diff --git a/src/routes/components/Navbar/TitleSearchModal.svelte b/src/routes/components/Navbar/TitleSearchModal.svelte index c4a4192..d36a023 100644 --- a/src/routes/components/Navbar/TitleSearchModal.svelte +++ b/src/routes/components/Navbar/TitleSearchModal.svelte @@ -6,7 +6,6 @@ import { TmdbApi } from '$lib/tmdb-api'; import type { MultiSearchResponse } from '$lib/tmdb-api'; import { TMDB_IMAGES } from '$lib/constants'; - import { onMount } from 'svelte'; export let visible = false; let searchValue = ''; @@ -41,17 +40,10 @@ }) .finally(() => (fetching = false)); }; - - onMount(() => { - searchValue = 'incepti'; - searchMovie('incepti'); - }); - - $: console.log(results); - +
+ import Modal from '../Modal/Modal.svelte'; + import ModalContent from '../Modal/ModalContent.svelte'; + import { getReleases, queueRelease } from '$lib/radarr/radarr'; + import { formatSize } from '$lib/utils'; + import IconButton from '../IconButton.svelte'; + import { Download } from 'radix-icons-svelte'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher(); + + export let visible = false; + function close() { + visible = false; + } + + export let radarrId; + + const { data: releases, load: loadReleases, didLoad: didLoadReleases } = getReleases(); + const { data, load: downloadRelease } = queueRelease(); + + $: if (visible) loadReleases(radarrId); + + let releasesFiltered = []; + let releasesSkipped = 0; + releases.subscribe((releases: any[]) => { + if (!releases) return; + releasesFiltered = releases + .filter((release) => { + if (release.seeders < 5) return false; + else return true; + }) + .sort((a, b) => b.size - a.size) + .slice(0, 5); + releasesSkipped = releases.length - releasesFiltered.length; + }); + + function handleDownload(guid) { + downloadRelease(guid).then(() => { + dispatch('download'); + }); + } + + + + + {#if releasesFiltered?.length} + Releases: +
+ {#each releasesFiltered as release} +
+
{formatSize(release.size)}
+ handleDownload(release.guid)}> + + +
+ {/each} +
+ {#if releasesSkipped > 0} +
{releasesSkipped} releases hidden
+ {/if} + {:else if !$didLoadReleases} + Loading... + {:else} + No releases found + {/if} +
+
diff --git a/src/routes/components/ResourceDetails/ResourceDetails.svelte b/src/routes/components/ResourceDetails/ResourceDetails.svelte index 34ada01..f6a4481 100644 --- a/src/routes/components/ResourceDetails/ResourceDetails.svelte +++ b/src/routes/components/ResourceDetails/ResourceDetails.svelte @@ -1,14 +1,18 @@ @@ -75,7 +80,7 @@ transition:fade src={'https://www.youtube.com/embed/' + video.key + - '?autoplay=1&mute=1&loop=1&color=white&controls=0&modestbranding=1&playsinline=1&rel=0&enablejsapi=1'} + '?autoplay=1&mute=1&loop=1&controls=0&modestbranding=1&playsinline=1&rel=0&enablejsapi=1'} title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" @@ -131,22 +136,19 @@
- +
- Watch Trailer
@@ -165,9 +167,13 @@
Currently Streaming
-
- {remoteResource.vote_average} TMDB -
+ + {remoteResource.vote_average.toFixed(1)} TMDB +
Starring
{#each remoteResource.credits.slice(0, 5) as a} -
{a.name}
+ {a.name} {/each} + View all...
diff --git a/src/routes/components/ResourceDetails/ResourceLocalDetails.svelte b/src/routes/components/ResourceDetails/ResourceLocalDetails.svelte new file mode 100644 index 0000000..beb64aa --- /dev/null +++ b/src/routes/components/ResourceDetails/ResourceLocalDetails.svelte @@ -0,0 +1,124 @@ + + +
+ {#if !$didLoad} + Loading... + {:else if !$localResource?.movieFile} +
+

No sources found

+

+ No local or remote sources found for this title. You can configure your sources on the sources page. +

+
+ {/if} + {#if $localResource} +
+
+
Local Library
+ + + +
+ {#each $queueResponse || [] as downloadingFile} +
+
+ {downloadingFile.quality.quality.resolution}p + +

{formatSize(downloadingFile.size)} on disk

+ +

+ {downloadingFile.quality.quality.source} +

+ +

+ Completed in {downloadingFile.timeleft} +

+
+ +
+ {/each} + {#each $localResource?.movieFile ? [$localResource.movieFile] : [] as movieFile (movieFile.id)} +
+
+ {movieFile.quality.quality.resolution}p + +

{formatSize(movieFile.size)} on disk

+ +

+ {movieFile.quality.quality.source} +

+ +

+ {movieFile.mediaInfo.videoCodec} +

+
+ +
+ {/each} +
+ {/if} +
+
+ {#if !$localResource && $didLoad && tmdbId} + + {/if} + {#if $localResource?.movieFile} + + {/if} +
+{#if $localResource?.id} + refreshRadarrMovie()} + /> +{/if} diff --git a/src/routes/components/SmallHorizontalPoster/SmallHorizontalPoster.svelte b/src/routes/components/SmallHorizontalPoster/SmallHorizontalPoster.svelte index f1af780..8f56be3 100644 --- a/src/routes/components/SmallHorizontalPoster/SmallHorizontalPoster.svelte +++ b/src/routes/components/SmallHorizontalPoster/SmallHorizontalPoster.svelte @@ -1,6 +1,6 @@ + + diff --git a/src/routes/library/+page.ts b/src/routes/library/+page.ts index 9375b9d..19ce0dd 100644 --- a/src/routes/library/+page.ts +++ b/src/routes/library/+page.ts @@ -1,18 +1,16 @@ import type { PageLoad } from './$types'; -import { radarrApi } from '$lib/servarr-api'; -import { fetchMovieDetails } from '$lib/tmdb-api'; +import { fetchFullMovieDetails } from '$lib/tmdb-api'; +import { RadarrApi } from '$lib/radarr/radarr'; export const load = (async () => { - const radarrMovies = await radarrApi - .get('/api/v3/movie', { - params: {} - }) - .then((r) => r.data); + const radarrMovies = await RadarrApi.get('/api/v3/movie', { + params: {} + }).then((r) => r.data); let tmdbMovies; if (radarrMovies) { tmdbMovies = await Promise.all( - radarrMovies.filter((m) => m.tmdbId).map((m) => fetchMovieDetails(m.tmdbId as any)) + radarrMovies.filter((m) => m.tmdbId).map((m) => fetchFullMovieDetails(String(m.tmdbId))) ); } @@ -21,14 +19,12 @@ export const load = (async () => { return { radarrMovies, tmdbMovies, - downloading: await radarrApi - .get('/api/v3/queue', { - params: { - query: { - includeMovie: true - } + downloading: await RadarrApi.get('/api/v3/queue', { + params: { + query: { + includeMovie: true } - }) - .then((r) => r.data?.records) + } + }).then((r) => r.data?.records) }; }) satisfies PageLoad; diff --git a/src/routes/movie/[id]/+page.svelte b/src/routes/movie/[id]/+page.svelte index 25aa979..509cb64 100644 --- a/src/routes/movie/[id]/+page.svelte +++ b/src/routes/movie/[id]/+page.svelte @@ -1,7 +1,9 @@ + diff --git a/src/routes/movie/[id]/+page.ts b/src/routes/movie/[id]/+page.ts index 7bb830b..2bbed90 100644 --- a/src/routes/movie/[id]/+page.ts +++ b/src/routes/movie/[id]/+page.ts @@ -1,18 +1,16 @@ import type { PageLoad } from './$types'; -import { radarrApi } from '$lib/servarr-api'; -import { fetchMovieDetails, TmdbApi } from '$lib/tmdb-api'; +import { fetchFullMovieDetails, TmdbApi } from '$lib/tmdb-api'; +import { RadarrApi } from '$lib/radarr/radarr'; export const load = (async ({ params }) => { return { - movie: await radarrApi - .get('/api/v3/movie', { - params: { - query: { - tmdbId: Number(params.id) - } + movie: await RadarrApi.get('/api/v3/movie', { + params: { + query: { + tmdbId: Number(params.id) } - }) - .then((res) => res.data?.[0]), - remoteMovie: fetchMovieDetails(params.id) + } + }).then((res) => res.data?.[0]), + remoteMovie: fetchFullMovieDetails(params.id) }; }) satisfies PageLoad; diff --git a/src/routes/people/+page.svelte b/src/routes/people/+page.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/people/+page.ts b/src/routes/people/+page.ts new file mode 100644 index 0000000..e69de29 diff --git a/tailwind.config.js b/tailwind.config.js index c11614f..767d504 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,7 +8,7 @@ export default { display: ['Inter', 'system', 'sans-serif'] }, colors: { - darken: '#070501bf', + darken: '#07050199', 'highlight-dim': '#fde68a20' } }