diff --git a/backend/src/user-data/library/library.controller.ts b/backend/src/user-data/library/library.controller.ts index 1e90c19..1384684 100644 --- a/backend/src/user-data/library/library.controller.ts +++ b/backend/src/user-data/library/library.controller.ts @@ -68,12 +68,7 @@ export class LibraryController { direction, }); - return { - ...response, - items: await Promise.all( - response.items.map((i) => this.libraryService.getLibraryItemDto(i)), - ), - }; + return response } @Get('catalogue/:sourceId') diff --git a/backend/src/user-data/library/library.dto.ts b/backend/src/user-data/library/library.dto.ts index a40e69f..08a6ab4 100644 --- a/backend/src/user-data/library/library.dto.ts +++ b/backend/src/user-data/library/library.dto.ts @@ -1,6 +1,8 @@ import { ApiProperty, PickType } from '@nestjs/swagger'; import { TmdbItemDto } from 'src/metadata/tmdb/tmdb.dto'; import { LibraryItem } from './library.entity'; +import { PlayStateDto } from '../play-state/play-state.dto'; +import { PlayState } from '../play-state/play-state.entity'; export enum OrderDirection { Asc = 'asc', @@ -44,6 +46,9 @@ export class LibraryItemDto extends PickType(LibraryItem, [ @ApiProperty() tmdbItem: TmdbItemDto; + @ApiProperty({ type: PlayStateDto, required: false }) + lastPlayState?: PlayState; + @ApiProperty({ required: false }) watched?: boolean; } diff --git a/backend/src/user-data/library/library.service.ts b/backend/src/user-data/library/library.service.ts index dc00302..0ce6da0 100644 --- a/backend/src/user-data/library/library.service.ts +++ b/backend/src/user-data/library/library.service.ts @@ -47,7 +47,7 @@ export class LibraryService { status?: MyListStatusFilter; order?: MyListOrder; direction?: OrderDirection; - }): Promise> { + }): Promise> { const { userId, pagination, @@ -191,7 +191,9 @@ export class LibraryService { // console.log(builder.getQuery()); return { - items, + items: await Promise.all( + items.map((item) => this.getLibraryItemDto(item)), + ), total, itemsPerPage: pagination.itemsPerPage, page: pagination.page, @@ -286,7 +288,7 @@ export class LibraryService { ), }; } else if (type === CatalogueTypeFilter.Missing && missing) { - const tmdbIdToMyListItem: Record = {}; + const tmdbIdToMyListItem: Record = {}; const myListItems = await this.getMyList({ pagination: { itemsPerPage: 500, @@ -315,12 +317,7 @@ export class LibraryService { direction, }); - return { - ...response, - items: await Promise.all( - response.items.map((item) => this.getLibraryItemDto(item)), - ), - }; + return response; } throw new Error( @@ -404,6 +401,7 @@ export class LibraryService { mediaType: mediaType === 'movie' ? MediaType.Movie : MediaType.Series, watched, playStates, + lastPlayState: playStates?.[0], tmdbItem: { id: movieMetadata?.tmdbMovie.id ?? seriesMetadata?.tmdbSeries.id, poster_path: diff --git a/src/lib/apis/tmdb/tmdb-api.ts b/src/lib/apis/tmdb/tmdb-api.ts index d6756e9..66afdc9 100644 --- a/src/lib/apis/tmdb/tmdb-api.ts +++ b/src/lib/apis/tmdb/tmdb-api.ts @@ -1,3 +1,4 @@ +import { networks } from '$lib/components/Collection/collections'; import { formatDateToYearMonthDay } from '$lib/utils'; import createClient from 'openapi-fetch'; import { get } from 'svelte/store'; @@ -8,16 +9,25 @@ import { user } from '../../stores/user.store'; import type { Api } from '../api.interface'; import { TmdbApiGenerated, + type MovieCreditsData, + type MovieDetailsData, + type MovieExternalIdsData, + type MovieImagesData, + type MovieVideosData, type PersonDetailsData, type PersonExternalIdsData, type PersonImagesData, type PersonMovieCreditsData, - type PersonTvCreditsData + type PersonTvCreditsData, + type TvSeriesAggregateCreditsData, + type TvSeriesDetailsData, + type TvSeriesExternalIdsData, + type TvSeriesImagesData, + type TvSeriesVideosData } from './tmdb-v3.openapi'; import { TmdbApi4Generated } from './tmdb-v4.openapi'; import type { operations, paths } from './tmdb.generated'; import type { paths as paths4 } from './tmdb4.generated'; -import { networks } from '$lib/components/Collection/collections'; const CACHE_ONE_DAY = 'max-age=86400'; const CACHE_FOUR_DAYS = 'max-age=345600'; @@ -177,6 +187,36 @@ export class TmdbApiNew extends TmdbApiGenerated { append_to_response: 'images,movie_credits,tv_credits,external_ids' }) .then((res) => res.data); + + getMovieFull = async (tmdbId: number) => + this.v3 + .movieDetails( + tmdbId, + { + append_to_response: 'videos,credits,external_ids,images' + }, + { + headers: { + 'Cache-Control': CACHE_ONE_DAY + } + } + ) + .then((r) => r.data as TmdbMovieFull); + + getSeriesFull = async (tmdbId: number) => + this.v3 + .tvSeriesDetails( + tmdbId, + { + append_to_response: 'videos,aggregate_credits,external_ids,images' + }, + { + headers: { + 'Cache-Control': CACHE_ONE_DAY + } + } + ) + .then((r) => r.data as TmdbSeriesFull); } export class TmdbApi4New extends TmdbApi4Generated { @@ -353,13 +393,11 @@ export class TmdbApi4New extends TmdbApi4Generated { }; } -export type TmdbMovie = - operations['movie-details']['responses']['200']['content']['application/json']; +export type TmdbMovie = MovieDetailsData; export type TmdbMovieSmall = NonNullable< operations['discover-movie']['responses']['200']['content']['application/json']['results'] >[0]; -export type TmdbSeries = - operations['tv-series-details']['responses']['200']['content']['application/json']; +export type TmdbSeries = TvSeriesDetailsData; export type TmdbSeriesSmall = NonNullable< operations['discover-tv']['responses']['200']['content']['application/json']['results'] >[0]; @@ -383,17 +421,17 @@ export interface TmdbPersonFull extends TmdbPerson { } export interface TmdbMovieFull extends TmdbMovie { - videos: operations['movie-videos']['responses']['200']['content']['application/json']; - credits: operations['movie-credits']['responses']['200']['content']['application/json']; - external_ids: operations['movie-external-ids']['responses']['200']['content']['application/json']; - images: operations['movie-images']['responses']['200']['content']['application/json']; + videos: MovieVideosData; + credits: MovieCreditsData; + external_ids: MovieExternalIdsData; + images: MovieImagesData; } export interface TmdbSeriesFull extends TmdbSeries { - videos: operations['tv-series-videos']['responses']['200']['content']['application/json']; - aggregate_credits: operations['tv-series-aggregate-credits']['responses']['200']['content']['application/json']; - external_ids: operations['tv-series-external-ids']['responses']['200']['content']['application/json']; - images: operations['tv-series-images']['responses']['200']['content']['application/json']; + videos: TvSeriesVideosData; + aggregate_credits: TvSeriesAggregateCreditsData; + external_ids: TvSeriesExternalIdsData; + images: TvSeriesImagesData; } export class TmdbApi implements Api { diff --git a/src/lib/components/Card/TmdbCard.svelte b/src/lib/components/Card/TmdbCard.svelte index 1c41513..6fdd7a2 100644 --- a/src/lib/components/Card/TmdbCard.svelte +++ b/src/lib/components/Card/TmdbCard.svelte @@ -9,6 +9,7 @@ | Pick | Pick; export let progress = 0; + let title = ''; let type: TitleType = 'movie'; diff --git a/src/lib/components/VideoPlayer/TmdbVideoPlayer.svelte b/src/lib/components/VideoPlayer/TmdbVideoPlayer.svelte index a99cfb0..279c2d7 100644 --- a/src/lib/components/VideoPlayer/TmdbVideoPlayer.svelte +++ b/src/lib/components/VideoPlayer/TmdbVideoPlayer.svelte @@ -1,15 +1,14 @@ diff --git a/src/lib/components/VideoPlayer/VideoElement.svelte b/src/lib/components/VideoPlayer/VideoElement.svelte index db5a5f6..883398c 100644 --- a/src/lib/components/VideoPlayer/VideoElement.svelte +++ b/src/lib/components/VideoPlayer/VideoElement.svelte @@ -98,6 +98,9 @@ onDestroy(() => { video?.textTracks.removeEventListener('addtrack', () => updateSubtitlesVisibility(subtitles)); + video?.pause(); + video?.removeAttribute('src'); + video?.load(); }); diff --git a/src/lib/pages/LibraryPage/CatalogueTab.svelte b/src/lib/pages/LibraryPage/CatalogueTab.svelte index 2896563..e60d39d 100644 --- a/src/lib/pages/LibraryPage/CatalogueTab.svelte +++ b/src/lib/pages/LibraryPage/CatalogueTab.svelte @@ -38,37 +38,40 @@ selectedFilter = filters[0] ?? ''; } - const { interactionObserver, data, reset } = usePaginatedRequest(async (page) => { - const type = { - All: 'all' as const, - Movies: 'movies' as const, - Series: 'series' as const, - Missing: 'missing' as const - }[selectedFilter]; + const { interactionObserver, data, load } = usePaginatedRequest( + async (page) => { + const type = { + All: 'all' as const, + Movies: 'movies' as const, + Series: 'series' as const, + Missing: 'missing' as const + }[selectedFilter]; - if (!type) { - return { - items: [], - total: 0, - itemsPerPage: 0, - page: 0 - }; - } + if (!type) { + return { + items: [], + total: 0, + itemsPerPage: 0, + page: 0 + }; + } - return reiverrApi.library - .getCatalogue(source.userId, source.id, { - type, - order: $viewSettings.order, - direction: $viewSettings.direction, - page - }) - .then((r) => r.data); - }); + return reiverrApi.library + .getCatalogue(source.userId, source.id, { + type, + order: $viewSettings.order, + direction: $viewSettings.direction, + page + }) + .then((r) => r.data); + }, + { loadOnInit: false } + ); $: { $viewSettings; selectedFilter; - reset(); + load(); } // $: items = selectedFilter diff --git a/src/lib/pages/LibraryPage/MyListTab.svelte b/src/lib/pages/LibraryPage/MyListTab.svelte index 47a7e0b..cb1cce7 100644 --- a/src/lib/pages/LibraryPage/MyListTab.svelte +++ b/src/lib/pages/LibraryPage/MyListTab.svelte @@ -15,7 +15,7 @@ import { libraryViewSettings } from './LibraryPage'; import MyListOptions from './MyListOptions.svelte'; import TabItem from './TabItem.svelte'; - import { usePaginatedRequest } from '$lib/stores/data.store'; + import { libraryRefresher, usePaginatedRequest } from '$lib/stores/data.store'; const { registrar } = getStackRouterPage(); const { topVisible } = getScrollContext(); @@ -26,7 +26,7 @@ const { data: upcoming, interactionObserver: upcomingObserver, - reset: resetUpcoming + load: loadUpcoming } = usePaginatedRequest( async (page) => { if (!$user?.id || !$libraryViewSettings.separateWatched) { @@ -43,13 +43,16 @@ }) .then((i) => i.data); }, - { loadFirstPage: false } + { + loadOnInit: false, + refresher: libraryRefresher + } ); const { data: watched, interactionObserver: watchedObserver, - reset: resetWatched + load: loadWatched } = usePaginatedRequest( async (page) => { if (!$user?.id || !$libraryViewSettings.separateWatched) { @@ -66,10 +69,10 @@ }) .then((i) => i.data); }, - { loadFirstPage: false } + { loadOnInit: false, refresher: libraryRefresher } ); - const { interactionObserver, data, reset } = usePaginatedRequest( + const { interactionObserver, data, load } = usePaginatedRequest( async (page) => { if (!$user?.id) { return { items: [], total: 0, itemsPerPage: 0, page: 0 }; @@ -85,16 +88,16 @@ }) .then((i) => i.data); }, - { loadFirstPage: false } + { loadOnInit: false, refresher: libraryRefresher } ); $: { $libraryViewSettings; category; $user; - reset({ loadFirstPage: true }); - resetUpcoming({ loadFirstPage: true }); - resetWatched({ loadFirstPage: true }); + load(); + loadUpcoming(); + loadWatched(); } $: viewSettingsKey = $libraryViewSettings && Symbol(); diff --git a/src/lib/pages/ManagePage/EditMediaSourceDialog.ManagePage.svelte b/src/lib/pages/ManagePage/EditMediaSourceDialog.ManagePage.svelte index 7f7bc95..95e6a1e 100644 --- a/src/lib/pages/ManagePage/EditMediaSourceDialog.ManagePage.svelte +++ b/src/lib/pages/ManagePage/EditMediaSourceDialog.ManagePage.svelte @@ -1,6 +1,12 @@ @@ -67,7 +78,12 @@ Continue Watching {#key libraryContinueWatchingKey} {#each $continueWatching?.items ?? [] as item (item.tmdbId)} - + {/each} {/key} diff --git a/src/lib/pages/SeriesHomePage.svelte b/src/lib/pages/SeriesHomePage.svelte index 22bfad4..7ba2f70 100644 --- a/src/lib/pages/SeriesHomePage.svelte +++ b/src/lib/pages/SeriesHomePage.svelte @@ -5,11 +5,15 @@ import { createBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack'; import TmdbSeriesHeroShowcase from '$lib/components/HeroShowcase/TmdbSeriesHeroShowcase.svelte'; import { scrollIntoView } from '$lib/selectable'; - import { continueWatchingSeriesDataStore } from '$lib/stores/data.store'; + import { + libraryRefresher, + useRequest + } from '$lib/stores/data.store'; import { setScrollContext } from '$lib/stores/scroll.store'; import { setUiVisibilityContext } from '$lib/stores/ui-visibility.store'; - import { tmdbApi, tmdbApi4 } from '$lib/stores/user.store'; + import { reiverrApi, tmdbApi, tmdbApi4, user } from '$lib/stores/user.store'; import { onDestroy } from 'svelte'; + import { get } from 'svelte/store'; import { TMDB_SERIES_GENRES } from '../apis/tmdb/tmdb-api'; import TmdbCard from '../components/Card/TmdbCard.svelte'; import Carousel from '../components/Carousel/Carousel.svelte'; @@ -19,7 +23,18 @@ const { registrar: registerScroll } = setScrollContext(); const { visibleStyle } = setUiVisibilityContext(); - const { ...continueWatching } = continueWatchingSeriesDataStore.subscribe(); + const { unsubscribe, ...continueWatching } = useRequest( + () => + reiverrApi.library + .getMyList(String(get(user)?.id), { + type: 'series', + order: 'last-played', + status: 'continue-watching' + }) + .then((r) => r.data), + { refresher: libraryRefresher } + ); + $: libraryContinueWatchingKey = $continueWatching && Symbol(); const popular = tmdbApi.getTrendingSeries(); @@ -28,7 +43,7 @@ const recommendations = tmdbApi4.getRecommendedSeries(); onDestroy(() => { - continueWatching.unsubscribe(); + unsubscribe(); }); @@ -47,7 +62,12 @@ Continue Watching {#key libraryContinueWatchingKey} {#each $continueWatching?.items ?? [] as item (item.tmdbId)} - + {/each} {/key} diff --git a/src/lib/pages/TitlePages/EpisodePage.svelte b/src/lib/pages/TitlePages/EpisodePage.svelte index b570479..6305c09 100644 --- a/src/lib/pages/TitlePages/EpisodePage.svelte +++ b/src/lib/pages/TitlePages/EpisodePage.svelte @@ -2,8 +2,7 @@ import Container from '$components/Container.svelte'; import { createBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack'; import HeroCarousel from '$lib/components/HeroShowcase/HeroCarousel.svelte'; - import { type StackRouterPageProps } from '$lib/components/StackRouter/StackRouterPage.type'; - import { tmdbEpisodeDataStore } from '$lib/stores/data.store'; + import { getStackRouterPage } from '$lib/components/StackRouter/StackRouter'; import { useEpisodeUserData } from '$lib/stores/media-user-data.store'; import { Check, ExternalLink, Play } from 'radix-icons-svelte'; import { onDestroy } from 'svelte'; @@ -11,7 +10,6 @@ import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants'; import { formatThousands } from '../../utils'; import TitleProperties from './HeroTitleInfo.svelte'; - import { getStackRouterPage } from '$lib/components/StackRouter/StackRouter'; export let id: string; // Series tmdbId export let season: string; @@ -20,11 +18,10 @@ const { registrar } = getStackRouterPage(); const background = createBackgroundPage({ videoMediaId: id }); - const { promise: tmdbEpisode, unsubscribe: unsubscribeTmdbEpisode } = - tmdbEpisodeDataStore.subscribe(Number(id), Number(season), Number(episode)); const { progress, + tmdbEpisode, handleAutoplay, handleOpenStreamSelector, canStream, @@ -66,7 +63,6 @@ onDestroy(() => { unsubscribe(); - unsubscribeTmdbEpisode(); }); diff --git a/src/lib/pages/TitlePages/MoviePage/MoviePage.svelte b/src/lib/pages/TitlePages/MoviePage/MoviePage.svelte index 1ecdfea..228b185 100644 --- a/src/lib/pages/TitlePages/MoviePage/MoviePage.svelte +++ b/src/lib/pages/TitlePages/MoviePage/MoviePage.svelte @@ -10,7 +10,6 @@ import { getStackRouterPage } from '$lib/components/StackRouter/StackRouter'; import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '$lib/constants'; import { scrollIntoView } from '$lib/selectable'; - import { tmdbMovieDataStore } from '$lib/stores/data.store'; import { localSettings } from '$lib/stores/localstorage.store'; import { useMovieUserData } from '$lib/stores/media-user-data.store'; import { setScrollContext } from '$lib/stores/scroll.store'; @@ -26,9 +25,8 @@ const tmdbId = Number(id); const background = createBackgroundPage({ backgroundMediaId: id, videoMediaId: id }); - const { promise: tmdbMovie, unsubscribe: unsubscribeTmdbMovie } = - tmdbMovieDataStore.subscribe(tmdbId); const { + tmdbMovie, inLibrary, progress, handleAddToLibrary, @@ -99,7 +97,6 @@ onDestroy(() => { unsubscribe(); - unsubscribeTmdbMovie(); }); diff --git a/src/lib/stores/data.store.ts b/src/lib/stores/data.store.ts index a29c989..524490a 100644 --- a/src/lib/stores/data.store.ts +++ b/src/lib/stores/data.store.ts @@ -1,17 +1,106 @@ import { tick } from 'svelte'; -import { derived, get, writable } from 'svelte/store'; +import { derived, get, writable, type Readable } from 'svelte/store'; import { tmdbApi } from '../apis/tmdb/tmdb-api'; import { awaitAppInitialization, reiverrApi, user } from './user.store'; import type { PaginatedResponseDto } from '$lib/apis/reiverr/reiverr.openapi'; import type { Action } from 'svelte/action'; +import { getStackRouterPage } from '$lib/components/StackRouter/StackRouter'; type Request = ReturnType>; +// type Refresher = ReturnType; -export function useRequest(fn: () => Promise) { +export class Refresher { + private subscribers: { + key?: string; + isActive?: Readable; + unsubscribe?: () => void; + refresh: () => Promise; + refreshIn: (ms: number) => Promise; + }[] = []; + + subscribe(options: { + isActive?: Readable; + refresh: () => Promise; + refreshIn: (ms: number) => Promise; + key?: string; + }) { + const subscriber = { + key: options.key, + isActive: options.isActive, + refresh: options.refresh, + refreshIn: options.refreshIn, + unsubscribe: () => {} + }; + + this.subscribers.push(subscriber); + + return () => { + subscriber.unsubscribe?.(); + this.subscribers = this.subscribers.filter((s) => s !== subscriber); + }; + } + + refresh(key?: string): Promise { + const promises = this.subscribers.map((s) => { + if (!key || key === s.key) { + if (s.unsubscribe) s.unsubscribe(); + + if (s.isActive && !get(s.isActive)) { + s.unsubscribe = s.isActive.subscribe((isActive) => { + if (isActive) { + s.refresh(); + s.unsubscribe?.(); + } + }); + } else { + return s.refresh(); + } + } + + return Promise.resolve(); + }); + + return Promise.all(promises); + } + + refreshIn(ms: number, key?: string): Promise { + const promises = this.subscribers.map((s) => { + if (!key || key === s.key) { + if (s.unsubscribe) s.unsubscribe(); + + if (s.isActive && !get(s.isActive)) { + s.unsubscribe = s.isActive.subscribe((isActive) => { + if (isActive) { + s.refreshIn(ms); + s.unsubscribe?.(); + } + }); + } else { + return s.refreshIn(ms); + } + } + + return Promise.resolve(); + }); + + return Promise.all(promises); + } +} + +export function useRequest( + fn: () => Promise, + options: { + refresher?: Refresher; + key?: string; + } = {} +) { async function _createPromise() { return awaitAppInitialization().then(() => fn()); } + const { hasFocus: isActive } = getStackRouterPage(); + const { refresher, key } = options; + const initialPromise = _createPromise(); const promise = writable(initialPromise); const isLoading = writable(true); @@ -42,49 +131,18 @@ export function useRequest(fn: () => Promise) { }); } + let unsubscribeRefresher = () => {}; + if (refresher) { + unsubscribeRefresher = refresher.subscribe({ isActive, refresh, refreshIn, key }); + } + return { subscribe: data.subscribe, isLoading: { subscribe: isLoading.subscribe }, promise: { subscribe: promise.subscribe }, refresh, - refreshIn - }; -} - -export function useDerivedRequest( - request: Request, - fn: (r: TResponse2) => Promise -): Request { - const isLoading = writable(true); - const data = writable(undefined); - const promise = derived(request.promise, async (p) => { - isLoading.set(true); - - return p - .then((r) => fn(r)) - .then((d) => { - data.set(d); - return d; - }) - .finally(() => { - isLoading.set(false); - }); - }); - - return { - subscribe: data.subscribe, - isLoading: { subscribe: isLoading.subscribe }, - promise: { subscribe: promise.subscribe }, - refresh: async () => { - await request.refresh(); - await tick(); - return get(promise); - }, - refreshIn: async (ms?: number) => { - await request.refreshIn(ms); - await tick(); - return get(promise); - } + refreshIn, + unsubscribe: () => unsubscribeRefresher() }; } @@ -157,32 +215,17 @@ export function useRequestsStore, TResponse>( }; } -export function useDerivedRequestsStore, TResponse, TResponse2>( - dataStore: ReturnType>, - fn: (res: TResponse) => Promise -) { - type Res = ReturnType>; - function subscribe(...args: TArgs): RequestStoreRequest { - const request = dataStore.subscribe(...args); - const derivedRequest = useDerivedRequest(request, fn); - - return { - ...derivedRequest, - unsubscribe: request.unsubscribe - }; - } - - return { - ...dataStore, - subscribe - }; -} - export function usePaginatedRequest( fn: (page: number) => Promise<{ items: TResponseItem[] } & PaginatedResponseDto>, - options: { initialPage?: number; loadFirstPage?: boolean } = {} + options: { + initialPage?: number; + loadOnInit?: boolean; + refresher?: Refresher; + key?: string; + } = {} ) { - const initialPage = options.initialPage ?? 1; + const { hasFocus: isActive } = getStackRouterPage(); + const { refresher, key, initialPage = 1 } = options; let requestId = Symbol(); const nextPage = writable(initialPage); @@ -192,7 +235,7 @@ export function usePaginatedRequest( const isLoading = writable(false); let promise: Promise | undefined; - if (options.loadFirstPage !== false) requestNextPage(); + if (options.loadOnInit !== false) load(); async function requestNextPage() { if (get(loadingPage) === get(nextPage)) return; @@ -248,7 +291,7 @@ export function usePaginatedRequest( }; }; - function reset(resetOptions: { loadFirstPage?: boolean } = { loadFirstPage: false }) { + async function load() { nextPage.set(initialPage); loadingPage.set(initialPage - 1); hasNextPage = true; @@ -256,8 +299,27 @@ export function usePaginatedRequest( promise = undefined; isLoading.set(false); requestId = Symbol(); - if (resetOptions.loadFirstPage !== false) requestNextPage(); - else if (options.loadFirstPage !== false) requestNextPage(); + return requestNextPage(); + } + + let updateTimeout: ReturnType; + function loadIn(ms: number) { + return new Promise((resolve) => { + clearTimeout(updateTimeout); + updateTimeout = setTimeout(() => { + load().then(resolve); + }, ms); + }); + } + + let unsubscribeRefresher = () => {}; + if (refresher) { + unsubscribeRefresher = refresher.subscribe({ + isActive, + refresh: load, + refreshIn: loadIn, + key + }); } return { @@ -269,56 +331,12 @@ export function usePaginatedRequest( }, requestNextPage, interactionObserver, - reset + load, + unsubscribe: () => unsubscribeRefresher() }; } -export const tmdbMovieDataStore = useRequestsStore((id: number) => tmdbApi.getTmdbMovie(id)); -export const tmdbSeriesDataStore = useRequestsStore((id: number) => tmdbApi.getTmdbSeries(id)); -export const tmdbEpisodeDataStore = useRequestsStore( - (tmdbId: number, season: number, episode: number) => tmdbApi.getEpisode(tmdbId, season, episode) -); - -export const movieUserDataStore = useRequestsStore((id: string) => - reiverrApi.users.getMovieUserData(get(user)?.id as string, id).then((r) => r.data) -); -export const seriesUserDataStore = useRequestsStore((id: string) => - reiverrApi.users.getSeriesUserData(get(user)?.id as string, id).then((r) => r.data) -); -export const episodeUserDataStore = useRequestsStore( - (id: string, season: number, episode: number) => - reiverrApi.users - .getEpisodeUserData(get(user)?.id as string, id, season, episode) - .then((r) => r.data) -); - -export const continueWatchingMoviesDataStore = useRequestsStore(() => - reiverrApi.library - .getMyList(String(get(user)?.id), { - type: 'movies', - order: 'last-played', - status: 'continue-watching' - }) - .then((r) => r.data) -); - -export const continueWatchingSeriesDataStore = useRequestsStore(() => - reiverrApi.library - .getMyList(String(get(user)?.id), { - type: 'series', - order: 'last-played', - status: 'continue-watching' - }) - .then((r) => r.data) -); - -export const mediaSourcesDataStore = useRequestsStore(() => - reiverrApi.users - .findUserById(get(user)?.id || '') - .then((r) => r.data.mediaSources?.sort((a, b) => a.priority - b.priority) ?? []) -); - -export function refreshLibraryDerivatives(timeout = 0) { - continueWatchingMoviesDataStore.refreshIn(timeout); - continueWatchingSeriesDataStore.refreshIn(timeout); -} +export const movieUserDataRefresher = new Refresher(); +export const seriesUserDataRefresher = new Refresher(); +export const episodeUserDataRefresher = new Refresher(); +export const libraryRefresher = new Refresher(); diff --git a/src/lib/stores/media-user-data.store.ts b/src/lib/stores/media-user-data.store.ts index bf7aa3c..b7cfa16 100644 --- a/src/lib/stores/media-user-data.store.ts +++ b/src/lib/stores/media-user-data.store.ts @@ -11,12 +11,13 @@ import type { StreamCandidateDto } from '../apis/reiverr/reiverr.openapi'; import { - episodeUserDataStore, - movieUserDataStore, - seriesUserDataStore, - tmdbSeriesDataStore + episodeUserDataRefresher, + libraryRefresher, + movieUserDataRefresher, + seriesUserDataRefresher, + useRequest } from './data.store'; -import { reiverrApi, user } from './user.store'; +import { reiverrApi, tmdbApi, user } from './user.store'; export type EpisodeData = { season: number; @@ -76,11 +77,11 @@ async function getAutoplayStream(options: { tmdbId: string; season?: number; epi function useUserLibrary( mediaType: 'movie' | 'series', tmdbId: string, - userDataP: Readable + userData: Readable ) { const inLibrary = writable(undefined); - userDataP.subscribe((d) => { + userData.subscribe((d) => { inLibrary.set(d?.inLibrary ?? false); }); @@ -97,7 +98,7 @@ function useUserLibrary( .then((r) => r.data.success); if (success) { inLibrary.set(true); - libraryItemsDataStore.refreshIn(1500); + libraryRefresher.refreshIn(1500); } } @@ -114,7 +115,7 @@ function useUserLibrary( .then((r) => r.data.success); if (success) { inLibrary.set(false); - libraryItemsDataStore.refreshIn(1500); + libraryRefresher.refreshIn(500); } } @@ -145,7 +146,7 @@ function useIsWatched( return toggleFn(userId, !watched).finally(() => { isWatched.set(!watched); - libraryItemsDataStore.refreshIn(1500); + libraryRefresher.refreshIn(500); }); } @@ -166,8 +167,14 @@ function useCanStream() { export function useSeriesUserData(tmdbId: string) { const background = getBackgroundPage(); - const userDataRequest = seriesUserDataStore.subscribe(tmdbId); - const tmdbSeriesRequest = tmdbSeriesDataStore.subscribe(Number(tmdbId)); + const userDataRequest = useRequest( + () => reiverrApi.users.getSeriesUserData(get(user)?.id as string, tmdbId).then((r) => r.data), + { + refresher: seriesUserDataRefresher, + key: tmdbId + } + ); + const tmdbSeriesRequest = useRequest(() => tmdbApi.getSeriesFull(Number(tmdbId))); const libraryStore = useUserLibrary('series', tmdbId, userDataRequest); const canStreamStore = useCanStream(); const episodesUserData = writable([]); @@ -187,12 +194,18 @@ export function useSeriesUserData(tmdbId: string) { const episodesData: EpisodeData[] = []; let foundNext = false; + const lastWatchedPlayState = userData?.playStates?.filter((p) => p.watched).pop(); for (let season = 1; season <= (tmdbSeries.number_of_seasons ?? 0); season++) { const s = tmdbSeries.seasons?.find((s) => s.season_number === season); for (let episode = 1; episode <= (s?.episode_count ?? 0); episode++) { const ep = userData?.playStates?.find((p) => p.season === season && p.episode === episode); const upcoming = !s?.air_date || new Date(s.air_date) > new Date(); - if (!foundNext && !ep?.watched) { + if ( + !foundNext && + ((lastWatchedPlayState?.season ?? 0) < season || + ((lastWatchedPlayState?.season ?? 0) === season && + (lastWatchedPlayState?.episode ?? 0) < episode)) + ) { nextEpisode.set({ season, episode, @@ -233,14 +246,37 @@ export function useSeriesUserData(tmdbId: string) { })) }) .then(async (states) => { - await seriesUserDataStore.refresh(tmdbId); + await seriesUserDataRefresher.refresh(tmdbId); return states; }) .finally(() => { - libraryItemsDataStore.refreshIn(1500); + libraryRefresher.refreshIn(500); }); } + const getVideoProps = async () => { + const tmdbSeriesData = get(tmdbSeriesRequest); + const { season, episode, progress } = get(nextEpisode) ?? {}; + + if (season === undefined || episode === undefined) { + createErrorNotification('Could not find next episode'); + return; + } + + const tmdbEpisode = await tmdbApi.v3 + .tvEpisodeDetails(Number(tmdbId), season, episode) + .then((r) => r.data); + + return { + tmdbId, + season, + episode, + progress: progress ?? 0, + title: tmdbEpisode?.name ?? 'Unknown', + subtitle: tmdbSeriesData?.name ?? 'Unknown' + }; + }; + return { tmdbSeries: tmdbSeriesRequest.promise, ...libraryStore, @@ -250,12 +286,11 @@ export function useSeriesUserData(tmdbId: string) { isWatched, toggleIsWatched, handleAutoplay: async () => { - const { season, episode, progress } = get(nextEpisode) ?? {}; + const videoProps = await getVideoProps(); - if (season === undefined || episode === undefined) { - createErrorNotification('Could not find next episode'); - return; - } + if (!videoProps) return; + + const { season, episode } = videoProps; const { key, source } = await getAutoplayStream({ tmdbId, season, episode }); @@ -268,10 +303,7 @@ export function useSeriesUserData(tmdbId: string) { id: Symbol(), component: TmdbVideoPlayer, props: { - tmdbId, - season, - episode, - progress, + ...videoProps, key, source }, @@ -281,12 +313,11 @@ export function useSeriesUserData(tmdbId: string) { background?.focus(); }, handleOpenStreamSelector: async () => { - const { season, episode } = get(nextEpisode) ?? {}; + const videoProps = await getVideoProps(); - if (season === undefined || episode === undefined) { - createErrorNotification('Could not find next episode'); - return; - } + if (!videoProps) return; + + const { season, episode } = videoProps; createModal(StreamSelectorModal, { getStreams: (s) => getStreams(s, tmdbId, season, episode), @@ -295,10 +326,7 @@ export function useSeriesUserData(tmdbId: string) { id: Symbol(), component: TmdbVideoPlayer, props: { - tmdbId, - season, - episode, - progress: get(nextEpisode)?.progress, + ...videoProps, key: stream.key, source }, @@ -320,7 +348,17 @@ export function useSeriesUserData(tmdbId: string) { export function useMovieUserData(tmdbId: string) { const background = getBackgroundPage(); - const userData = movieUserDataStore.subscribe(tmdbId); + + const userData = useRequest( + () => reiverrApi.users.getMovieUserData(get(user)?.id as string, tmdbId).then((r) => r.data), + { + refresher: movieUserDataRefresher, + key: tmdbId + } + ); + + const tmdbMovie = useRequest(() => tmdbApi.getMovieFull(Number(tmdbId))); + const libraryStore = useUserLibrary('movie', tmdbId, userData); const canStreamStore = useCanStream(); const isWatchedStore = useIsWatched(userData, (userId, watched) => @@ -330,10 +368,24 @@ export function useMovieUserData(tmdbId: string) { ); const progress = derived(userData, ($userData) => $userData?.playState?.progress ?? 0); + const getVideoProps = () => { + const tmdbMovieData = get(tmdbMovie); + + return { + tmdbId, + progress: get(progress), + title: tmdbMovieData?.title ?? 'Unknown', + subtitle: tmdbMovieData?.release_date + ? new Date(tmdbMovieData.release_date).getFullYear() + : undefined + }; + }; + return { ...libraryStore, ...canStreamStore, ...isWatchedStore, + tmdbMovie: { subscribe: tmdbMovie.promise.subscribe }, progress, handleAutoplay: async () => { const { key, source } = await getAutoplayStream({ tmdbId }); @@ -347,8 +399,7 @@ export function useMovieUserData(tmdbId: string) { id: Symbol(), component: TmdbVideoPlayer, props: { - tmdbId, - progress: get(progress), + ...getVideoProps(), key, source }, @@ -365,8 +416,7 @@ export function useMovieUserData(tmdbId: string) { id: Symbol(), component: TmdbVideoPlayer, props: { - tmdbId, - progress: get(progress), + ...getVideoProps(), key: stream.key, source }, @@ -377,27 +427,60 @@ export function useMovieUserData(tmdbId: string) { } }); }, - unsubscribe: () => userData.unsubscribe() + unsubscribe: () => { + userData.unsubscribe(); + tmdbMovie.unsubscribe(); + } }; } export function useEpisodeUserData(tmdbId: string, season: number, episode: number) { const background = getBackgroundPage(); - const userData = episodeUserDataStore.subscribe(tmdbId, season, episode); + const userData = useRequest( + () => + reiverrApi.users + .getEpisodeUserData(get(user)?.id as string, tmdbId, season, episode) + .then((r) => r.data), + { + refresher: episodeUserDataRefresher, + key: `${tmdbId}-${season}-${episode}` + } + ); + + const tmdbEpisode = useRequest(() => + tmdbApi.v3.tvEpisodeDetails(Number(tmdbId), season, episode).then((r) => r.data) + ); + const canStreamStore = useCanStream(); const isWatchedStore = useIsWatched(userData, (userId, watched) => reiverrApi.users .updateEpisodePlayStateByTmdbId(userId, tmdbId, season, episode, { watched }) - .finally(() => seriesUserDataStore.refresh(tmdbId)) + .finally(() => seriesUserDataRefresher.refresh(tmdbId)) ); const progress = derived(userData, ($userData) => $userData?.playState?.progress ?? 0); + const getVideoProps = async () => { + const tmdbEpisodeData = get(tmdbEpisode); + + const tmdbSeries = await tmdbApi.getSeriesFull(Number(tmdbId)); + + return { + tmdbId, + season, + episode, + progress: get(progress), + title: tmdbEpisodeData?.name ?? 'Unknown', + subtitle: tmdbSeries?.name ?? 'Unknown' + }; + }; + return { ...canStreamStore, ...isWatchedStore, + tmdbEpisode: { subscribe: tmdbEpisode.promise.subscribe }, progress, handleAutoplay: async () => { // getAutoplayStream({ tmdbId, season, episode, progress: get(progress) }); @@ -412,10 +495,7 @@ export function useEpisodeUserData(tmdbId: string, season: number, episode: numb id: Symbol(), component: TmdbVideoPlayer, props: { - tmdbId, - season, - episode, - progress: get(progress), + ...(await getVideoProps()), key, source }, @@ -427,15 +507,12 @@ export function useEpisodeUserData(tmdbId: string, season: number, episode: numb handleOpenStreamSelector: async () => { createModal(StreamSelectorModal, { getStreams: (s) => getStreams(s, tmdbId, season, episode), - selectStream: (source, stream) => { + selectStream: async (source, stream) => { background?.setVideo({ id: Symbol(), component: TmdbVideoPlayer, props: { - tmdbId, - season, - episode, - progress: get(progress), + ...(await getVideoProps()), key: stream.key, source }, @@ -446,6 +523,9 @@ export function useEpisodeUserData(tmdbId: string, season: number, episode: numb } }); }, - unsubscribe: () => userData.unsubscribe() + unsubscribe: () => { + userData.unsubscribe(); + tmdbEpisode.unsubscribe(); + } }; }