diff --git a/backend/packages/jellyfin.plugin/src/index.ts b/backend/packages/jellyfin.plugin/src/index.ts index 08dcec2..96c857b 100644 --- a/backend/packages/jellyfin.plugin/src/index.ts +++ b/backend/packages/jellyfin.plugin/src/index.ts @@ -114,7 +114,7 @@ export class JellyfinCatalogueProvider implements CatalogueProvider { // const items = ( // await getLibraryItems(new PluginContext(context.settings, context.token)) // ).filter((i) => i.ProviderIds?.Tmdb && i.Type === 'Movie'); - const items = await new PluginContext( + const data = await new PluginContext( context.settings, context.token, ).api.items @@ -137,21 +137,18 @@ export class JellyfinCatalogueProvider implements CatalogueProvider { startIndex: (pagination.page - 1) * pagination.itemsPerPage, limit: pagination.itemsPerPage, }) - .then((res) => res.data.Items ?? []) - .catch((e) => { - console.error('error fetching items', e); - return []; - }); + .then((res) => res.data); return { - total: items.length, + total: data.TotalRecordCount ?? data.Items?.length ?? 0, page: pagination.page, itemsPerPage: pagination.itemsPerPage, - items: items.map((item) => ({ - id: item.ProviderIds?.Tmdb, - tmdbId: item.ProviderIds?.Tmdb, - mediaType: 'movie' as const, - })), + items: + data?.Items?.map((item) => ({ + id: item.ProviderIds?.Tmdb, + tmdbId: item.ProviderIds?.Tmdb, + mediaType: 'movie' as const, + })) ?? [], }; }; @@ -173,7 +170,7 @@ export class JellyfinCatalogueProvider implements CatalogueProvider { sortBy.push(ItemSortBy.DateCreated); } - const items = await new PluginContext( + const data = await new PluginContext( context.settings, context.token, ).api.items @@ -196,21 +193,18 @@ export class JellyfinCatalogueProvider implements CatalogueProvider { startIndex: (pagination.page - 1) * pagination.itemsPerPage, limit: pagination.itemsPerPage, }) - .then((res) => res.data.Items ?? []) - .catch((e) => { - console.error('error fetching items', e); - return []; - }); + .then((res) => res.data); return { - total: items.length, + total: data.TotalRecordCount ?? data.Items?.length ?? 0, page: pagination.page, itemsPerPage: pagination.itemsPerPage, - items: items.map((item) => ({ - id: item.ProviderIds?.Tmdb, - tmdbId: item.ProviderIds?.Tmdb, - mediaType: 'series' as const, - })), + items: + data?.Items?.map((item) => ({ + id: item.ProviderIds?.Tmdb, + tmdbId: item.ProviderIds?.Tmdb, + mediaType: 'series' as const, + })) ?? [], }; }; } diff --git a/backend/src/common/common.decorator.ts b/backend/src/common/common.decorator.ts index aa511a1..0774d55 100644 --- a/backend/src/common/common.decorator.ts +++ b/backend/src/common/common.decorator.ts @@ -4,11 +4,16 @@ import { ExecutionContext, Type, } from '@nestjs/common'; -import { PaginatedResponseDto, PaginationParamsDto } from './common.dto'; -import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger'; +import { PaginatedResponseDto, PaginationDto } from './common.dto'; +import { + ApiExtraModels, + ApiOkResponse, + ApiQuery, + getSchemaPath, +} from '@nestjs/swagger'; export const GetPaginationParams = createParamDecorator( - (data: number | undefined, ctx: ExecutionContext): PaginationParamsDto => { + (data: number | undefined, ctx: ExecutionContext): PaginationDto => { const request = ctx.switchToHttp().getRequest(); const page = parseInt(request.query.page, 10) || 1; const itemsPerPage = parseInt(request.query.itemsPerPage, 10) || data || 50; @@ -20,6 +25,20 @@ export const GetPaginationParams = createParamDecorator( }, ); +export const PaginationApiQuery = () => + applyDecorators( + ApiQuery({ + name: 'page', + required: false, + type: 'number', + }), + ApiQuery({ + name: 'itemsPerPage', + required: false, + type: 'number', + }), + ); + export const PaginatedApiOkResponse = >( data: GenericType, ) => diff --git a/backend/src/common/common.dto.ts b/backend/src/common/common.dto.ts index 5b5c865..a234ae1 100644 --- a/backend/src/common/common.dto.ts +++ b/backend/src/common/common.dto.ts @@ -35,7 +35,7 @@ export class PaginatedResponseDto implements PaginatedResponse { items: T[]; } -export class PaginationParamsDto implements PaginationParams { +export class PaginationDto implements PaginationParams { @ApiProperty() page: number; diff --git a/backend/src/user-data/library/library.controller.ts b/backend/src/user-data/library/library.controller.ts index 0c3e743..1e90c19 100644 --- a/backend/src/user-data/library/library.controller.ts +++ b/backend/src/user-data/library/library.controller.ts @@ -12,13 +12,14 @@ import { import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; import { GetAuthToken, UserAccessControl } from 'src/auth/auth.guard'; import { - GetPaginationParams, + GetPaginationParams as GetPaginationQuery, PaginatedApiOkResponse, + PaginationApiQuery, } from 'src/common/common.decorator'; import { MediaType, PaginatedResponseDto, - PaginationParamsDto, + PaginationDto, SuccessResponseDto, } from 'src/common/common.dto'; import { @@ -42,9 +43,10 @@ export class LibraryController { @ApiQuery({ name: 'type', enum: MyListTypeFilter, required: false }) @ApiQuery({ name: 'order', enum: MyListOrder, required: false }) @ApiQuery({ name: 'direction', enum: OrderDirection, required: false }) + @PaginationApiQuery() @PaginatedApiOkResponse(LibraryItemDto) async getMyList( - @GetPaginationParams() pagination: PaginationParamsDto, + @GetPaginationQuery() pagination: PaginationDto, @Param('userId') userId: string, @Query('status', new ParseEnumPipe(MyListStatusFilter, { optional: true })) status?: MyListStatusFilter, @@ -78,9 +80,10 @@ export class LibraryController { @ApiQuery({ name: 'type', enum: CatalogueTypeFilter, required: false }) @ApiQuery({ name: 'order', required: false }) @ApiQuery({ name: 'direction', required: false }) + @PaginationApiQuery() @PaginatedApiOkResponse(LibraryItemDto) async getCatalogue( - @GetPaginationParams() pagination: PaginationParamsDto, + @GetPaginationQuery() pagination: PaginationDto, @Param('userId') userId: string, @Param('sourceId') sourceId: string, @GetAuthToken() token: string, @@ -91,7 +94,7 @@ export class LibraryController { @Query('direction') direction?: string, ): Promise> { - const items = this.libraryService.getCatalogueItems({ + const items = await this.libraryService.getCatalogueItems({ sourceId, token, pagination, diff --git a/backend/src/user-data/library/library.service.ts b/backend/src/user-data/library/library.service.ts index 18c7ac9..cae7b87 100644 --- a/backend/src/user-data/library/library.service.ts +++ b/backend/src/user-data/library/library.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { MediaType, PaginatedResponseDto, - PaginationParamsDto, + PaginationDto, } from 'src/common/common.dto'; import { MetadataService } from 'src/metadata/metadata.service'; import { MediaSourcesService } from 'src/users/media-sources/media-sources.service'; @@ -42,7 +42,7 @@ export class LibraryService { /** TODO: decouple librayItem and movie/seriesItem */ async getMyList(options: { userId: string; - pagination: PaginationParamsDto; + pagination: PaginationDto; type?: MyListTypeFilter; status?: MyListStatusFilter; order?: MyListOrder; @@ -244,7 +244,7 @@ export class LibraryService { async getCatalogueItems(options: { sourceId: string; token: string; - pagination: PaginationParamsDto; + pagination: PaginationDto; type?: CatalogueTypeFilter; order?: string; direction?: string; @@ -260,7 +260,12 @@ export class LibraryService { const connection = await this.mediaSourceService.getConnection(sourceId); - if (!connection) return; + if (!connection) { + console.error( + `No connection found for sourceId: ${sourceId}. Please check your media source configuration.`, + ); + throw new Error('No connection found'); + } const combined = connection.provider.catalogueProvider.getCatalogue; const movies = connection.provider.catalogueProvider.getMovieCatalogue; @@ -360,6 +365,10 @@ export class LibraryService { ), }; } + + throw new Error( + `No catalogue provider found for type: ${type}. Please check your media source configuration.`, + ); } async findByTmdbId( diff --git a/backend/src/users/media-sources/media-sources.service.ts b/backend/src/users/media-sources/media-sources.service.ts index b7412af..aa489ad 100644 --- a/backend/src/users/media-sources/media-sources.service.ts +++ b/backend/src/users/media-sources/media-sources.service.ts @@ -1,6 +1,10 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { + SourceProvider, + ValidationResponse, +} from '@aleksilassila/reiverr-plugin'; +import { Inject, Injectable } from '@nestjs/common'; +import { SourceProvidersService } from 'src/source-providers/source-providers.service'; import { User } from 'src/users/user.entity'; -import { UsersService } from 'src/users/users.service'; import { Repository } from 'typeorm'; import { MediaSourceDto, @@ -8,12 +12,6 @@ import { } from './media-source.dto'; import { MediaSource } from './media-source.entity'; import { MEIDA_SOURCE_REPOSITORY } from './media-source.providers'; -import { SourceProvidersService } from 'src/source-providers/source-providers.service'; -import { - SourceProvider, - ValidationResponse, -} from '@aleksilassila/reiverr-plugin'; -import { PaginationParamsDto } from 'src/common/common.dto'; export enum MediaSourcesServiceError { SourceNotFound = 'SourceNotFound', diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index a2fa10c..d56f51a 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -1621,6 +1621,8 @@ export class Api extends HttpClient @@ -1651,6 +1653,8 @@ export class Api extends HttpClient diff --git a/src/lib/pages/LibraryPage/CatalogueOptions.svelte b/src/lib/pages/LibraryPage/CatalogueOptions.svelte index b237ec6..a4a5097 100644 --- a/src/lib/pages/LibraryPage/CatalogueOptions.svelte +++ b/src/lib/pages/LibraryPage/CatalogueOptions.svelte @@ -1,5 +1,5 @@ @@ -73,11 +108,21 @@ Options - {#await items then items} + + {#if $data.length} + + {#each $data.map((i) => i.tmdbItem) as item (item.id)} + + {/each} + +
+ {/if} + + diff --git a/src/lib/pages/LibraryPage/MyListOptions.svelte b/src/lib/pages/LibraryPage/MyListOptions.svelte index 4c46a95..4cd0605 100644 --- a/src/lib/pages/LibraryPage/MyListOptions.svelte +++ b/src/lib/pages/LibraryPage/MyListOptions.svelte @@ -55,7 +55,7 @@ on:change={({ detail: separateWatched }) => libraryViewSettings.update((settings) => ({ ...settings, - separateWatched: !separateWatched + separateWatched: !settings.separateWatched }))} />
diff --git a/src/lib/pages/LibraryPage/MyListTab.svelte b/src/lib/pages/LibraryPage/MyListTab.svelte index b72d95f..e33e767 100644 --- a/src/lib/pages/LibraryPage/MyListTab.svelte +++ b/src/lib/pages/LibraryPage/MyListTab.svelte @@ -15,6 +15,7 @@ import { libraryViewSettings } from './LibraryPage'; import MyListOptions from './MyListOptions.svelte'; import TabItem from './TabItem.svelte'; + import { usePaginatedRequest } from '$lib/stores/data.store'; const { registrar } = getStackRouterControls(); const { topVisible } = getScrollContext(); @@ -22,40 +23,79 @@ let didMount = false; let category: 'all' | 'series' | 'movies' = 'all'; - $: upcoming = - $libraryViewSettings.separateWatched && $user?.id - ? reiverrApi.library - .getMyList($user.id, { - status: 'upcoming', - order: $libraryViewSettings.order, - type: category, - direction: $libraryViewSettings.direction - }) - .then((i) => i.data.items) - : Promise.resolve([]); + const { + data: upcoming, + interactionObserver: upcomingObserver, + reset: resetUpcoming + } = usePaginatedRequest( + async (page) => { + if (!$user?.id || !$libraryViewSettings.separateWatched) { + return { items: [], total: 0, itemsPerPage: 0, page: 0 }; + } - $: watched = - $libraryViewSettings.separateWatched && $user?.id - ? reiverrApi.library - .getMyList($user.id, { - status: 'watched', - type: category, - order: $libraryViewSettings.order, - direction: $libraryViewSettings.direction - }) - .then((i) => i.data.items) - : Promise.resolve([]); - - $: items = $user?.id - ? reiverrApi.library + return reiverrApi.library .getMyList($user.id, { - ...($libraryViewSettings.separateWatched ? { status: 'unwatched' } : {}), + status: 'upcoming', type: category, order: $libraryViewSettings.order, - direction: $libraryViewSettings.direction + direction: $libraryViewSettings.direction, + page }) - .then((i) => i.data.items) - : Promise.resolve([]); + .then((i) => i.data); + }, + { loadFirstPage: false } + ); + + const { + data: watched, + interactionObserver: watchedObserver, + reset: resetWatched + } = usePaginatedRequest( + async (page) => { + if (!$user?.id || !$libraryViewSettings.separateWatched) { + return { items: [], total: 0, itemsPerPage: 0, page: 0 }; + } + + return reiverrApi.library + .getMyList($user.id, { + status: 'watched', + type: category, + order: $libraryViewSettings.order, + direction: $libraryViewSettings.direction, + page + }) + .then((i) => i.data); + }, + { loadFirstPage: false } + ); + + const { interactionObserver, data, reset } = usePaginatedRequest( + async (page) => { + if (!$user?.id) { + return { items: [], total: 0, itemsPerPage: 0, page: 0 }; + } + + return reiverrApi.library + .getMyList($user.id, { + type: category, + order: $libraryViewSettings.order, + direction: $libraryViewSettings.direction, + ...($libraryViewSettings.separateWatched ? { status: 'unwatched' } : {}), + page + }) + .then((i) => i.data); + }, + { loadFirstPage: false } + ); + + $: { + $libraryViewSettings; + category; + $user; + reset({ loadFirstPage: true }); + resetUpcoming({ loadFirstPage: true }); + resetWatched({ loadFirstPage: true }); + } $: viewSettingsKey = $libraryViewSettings && Symbol(); @@ -96,68 +136,68 @@ focusedChild class="flex-1 flex flex-col" > - {#await upcoming then upcoming} - {#if upcoming.length} -
- - {#key viewSettingsKey} - {#each upcoming as item (item.tmdbId)} - - {/each} - {/key} - -
- {/if} - {/await} - {#await items then items} - {#if items.length} -
-
My List
- - {#key viewSettingsKey} - {#each items as item, index (item.tmdbId)} - - {/each} - {/key} - -
- {/if} - {/await} - {#await watched then watched} - {#if watched.length} -
-
Watched
- - {#key viewSettingsKey} - {#each watched as item (item.tmdbId)} - - {/each} - {/key} - -
- {/if} - {/await} - {#await Promise.all([upcoming, items, watched]) then [upcoming, items, watched]} + {#if $upcoming.length} +
+ + {#key viewSettingsKey} + {#each $upcoming as item (item.tmdbId)} + + {/each} + {/key} +
+ +
+ {/if} + {#if $data.length} +
+
My List
+ + {#key viewSettingsKey} + {#each $data as item, index (item.tmdbId)} + + {/each} + {/key} + +
+
+ {/if} + {#if $watched.length} +
+
Watched
+ + {#key viewSettingsKey} + {#each $watched as item (item.tmdbId)} + + {/each} + {/key} + +
+
+ {/if} + + {#if !$upcoming.length && !$data.length && !$watched.length} + + Add content to your list to see it here. + + {/if} + +
diff --git a/src/lib/stores/data.store.ts b/src/lib/stores/data.store.ts index 26aed26..de4aa97 100644 --- a/src/lib/stores/data.store.ts +++ b/src/lib/stores/data.store.ts @@ -2,6 +2,8 @@ import { tick } from 'svelte'; import { derived, get, writable } 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'; type Request = ReturnType>; @@ -176,6 +178,101 @@ export function useDerivedRequestsStore, TResponse, }; } +export function usePaginatedRequest( + fn: (page: number) => Promise<{ items: TResponseItem[] } & PaginatedResponseDto>, + options: { initialPage?: number; loadFirstPage?: boolean } = {} +) { + const initialPage = options.initialPage ?? 1; + + let requestId = Symbol(); + const nextPage = writable(initialPage); + const loadingPage = writable(initialPage - 1); + let hasNextPage = true; + const data = writable([]); + const isLoading = writable(false); + let promise: Promise | undefined; + + if (options.loadFirstPage !== false) requestNextPage(); + + async function requestNextPage() { + if (get(loadingPage) === get(nextPage)) return; + if (!hasNextPage) return; + + loadingPage.update((p) => p + 1); + + const currentPage = get(nextPage); + const id = requestId; + + if (promise) await promise; + + if (!hasNextPage) return; + + isLoading.set(true); + promise = fn(currentPage) + .then((res) => { + if (id !== requestId) return; + + if (res.items.length < res.itemsPerPage) { + hasNextPage = false; + } + + data.update((d) => [...d, ...res.items]); + }) + .finally(() => { + if (id !== requestId) return; + + nextPage.update((p) => p + 1); + isLoading.set(false); + }); + } + + const interactionObserver: Action = (node) => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + requestNextPage(); + } + }); + }, + { + threshold: 0.1 + } + ); + observer.observe(node); + + return { + destroy() { + observer.unobserve(node); + } + }; + }; + + function reset(resetOptions: { loadFirstPage?: boolean } = { loadFirstPage: false }) { + nextPage.set(initialPage); + loadingPage.set(initialPage - 1); + hasNextPage = true; + data.set([]); + promise = undefined; + isLoading.set(false); + requestId = Symbol(); + if (resetOptions.loadFirstPage !== false) requestNextPage(); + else if (options.loadFirstPage !== false) requestNextPage(); + } + + return { + data: { + subscribe: data.subscribe + }, + isLoading: { + subscribe: isLoading.subscribe + }, + requestNextPage, + interactionObserver, + reset + }; +} + export const tmdbMovieDataStore = useRequestsStore((id: number) => tmdbApi.getTmdbMovie(id)); export const tmdbSeriesDataStore = useRequestsStore((id: number) => tmdbApi.getTmdbSeries(id)); export const tmdbEpisodeDataStore = useRequestsStore(