diff --git a/src/app.css b/src/app.css index d57018a..1d7ad3f 100644 --- a/src/app.css +++ b/src/app.css @@ -10,3 +10,7 @@ a { @apply bg-[#ffffff11]; @apply animate-pulse; } + +.selectable { + @apply focus-within:outline outline-2 outline-lighten outline-offset-2; +} diff --git a/src/lib/apis/jellyfin/jellyfinApi.ts b/src/lib/apis/jellyfin/jellyfinApi.ts index a2ad8ac..1e11d08 100644 --- a/src/lib/apis/jellyfin/jellyfinApi.ts +++ b/src/lib/apis/jellyfin/jellyfinApi.ts @@ -16,21 +16,21 @@ export const JellyfinApi = createClient({ } }); -export const getJellyfinContinueWatching = (): Promise => +export const getJellyfinContinueWatching = (limit = 8): Promise => JellyfinApi.get('/Users/{userId}/Items/Resume', { params: { path: { userId: JELLYFIN_USER_ID }, query: { - limit: 8, + limit, mediaTypes: ['Video'], fields: ['ProviderIds'] } } }).then((r) => r.data?.Items || []); -export const getJellyfinItemByTmdbId = (tmdbId: string) => +export const getJellyfinItems = () => JellyfinApi.get('/Users/{userId}/Items', { params: { path: { @@ -39,11 +39,41 @@ export const getJellyfinItemByTmdbId = (tmdbId: string) => query: { hasTmdbId: true, recursive: true, - isMovie: true, + includeItemTypes: ['Movie', 'Series'], fields: ['ProviderIds'] } } - }).then((r) => r.data?.Items?.find((i) => i.ProviderIds?.Tmdb == tmdbId)); + }).then((r) => r.data?.Items || []); + +// export const getJellyfinSeries = () => +// JellyfinApi.get('/Users/{userId}/Items', { +// params: { +// path: { +// userId: JELLYFIN_USER_ID +// }, +// query: { +// hasTmdbId: true, +// recursive: true, +// includeItemTypes: ['Series'], +// } +// } +// }).then((r) => r.data?.Items || []); + +export const getJellyfinEpisodes = (seriesId: string) => + JellyfinApi.get('/Users/{userId}/Items', { + params: { + path: { + userId: JELLYFIN_USER_ID + }, + query: { + recursive: true, + includeItemTypes: ['Episode'] + } + } + }).then((r) => r.data?.Items?.filter((i) => i.SeriesId === seriesId) || []); + +export const getJellyfinItemByTmdbId = (tmdbId: string) => + getJellyfinItems().then((items) => items.find((i) => i.ProviderIds?.Tmdb == tmdbId)); export const getJellyfinItem = (itemId: string) => JellyfinApi.get('/Users/{userId}/Items/{itemId}', { diff --git a/src/lib/apis/tmdb/tmdbApi.ts b/src/lib/apis/tmdb/tmdbApi.ts index f4a6028..2689fa6 100644 --- a/src/lib/apis/tmdb/tmdbApi.ts +++ b/src/lib/apis/tmdb/tmdbApi.ts @@ -39,7 +39,7 @@ export const getTmdbPopularMovies = () => params: {} }).then((res) => res.data?.results || []); -export const getTmdbIdFromTvdbId = async (tvdbId: number) => +export const getTmdbSeriesFromTvdbId = async (tvdbId: number): Promise => TmdbApiOpen.get('/3/find/{external_id}', { params: { path: { @@ -49,9 +49,10 @@ export const getTmdbIdFromTvdbId = async (tvdbId: number) => external_source: 'tvdb_id' } } - }) - .then((res) => res.data?.tv_results?.[0]) - .then((res: any) => res?.id as number | undefined); + }).then((res) => res.data?.tv_results?.[0]); + +export const getTmdbIdFromTvdbId = async (tvdbId: number) => + getTmdbSeriesFromTvdbId(tvdbId).then((res: any) => res?.id as number | undefined); export const getTmdbSeries = async (tmdbId: number): Promise => await TmdbApiOpen.get('/3/tv/{series_id}', { @@ -75,6 +76,9 @@ export const getTmdbSeriesSeason = async (tmdbId: number, season: number) => } }).then((res) => res.data); +export const getTmdbSeriesSeasons = async (tmdbId: number, seasons: number) => + Promise.all([...Array(seasons).keys()].map((i) => getTmdbSeriesSeason(tmdbId, i + 1))); + export const getTmdbSeriesImages = async (tmdbId: number) => TmdbApiOpen.get('/3/tv/{series_id}/images', { params: { diff --git a/src/lib/components/Card/Card.svelte b/src/lib/components/Card/Card.svelte index 1c8b517..f904eb2 100644 --- a/src/lib/components/Card/Card.svelte +++ b/src/lib/components/Card/Card.svelte @@ -63,18 +63,16 @@ {/if} {#if seasons}
- {seasons} seasons + {seasons} Season{seasons > 1 ? 's' : ''}
{/if} - {#if rating} -
- -
- {rating.toFixed(1)} -
+
+ +
+ {rating ? rating.toFixed(1) : 'N/A'}
- {/if} +
{/if}
diff --git a/src/lib/components/Card/CardPlaceholder.svelte b/src/lib/components/Card/CardPlaceholder.svelte index ce692ba..3552ac8 100644 --- a/src/lib/components/Card/CardPlaceholder.svelte +++ b/src/lib/components/Card/CardPlaceholder.svelte @@ -2,14 +2,14 @@ import classNames from 'classnames'; export let index = 0; - export let type: 'dynamic' | 'md' | 'large' = 'md'; + export let size: 'dynamic' | 'md' | 'large' = 'md';
diff --git a/src/lib/components/Carousel/Carousel.svelte b/src/lib/components/Carousel/Carousel.svelte index 6421110..1e77563 100644 --- a/src/lib/components/Carousel/Carousel.svelte +++ b/src/lib/components/Carousel/Carousel.svelte @@ -7,7 +7,7 @@ let scrollX: number; -
+
diff --git a/src/lib/components/Carousel/CarouselPlaceholderItems.svelte b/src/lib/components/Carousel/CarouselPlaceholderItems.svelte index 3a11c67..c3725dc 100644 --- a/src/lib/components/Carousel/CarouselPlaceholderItems.svelte +++ b/src/lib/components/Carousel/CarouselPlaceholderItems.svelte @@ -1,8 +1,8 @@ {#each Array(10) as _, i (i)} - + {/each} diff --git a/src/lib/components/Carousel/UICarousel.svelte b/src/lib/components/Carousel/UICarousel.svelte index 6d52a6e..459a536 100644 --- a/src/lib/components/Carousel/UICarousel.svelte +++ b/src/lib/components/Carousel/UICarousel.svelte @@ -1,7 +1,35 @@ -
+
diff --git a/src/lib/components/EpisodeCard/EpisodeCard.svelte b/src/lib/components/EpisodeCard/EpisodeCard.svelte index 42c6b6a..1e52e01 100644 --- a/src/lib/components/EpisodeCard/EpisodeCard.svelte +++ b/src/lib/components/EpisodeCard/EpisodeCard.svelte @@ -1,42 +1,60 @@ +
-
+
- - {episodeTag} + + {episodeNumber}
- {runtime} min + + {runtime} min +
-
+
{subtitle}
{title} @@ -47,9 +65,10 @@
- +
+
diff --git a/src/lib/components/ResourceDetails/LibraryDetails.svelte b/src/lib/components/ResourceDetails/LibraryDetails.svelte index 1b669ad..9327b51 100644 --- a/src/lib/components/ResourceDetails/LibraryDetails.svelte +++ b/src/lib/components/ResourceDetails/LibraryDetails.svelte @@ -1,12 +1,12 @@
- {#await fetchSeasons(totalSeasons)} + {#await seasonsPromise}
{#each [...Array(3).keys()] as season} @@ -30,58 +103,87 @@
{/each}
- + + {#each Array(10) as _, i (i)} +
+ +
+ {/each} - {:then seasons} + {:then { tmdbSeasons, jellyfinEpisodes }}
- {#each seasons as season} -
- - - {#each seasons as season} - - {/each} - - {#each season?.episodes || [] as episode} + + + {#each tmdbSeasons as season} + + {/each} + + {#each tmdbSeasons as season} + {#if season?.season_number === visibleSeason} + {#each season?.episodes || [] as tmdbEpisode} {@const upcoming = - new Date(episode.air_date || Date.now()) > new Date() || episode.runtime === null} -
+ new Date(tmdbEpisode.air_date || Date.now()) > new Date() || + tmdbEpisode.runtime === null} + {@const jellyfinEpisode = jellyfinEpisodes?.find( + (e) => + e.IndexNumber === tmdbEpisode.episode_number && + e.ParentIndexNumber === season?.season_number + )} +
streamJellyfinId(jellyfinEpisode?.Id || '') + : undefined} > -
+
{#if upcoming} - {@const date = new Date(episode.air_date || Date.now())} - + {@const date = new Date(tmdbEpisode.air_date || Date.now())} + {`${date.getDay()}. ${date.toLocaleDateString('en', { month: 'short' })}`} {:else} - {episode.vote_average?.toFixed(1)} + {tmdbEpisode.vote_average?.toFixed(1)} {/if}
+
+ {#if jellyfinEpisode?.UserData?.Played} +
+ Watched +
+ {:else if jellyfinEpisode?.UserData?.PlayedPercentage} + {@const runtime = tmdbEpisode.runtime || 0} + {( + runtime - + runtime * (jellyfinEpisode?.UserData?.PlayedPercentage / 100) + ).toFixed(0)} min left + {:else} + {tmdbEpisode.runtime} min + {/if} +
{/each} - -
- {/each} + {/if} + {/each} +
{/await} diff --git a/src/lib/components/VideoPlayer/VideoPlayer.svelte b/src/lib/components/VideoPlayer/VideoPlayer.svelte index 87098ee..6664642 100644 --- a/src/lib/components/VideoPlayer/VideoPlayer.svelte +++ b/src/lib/components/VideoPlayer/VideoPlayer.svelte @@ -34,6 +34,8 @@ const hls = new Hls(); + console.log(item); + hls.loadSource(PUBLIC_JELLYFIN_URL + uri); hls.attachMedia(video); video diff --git a/src/lib/stores/library.store.ts b/src/lib/stores/library.store.ts index f745a85..83f8891 100644 --- a/src/lib/stores/library.store.ts +++ b/src/lib/stores/library.store.ts @@ -1,4 +1,9 @@ -import { getJellyfinContinueWatching, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi'; +import { + getJellyfinContinueWatching, + getJellyfinItem, + getJellyfinItems, + type JellyfinItem +} from '$lib/apis/jellyfin/jellyfinApi'; import { getRadarrDownloads, getRadarrMovies, @@ -14,6 +19,7 @@ import { import { fetchTmdbMovieImages, getTmdbIdFromTvdbId, + getTmdbSeriesFromTvdbId, getTmdbSeriesImages } from '$lib/apis/tmdb/tmdbApi'; import { writable } from 'svelte/store'; @@ -28,11 +34,14 @@ interface PlayableItem { progress: number; length: number; }; + isPlayed: boolean; + jellyfinId?: string; } export interface PlayableRadarrMovie extends RadarrMovie, PlayableItem {} export interface PlayableSonarrSeries extends SonarrSeries, PlayableItem { tmdbId?: number; + tmdbRating: number; } export interface Library { @@ -52,19 +61,22 @@ async function getLibrary(): Promise { const sonarrDownloadsPromise = getSonarrDownloads(); const continueWatchingPromise = getJellyfinContinueWatching(); + const jellyfinLibraryItemsPromise = getJellyfinItems(); const movies: PlayableRadarrMovie[] = await radarrMoviesPromise.then(async (radarrMovies) => { const radarrDownloads = await radarrDownloadsPromise; const continueWatching = await continueWatchingPromise; + const jellyfinItems = await jellyfinLibraryItemsPromise; - return getLibraryMovies(radarrMovies, radarrDownloads, continueWatching); + return getLibraryMovies(radarrMovies, radarrDownloads, continueWatching, jellyfinItems); }); const series: PlayableSonarrSeries[] = await sonarrSeriesPromise.then(async (sonarrSeries) => { const sonarrDownloads = await sonarrDownloadsPromise; const continueWatching = await continueWatchingPromise; + const jellyfinItems = await jellyfinLibraryItemsPromise; - return getLibrarySeries(sonarrSeries, sonarrDownloads, continueWatching); + return getLibrarySeries(sonarrSeries, sonarrDownloads, continueWatching, jellyfinItems); }); return { @@ -82,13 +94,12 @@ export const library = writable>(getLibrary()); async function getLibraryMovies( radarrMovies: RadarrMovie[], radarrDownloads: RadarrDownload[], - jellyfinContinueWatching: JellyfinItem[] + jellyfinContinueWatching: JellyfinItem[], + jellyfinItems: JellyfinItem[] ): Promise { const playableMoviesPromises = radarrMovies.map(async (m) => { const radarrDownload = radarrDownloads.find((d) => d.movie.tmdbId === m.tmdbId); - const jellyfinItem = jellyfinContinueWatching.find( - (i) => i.ProviderIds?.Tmdb === String(m.tmdbId) - ); + const jellyfinItem = jellyfinItems.find((i) => i.ProviderIds?.Tmdb === String(m.tmdbId)); const downloadProgress = radarrDownload?.sizeleft && radarrDownload?.size @@ -105,7 +116,11 @@ async function getLibraryMovies( : undefined; const watchingProgress = jellyfinItem?.UserData?.PlayedPercentage; const continueWatching = - length && watchingProgress ? { length, progress: watchingProgress } : undefined; + length && + watchingProgress && + !!jellyfinContinueWatching.find((i) => i.Id === jellyfinItem?.Id) + ? { length, progress: watchingProgress } + : undefined; const backdropUrl = await fetchTmdbMovieImages(String(m.tmdbId)).then( (r) => r.backdrops.find((b) => b.iso_639_1 === 'en')?.file_path @@ -115,7 +130,9 @@ async function getLibraryMovies( ...m, cardBackdropUrl: backdropUrl || '', download, - continueWatching + continueWatching, + isPlayed: jellyfinItem?.UserData?.Played || false, + jellyfinId: jellyfinItem?.Id }; }); @@ -125,13 +142,12 @@ async function getLibraryMovies( async function getLibrarySeries( sonarrSeries: SonarrSeries[], sonarrDownloads: SonarrDownload[], - jellyfinContinueWatching: JellyfinItem[] + jellyfinContinueWatching: JellyfinItem[], + jellyfinItems: JellyfinItem[] ): Promise { const playableSeriesPromises = sonarrSeries.map(async (s) => { const sonarrDownload = sonarrDownloads.find((d) => d.series.tvdbId === s.tvdbId); - const jellyfinItem = jellyfinContinueWatching.find( - (i) => i.ProviderIds?.TvdbId === String(s.tvdbId) - ); + const jellyfinItem = jellyfinItems.find((i) => i.ProviderIds?.Tvdb === String(s.tvdbId)); const downloadProgress = sonarrDownload?.sizeleft && sonarrDownload?.size @@ -148,9 +164,14 @@ async function getLibrarySeries( : undefined; const watchingProgress = jellyfinItem?.UserData?.PlayedPercentage; const continueWatching = - length && watchingProgress ? { length, progress: watchingProgress } : undefined; + length && + watchingProgress && + !!jellyfinContinueWatching.find((i) => i.Id === jellyfinItem?.Id) + ? { length, progress: watchingProgress } + : undefined; - const tmdbId = s.tvdbId ? await getTmdbIdFromTvdbId(s.tvdbId) : undefined; + const tmdbItem = s.tvdbId ? await getTmdbSeriesFromTvdbId(s.tvdbId) : undefined; + const tmdbId = tmdbItem?.id || undefined; const backdropUrl = tmdbId ? await getTmdbSeriesImages(tmdbId).then( @@ -163,7 +184,10 @@ async function getLibrarySeries( tmdbId, cardBackdropUrl: backdropUrl || '', download, - continueWatching + continueWatching, + isPlayed: jellyfinItem?.UserData?.Played || false, + tmdbRating: tmdbItem.vote_average || 0, + jellyfinId: jellyfinItem?.Id }; }); diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index 753a9b0..5668a4e 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -12,18 +12,18 @@ import { ChevronDown, MagnifyingGlass, TextAlignBottom, Trash } from 'radix-icons-svelte'; import type { ComponentProps } from 'svelte'; - const watched = []; - const posterGridStyle = - 'grid gap-x-4 gap-y-8 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'; + 'grid gap-x-4 gap-y-8 grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5'; const headerStyle = 'uppercase tracking-widest font-bold'; const headerContaienr = 'flex items-center justify-between mt-2'; let itemsVisible: 'all' | 'movies' | 'shows' = 'all'; let sortBy: 'added' | 'rating' | 'year' | 'size' | 'name' = 'added'; + let loading = true; let downloadingProps: ComponentProps[] = []; let availableProps: ComponentProps[] = []; + let watchedProps: ComponentProps[] = []; let unavailableProps: ComponentProps[] = []; function itemIsSeries( @@ -46,6 +46,7 @@ for (let item of items) { let props: ComponentProps; if (itemIsSeries(item)) { + console.log(item); props = { size: 'dynamic', type: 'series', @@ -53,7 +54,7 @@ title: item.title || '', genres: item.genres || [], backdropUrl: item.cardBackdropUrl, - rating: item.ratings?.tmdb?.value || item.ratings?.imdb?.value || 7.5, + rating: item.ratings?.value || item.ratings?.value || item.tmdbRating || 0, seasons: item.seasons?.length || 0 }; } else if (itemIsMovie(item)) { @@ -64,12 +65,11 @@ title: item.title || '', genres: item.genres || [], backdropUrl: item.cardBackdropUrl, - rating: item.ratings?.tmdb?.value || item.ratings?.imdb?.value || 7.5, + rating: item.ratings?.tmdb?.value || item.ratings?.imdb?.value || 0, runtimeMinutes: item.runtime || 0 }; } else { - console.log('RETURNING'); - return; + continue; } if (item.download) { @@ -78,13 +78,14 @@ progress: item.download.progress, completionTime: item.download.completionTime }); + } else if (item.isPlayed) { + watchedProps.push({ ...props, available: false }); } else if ( ((item as PlayableRadarrMovie)?.isAvailable && (item as PlayableRadarrMovie)?.movieFile) || (item as PlayableSonarrSeries)?.seasons?.find( (season) => !!season?.statistics?.episodeFileCount ) ) { - console.log(item); availableProps.push(props); } else { unavailableProps.push({ ...props, available: false }); @@ -93,7 +94,10 @@ downloadingProps = downloadingProps; availableProps = availableProps; + watchedProps = watchedProps; unavailableProps = unavailableProps; + + loading = false; }); function sortItems(arr: any[]) { @@ -116,9 +120,6 @@
- - -
--> - {#await $library} + {#if loading}
{#each [...Array(20).keys()] as index (index)} - + {/each}
- {:then libraryData} - - + {:else} {#if downloadingProps.length > 0}

@@ -195,23 +179,6 @@ {#each downloadingProps as props} {/each} -

{/if} @@ -228,20 +195,22 @@ {#each availableProps as props} {/each} - +
+ {/if} + + {#if watchedProps.length > 0} +
+

+ Watched {watchedProps.length} +

+ + + +
+
+ {#each watchedProps as props} + + {/each}
{/if} @@ -258,20 +227,8 @@ {#each unavailableProps as props} {/each} -
{/if} - {/await} + {/if}
diff --git a/src/routes/series/[id]/+page.svelte b/src/routes/series/[id]/+page.svelte index a60a542..bd541ae 100644 --- a/src/routes/series/[id]/+page.svelte +++ b/src/routes/series/[id]/+page.svelte @@ -16,7 +16,7 @@ endDate={series.last_air_date && !series.in_production ? new Date(series.last_air_date) : undefined} - seasons={series.seasons?.length || 0} + seasons={series.number_of_seasons || 0} tagline={series.tagline || ''} overview={series.overview || ''} backdropPath={series.backdrop_path || ''} @@ -32,7 +32,7 @@ title: lastEpisode.name, subtitle: 'Latest Episode', runtime: lastEpisode.runtime || 0, - episodeTag: + episodeNumber: (lastEpisode.season_number ? `S${lastEpisode.season_number}` : '') + (lastEpisode.episode_number ? `E${lastEpisode.episode_number}` : '') }