diff --git a/backend/packages/jellyfin.plugin/src/index.ts b/backend/packages/jellyfin.plugin/src/index.ts index 5d16c1f..08dcec2 100644 --- a/backend/packages/jellyfin.plugin/src/index.ts +++ b/backend/packages/jellyfin.plugin/src/index.ts @@ -1,8 +1,10 @@ import { CatalogueItem, CatalogueProvider, + DirectionOption, EpisodeMetadata, MovieMetadata, + OrderOption, PaginatedResponse, PaginationParams, PlaybackConfig, @@ -16,7 +18,12 @@ import { UserContext, } from '@aleksilassila/reiverr-plugin'; import { Readable } from 'stream'; -import { BaseItemKind, ItemFields } from './jellyfin.openapi'; +import { + BaseItemKind, + ItemFields, + ItemSortBy, + SortOrder, +} from './jellyfin.openapi'; import { JellyfinSettings, PluginContext } from './plugin-context'; import { JellyfinSettingsManager } from './settings'; import { @@ -55,56 +62,154 @@ async function getLibraryItems(context: PluginContext) { .then((res) => res.data.Items ?? []); } -export class JellyfinCatalogueProvider extends CatalogueProvider { - getMovieCatalogue = async ( - userContext: UserContext, - pagination: PaginationParams, - ): Promise> => { - const items = ( - await getLibraryItems( - new PluginContext(userContext.settings, userContext.token), - ) - ).filter((i) => i.ProviderIds?.Tmdb && i.Type === 'Movie'); +export class JellyfinCatalogueProvider implements CatalogueProvider { + getOrderOptions?: () => Promise = async () => { + const directions: DirectionOption[] = [ + { + label: 'Ascending', + value: 'asc', + }, + { + label: 'Descending', + value: 'desc', + }, + ]; - const startIndex = (pagination.page - 1) * pagination.itemsPerPage; - const endIndex = startIndex + pagination.itemsPerPage; + return [ + { + label: 'Title', + value: 'title', + directions, + }, + { + label: 'Date Added', + value: 'date-added', + directions, + }, + { + label: 'Date Created', + value: 'date-created', + directions, + }, + ]; + }; + + getMovieCatalogue?: (options: { + context: UserContext; + pagination: PaginationParams; + order?: string; + direction?: string; + }) => Promise> = async (options) => { + const { context, pagination, order, direction } = options; + + const sortBy: ItemSortBy[] = []; + + if (order === 'title') { + sortBy.push(ItemSortBy.Name); + } else if (order === 'date-added') { + sortBy.push(ItemSortBy.DateLastContentAdded); + } else if (order === 'date-created') { + sortBy.push(ItemSortBy.DateCreated); + } + // const items = ( + // await getLibraryItems(new PluginContext(context.settings, context.token)) + // ).filter((i) => i.ProviderIds?.Tmdb && i.Type === 'Movie'); + const items = await new PluginContext( + context.settings, + context.token, + ).api.items + .getItems({ + userId: context.settings.userId, + hasTmdbId: true, + recursive: true, + includeItemTypes: [BaseItemKind.Movie], + fields: [ + ItemFields.ProviderIds, + ItemFields.Genres, + ItemFields.DateLastMediaAdded, + ItemFields.DateCreated, + ItemFields.MediaSources, + ], + sortBy, + sortOrder: [ + direction === 'asc' ? SortOrder.Ascending : SortOrder.Descending, + ], + startIndex: (pagination.page - 1) * pagination.itemsPerPage, + limit: pagination.itemsPerPage, + }) + .then((res) => res.data.Items ?? []) + .catch((e) => { + console.error('error fetching items', e); + return []; + }); return { total: items.length, page: pagination.page, itemsPerPage: pagination.itemsPerPage, - items: items.slice(startIndex, endIndex).map((item) => ({ + items: items.map((item) => ({ id: item.ProviderIds?.Tmdb, tmdbId: item.ProviderIds?.Tmdb, - mediaType: 'movie', + mediaType: 'movie' as const, })), }; }; - getSeriesCatalogue?: ( - context: UserContext, - pagination: PaginationParams, - ) => Promise> = async ( - ContextCreator, - pagination, - ) => { - const items = ( - await getLibraryItems( - new PluginContext(ContextCreator.settings, ContextCreator.token), - ) - ).filter((i) => i.ProviderIds?.Tmdb && i.Type === 'Series'); + getSeriesCatalogue?: (options: { + context: UserContext; + pagination: PaginationParams; + order?: string; + direction?: 'asc' | 'desc'; + }) => Promise> = async (options) => { + const { context, pagination, order, direction } = options; - const startIndex = (pagination.page - 1) * pagination.itemsPerPage; - const endIndex = startIndex + pagination.itemsPerPage; + const sortBy: ItemSortBy[] = []; + + if (order === 'title') { + sortBy.push(ItemSortBy.Name); + } else if (order === 'date-added') { + sortBy.push(ItemSortBy.DateLastContentAdded); + } else if (order === 'date-created') { + sortBy.push(ItemSortBy.DateCreated); + } + + const items = await new PluginContext( + context.settings, + context.token, + ).api.items + .getItems({ + userId: context.settings.userId, + hasTmdbId: true, + recursive: true, + includeItemTypes: [BaseItemKind.Series], + fields: [ + ItemFields.ProviderIds, + ItemFields.Genres, + ItemFields.DateLastMediaAdded, + ItemFields.DateCreated, + ItemFields.MediaSources, + ], + sortBy, + sortOrder: [ + direction === 'asc' ? SortOrder.Ascending : SortOrder.Descending, + ], + startIndex: (pagination.page - 1) * pagination.itemsPerPage, + limit: pagination.itemsPerPage, + }) + .then((res) => res.data.Items ?? []) + .catch((e) => { + console.error('error fetching items', e); + return []; + }); return { total: items.length, page: pagination.page, itemsPerPage: pagination.itemsPerPage, - items: items.slice(startIndex, endIndex).map((item) => ({ + items: items.map((item) => ({ id: item.ProviderIds?.Tmdb, tmdbId: item.ProviderIds?.Tmdb, - mediaType: 'series', + mediaType: 'series' as const, })), }; }; diff --git a/backend/packages/reiverr-plugin/src/reiverr-plugin.ts b/backend/packages/reiverr-plugin/src/reiverr-plugin.ts index a8a82fd..62b8a76 100644 --- a/backend/packages/reiverr-plugin/src/reiverr-plugin.ts +++ b/backend/packages/reiverr-plugin/src/reiverr-plugin.ts @@ -10,7 +10,8 @@ import { ValidationResponse, Stream, StreamCandidate, - CatalogueSort, + DirectionOption, + OrderOption, } from './types'; import * as packageJson from '../package.json'; @@ -42,11 +43,21 @@ export class SettingsManager { } export class CatalogueProvider { - getSortOptions?: () => Promise = () => + getOrderOptions?: () => Promise = () => Promise.resolve([ { label: 'Title', - name: 'title', + value: 'title', + directions: [ + { + label: 'Ascending', + value: 'asc', + }, + { + label: 'Descending', + value: 'desc', + }, + ], }, ]); @@ -56,35 +67,43 @@ export class CatalogueProvider { /** * Returns an index of all items available in the source. */ - getCatalogue?: ( - context: UserContext, - pagination: PaginationParams, - ) => Promise>; + getCatalogue?: (options: { + context: UserContext; + pagination: PaginationParams; + order?: string; + direction?: string; + }) => Promise>; /** * Returns an index of all movies available in the source. */ - getMovieCatalogue?: ( - context: UserContext, - pagination: PaginationParams, - ) => Promise>; + getMovieCatalogue?: (options: { + context: UserContext; + pagination: PaginationParams; + order?: string; + direction?: string; + }) => Promise>; /** * Returns an index of all series available in the source. */ - getSeriesCatalogue?: ( - context: UserContext, - pagination: PaginationParams, - ) => Promise>; + getSeriesCatalogue?: (options: { + context: UserContext; + pagination: PaginationParams; + order?: string; + direction?: string; + }) => Promise>; /** * Filters my list items to only include those that are not available in the source. */ - getMissingInCatalogue?: ( - context: UserContext, - pagination: PaginationParams, - myListItems: Record, - ) => Promise>; + getMissingInCatalogue?: (options: { + context: UserContext; + pagination: PaginationParams; + order?: string; + direction?: string; + myListItems: Record; + }) => Promise>; } /** diff --git a/backend/packages/reiverr-plugin/src/types.ts b/backend/packages/reiverr-plugin/src/types.ts index 6a5d0ef..5e254ca 100644 --- a/backend/packages/reiverr-plugin/src/types.ts +++ b/backend/packages/reiverr-plugin/src/types.ts @@ -163,10 +163,15 @@ export type PaginationParams = { itemsPerPage: number; }; -// export type CatalogueFilters = {}; -export type CatalogueSort = { +export type DirectionOption = { label: string; - name: string; + value: string; +}; + +export type OrderOption = { + label: string; + value: string; + directions: DirectionOption[]; }; interface Metadata { diff --git a/backend/src/user-data/library/library.controller.ts b/backend/src/user-data/library/library.controller.ts index 55c29f5..0c3e743 100644 --- a/backend/src/user-data/library/library.controller.ts +++ b/backend/src/user-data/library/library.controller.ts @@ -22,7 +22,7 @@ import { SuccessResponseDto, } from 'src/common/common.dto'; import { - CatalogueFilter, + CatalogueTypeFilter as CatalogueTypeFilter, LibraryItemDto, MyListOrder, MyListStatusFilter, @@ -75,20 +75,29 @@ export class LibraryController { } @Get('catalogue/:sourceId') + @ApiQuery({ name: 'type', enum: CatalogueTypeFilter, required: false }) + @ApiQuery({ name: 'order', required: false }) + @ApiQuery({ name: 'direction', required: false }) @PaginatedApiOkResponse(LibraryItemDto) async getCatalogue( @GetPaginationParams() pagination: PaginationParamsDto, @Param('userId') userId: string, @Param('sourceId') sourceId: string, @GetAuthToken() token: string, - @Query('filter', new ParseEnumPipe(CatalogueFilter, { optional: true })) - filter: CatalogueFilter = CatalogueFilter.All, + @Query('type', new ParseEnumPipe(CatalogueTypeFilter, { optional: true })) + type?: CatalogueTypeFilter, + @Query('order') + order?: string, + @Query('direction') + direction?: string, ): Promise> { const items = this.libraryService.getCatalogueItems({ sourceId, token, pagination, - filter, + type, + order, + direction, }); if (!items) { diff --git a/backend/src/user-data/library/library.dto.ts b/backend/src/user-data/library/library.dto.ts index 4ee0d7d..e2afc7e 100644 --- a/backend/src/user-data/library/library.dto.ts +++ b/backend/src/user-data/library/library.dto.ts @@ -28,7 +28,7 @@ export enum MyListTypeFilter { All = 'all', } -export enum CatalogueFilter { +export enum CatalogueTypeFilter { All = 'all', Movies = 'movies', Series = 'series', diff --git a/backend/src/user-data/library/library.service.ts b/backend/src/user-data/library/library.service.ts index 2947abc..18c7ac9 100644 --- a/backend/src/user-data/library/library.service.ts +++ b/backend/src/user-data/library/library.service.ts @@ -14,6 +14,7 @@ import { MyListOrder, OrderDirection, MyListStatusFilter, + CatalogueTypeFilter, } from './library.dto'; import { LibraryItem } from './library.entity'; import { USER_LIBRARY_REPOSITORY } from './library.providers'; @@ -244,9 +245,18 @@ export class LibraryService { sourceId: string; token: string; pagination: PaginationParamsDto; - filter?: 'all' | 'movies' | 'series' | 'missing'; + type?: CatalogueTypeFilter; + order?: string; + direction?: string; }): Promise | undefined> { - const { sourceId, token, pagination, filter = 'all' } = options; + const { + sourceId, + token, + pagination, + type = CatalogueTypeFilter.All, + order, + direction, + } = options; const connection = await this.mediaSourceService.getConnection(sourceId); @@ -256,16 +266,18 @@ export class LibraryService { const movies = connection.provider.catalogueProvider.getMovieCatalogue; const series = connection.provider.catalogueProvider.getSeriesCatalogue; const missing = connection.provider.catalogueProvider.getMissingInCatalogue; - if (filter === 'all' && combined) { - const response = await combined( - { + if (type === CatalogueTypeFilter.All && combined) { + const response = await combined({ + context: { userId: connection.mediaSource.userId, settings: connection.mediaSource.pluginSettings, sourceId: connection.mediaSource.id, token, }, pagination, - ); + order, + direction, + }); return { ...response, @@ -273,16 +285,18 @@ export class LibraryService { response.items.map(async (item) => this.getLibraryItemDto(item)), ), }; - } else if (filter === 'movies' && movies) { - const response = await movies( - { + } else if (type === CatalogueTypeFilter.Movies && movies) { + const response = await movies({ + context: { userId: connection.mediaSource.userId, settings: connection.mediaSource.pluginSettings, sourceId: connection.mediaSource.id, token, }, pagination, - ); + order, + direction, + }); return { ...response, @@ -290,16 +304,18 @@ export class LibraryService { response.items.map(async (item) => this.getLibraryItemDto(item)), ), }; - } else if (filter === 'series' && series) { - const response = await series( - { + } else if (type === CatalogueTypeFilter.Series && series) { + const response = await series({ + context: { userId: connection.mediaSource.userId, settings: connection.mediaSource.pluginSettings, sourceId: connection.mediaSource.id, token, }, pagination, - ); + order, + direction, + }); return { ...response, @@ -307,7 +323,7 @@ export class LibraryService { response.items.map(async (item) => this.getLibraryItemDto(item)), ), }; - } else if (filter === 'missing' && missing) { + } else if (type === CatalogueTypeFilter.Missing && missing) { const tmdbIdToMyListItem: Record = {}; const myListItems = await this.getMyList({ pagination: { @@ -324,16 +340,18 @@ export class LibraryService { tmdbIdToMyListItem[i.tmdbId] = i; }); - const response = await missing( - { + const response = await missing({ + context: { userId: connection.mediaSource.userId, settings: connection.mediaSource.pluginSettings, sourceId: connection.mediaSource.id, token, }, pagination, - tmdbIdToMyListItem, - ); + myListItems: tmdbIdToMyListItem, + order, + direction, + }); return { ...response, diff --git a/backend/src/users/media-sources/media-source.dto.ts b/backend/src/users/media-sources/media-source.dto.ts index defd2d6..5763660 100644 --- a/backend/src/users/media-sources/media-source.dto.ts +++ b/backend/src/users/media-sources/media-source.dto.ts @@ -1,8 +1,27 @@ import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger'; import { PickAndPartial } from 'src/common/common.dto'; -import { MediaSource } from './media-source.entity'; import { ValidationResponseDto } from 'src/source-providers/source-provider.dto'; -import { SourceProvider } from '@aleksilassila/reiverr-plugin'; +import { MediaSource } from './media-source.entity'; +import { DirectionOption, OrderOption } from '@aleksilassila/reiverr-plugin'; + +class CatalogueOrderDirectionOption implements DirectionOption { + @ApiProperty() + label: string; + + @ApiProperty() + value: string; +} + +class CatalogueSortOptionDto implements OrderOption { + @ApiProperty() + label: string; + + @ApiProperty() + value: string; + + @ApiProperty({ type: [CatalogueOrderDirectionOption] }) + directions: CatalogueOrderDirectionOption[]; +} export class MediaSourceCapabilitiesDto { @ApiProperty() @@ -20,6 +39,9 @@ export class MediaSourceCapabilitiesDto { @ApiProperty() missingCatalogue: boolean; + @ApiProperty({ type: [CatalogueSortOptionDto] }) + sortOptions: CatalogueSortOptionDto[]; + // @ApiProperty() // request: boolean; diff --git a/backend/src/users/media-sources/media-sources.service.ts b/backend/src/users/media-sources/media-sources.service.ts index ad29397..b7412af 100644 --- a/backend/src/users/media-sources/media-sources.service.ts +++ b/backend/src/users/media-sources/media-sources.service.ts @@ -172,6 +172,7 @@ export class MediaSourcesService { const seriesCatalogue = !!catalogueProvider?.getSeriesCatalogue; const combinedCatalogue = !!catalogueProvider?.getCatalogue; const missingCatalogue = !!catalogueProvider?.getMissingInCatalogue; + const sortOptions = catalogueProvider?.getOrderOptions(); return { ...mediaSource, @@ -186,6 +187,7 @@ export class MediaSourcesService { seriesCatalogue, combinedCatalogue, missingCatalogue, + sortOptions: (await sortOptions) ?? [], }, }; } diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index f8e8cda..a2fa10c 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -126,12 +126,24 @@ export interface LibraryItem { createdAt: string; } +export interface CatalogueOrderDirectionOption { + label: string; + value: string; +} + +export interface CatalogueSortOptionDto { + label: string; + value: string; + directions: CatalogueOrderDirectionOption[]; +} + export interface MediaSourceCapabilitiesDto { catalogues: boolean; moviesCatalogue: boolean; seriesCatalogue: boolean; combinedCatalogue: boolean; missingCatalogue: boolean; + sortOptions: CatalogueSortOptionDto[]; } export interface MediaSourceDto { @@ -1635,8 +1647,10 @@ export class Api extends HttpClient diff --git a/src/lib/pages/LibraryPage/CatalogueOptions.svelte b/src/lib/pages/LibraryPage/CatalogueOptions.svelte new file mode 100644 index 0000000..b237ec6 --- /dev/null +++ b/src/lib/pages/LibraryPage/CatalogueOptions.svelte @@ -0,0 +1,83 @@ + + + +

+ View Options +

+ + {#if sortOptions.length} + handleSelectSort(order)} + /> + {/if} + + {#if directionOptions.length > 0} + handleSelectDirection(direction)} + /> + {/if} + + {#if !directionOptions.length && !sortOptions.length} +

This catalogue doesn't have any view options.

+ {/if} + + +
diff --git a/src/lib/pages/LibraryPage/CatalogueTab.svelte b/src/lib/pages/LibraryPage/CatalogueTab.svelte index 040ee02..8177617 100644 --- a/src/lib/pages/LibraryPage/CatalogueTab.svelte +++ b/src/lib/pages/LibraryPage/CatalogueTab.svelte @@ -4,12 +4,24 @@ import CardGrid from '$lib/components/CardGrid.svelte'; import Container from '$lib/components/Container.svelte'; import { getStackRouterControls } from '$lib/components/StackRouter/StackRouter'; + import { createLocalStorageStore } from '$lib/stores/localstorage.store'; import { reiverrApi } from '$lib/stores/user.store'; + import { MixerHorizontal } from 'radix-icons-svelte'; + import CatalogueOptions from './CatalogueOptions.svelte'; import TabItem from './TabItem.svelte'; + import Button from '$lib/components/Button.svelte'; + import { createModal } from '$lib/components/Modal/modal.store'; export let source: MediaSourceDto; const { registrar } = getStackRouterControls(); + const viewSettings = createLocalStorageStore<{ + order: string | undefined; + direction: string | undefined; + }>('catalogue-view-settings-' + source.id, { + order: source.capabilities.sortOptions[0]?.value, + direction: source.capabilities.sortOptions[0]?.directions[0]?.value + }); let filters: string[] = []; let selectedFilter = ''; @@ -27,25 +39,39 @@ $: items = selectedFilter ? reiverrApi.library .getCatalogue(source.userId, source.id, { - filter: + type: { - All: 'all', - Movies: 'movies', - Series: 'series', - Missing: 'missing' - }[selectedFilter] ?? 'all' + All: 'all' as const, + Movies: 'movies' as const, + Series: 'series' as const, + Missing: 'missing' as const + }[selectedFilter] ?? 'all', + order: $viewSettings.order, + direction: $viewSettings.direction }) .then((r) => r.data.items) : Promise.resolve([]); - - - {#each filters ?? [] as filter} - (selectedFilter = filter)}> - {filter} - - {/each} + + +
+ {#each filters ?? [] as filter} + (selectedFilter = filter)}> + {filter} + + {/each} +
+
{#await items then items} diff --git a/src/lib/pages/LibraryPage/OptionsDialog.LibraryPage.svelte b/src/lib/pages/LibraryPage/MyListOptions.svelte similarity index 100% rename from src/lib/pages/LibraryPage/OptionsDialog.LibraryPage.svelte rename to src/lib/pages/LibraryPage/MyListOptions.svelte diff --git a/src/lib/pages/LibraryPage/MyListTab.svelte b/src/lib/pages/LibraryPage/MyListTab.svelte index dc9ede9..b72d95f 100644 --- a/src/lib/pages/LibraryPage/MyListTab.svelte +++ b/src/lib/pages/LibraryPage/MyListTab.svelte @@ -1,5 +1,4 @@