diff --git a/.prettierignore b/.prettierignore index 76e12cd..6e6d914 100644 --- a/.prettierignore +++ b/.prettierignore @@ -33,3 +33,4 @@ yarn.lock backend\packages\jellyfin.plugin\src\jellyfin.openapi.ts backend\swagger-spec.json **/*.generated.d.ts +**/*.openapi.ts \ No newline at end of file diff --git a/backend/packages/jellyfin.plugin/src/index.ts b/backend/packages/jellyfin.plugin/src/index.ts index 85eb6b5..5d16c1f 100644 --- a/backend/packages/jellyfin.plugin/src/index.ts +++ b/backend/packages/jellyfin.plugin/src/index.ts @@ -1,5 +1,6 @@ import { CatalogueItem, + CatalogueProvider, EpisodeMetadata, MovieMetadata, PaginatedResponse, @@ -32,43 +33,35 @@ export default class JellyfinPluginProvider extends PluginProvider { } } -class JellyfinProvider extends SourceProvider { - name: string = 'jellyfin'; - - private getProxyUrl(sourceId: string) { - return `/api/sources/${sourceId}/proxy`; - } - - settingsManager: SettingsManager = new JellyfinSettingsManager(); - - private async getLibraryItems(context: PluginContext) { - return context.api.items - .getItems({ - userId: context.settings.userId, - // hasTmdbId: true, - recursive: true, - includeItemTypes: [ - BaseItemKind.Movie, - BaseItemKind.Series, - BaseItemKind.Episode, - ], - fields: [ - ItemFields.ProviderIds, - ItemFields.Genres, - ItemFields.DateLastMediaAdded, - ItemFields.DateCreated, - ItemFields.MediaSources, - ], - }) - .then((res) => res.data.Items ?? []); - } +async function getLibraryItems(context: PluginContext) { + return context.api.items + .getItems({ + userId: context.settings.userId, + // hasTmdbId: true, + recursive: true, + includeItemTypes: [ + BaseItemKind.Movie, + BaseItemKind.Series, + BaseItemKind.Episode, + ], + fields: [ + ItemFields.ProviderIds, + ItemFields.Genres, + ItemFields.DateLastMediaAdded, + ItemFields.DateCreated, + ItemFields.MediaSources, + ], + }) + .then((res) => res.data.Items ?? []); +} +export class JellyfinCatalogueProvider extends CatalogueProvider { getMovieCatalogue = async ( userContext: UserContext, pagination: PaginationParams, ): Promise> => { const items = ( - await this.getLibraryItems( + await getLibraryItems( new PluginContext(userContext.settings, userContext.token), ) ).filter((i) => i.ProviderIds?.Tmdb && i.Type === 'Movie'); @@ -83,10 +76,51 @@ class JellyfinProvider extends SourceProvider { items: items.slice(startIndex, endIndex).map((item) => ({ id: item.ProviderIds?.Tmdb, tmdbId: item.ProviderIds?.Tmdb, + mediaType: 'movie', })), }; }; + 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'); + + const startIndex = (pagination.page - 1) * pagination.itemsPerPage; + const endIndex = startIndex + pagination.itemsPerPage; + + return { + total: items.length, + page: pagination.page, + itemsPerPage: pagination.itemsPerPage, + items: items.slice(startIndex, endIndex).map((item) => ({ + id: item.ProviderIds?.Tmdb, + tmdbId: item.ProviderIds?.Tmdb, + mediaType: 'series', + })), + }; + }; +} + +class JellyfinProvider extends SourceProvider { + name: string = 'jellyfin'; + + private getProxyUrl(sourceId: string) { + return `/api/sources/${sourceId}/proxy`; + } + + settingsManager: SettingsManager = new JellyfinSettingsManager(); + + catalogueProvider: CatalogueProvider = new JellyfinCatalogueProvider(); + getMovieStreams = async ( tmdbId: string, metadata: MovieMetadata, @@ -125,7 +159,7 @@ class JellyfinProvider extends SourceProvider { config?: PlaybackConfig, ): Promise => { const context = new PluginContext(userContext.settings, userContext.token); - const items = await this.getLibraryItems(context); + const items = await getLibraryItems(context); const movie = items.find((item) => item.ProviderIds?.Tmdb === tmdbId); @@ -271,7 +305,7 @@ class JellyfinProvider extends SourceProvider { config?: PlaybackConfig, ): Promise => { const context = new PluginContext(userContext.settings, userContext.token); - const items = await this.getLibraryItems(context); + const items = await getLibraryItems(context); const show = items.find( (item) => item.ProviderIds?.Tmdb === tmdbId, diff --git a/backend/packages/reiverr-plugin/src/types.ts b/backend/packages/reiverr-plugin/src/types.ts index 00a95ca..6a5d0ef 100644 --- a/backend/packages/reiverr-plugin/src/types.ts +++ b/backend/packages/reiverr-plugin/src/types.ts @@ -148,6 +148,7 @@ export type PlaybackConfig = { export type CatalogueItem = { tmdbId: string; + mediaType: 'movie' | 'series'; }; export type PaginatedResponse = { diff --git a/backend/src/source-providers/source-provider.dto.ts b/backend/src/source-providers/source-provider.dto.ts index 50cf2b8..aa83e54 100644 --- a/backend/src/source-providers/source-provider.dto.ts +++ b/backend/src/source-providers/source-provider.dto.ts @@ -25,6 +25,8 @@ export class CatalogueItemDto implements CatalogueItem { @ApiProperty() tmdbId: string; @ApiProperty() + mediaType: 'movie' | 'series'; + @ApiProperty() tmdbItem: TmdbItemDto; } diff --git a/backend/src/user-data/library/library.controller.ts b/backend/src/user-data/library/library.controller.ts index afbc922..bde1378 100644 --- a/backend/src/user-data/library/library.controller.ts +++ b/backend/src/user-data/library/library.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Controller, Delete, Get, @@ -9,7 +10,7 @@ import { UseGuards, } from '@nestjs/common'; import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { UserAccessControl } from 'src/auth/auth.guard'; +import { GetAuthToken, UserAccessControl } from 'src/auth/auth.guard'; import { GetPaginationParams, PaginatedApiOkResponse, @@ -20,38 +21,43 @@ import { PaginationParamsDto, SuccessResponseDto, } from 'src/common/common.dto'; -import { LibraryItemDto } from './library.dto'; +import { MediaSourcesService } from 'src/users/media-sources/media-sources.service'; import { - LibraryService, - LibrarySortBy, - MyListFilter as MyListFilter, + CatalogueFilter, + LibraryItemDto, + MyListSortBy, + MyListFilter, SortByDirection, -} from './library.service'; +} from './library.dto'; +import { LibraryService } from './library.service'; -@ApiTags('users') +@ApiTags('library') @Controller('users/:userId/library') @UseGuards(UserAccessControl) export class LibraryController { - constructor(private libraryService: LibraryService) {} + constructor( + private libraryService: LibraryService, + private mediaSourceService: MediaSourcesService, + ) {} @Get('my-list') @ApiQuery({ name: 'filter', enum: MyListFilter, required: false }) - @ApiQuery({ name: 'sortBy', enum: LibrarySortBy, required: false }) + @ApiQuery({ name: 'sortBy', enum: MyListSortBy, required: false }) @ApiQuery({ name: 'direction', enum: SortByDirection, required: false }) @PaginatedApiOkResponse(LibraryItemDto) - async getLibraryItems( + async getMyList( @GetPaginationParams() pagination: PaginationParamsDto, @Param('userId') userId: string, @Query('filter', new ParseEnumPipe(MyListFilter, { optional: true })) filter?: MyListFilter, - @Query('sortBy', new ParseEnumPipe(LibrarySortBy, { optional: true })) - sortBy?: LibrarySortBy, + @Query('sortBy', new ParseEnumPipe(MyListSortBy, { optional: true })) + sortBy?: MyListSortBy, @Query('direction', new ParseEnumPipe(SortByDirection, { optional: true })) direction?: SortByDirection, ): Promise> { // const user = await this.userService.findOne(userId); - const items = await this.libraryService.getMyListDtos({ + const response = await this.libraryService.getMyList({ userId, pagination, filter, @@ -59,6 +65,40 @@ export class LibraryController { direction, }); + return { + ...response, + items: await Promise.all( + response.items.map((i) => + this.libraryService.getLibraryItemDto({ + ...i, + mediaType: i.mediaType === MediaType.Movie ? 'movie' : 'series', + }), + ), + ), + }; + } + + @Get('catalogue/:sourceId') + @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, + ): Promise> { + const items = this.libraryService.getCatalogueItems({ + sourceId, + token, + pagination, + filter, + }); + + if (!items) { + throw new BadRequestException(); + } + return items; } diff --git a/backend/src/user-data/library/library.dto.ts b/backend/src/user-data/library/library.dto.ts index c3f019e..778842f 100644 --- a/backend/src/user-data/library/library.dto.ts +++ b/backend/src/user-data/library/library.dto.ts @@ -1,73 +1,40 @@ import { ApiProperty, PickType } from '@nestjs/swagger'; -import { MediaType } from 'src/common/common.dto'; -import { MovieMetadata, SeriesMetadata } from 'src/metadata/metadata.entity'; import { TmdbItemDto } from 'src/metadata/tmdb/tmdb.dto'; import { LibraryItem } from './library.entity'; +export enum SortByDirection { + Asc = 'asc', + Desc = 'desc', +} + +export enum MyListSortBy { + DateAdded = 'dateAdded', + Name = 'name', + FirstReleaseDate = 'firstReleaseDate', + LastReleaseDate = 'lastReleaseDate', +} + +export enum MyListFilter { + Movie = 'movie', + Series = 'series', + All = 'all', +} + +export enum CatalogueFilter { + All = 'all', + Movies = 'movies', + Series = 'series', + Missing = 'missing', +} + export class LibraryItemDto extends PickType(LibraryItem, [ 'tmdbId', 'mediaType', 'playStates', - 'createdAt', ]) { @ApiProperty() tmdbItem: TmdbItemDto; @ApiProperty({ required: false }) watched?: boolean; - - static create(options: { - libraryItem: LibraryItem; - movieMetadata?: MovieMetadata; - seriesMetadata?: SeriesMetadata; - }): LibraryItemDto { - const { libraryItem, movieMetadata, seriesMetadata } = options; - - if (!movieMetadata && !seriesMetadata) { - throw new Error( - 'At least one of movieMetadata or seriesMetadata must be provided', - ); - } - - let watched = false; - - if (libraryItem.mediaType === MediaType.Movie) { - watched = libraryItem.playStates?.some((state) => state.watched) ?? false; - } else if ( - libraryItem.mediaType === MediaType.Series && - seriesMetadata?.tmdbSeries?.last_episode_to_air - ) { - const { season_number: season, episode_number: episode } = - seriesMetadata?.tmdbSeries.last_episode_to_air; - watched = - libraryItem.playStates?.some( - (state) => - state.season === season && - state.episode === episode && - state.watched, - ) ?? false; - } - - return { - ...libraryItem, - watched, - tmdbItem: { - id: movieMetadata?.tmdbMovie.id ?? seriesMetadata?.tmdbSeries.id, - poster_path: - movieMetadata?.tmdbMovie.poster_path ?? - seriesMetadata?.tmdbSeries.poster_path, - vote_average: - movieMetadata?.tmdbMovie.vote_average ?? - seriesMetadata?.tmdbSeries.vote_average, - title: movieMetadata?.tmdbMovie.title, - release_date: movieMetadata?.tmdbMovie.release_date, - runtime: movieMetadata?.tmdbMovie.runtime, - name: seriesMetadata?.tmdbSeries.name, - first_air_date: seriesMetadata?.tmdbSeries.first_air_date, - last_air_date: seriesMetadata?.tmdbSeries.last_air_date, - next_episode_to_air: seriesMetadata?.tmdbSeries.next_episode_to_air, - seasons: seriesMetadata?.tmdbSeries.seasons, - }, - }; - } } diff --git a/backend/src/user-data/library/library.module.ts b/backend/src/user-data/library/library.module.ts index 3b1963d..9867be3 100644 --- a/backend/src/user-data/library/library.module.ts +++ b/backend/src/user-data/library/library.module.ts @@ -3,9 +3,11 @@ import { MetadataModule } from 'src/metadata/metadata.module'; import { LibraryController } from './library.controller'; import { libraryProviders } from './library.providers'; import { LibraryService } from './library.service'; +import { UsersModule } from 'src/users/users.module'; +import { SourceProvidersModule } from 'src/source-providers/source-providers.module'; @Module({ - imports: [MetadataModule], + imports: [UsersModule, MetadataModule, SourceProvidersModule], providers: [...libraryProviders, LibraryService], controllers: [LibraryController], exports: [LibraryService], diff --git a/backend/src/user-data/library/library.service.ts b/backend/src/user-data/library/library.service.ts index 52c5366..387a3b4 100644 --- a/backend/src/user-data/library/library.service.ts +++ b/backend/src/user-data/library/library.service.ts @@ -6,33 +6,17 @@ import { } from 'src/common/common.dto'; import { MetadataService } from 'src/metadata/metadata.service'; import { Repository } from 'typeorm'; -import { LibraryItemDto } from './library.dto'; +import { + LibraryItemDto, + MyListSortBy, + MyListFilter, + SortByDirection, +} from './library.dto'; import { LibraryItem } from './library.entity'; import { USER_LIBRARY_REPOSITORY } from './library.providers'; - -export enum SortByDirection { - Asc = 'asc', - Desc = 'desc', -} - -export enum LibrarySortBy { - DateAdded = 'dateAdded', - Name = 'name', - FirstReleaseDate = 'firstReleaseDate', - LastReleaseDate = 'lastReleaseDate', -} - -export enum MyListFilter { - Movie = 'movie', - Series = 'series', -} - -export enum CatalogueFilter { - All = 'all', - Movies = 'movies', - Series = 'series', - Unavailable = 'unavailable', -} +import { SourceProvidersService } from 'src/source-providers/source-providers.service'; +import { MediaSourcesService } from 'src/users/media-sources/media-sources.service'; +import { PlayState } from '../play-state/play-state.entity'; @Injectable() export class LibraryService { @@ -40,41 +24,15 @@ export class LibraryService { @Inject(USER_LIBRARY_REPOSITORY) private readonly libraryRepository: Repository, private readonly metadataService: MetadataService, + private readonly mediaSourceService: MediaSourcesService, ) {} - async getMyListDtos( - ...args: Parameters - ): Promise> { - const paginatedItems = await this.getMyList(...args); - - const items = await Promise.all( - paginatedItems.items.map(async (item) => { - const seriesMetadata = - item.mediaType === MediaType.Series - ? await this.metadataService.getSeriesByTmdbId(item.tmdbId) - : undefined; - const movieMetadata = - item.mediaType === MediaType.Movie - ? await this.metadataService.getMovieByTmdbId(item.tmdbId) - : undefined; - - return LibraryItemDto.create({ - libraryItem: item, - seriesMetadata, - movieMetadata, - }); - }), - ); - - return { ...paginatedItems, items }; - } - /** TODO: decouple librayItem and movie/seriesItem */ - private async getMyList(options: { + async getMyList(options: { userId: string; pagination: PaginationParamsDto; filter?: MyListFilter; - sortBy?: LibrarySortBy; + sortBy?: MyListSortBy; direction?: SortByDirection; }): Promise> { const { @@ -88,8 +46,8 @@ export class LibraryService { const directon = direction === SortByDirection.Asc ? 'ASC' : 'DESC'; const order = { - [LibrarySortBy.DateAdded]: { createdAt: directon } as const, - [LibrarySortBy.Name]: { + [MyListSortBy.DateAdded]: { createdAt: directon } as const, + [MyListSortBy.Name]: { seriesMetadata: { name: directon, }, @@ -97,7 +55,7 @@ export class LibraryService { name: directon, }, } as const, - [LibrarySortBy.FirstReleaseDate]: { + [MyListSortBy.FirstReleaseDate]: { movieMetadata: { releaseDate: directon, }, @@ -105,7 +63,7 @@ export class LibraryService { firstReleaseDate: directon, }, } as const, - [LibrarySortBy.LastReleaseDate]: { + [MyListSortBy.LastReleaseDate]: { movieMetadata: { releaseDate: directon, }, @@ -156,6 +114,115 @@ export class LibraryService { }; } + async getCatalogueItems(options: { + sourceId: string; + token: string; + pagination: PaginationParamsDto; + filter?: 'all' | 'movies' | 'series' | 'missing'; + }): Promise | undefined> { + const { sourceId, token, pagination, filter = 'all' } = options; + + const connection = await this.mediaSourceService.getConnection(sourceId); + + if (!connection) return; + + const combined = connection.provider.catalogueProvider.getCatalogue; + 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( + { + userId: connection.mediaSource.userId, + settings: connection.mediaSource.pluginSettings, + sourceId: connection.mediaSource.id, + token, + }, + pagination, + ); + + return { + ...response, + items: await Promise.all( + response.items.map(async (item) => this.getLibraryItemDto(item)), + ), + }; + } else if (filter === 'movies' && movies) { + const response = await movies( + { + userId: connection.mediaSource.userId, + settings: connection.mediaSource.pluginSettings, + sourceId: connection.mediaSource.id, + token, + }, + pagination, + ); + + return { + ...response, + items: await Promise.all( + response.items.map(async (item) => this.getLibraryItemDto(item)), + ), + }; + } else if (filter === 'series' && series) { + const response = await series( + { + userId: connection.mediaSource.userId, + settings: connection.mediaSource.pluginSettings, + sourceId: connection.mediaSource.id, + token, + }, + pagination, + ); + + return { + ...response, + items: await Promise.all( + response.items.map(async (item) => this.getLibraryItemDto(item)), + ), + }; + } else if (filter === 'missing' && missing) { + const tmdbIdToMyListItem: Record = {}; + const myListItems = await this.getMyList({ + pagination: { + itemsPerPage: 500, + page: 1, + }, + userId: connection.mediaSource.userId, + filter: MyListFilter.All, + sortBy: MyListSortBy.DateAdded, + }).then((res) => res.items); + + myListItems.forEach((i) => { + tmdbIdToMyListItem[i.tmdbId] = i; + }); + + const response = await missing( + { + userId: connection.mediaSource.userId, + settings: connection.mediaSource.pluginSettings, + sourceId: connection.mediaSource.id, + token, + }, + pagination, + tmdbIdToMyListItem, + ); + + return { + ...response, + items: await Promise.all( + response.items.map(async (item) => + this.getLibraryItemDto({ + ...item, + mediaType: + item.mediaType === MediaType.Movie ? 'movie' : 'series', + }), + ), + ), + }; + } + } + async findByTmdbId( userId: string, tmdbId: string, @@ -185,4 +252,72 @@ export class LibraryService { async deleteByTmdbId(userId: string, tmdbId: string) { return await this.libraryRepository.delete({ userId, tmdbId }); } + + async getLibraryItemDto(options: { + tmdbId: string; + mediaType: 'series' | 'movie'; + playStates?: PlayState[]; + }): Promise { + const { tmdbId, mediaType, playStates } = options; + + const seriesMetadata = + mediaType === 'series' + ? await this.metadataService.getSeriesByTmdbId(tmdbId) + : undefined; + const movieMetadata = + mediaType === 'movie' + ? await this.metadataService.getMovieByTmdbId(tmdbId) + : undefined; + + if (!movieMetadata && !seriesMetadata) { + throw new Error( + 'At least one of movieMetadata or seriesMetadata must be provided', + ); + } + + let watched = false; + + if (mediaType === 'movie') { + watched = playStates?.some((state) => state.watched) ?? false; + } else if ( + mediaType === 'series' && + seriesMetadata?.tmdbSeries?.last_episode_to_air + ) { + const { season_number: season, episode_number: episode } = + seriesMetadata?.tmdbSeries.last_episode_to_air; + watched = + playStates?.some( + (state) => + state.season === season && + state.episode === episode && + state.watched, + ) ?? false; + } + + const libraryItem: LibraryItemDto = { + tmdbId, + mediaType: mediaType === 'movie' ? MediaType.Movie : MediaType.Series, + watched, + playStates, + tmdbItem: { + id: movieMetadata?.tmdbMovie.id ?? seriesMetadata?.tmdbSeries.id, + poster_path: + movieMetadata?.tmdbMovie.poster_path ?? + seriesMetadata?.tmdbSeries.poster_path, + vote_average: + movieMetadata?.tmdbMovie.vote_average ?? + seriesMetadata?.tmdbSeries.vote_average, + title: movieMetadata?.tmdbMovie.title, + release_date: movieMetadata?.tmdbMovie.release_date, + runtime: movieMetadata?.tmdbMovie.runtime, + name: seriesMetadata?.tmdbSeries.name, + first_air_date: seriesMetadata?.tmdbSeries.first_air_date, + last_air_date: seriesMetadata?.tmdbSeries.last_air_date, + next_episode_to_air: seriesMetadata?.tmdbSeries.next_episode_to_air, + seasons: seriesMetadata?.tmdbSeries.seasons, + }, + }; + + return libraryItem; + } } diff --git a/backend/src/users/media-sources/media-sources.controller.ts b/backend/src/users/media-sources/media-sources.controller.ts index bc7d5e3..5c3d34d 100644 --- a/backend/src/users/media-sources/media-sources.controller.ts +++ b/backend/src/users/media-sources/media-sources.controller.ts @@ -30,17 +30,8 @@ import { GetAuthUser, UserAccessControl, } from 'src/auth/auth.guard'; -import { - GetPaginationParams, - PaginatedApiOkResponse, -} from 'src/common/common.decorator'; -import { - PaginatedResponseDto, - PaginationParamsDto, -} from 'src/common/common.dto'; import { MetadataService } from 'src/metadata/metadata.service'; import { - CatalogueItemDto, PlaybackConfigDto, StreamCandidatesDto, StreamDto, @@ -49,12 +40,6 @@ import { SourceProvidersService } from 'src/source-providers/source-providers.se import { User } from 'src/users/user.entity'; import { MediaSource } from './media-source.entity'; import { MediaSourcesService } from './media-sources.service'; -import { MediaSourceCapabilitiesDto } from './media-source.dto'; - -type MediaSourceConnection = { - provider: SourceProvider; - mediaSource: MediaSource; -}; @Injectable() export class ServiceOwnershipValidator implements CanActivate { @@ -70,8 +55,9 @@ export class ServiceOwnershipValidator implements CanActivate { if (!sourceId) return true; - const mediaSource = - await this.mediaSourcesService.findMediaSource(sourceId); + const mediaSource = await this.mediaSourcesService.findMediaSource( + sourceId, + ); if (!mediaSource) throw new NotFoundException('Source not found'); @@ -93,91 +79,6 @@ export class MediaSourcesController { private metadataService: MetadataService, ) {} - @Get(':sourceId/catalogue/movies') - @PaginatedApiOkResponse(CatalogueItemDto) - async getMovieCatalogue( - @GetAuthUser() user: User, - @Param('sourceId') - sourceId: string, - @GetAuthToken() token: string, - @GetPaginationParams() pagination: PaginationParamsDto, - ): Promise> { - const connection = await this.getConnection(sourceId); - - const catalogue = - await connection.provider.catalogueProvider?.getMovieCatalogue?.( - { - userId: user.id, - settings: connection.mediaSource.pluginSettings, - token, - sourceId: connection.mediaSource.id, - }, - pagination, - ); - - const items = await Promise.all( - catalogue.items.map(async (item) => { - const metadata = await this.metadataService.getMovieByTmdbId( - item.tmdbId, - ); - - return { - ...item, - tmdbItem: metadata.tmdbMovie, - }; - }), - ); - - return { - items, - itemsPerPage: catalogue?.itemsPerPage ?? pagination.itemsPerPage, - page: catalogue?.page ?? pagination.page, - total: catalogue?.total ?? 0, - }; - } - - @Get(':sourceId/catalogue/series') - @PaginatedApiOkResponse(CatalogueItemDto) - async getEpisodeCatalogue( - @GetAuthUser() user: User, - @Param('sourceId') sourceId: string, - @GetAuthToken() token: string, - @GetPaginationParams() pagination: PaginationParamsDto, - ): Promise> { - const connection = await this.getConnection(sourceId); - - const catalogue = - await connection.provider.catalogueProvider.getSeriesCatalogue?.( - { - userId: user.id, - settings: connection.mediaSource.pluginSettings, - token, - sourceId: connection.mediaSource.id, - }, - pagination, - ); - - const items = await Promise.all( - catalogue.items.map(async (item) => { - const metadata = await this.metadataService.getSeriesByTmdbId( - item.tmdbId, - ); - - return { - ...item, - tmdbItem: metadata.tmdbSeries, - }; - }), - ); - - return { - items, - itemsPerPage: catalogue?.itemsPerPage ?? pagination.itemsPerPage, - page: catalogue?.page ?? pagination.page, - total: catalogue?.total ?? 0, - }; - } - @Get(':sourceId/movies/tmdb/:tmdbId/streams') @ApiOkResponse({ description: 'Movie sources', @@ -341,8 +242,9 @@ export class MediaSourcesController { @GetAuthToken() token: string, ) { const sourceId = params.sourceId; - const mediaSource = - await this.mediaSourcesService.findMediaSource(sourceId); + const mediaSource = await this.mediaSourcesService.findMediaSource( + sourceId, + ); if (!mediaSource) throw new NotFoundException('Source not found'); @@ -407,21 +309,12 @@ export class MediaSourcesController { } async getConnection(sourceId: string) { - const mediaSource = - await this.mediaSourcesService.findMediaSource(sourceId); + const connection = await this.mediaSourcesService.getConnection(sourceId); - if (!mediaSource.pluginId || !mediaSource.enabled) { - throw new BadRequestException('Source not configured'); + if (!connection) { + throw new BadRequestException('Invalid source'); } - const provider = this.sourceProvidersService.getProvider( - mediaSource.pluginId, - ); - - if (!provider) { - throw new NotFoundException('Plugin not found'); - } - - return { provider, mediaSource }; + return connection; } -} \ No newline at end of file +} diff --git a/backend/src/users/media-sources/media-sources.service.ts b/backend/src/users/media-sources/media-sources.service.ts index 0922dcd..ad29397 100644 --- a/backend/src/users/media-sources/media-sources.service.ts +++ b/backend/src/users/media-sources/media-sources.service.ts @@ -9,7 +9,11 @@ import { import { MediaSource } from './media-source.entity'; import { MEIDA_SOURCE_REPOSITORY } from './media-source.providers'; import { SourceProvidersService } from 'src/source-providers/source-providers.service'; -import { ValidationResponse } from '@aleksilassila/reiverr-plugin'; +import { + SourceProvider, + ValidationResponse, +} from '@aleksilassila/reiverr-plugin'; +import { PaginationParamsDto } from 'src/common/common.dto'; export enum MediaSourcesServiceError { SourceNotFound = 'SourceNotFound', @@ -137,6 +141,26 @@ export class MediaSourcesService { ?.find((source) => source.id === sourceId)?.pluginSettings; } + async getConnection(sourceId: string): Promise< + | { + provider: SourceProvider; + mediaSource: MediaSource; + } + | undefined + > { + const mediaSource = await this.findMediaSource(sourceId); + + const provider = this.sourceProvidersService.getProvider( + mediaSource.pluginId, + ); + + if (provider && mediaSource) { + return { provider, mediaSource }; + } + + return undefined; + } + async getMediaSourceDto(mediaSource: MediaSource): Promise { const sourceProvider = this.sourceProvidersService.getProvider( mediaSource.pluginId, diff --git a/backend/src/users/media-sources/media-sources.settings.controller.ts b/backend/src/users/media-sources/media-sources.settings.controller.ts index d3938f5..d15367d 100644 --- a/backend/src/users/media-sources/media-sources.settings.controller.ts +++ b/backend/src/users/media-sources/media-sources.settings.controller.ts @@ -64,8 +64,9 @@ export class MediaSourcesSettingsController { } return { - mediaSource: - await this.mediaSourcesService.getMediaSourceDto(updatedSource), + mediaSource: await this.mediaSourcesService.getMediaSourceDto( + updatedSource, + ), validationResponse, }; } diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 99f1014..51b7320 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -53,9 +53,7 @@ export class UsersController { const users = await this.usersService.findAll(); - return Promise.all( - users.map((user) => this.usersService.getUserDto(user)), - ); + return Promise.all(users.map((user) => this.usersService.getUserDto(user))); } @UseGuards(UserAccessControl) diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 96ebb18..26a188a 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -22,6 +22,6 @@ import { UsersService } from './users.service'; MediaSourcesController, MediaSourcesSettingsController, ], - exports: [UsersService], + exports: [UsersService, MediaSourcesService], }) export class UsersModule {} diff --git a/src/lib/apis/reiverr/reiverr-api.ts b/src/lib/apis/reiverr/reiverr-api.ts index b908fb0..b4a6d3e 100644 --- a/src/lib/apis/reiverr/reiverr-api.ts +++ b/src/lib/apis/reiverr/reiverr-api.ts @@ -26,4 +26,4 @@ export const getReiverrApi = (session = get(sessions).activeSession) => { }); }; -export const getPublicReiverrApi = (baseUrl: string) => new ReiverrApi({ baseURL: baseUrl }); +export const getPublicReiverrApi = (baseUrl: string) => new ReiverrApi({ baseURL: baseUrl }); diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index b444e00..9696c57 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -174,46 +174,6 @@ export interface UpdateUserDto { oldPassword?: string; } -export interface PaginatedResponseDto { - total: number; - page: number; - itemsPerPage: number; -} - -export interface NextEpisodeToAir { - air_date?: string; -} - -export interface Season { - air_date?: string; - episode_count?: number; - id?: number; - name?: string; - overview?: string; - poster_path?: string; - season_number?: number; - vote_average?: number; -} - -export interface TmdbItemDto { - id?: number; - poster_path?: string; - vote_average?: number; - title?: string; - release_date?: string; - runtime?: number; - name?: string; - first_air_date?: string; - last_air_date?: string; - next_episode_to_air?: NextEpisodeToAir; - seasons?: Season[]; -} - -export interface CatalogueItemDto { - tmdbId: string; - tmdbItem: TmdbItemDto; -} - export interface VideoStreamPropertyDto { label: string; value: string | number; @@ -562,11 +522,45 @@ export interface BulkUpdatePlayStateDto { playStates: UpdatePlayStateDto[]; } +export interface PaginatedResponseDto { + total: number; + page: number; + itemsPerPage: number; +} + +export interface NextEpisodeToAir { + air_date?: string; +} + +export interface Season { + air_date?: string; + episode_count?: number; + id?: number; + name?: string; + overview?: string; + poster_path?: string; + season_number?: number; + vote_average?: number; +} + +export interface TmdbItemDto { + id?: number; + poster_path?: string; + vote_average?: number; + title?: string; + release_date?: string; + runtime?: number; + name?: string; + first_air_date?: string; + last_air_date?: string; + next_episode_to_air?: NextEpisodeToAir; + seasons?: Season[]; +} + export interface LibraryItemDto { tmdbId: string; mediaType: 'Movie' | 'Series' | 'Episode'; playStates?: PlayStateDto[]; - createdAt: string; tmdbItem: TmdbItemDto; watched?: boolean; } @@ -1044,116 +1038,9 @@ export class Api extends HttpClient - this.request< - PaginatedResponseDto & { - items: LibraryItemDto[]; - }, - any - >({ - path: `/api/users/${userId}/library/my-list`, - method: 'GET', - query: query, - format: 'json', - ...params - }), - - /** - * No description - * - * @tags users - * @name AddLibraryItem - * @request PUT:/api/users/{userId}/library/tmdb/{tmdbId} - */ - addLibraryItem: ( - userId: string, - tmdbId: string, - query: { - mediaType: 'Movie' | 'Series' | 'Episode'; - }, - params: RequestParams = {} - ) => - this.request({ - path: `/api/users/${userId}/library/tmdb/${tmdbId}`, - method: 'PUT', - query: query, - format: 'json', - ...params - }), - - /** - * No description - * - * @tags users - * @name RemoveLibraryItem - * @request DELETE:/api/users/{userId}/library/tmdb/{tmdbId} - */ - removeLibraryItem: (userId: string, tmdbId: string, params: RequestParams = {}) => - this.request({ - path: `/api/users/${userId}/library/tmdb/${tmdbId}`, - method: 'DELETE', - format: 'json', - ...params }) }; sources = { - /** - * No description - * - * @tags sources - * @name GetMovieCatalogue - * @request GET:/api/sources/{sourceId}/catalogue/movies - */ - getMovieCatalogue: (sourceId: string, params: RequestParams = {}) => - this.request< - PaginatedResponseDto & { - items: CatalogueItemDto[]; - }, - any - >({ - path: `/api/sources/${sourceId}/catalogue/movies`, - method: 'GET', - format: 'json', - ...params - }), - - /** - * No description - * - * @tags sources - * @name GetEpisodeCatalogue - * @request GET:/api/sources/{sourceId}/catalogue/series - */ - getEpisodeCatalogue: (sourceId: string, params: RequestParams = {}) => - this.request< - PaginatedResponseDto & { - items: CatalogueItemDto[]; - }, - any - >({ - path: `/api/sources/${sourceId}/catalogue/series`, - method: 'GET', - format: 'json', - ...params - }), - /** * No description * @@ -1704,4 +1591,100 @@ export class Api extends HttpClient + this.request< + PaginatedResponseDto & { + items: LibraryItemDto[]; + }, + any + >({ + path: `/api/users/${userId}/library/my-list`, + method: 'GET', + query: query, + format: 'json', + ...params + }), + + /** + * No description + * + * @tags library + * @name GetCatalogue + * @request GET:/api/users/{userId}/library/catalogue/{sourceId} + */ + getCatalogue: ( + userId: string, + sourceId: string, + query: { + filter: string; + }, + params: RequestParams = {} + ) => + this.request< + PaginatedResponseDto & { + items: LibraryItemDto[]; + }, + any + >({ + path: `/api/users/${userId}/library/catalogue/${sourceId}`, + method: 'GET', + query: query, + format: 'json', + ...params + }), + + /** + * No description + * + * @tags library + * @name AddLibraryItem + * @request PUT:/api/users/{userId}/library/tmdb/{tmdbId} + */ + addLibraryItem: ( + userId: string, + tmdbId: string, + query: { + mediaType: 'Movie' | 'Series' | 'Episode'; + }, + params: RequestParams = {} + ) => + this.request({ + path: `/api/users/${userId}/library/tmdb/${tmdbId}`, + method: 'PUT', + query: query, + format: 'json', + ...params + }), + + /** + * No description + * + * @tags library + * @name RemoveLibraryItem + * @request DELETE:/api/users/{userId}/library/tmdb/{tmdbId} + */ + removeLibraryItem: (userId: string, tmdbId: string, params: RequestParams = {}) => + this.request({ + path: `/api/users/${userId}/library/tmdb/${tmdbId}`, + method: 'DELETE', + format: 'json', + ...params + }) + }; } diff --git a/src/lib/components/Container.svelte b/src/lib/components/Container.svelte index 4217142..4fef0f2 100644 --- a/src/lib/components/Container.svelte +++ b/src/lib/components/Container.svelte @@ -22,6 +22,7 @@ navigate: NavigateEvent; back: KeyEvent; playPause: KeyEvent; + blur: Selectable; }>(); export let name: Required['name'] = ''; @@ -63,6 +64,10 @@ stopPropagation, direction: options.direction }); + + if (willLeaveContainer) { + dispatch('blur', selectable); + } }) .setOnSelect(() => { dispatch('select'); diff --git a/src/lib/components/GlobalBackground/BackgroundCard.svelte b/src/lib/components/GlobalBackground/BackgroundCard.svelte index ae55542..aa644c2 100644 --- a/src/lib/components/GlobalBackground/BackgroundCard.svelte +++ b/src/lib/components/GlobalBackground/BackgroundCard.svelte @@ -4,4 +4,12 @@ export let backdropUrl: string; - + diff --git a/src/lib/pages/CollectionPages/CollectionPage.svelte b/src/lib/pages/CollectionPages/CollectionPage.svelte index 464c1db..00ead02 100644 --- a/src/lib/pages/CollectionPages/CollectionPage.svelte +++ b/src/lib/pages/CollectionPages/CollectionPage.svelte @@ -63,10 +63,7 @@ {#await items} Loading... {:then items} - + {#each items as item, index} { diff --git a/src/lib/pages/CollectionPages/CompanyPage.svelte b/src/lib/pages/CollectionPages/CompanyPage.svelte index 3cf4452..ed42dfe 100644 --- a/src/lib/pages/CollectionPages/CompanyPage.svelte +++ b/src/lib/pages/CollectionPages/CompanyPage.svelte @@ -21,4 +21,10 @@ : Promise.resolve([]); - + diff --git a/src/lib/pages/LibraryPage/CatalogueTab.svelte b/src/lib/pages/LibraryPage/CatalogueTab.svelte index f5c2cb3..1c3660a 100644 --- a/src/lib/pages/LibraryPage/CatalogueTab.svelte +++ b/src/lib/pages/LibraryPage/CatalogueTab.svelte @@ -4,16 +4,49 @@ import CardGrid from '$lib/components/CardGrid.svelte'; import Container from '$lib/components/Container.svelte'; import { reiverrApi } from '$lib/stores/user.store'; + import TabItem from './TabItem.svelte'; export let source: MediaSourceDto; - $: items = reiverrApi.sources.getMovieCatalogue(source.id).then((r) => r.data.items); + let filters: string[] = []; + let selectedFilter = ''; + + $: { + filters = [ + ...(source.capabilities.combinedCatalogue ? ['All'] : []), + ...(source.capabilities.seriesCatalogue ? ['Series'] : []), + ...(source.capabilities.moviesCatalogue ? ['Movies'] : []), + ...(source.capabilities.missingCatalogue ? ['Missing'] : []) + ]; + selectedFilter = filters[0] ?? ''; + } + + $: items = selectedFilter + ? reiverrApi.library + .getCatalogue(source.userId, source.id, { + filter: + { + All: 'all', + Movies: 'movies', + Series: 'series', + Missing: 'missing' + }[selectedFilter] ?? 'all' + }) + .then((r) => r.data.items) + : Promise.resolve([]); - + + + {#each filters ?? [] as filter} + (selectedFilter = filter)}> + {filter} + + {/each} + {#await items then items} - {#each items.map((i) => i.tmdbItem) as item} + {#each items.map((i) => i.tmdbItem) as item (item.id)} {/each} diff --git a/src/lib/pages/LibraryPage/LibraryPage.svelte b/src/lib/pages/LibraryPage/LibraryPage.svelte index 0b82f48..f47ed42 100644 --- a/src/lib/pages/LibraryPage/LibraryPage.svelte +++ b/src/lib/pages/LibraryPage/LibraryPage.svelte @@ -3,42 +3,29 @@ import { useTabs } from '$lib/components/Tab/Tab'; import Tab from '$lib/components/Tab/Tab.svelte'; import TabContainer from '$lib/components/Tab/TabContainer.svelte'; - import TabSelect from '$lib/components/Tab/TabSelect.svelte'; - import classNames from 'classnames'; - import MyListTab from './MyListTab.svelte'; - import CatalogueTab from './CatalogueTab.svelte'; import { user } from '$lib/stores/user.store'; + import CatalogueTab from './CatalogueTab.svelte'; + import MyListTab from './MyListTab.svelte'; + import TabItem from './TabItem.svelte'; const tab = useTabs(0, { remount: true }); const catalogues = $user?.mediaSources.filter((s) => s.capabilities.catalogues) ?? []; - - - - - My List - - + + { + selectable.activateChild($tab); + }} + > + tab.set(0)}>My List {#each catalogues as catalogue, index} - - - {catalogue.name} - - + tab.set(index + 1)}> + {catalogue.name} + {/each} diff --git a/src/lib/pages/LibraryPage/MyListTab.svelte b/src/lib/pages/LibraryPage/MyListTab.svelte index ac097bf..ab18540 100644 --- a/src/lib/pages/LibraryPage/MyListTab.svelte +++ b/src/lib/pages/LibraryPage/MyListTab.svelte @@ -16,9 +16,6 @@ import OptionsDialog from './OptionsDialog.LibraryPage.svelte'; import TabItem from './TabItem.svelte'; - // export let registrar: StackRouterPageProps['registrar']; - // export let handleGoBack: StackRouterPageProps['handleGoBack']; - const { registrar, handleGoBack } = getStackRouterControls(); let didMount = false; @@ -97,8 +94,8 @@ return ( filtered?.sort((a, b) => { - const aCreatedAt = a.createdAt; - const bCreatedAt = b.createdAt; + // const aCreatedAt = a.createdAt; + // const bCreatedAt = b.createdAt; const aReleaseDate = a.tmdbItem.release_date || ''; const bReleaseDate = b.tmdbItem.release_date || ''; @@ -115,7 +112,8 @@ const direction = viewSettings.sortDirection === 'asc' ? 1 : -1; if (viewSettings.sortBy === 'date-added') { - return direction * aCreatedAt.localeCompare(bCreatedAt); + // return direction * aCreatedAt.localeCompare(bCreatedAt); + return direction * aFirstAirDate.localeCompare(bFirstAirDate); } else if (viewSettings.sortBy === 'first-release-date') { return direction * aFirstAirDate.localeCompare(bFirstAirDate); } else if (viewSettings.sortBy === 'last-release-date') { @@ -140,7 +138,13 @@ {#if !$isLoading}
- + { + selectable.activateChild($category === 'all' ? 0 : $category === 'series' ? 1 : 2); + }} + > category.set('all')}>All category.set('series')}> diff --git a/src/lib/scroll-into-view.ts b/src/lib/scroll-into-view.ts index f017f89..5c8b630 100644 --- a/src/lib/scroll-into-view.ts +++ b/src/lib/scroll-into-view.ts @@ -174,7 +174,6 @@ function smoothScrollTo(options: { const d = Math.max(Math.abs(yDifference), Math.abs(xDifference)); const duration = options.duration || Math.min(500, Math.max(250, d / 2)); - console.log(duration, startY, top, yDifference, element.scrollHeight); const animate = () => { const progress = (performance.now() - startTime) / duration; diff --git a/src/lib/stores/data.store.ts b/src/lib/stores/data.store.ts index 70dfc91..26aed26 100644 --- a/src/lib/stores/data.store.ts +++ b/src/lib/stores/data.store.ts @@ -196,7 +196,7 @@ export const episodeUserDataStore = useRequestsStore( ); export const libraryItemsDataStore = useRequestsStore( - () => reiverrApi.users.getLibraryItems(get(user)?.id as string).then((r) => r.data.items), + () => reiverrApi.library.getMyList(get(user)?.id as string).then((r) => r.data.items), { persistant: true } );