feat: Implemented jellyfin api

This commit is contained in:
Aleksi Lassila
2024-03-28 00:49:43 +02:00
parent da2b4ee6d5
commit 71b70e5868
15 changed files with 157 additions and 64 deletions

2
.idea/reiverr.iml generated
View File

@@ -9,6 +9,8 @@
<excludeFolder url="file://$MODULE_DIR$/.svelte-kit/output" /> <excludeFolder url="file://$MODULE_DIR$/.svelte-kit/output" />
<excludeFolder url="file://$MODULE_DIR$/dist" /> <excludeFolder url="file://$MODULE_DIR$/dist" />
<excludeFolder url="file://$MODULE_DIR$/tizen/dist" /> <excludeFolder url="file://$MODULE_DIR$/tizen/dist" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/tizen/.buildResult" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

View File

@@ -1,11 +1,15 @@
import type createClient from 'openapi-fetch'; import type createClient from 'openapi-fetch';
export abstract class Api<Paths extends NonNullable<unknown>> { export interface Api<Paths extends NonNullable<unknown>> {
protected abstract baseUrl: string; getClient(): ReturnType<typeof createClient<Paths>>;
protected abstract client: ReturnType<typeof createClient<Paths>>;
protected abstract isLoggedIn: boolean;
getApi() {
return this.client;
}
} }
// export abstract class Api<Paths extends NonNullable<unknown>> {
// protected abstract baseUrl: string;
// protected abstract client: ReturnType<typeof createClient<Paths>>;
// protected abstract isLoggedIn: boolean;
//
// getApi() {
// return this.client;
// }
// }

View File

@@ -4,11 +4,82 @@ import { get } from 'svelte/store';
import type { components, paths } from './jellyfin.generated'; import type { components, paths } from './jellyfin.generated';
import { settings } from '../../stores/settings.store'; import { settings } from '../../stores/settings.store';
import type { DeviceProfile } from './playback-profiles'; import type { DeviceProfile } from './playback-profiles';
import type { Api } from '../api.interface';
import { appState } from '../../stores/app-state.store';
export type JellyfinItem = components['schemas']['BaseItemDto']; export type JellyfinItem = components['schemas']['BaseItemDto'];
export const JELLYFIN_DEVICE_ID = 'Reiverr Client'; export const JELLYFIN_DEVICE_ID = 'Reiverr Client';
export class JellyfinApi implements Api<paths> {
getClient() {
const jellyfinSettings = get(appState).user?.settings.jellyfin;
const baseUrl = jellyfinSettings?.baseUrl;
const apiKey = jellyfinSettings?.apiKey;
return createClient<paths>({
baseUrl,
headers: {
Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${apiKey}"`
}
});
}
getUserId() {
return get(appState).user?.settings.jellyfin.userId || '';
}
async getContinueWatching(): Promise<JellyfinItem[] | undefined> {
return this.getClient()
.GET('/Users/{userId}/Items/Resume', {
params: {
path: {
userId: this.getUserId()
},
query: {
mediaTypes: ['Video'],
fields: ['ProviderIds', 'Genres']
}
}
})
.then((r) => r.data?.Items || []);
}
async getLibraryItems() {
return (
this.getClient()
.GET('/Users/{userId}/Items', {
params: {
path: {
userId: this.getUserId()
},
query: {
hasTmdbId: true,
recursive: true,
includeItemTypes: ['Movie', 'Series'],
fields: ['ProviderIds', 'Genres', 'DateLastMediaAdded', 'DateCreated']
}
}
})
.then((r) => r.data?.Items || []) || Promise.resolve([])
);
}
getPosterUrl(item: JellyfinItem, quality = 100, original = false) {
return item.ImageTags?.Primary
? `${get(appState).user?.settings.jellyfin.baseUrl}/Items/${
item?.Id
}/Images/Primary?quality=${quality}${original ? '' : '&fillWidth=432'}&tag=${
item?.ImageTags?.Primary
}`
: '';
}
}
export const jellyfinApi = new JellyfinApi();
export const getReiverrApiClient = jellyfinApi.getClient;
/*
function getJellyfinApi() { function getJellyfinApi() {
const baseUrl = get(settings)?.jellyfin.baseUrl; const baseUrl = get(settings)?.jellyfin.baseUrl;
const apiKey = get(settings)?.jellyfin.apiKey; const apiKey = get(settings)?.jellyfin.apiKey;
@@ -51,23 +122,6 @@ export const getJellyfinNextUp = async () =>
}) })
.then((r) => r.data?.Items || []); .then((r) => r.data?.Items || []);
export const getJellyfinItems = async () =>
getJellyfinApi()
?.GET('/Users/{userId}/Items', {
params: {
path: {
userId: get(settings)?.jellyfin.userId || ''
},
query: {
hasTmdbId: true,
recursive: true,
includeItemTypes: ['Movie', 'Series'],
fields: ['ProviderIds', 'Genres', 'DateLastMediaAdded', 'DateCreated']
}
}
})
.then((r) => r.data?.Items || []) || Promise.resolve([]);
// export const getJellyfinSeries = () => // export const getJellyfinSeries = () =>
// JellyfinApi.get('/Users/{userId}/Items', { // JellyfinApi.get('/Users/{userId}/Items', {
// params: { // params: {
@@ -285,13 +339,6 @@ export const getJellyfinUsers = async (
.then((res) => res.data || []) .then((res) => res.data || [])
.catch(() => []); .catch(() => []);
export const getJellyfinPosterUrl = (item: JellyfinItem, quality = 100, original = false) =>
item.ImageTags?.Primary
? `${get(settings).jellyfin.baseUrl}/Items/${item?.Id}/Images/Primary?quality=${quality}${
original ? '' : '&fillWidth=432'
}&tag=${item?.ImageTags?.Primary}`
: '';
export const getJellyfinBackdrop = (item: JellyfinItem, quality = 100) => { export const getJellyfinBackdrop = (item: JellyfinItem, quality = 100) => {
if (item.BackdropImageTags?.length) { if (item.BackdropImageTags?.length) {
return `${get(settings).jellyfin.baseUrl}/Items/${ return `${get(settings).jellyfin.baseUrl}/Items/${
@@ -303,3 +350,4 @@ export const getJellyfinBackdrop = (item: JellyfinItem, quality = 100) => {
}/Images/Primary?quality=${quality}&tag=${item?.ImageTags?.Primary}`; }/Images/Primary?quality=${quality}&tag=${item?.ImageTags?.Primary}`;
} }
}; };
*/

View File

@@ -2,11 +2,9 @@ import createClient from 'openapi-fetch';
import type { paths } from './reiverr.generated'; import type { paths } from './reiverr.generated';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { appState } from '../../stores/app-state.store'; import { appState } from '../../stores/app-state.store';
import type { Api } from '../api.interface';
interface ApiInterface<Paths extends NonNullable<unknown>> { export class ReiverrApi implements Api<paths> {
getClient(): ReturnType<typeof createClient<Paths>>;
}
export class ReiverrApi implements ApiInterface<paths> {
getClient(basePath?: string) { getClient(basePath?: string) {
const token = get(appState).token; const token = get(appState).token;

View File

@@ -2,7 +2,7 @@
import { import {
setJellyfinItemUnwatched, setJellyfinItemUnwatched,
setJellyfinItemWatched setJellyfinItemWatched
} from '../../../lib/apis/jellyfin/jellyfinApi'; } from '../../apis/jellyfin/jellyfin-api';
import { jellyfinItemsStore } from '../../../lib/stores/data.store'; import { jellyfinItemsStore } from '../../../lib/stores/data.store';
import classNames from 'classnames'; import classNames from 'classnames';
import { Check } from 'radix-icons-svelte'; import { Check } from 'radix-icons-svelte';

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getJellyfinEpisodes, type JellyfinItem } from '../../lib/apis/jellyfin/jellyfinApi'; import { getJellyfinEpisodes, type JellyfinItem } from '../apis/jellyfin/jellyfin-api';
import { addSeriesToSonarr, type SonarrSeries } from '../../lib/apis/sonarr/sonarrApi'; import { addSeriesToSonarr, type SonarrSeries } from '../../lib/apis/sonarr/sonarrApi';
import { import {
getTmdbIdFromTvdbId, getTmdbIdFromTvdbId,

View File

@@ -6,7 +6,7 @@
reportJellyfinPlaybackProgress, reportJellyfinPlaybackProgress,
reportJellyfinPlaybackStarted, reportJellyfinPlaybackStarted,
reportJellyfinPlaybackStopped reportJellyfinPlaybackStopped
} from '../../apis/jellyfin/jellyfinApi'; } from '../../apis/jellyfin/jellyfin-api';
import getDeviceProfile from '../../apis/jellyfin/playback-profiles'; import getDeviceProfile from '../../apis/jellyfin/playback-profiles';
import { getQualities } from '../../apis/jellyfin/qualities'; import { getQualities } from '../../apis/jellyfin/qualities';
import { settings } from '../../stores/settings.store'; import { settings } from '../../stores/settings.store';

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { jellyfinApi, type JellyfinItem } from '../../apis/jellyfin/jellyfin-api';
import Card from './Card.svelte';
export let item: JellyfinItem;
// return {
// tmdbId: Number(item.ProviderIds?.Tmdb) || 0,
// jellyfinId: item.Id,
// title: item.Name || undefined,
// subtitle: item.Genres?.join(', ') || undefined,
// backdropUrl: getJellyfinPosterUrl(item, 80),
// size: 'dynamic',
// ...(item.Type === 'Movie' ? { type: 'movie' } : { type: 'series' }),
// orientation: 'portrait',
// rating: item.CommunityRating || undefined
// };
</script>
<Card
tmdbId={Number(item.ProviderIds?.Tmdb) || 0}
jellyfinId={item.Id}
title={item.Name || undefined}
subtitle={item.Genres?.join(', ') || undefined}
backdropUrl={jellyfinApi.getPosterUrl(item, 80)}
size="dynamic"
type={item.Type === 'Movie' ? 'movie' : 'series'}
orientation="portrait"
rating={item.CommunityRating || undefined}
/>

View File

@@ -0,0 +1,3 @@
<div class="grid gap-x-4 gap-y-8 grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
<slot />
</div>

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition';
import IconButton from '../IconButton.svelte'; import IconButton from '../IconButton.svelte';
import { ChevronLeft, ChevronRight } from 'radix-icons-svelte'; import { ChevronLeft, ChevronRight } from 'radix-icons-svelte';
import classNames from 'classnames'; import classNames from 'classnames';
@@ -13,14 +12,15 @@
export let scrollClass = ''; export let scrollClass = '';
</script> </script>
<div class={classNames('flex flex-col gap-4 group/carousel', $$restProps.class)}> <div class={classNames('flex flex-col group/carousel', $$restProps.class)}>
<div class={'flex justify-between items-center gap-4 ' + scrollClass}> <div class={'flex justify-between items-center mb-2 ' + scrollClass}>
<slot name="title"> <slot name="title">
<div class="font-semibold text-xl">{heading}</div> <div class="font-semibold text-xl">{heading}</div>
</slot> </slot>
<div <div
class={classNames( class={classNames(
'flex gap-2 sm:opacity-0 transition-opacity sm:group-hover/carousel:opacity-100', 'flex gap-2 ml-4',
//'sm:opacity-0 transition-opacity sm:group-hover/carousel:opacity-100',
{ {
hidden: (carousel?.scrollWidth || 0) === (carousel?.clientWidth || 0) hidden: (carousel?.scrollWidth || 0) === (carousel?.clientWidth || 0)
} }
@@ -47,7 +47,7 @@
<Container horizontal> <Container horizontal>
<div <div
class={classNames( class={classNames(
'flex overflow-x-scroll items-center overflow-y-visible relative scrollbar-hide p-1', 'flex overflow-x-scroll items-center overflow-y-visible relative scrollbar-hide',
scrollClass scrollClass
)} )}
bind:this={carousel} bind:this={carousel}
@@ -59,14 +59,12 @@
</Container> </Container>
{#if scrollX > 50} {#if scrollX > 50}
<div <div
transition:fade={{ duration: 200 }}
class={'absolute inset-y-0 left-0 w-0 sm:w-16 md:w-24 bg-gradient-to-r ' + class={'absolute inset-y-0 left-0 w-0 sm:w-16 md:w-24 bg-gradient-to-r ' +
gradientFromColor} gradientFromColor}
/> />
{/if} {/if}
{#if carousel && scrollX < carousel?.scrollWidth - carousel?.clientWidth - 50} {#if carousel && scrollX < carousel?.scrollWidth - carousel?.clientWidth - 50}
<div <div
transition:fade={{ duration: 200 }}
class={'absolute inset-y-0 right-0 w-0 sm:w-16 md:w-24 bg-gradient-to-l ' + class={'absolute inset-y-0 right-0 w-0 sm:w-16 md:w-24 bg-gradient-to-l ' +
gradientFromColor} gradientFromColor}
/> />

View File

@@ -125,7 +125,7 @@
{/await} {/await}
</div> </div>
</div> </div>
<Container class="z-10"> <Container class="z-10 pt-8">
<slot /> <slot />
</Container> </Container>
</Container> </Container>

View File

@@ -6,7 +6,7 @@
reportJellyfinPlaybackProgress, reportJellyfinPlaybackProgress,
reportJellyfinPlaybackStarted, reportJellyfinPlaybackStarted,
reportJellyfinPlaybackStopped reportJellyfinPlaybackStopped
} from '../../apis/jellyfin/jellyfinApi'; } from '../../apis/jellyfin/jellyfin-api';
import getDeviceProfile from '../../apis/jellyfin/playback-profiles'; import getDeviceProfile from '../../apis/jellyfin/playback-profiles';
import { getQualities } from '../../apis/jellyfin/qualities'; import { getQualities } from '../../apis/jellyfin/qualities';
import { settings } from '../../stores/settings.store'; import { settings } from '../../stores/settings.store';

View File

@@ -6,7 +6,7 @@
import type { TitleType } from '../types'; import type { TitleType } from '../types';
import type { ComponentProps } from 'svelte'; import type { ComponentProps } from 'svelte';
import Poster from '../components/Card/Card.svelte'; import Poster from '../components/Card/Card.svelte';
import { getJellyfinItems, type JellyfinItem } from '../apis/jellyfin/jellyfinApi'; import { type JellyfinItem } from '../apis/jellyfin/jellyfin-api';
import { jellyfinItemsStore } from '../stores/data.store'; import { jellyfinItemsStore } from '../stores/data.store';
import Carousel from '../components/Carousel/Carousel.svelte'; import Carousel from '../components/Carousel/Carousel.svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
@@ -62,12 +62,12 @@
const fetchPopularMovies = () => getTmdbPopularMovies(); const fetchPopularMovies = () => getTmdbPopularMovies();
const fetchLibraryItems = async () => { // const fetchLibraryItems = async () => {
const items = await getJellyfinItems(); // const items = await getJellyfinItems();
const props = await fetchCardProps(items, 'series'); // const props = await fetchCardProps(items, 'series');
console.log('JellyfinItems', items, props); // console.log('JellyfinItems', items, props);
return props; // return props;
}; // };
function parseIncludedLanguages(includedLanguages: string) { function parseIncludedLanguages(includedLanguages: string) {
return includedLanguages.replace(' ', '').split(',').join('|'); return includedLanguages.replace(' ', '').split(',').join('|');
@@ -76,8 +76,8 @@
<Container focusOnMount> <Container focusOnMount>
<HeroShowcase items={getTmdbPopularMovies().then(getShowcasePropsFromTmdb)}> <HeroShowcase items={getTmdbPopularMovies().then(getShowcasePropsFromTmdb)}>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16"> <Carousel scrollClass="px-8">
<div slot="title" class="text-lg font-semibold text-zinc-300"> <div slot="title" class="text-xl font-semibold text-zinc-300">
{$_('discover.streamingNow')} {$_('discover.streamingNow')}
</div> </div>
{#await fetchNowStreaming()} {#await fetchNowStreaming()}

View File

@@ -1,9 +1,13 @@
<script lang="ts"> <script lang="ts">
import { settings } from '../stores/settings.store'; import { settings } from '../stores/settings.store';
import { jellyfinItemsStore } from '../stores/data.store'; import { jellyfinItemsStore } from '../stores/data.store';
import Carousel from '../components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '../components/Carousel/CarouselPlaceholderItems.svelte'; import CarouselPlaceholderItems from '../components/Carousel/CarouselPlaceholderItems.svelte';
import Container from '../../Container.svelte'; import Container from '../../Container.svelte';
import { jellyfinApi } from '../apis/jellyfin/jellyfin-api';
import CardGrid from '../components/CardGrid.svelte';
import JellyfinCard from '../components/Card/JellyfinCard.svelte';
const libraryItemsP = jellyfinApi.getLibraryItems();
settings.update((prev) => ({ settings.update((prev) => ({
...prev, ...prev,
@@ -21,9 +25,15 @@
}); });
</script> </script>
<Container focusOnMount> <Container focusOnMount class="pl-20">
<div>LibraryPage</div> <div>LibraryPage</div>
<Carousel> <CardGrid>
<CarouselPlaceholderItems /> {#await libraryItemsP}
</Carousel> <CarouselPlaceholderItems />
{:then items}
{#each items as item}
<JellyfinCard {item} />
{/each}
{/await}
</CardGrid>
</Container> </Container>

View File

@@ -1,6 +1,6 @@
import { derived, writable } from 'svelte/store'; import { derived, writable } from 'svelte/store';
import { settings } from './settings.store'; import { settings } from './settings.store';
import { getJellyfinItems, type JellyfinItem } from '../apis/jellyfin/jellyfinApi'; import { jellyfinApi, type JellyfinItem } from '../apis/jellyfin/jellyfin-api';
import { import {
getSonarrDownloads, getSonarrDownloads,
getSonarrSeries, getSonarrSeries,
@@ -59,7 +59,7 @@ function _createDataFetchStore<T>(fn: () => Promise<T>) {
}; };
} }
export const jellyfinItemsStore = _createDataFetchStore(getJellyfinItems); export const jellyfinItemsStore = _createDataFetchStore(jellyfinApi.getLibraryItems);
export function createJellyfinItemStore(tmdbId: number | Promise<number>) { export function createJellyfinItemStore(tmdbId: number | Promise<number>) {
const store = writable<{ loading: boolean; item?: JellyfinItem }>({ const store = writable<{ loading: boolean; item?: JellyfinItem }>({