diff --git a/backend/packages/reiverr-plugin/src/index.ts b/backend/packages/reiverr-plugin/src/index.ts index d62378b..fd92f18 100644 --- a/backend/packages/reiverr-plugin/src/index.ts +++ b/backend/packages/reiverr-plugin/src/index.ts @@ -1,3 +1,3 @@ export * from './types'; -export * from './plugin'; +export * from './reiverr-plugin'; export * from './device-profile'; diff --git a/backend/packages/reiverr-plugin/src/plugin.ts b/backend/packages/reiverr-plugin/src/reiverr-plugin.ts similarity index 83% rename from backend/packages/reiverr-plugin/src/plugin.ts rename to backend/packages/reiverr-plugin/src/reiverr-plugin.ts index c04fd49..a8a82fd 100644 --- a/backend/packages/reiverr-plugin/src/plugin.ts +++ b/backend/packages/reiverr-plugin/src/reiverr-plugin.ts @@ -10,6 +10,7 @@ import { ValidationResponse, Stream, StreamCandidate, + CatalogueSort, } from './types'; import * as packageJson from '../package.json'; @@ -40,6 +41,52 @@ export class SettingsManager { }); } +export class CatalogueProvider { + getSortOptions?: () => Promise = () => + Promise.resolve([ + { + label: 'Title', + name: 'title', + }, + ]); + + getSupportsSortDirection?: () => Promise = () => + Promise.resolve(false); + + /** + * Returns an index of all items available in the source. + */ + getCatalogue?: ( + context: UserContext, + pagination: PaginationParams, + ) => Promise>; + + /** + * Returns an index of all movies available in the source. + */ + getMovieCatalogue?: ( + context: UserContext, + pagination: PaginationParams, + ) => Promise>; + + /** + * Returns an index of all series available in the source. + */ + getSeriesCatalogue?: ( + context: UserContext, + pagination: PaginationParams, + ) => Promise>; + + /** + * Filters my list items to only include those that are not available in the source. + */ + getMissingInCatalogue?: ( + context: UserContext, + pagination: PaginationParams, + myListItems: Record, + ) => Promise>; +} + /** * SourceProvider is a class that provides a set of methods to interact with a streaming source. * @@ -61,21 +108,7 @@ export abstract class SourceProvider { settingsManager: SettingsManager = new SettingsManager(); - /** - * Returns an index of all movies available in the source. - */ - getMovieCatalogue?: ( - context: UserContext, - pagination: PaginationParams, - ) => Promise>; - - /** - * Returns an index of all episodes available in the source. - */ - getEpisodeCatalogue?: ( - context: UserContext, - pagination: PaginationParams, - ) => Promise>; + catalogueProvider: CatalogueProvider | undefined; /** * Returns a list of stream candidates for a movie that the user can choose to stream from. diff --git a/backend/packages/reiverr-plugin/src/types.ts b/backend/packages/reiverr-plugin/src/types.ts index 1b7c411..00a95ca 100644 --- a/backend/packages/reiverr-plugin/src/types.ts +++ b/backend/packages/reiverr-plugin/src/types.ts @@ -147,7 +147,6 @@ export type PlaybackConfig = { }; export type CatalogueItem = { - id: string; tmdbId: string; }; @@ -163,6 +162,12 @@ export type PaginationParams = { itemsPerPage: number; }; +// export type CatalogueFilters = {}; +export type CatalogueSort = { + label: string; + name: string; +}; + interface Metadata { tmdbId?: string; imdbId?: string; diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index a7ad279..c9ebf91 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -10,6 +10,7 @@ import { AuthService } from './auth.service'; import { SignInDto, UserDto } from '../users/user.dto'; import { ApiOkResponse, ApiProperty } from '@nestjs/swagger'; import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator'; +import { UsersService } from 'src/users/users.service'; export class SignInResponse { @ApiProperty() @@ -20,7 +21,10 @@ export class SignInResponse { @Controller('auth') export class AuthController { - constructor(private authService: AuthService) {} + constructor( + private authService: AuthService, + private usersService: UsersService, + ) {} @HttpCode(HttpStatus.OK) @Post() @@ -34,7 +38,7 @@ export class AuthController { return { accessToken: token, - user: UserDto.fromEntity(user), + user: this.usersService.getUserDto({ user }), }; } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 7354104..2b2471b 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; -import { AuthController } from './auth.controller'; -import { AuthService } from './auth.service'; -import { UsersModule } from '../users/users.module'; import { JwtModule } from '@nestjs/jwt'; import { JWT_SECRET } from '../consts'; +import { UsersModule } from '../users/users.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; @Module({ imports: [ diff --git a/backend/src/media-sources/media-source.dto.ts b/backend/src/media-sources/media-source.dto.ts index 8384648..defd2d6 100644 --- a/backend/src/media-sources/media-source.dto.ts +++ b/backend/src/media-sources/media-source.dto.ts @@ -2,6 +2,30 @@ 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'; + +export class MediaSourceCapabilitiesDto { + @ApiProperty() + catalogues: boolean; + + @ApiProperty() + moviesCatalogue: boolean; + + @ApiProperty() + seriesCatalogue: boolean; + + @ApiProperty() + combinedCatalogue: boolean; + + @ApiProperty() + missingCatalogue: boolean; + + // @ApiProperty() + // request: boolean; + + // @ApiProperty() + // delete: boolean; +} export class MediaSourceDto extends PickAndPartial( MediaSource, @@ -15,7 +39,10 @@ export class MediaSourceDto extends PickAndPartial( 'priority', ], ['pluginSettings'], -) {} +) { + @ApiProperty() + capabilities: MediaSourceCapabilitiesDto; +} export class UpdateOrCreateMediaSourceDto extends PickAndPartial( MediaSource, @@ -23,17 +50,18 @@ export class UpdateOrCreateMediaSourceDto extends PickAndPartial( ['id', 'adminControlled', 'name', 'priority'], ) {} -export class UpdateMediaSourceDto extends OmitType( - PartialType(MediaSourceDto), - ['id', 'pluginId', 'userId'], -) {} +export class UpdateMediaSourceDto extends OmitType(PartialType(MediaSource), [ + 'id', + 'pluginId', + 'userId', +]) {} -export class CreateMediaSourceDto extends OmitType(MediaSourceDto, [ +export class CreateMediaSourceDto extends OmitType(MediaSource, [ 'id', 'userId', ]) {} -export class UpdateMediaSourceResponse { +export class UpdateMediaSourceResponseDto { @ApiProperty({ type: MediaSourceDto }) mediaSource: MediaSourceDto; diff --git a/backend/src/media-sources/media-source.entity.ts b/backend/src/media-sources/media-source.entity.ts index fd1ee8f..b5f9f8e 100644 --- a/backend/src/media-sources/media-source.entity.ts +++ b/backend/src/media-sources/media-source.entity.ts @@ -16,6 +16,7 @@ export class MediaSource { @PrimaryGeneratedColumn('uuid') id: string; + /** TODO: Rename to providerId */ @ApiProperty({ required: true, type: 'string' }) @Column() pluginId: string; diff --git a/backend/src/media-sources/media-source.providers.ts b/backend/src/media-sources/media-source.providers.ts index da0a2ae..9697554 100644 --- a/backend/src/media-sources/media-source.providers.ts +++ b/backend/src/media-sources/media-source.providers.ts @@ -2,7 +2,7 @@ import { DataSource } from 'typeorm'; import { MediaSource } from './media-source.entity'; import { DATA_SOURCE } from 'src/database/database.providers'; -export const MEIDA_SOURCE_REPOSITORY = 'USER_SOURCE_REPOSITORY'; +export const MEIDA_SOURCE_REPOSITORY = 'MEIDA_SOURCE_REPOSITORY'; export const mediaSourceProviders = [ { diff --git a/backend/src/media-sources/media-sources.controller.ts b/backend/src/media-sources/media-sources.controller.ts index 47492b1..bc7d5e3 100644 --- a/backend/src/media-sources/media-sources.controller.ts +++ b/backend/src/media-sources/media-sources.controller.ts @@ -49,6 +49,7 @@ 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; @@ -103,25 +104,39 @@ export class MediaSourcesController { ): Promise> { const connection = await this.getConnection(sourceId); - const catalogue = await connection.provider.getMovieCatalogue?.( - { - userId: user.id, - settings: connection.mediaSource.pluginSettings, - token, - sourceId: connection.mediaSource.id, - }, - pagination, + 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: catalogue?.items ?? [], + items, itemsPerPage: catalogue?.itemsPerPage ?? pagination.itemsPerPage, page: catalogue?.page ?? pagination.page, total: catalogue?.total ?? 0, }; } - @Get(':sourceId/catalogue/episodes') + @Get(':sourceId/catalogue/series') @PaginatedApiOkResponse(CatalogueItemDto) async getEpisodeCatalogue( @GetAuthUser() user: User, @@ -131,17 +146,36 @@ export class MediaSourcesController { ): Promise> { const connection = await this.getConnection(sourceId); - const catalogue = await connection.provider.getEpisodeCatalogue?.( - { - userId: user.id, - settings: connection.mediaSource.pluginSettings, - token, - sourceId: connection.mediaSource.id, - }, - pagination, + 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 catalogue ?? { items: [], total: 0, itemsPerPage: 0, page: 0 }; + return { + items, + itemsPerPage: catalogue?.itemsPerPage ?? pagination.itemsPerPage, + page: catalogue?.page ?? pagination.page, + total: catalogue?.total ?? 0, + }; } @Get(':sourceId/movies/tmdb/:tmdbId/streams') @@ -390,4 +424,4 @@ export class MediaSourcesController { return { provider, mediaSource }; } -} +} \ No newline at end of file diff --git a/backend/src/media-sources/media-sources.module.ts b/backend/src/media-sources/media-sources.module.ts index 9e6642e..8c03d1b 100644 --- a/backend/src/media-sources/media-sources.module.ts +++ b/backend/src/media-sources/media-sources.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { mediaSourceProviders } from './media-source.providers'; import { MediaSourcesService } from './media-sources.service'; import { MediaSourcesController } from './media-sources.controller'; @@ -8,7 +8,11 @@ import { MetadataModule } from 'src/metadata/metadata.module'; import { UsersModule } from 'src/users/users.module'; @Module({ - imports: [UsersModule, SourceProvidersModule, MetadataModule], + imports: [ + forwardRef(() => UsersModule), + SourceProvidersModule, + MetadataModule, + ], providers: [...mediaSourceProviders, MediaSourcesService], controllers: [MediaSourcesController, MediaSourcesSettingsController], exports: [MediaSourcesService], diff --git a/backend/src/media-sources/media-sources.service.ts b/backend/src/media-sources/media-sources.service.ts index a393133..8afc039 100644 --- a/backend/src/media-sources/media-sources.service.ts +++ b/backend/src/media-sources/media-sources.service.ts @@ -1,8 +1,11 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { User } from 'src/users/user.entity'; import { UsersService } from 'src/users/users.service'; import { Repository } from 'typeorm'; -import { UpdateOrCreateMediaSourceDto } from './media-source.dto'; +import { + MediaSourceDto, + UpdateOrCreateMediaSourceDto, +} 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'; @@ -19,6 +22,7 @@ export class MediaSourcesService { @Inject(MEIDA_SOURCE_REPOSITORY) private readonly mediaSourceRepository: Repository, private sourceProvidersService: SourceProvidersService, + @Inject(forwardRef(() => UsersService)) private readonly usersService: UsersService, ) {} @@ -135,4 +139,37 @@ export class MediaSourcesService { ?.filter((s) => s?.enabled) ?.find((source) => source.id === sourceId)?.pluginSettings; } + + async getMediaSourceDto(options: { + mediaSource: MediaSource; + }): Promise { + const { mediaSource } = options; + + const sourceProvider = this.sourceProvidersService.getProvider( + mediaSource.pluginId, + ); + + const catalogueProvider = sourceProvider?.catalogueProvider; + + const moviesCatalogue = !!catalogueProvider?.getMovieCatalogue; + const seriesCatalogue = !!catalogueProvider?.getSeriesCatalogue; + const combinedCatalogue = !!catalogueProvider?.getCatalogue; + const missingCatalogue = !!catalogueProvider?.getMissingInCatalogue; + + return { + ...mediaSource, + enabled: mediaSource.enabled && !!sourceProvider, + capabilities: { + catalogues: + moviesCatalogue || + seriesCatalogue || + combinedCatalogue || + missingCatalogue, + moviesCatalogue, + seriesCatalogue, + combinedCatalogue, + missingCatalogue, + }, + }; + } } diff --git a/backend/src/media-sources/media-sources.settings.controller.ts b/backend/src/media-sources/media-sources.settings.controller.ts index 748e631..ebe4a81 100644 --- a/backend/src/media-sources/media-sources.settings.controller.ts +++ b/backend/src/media-sources/media-sources.settings.controller.ts @@ -15,14 +15,13 @@ import { UserDto } from 'src/users/user.dto'; import { User } from 'src/users/user.entity'; import { UsersService } from 'src/users/users.service'; import { - UpdateMediaSourceResponse, + UpdateMediaSourceResponseDto, UpdateOrCreateMediaSourceDto, } from './media-source.dto'; import { MediaSourcesService, MediaSourcesServiceError, } from './media-sources.service'; -import { MediaSource } from './media-source.entity'; @ApiTags('users') @Controller('users/:userId/sources') @@ -36,13 +35,13 @@ export class MediaSourcesSettingsController { @Put() @ApiOkResponse({ description: 'Source updated', - type: UpdateMediaSourceResponse, + type: UpdateMediaSourceResponseDto, }) async updateSource( @GetAuthUser() callerUser: User, @Param('userId') userId: string, @Body() sourceDto: UpdateOrCreateMediaSourceDto, - ): Promise { + ): Promise { const user = await this.usersService.findOne(userId); if (!user) { @@ -65,7 +64,9 @@ export class MediaSourcesSettingsController { } return { - mediaSource: updatedSource, + mediaSource: await this.mediaSourcesService.getMediaSourceDto({ + mediaSource: updatedSource, + }), validationResponse, }; } @@ -82,6 +83,6 @@ export class MediaSourcesSettingsController { callerUser, ); - return UserDto.fromEntity(updatedUser); + return this.usersService.getUserDto({ user: updatedUser }); } } diff --git a/backend/src/metadata/tmdb/tmdb.dto.ts b/backend/src/metadata/tmdb/tmdb.dto.ts index 1d1592d..c38194a 100644 --- a/backend/src/metadata/tmdb/tmdb.dto.ts +++ b/backend/src/metadata/tmdb/tmdb.dto.ts @@ -1,3 +1,4 @@ +import { ApiProperty } from '@nestjs/swagger'; import { TmdbApi } from './tmdb.providers'; export type MovieVideos = Awaited< @@ -45,3 +46,75 @@ export type TmdbSeriesFull = TmdbSeries & { external_ids: SeriesExternalIds; images: SeriesImages; }; + +class NextEpisodeToAir { + @ApiProperty({ required: false }) + air_date?: string; +} + +class Season { + @ApiProperty({ required: false }) + air_date?: string; + + @ApiProperty({ required: false }) + episode_count?: number; + + @ApiProperty({ required: false }) + id?: number; + + @ApiProperty({ required: false }) + name?: string; + + @ApiProperty({ required: false }) + overview?: string; + + @ApiProperty({ required: false }) + poster_path?: string; + + @ApiProperty({ required: false }) + season_number?: number; + + @ApiProperty({ required: false }) + vote_average?: number; +} + +export class TmdbItemDto implements TmdbMovie, TmdbSeries { + // TmdbMovie & TmdbSeries + + @ApiProperty({ required: false }) + id?: number; + + @ApiProperty({ required: false }) + poster_path?: string; + + @ApiProperty({ required: false }) + vote_average?: number; + + // TmdbMovie only + + @ApiProperty({ required: false }) + title?: string; + + @ApiProperty({ required: false }) + release_date?: string; + + @ApiProperty({ required: false }) + runtime?: number; + + // TmdbSeries only + + @ApiProperty({ required: false }) + name?: string; + + @ApiProperty({ required: false }) + first_air_date?: string; + + @ApiProperty({ required: false }) + last_air_date?: string; + + @ApiProperty({ required: false, type: NextEpisodeToAir }) + next_episode_to_air?: NextEpisodeToAir; + + @ApiProperty({ required: false, isArray: true, type: Season }) + seasons?: Season[]; +} diff --git a/backend/src/source-providers/source-provider.dto.ts b/backend/src/source-providers/source-provider.dto.ts index 60fddc3..50cf2b8 100644 --- a/backend/src/source-providers/source-provider.dto.ts +++ b/backend/src/source-providers/source-provider.dto.ts @@ -19,12 +19,13 @@ import { getSchemaPath, } from '@nestjs/swagger'; import { DeviceProfileDto } from './device-profile.dto'; +import { TmdbItemDto } from 'src/metadata/tmdb/tmdb.dto'; export class CatalogueItemDto implements CatalogueItem { - @ApiProperty() - id: string; @ApiProperty() tmdbId: string; + @ApiProperty() + tmdbItem: TmdbItemDto; } class PluginSettingsLinkDto implements SourceProviderSettingsLink { diff --git a/backend/src/source-providers/source-providers.controller.ts b/backend/src/source-providers/source-providers.controller.ts index c3af4f5..d257801 100644 --- a/backend/src/source-providers/source-providers.controller.ts +++ b/backend/src/source-providers/source-providers.controller.ts @@ -100,6 +100,7 @@ export class SourceProvidersController { return provider.settingsManager.validateSettings(settings.settings); } + /** @deprecated in favor of mediaSource capabilities */ @Get(':providerId/capabilities') @ApiOkResponse({ type: SourceProviderCapabilitiesDto, @@ -119,8 +120,8 @@ export class SourceProvidersController { // } return { - movieIndexing: !!provider.getMovieCatalogue, - episodeIndexing: !!provider.getEpisodeCatalogue, + movieIndexing: !!provider.catalogueProvider?.getMovieCatalogue, + episodeIndexing: !!provider.catalogueProvider?.getSeriesCatalogue, moviePlayback: !!provider.getMovieStreams && !!provider.getMovieStream, episodePlayback: !!provider.getEpisodeStreams && !!provider.getEpisodeStream, diff --git a/backend/src/user-data/library/library.dto.ts b/backend/src/user-data/library/library.dto.ts index c911df0..c3f019e 100644 --- a/backend/src/user-data/library/library.dto.ts +++ b/backend/src/user-data/library/library.dto.ts @@ -1,89 +1,17 @@ import { ApiProperty, PickType } from '@nestjs/swagger'; import { MediaType } from 'src/common/common.dto'; import { MovieMetadata, SeriesMetadata } from 'src/metadata/metadata.entity'; -import { TmdbMovie, TmdbSeries } from 'src/metadata/tmdb/tmdb.dto'; +import { TmdbItemDto } from 'src/metadata/tmdb/tmdb.dto'; import { LibraryItem } from './library.entity'; -class NextEpisodeToAir { - @ApiProperty({ required: false }) - air_date?: string; -} - -class Season { - @ApiProperty({ required: false }) - air_date?: string; - - @ApiProperty({ required: false }) - episode_count?: number; - - @ApiProperty({ required: false }) - id?: number; - - @ApiProperty({ required: false }) - name?: string; - - @ApiProperty({ required: false }) - overview?: string; - - @ApiProperty({ required: false }) - poster_path?: string; - - @ApiProperty({ required: false }) - season_number?: number; - - @ApiProperty({ required: false }) - vote_average?: number; -} - -export class LibraryItemDto - extends PickType(LibraryItem, [ - 'tmdbId', - 'mediaType', - 'playStates', - 'createdAt', - ]) - implements TmdbMovie, TmdbSeries -{ - // TmdbMovie & TmdbSeries - - @ApiProperty({ required: false }) - id?: number; - - @ApiProperty({ required: false }) - poster_path?: string; - - @ApiProperty({ required: false }) - vote_average?: number; - - // TmdbMovie only - - @ApiProperty({ required: false }) - title?: string; - - @ApiProperty({ required: false }) - release_date?: string; - - @ApiProperty({ required: false }) - runtime?: number; - - // TmdbSeries only - - @ApiProperty({ required: false }) - name?: string; - - @ApiProperty({ required: false }) - first_air_date?: string; - - @ApiProperty({ required: false }) - last_air_date?: string; - - @ApiProperty({ required: false, type: NextEpisodeToAir }) - next_episode_to_air?: NextEpisodeToAir; - - @ApiProperty({ required: false, isArray: true, type: Season }) - seasons?: Season[]; - - // Library Item +export class LibraryItemDto extends PickType(LibraryItem, [ + 'tmdbId', + 'mediaType', + 'playStates', + 'createdAt', +]) { + @ApiProperty() + tmdbItem: TmdbItemDto; @ApiProperty({ required: false }) watched?: boolean; @@ -123,21 +51,23 @@ export class LibraryItemDto return { ...libraryItem, watched, - 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, + 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/users/user.dto.ts b/backend/src/users/user.dto.ts index 1137ecc..7eb6aae 100644 --- a/backend/src/users/user.dto.ts +++ b/backend/src/users/user.dto.ts @@ -1,32 +1,17 @@ import { ApiProperty, OmitType, PartialType, PickType } from '@nestjs/swagger'; +import { MediaSourceDto } from 'src/media-sources/media-source.dto'; import { User } from './user.entity'; export class UserDto extends OmitType(User, [ 'password', 'profilePicture', + 'mediaSources', ] as const) { @ApiProperty({ type: 'string' }) profilePicture: string | null; - static fromEntity(entity: User, caller: User = entity): UserDto { - const out = { - ...entity, - // id: entity.id, - // name: entity.name, - // isAdmin: entity.isAdmin, - // settings: entity.settings, - // onboardingDone: entity.onboardingDone, - // mediaSources: entity.mediaSources, - password: '', - profilePicture: - 'data:image;base64,' + entity.profilePicture?.toString('base64'), - // pluginSettings: entity.pluginSettings, - }; - - delete (out as any).password; - - return out; - } + @ApiProperty({ type: [MediaSourceDto] }) + mediaSources: MediaSourceDto[]; } export class CreateUserDto extends PickType(User, [ diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index e090693..bee6445 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -36,7 +36,7 @@ export class UsersController { // throw new NotFoundException(); // } // - // return UserDto.fromEntity(user); + // return this.usersService.getUserDto({ user }); // } @UseGuards(UserAccessControl) @@ -53,7 +53,9 @@ export class UsersController { const users = await this.usersService.findAll(); - return users.map((user) => UserDto.fromEntity(user)); + return Promise.all( + users.map((user) => this.usersService.getUserDto({ user: user })), + ); } @UseGuards(UserAccessControl) @@ -74,7 +76,7 @@ export class UsersController { throw new NotFoundException(); } - return UserDto.fromEntity(user); + return this.usersService.getUserDto({ user: user }); } // @Get('isSetupDone') @@ -107,7 +109,7 @@ export class UsersController { throw new InternalServerErrorException(); } - return UserDto.fromEntity(user); + return this.usersService.getUserDto({ user: user }); } @UseGuards(UserAccessControl) @@ -136,7 +138,9 @@ export class UsersController { throw new InternalServerErrorException(); } - return UserDto.fromEntity(updated); + return this.usersService.getUserDto({ + user: updated, + }); } @UseGuards(UserAccessControl) diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index e08e20b..de5f08b 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -1,11 +1,12 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { SourceProvidersModule } from 'src/source-providers/source-providers.module'; import { userProviders } from './user.providers'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; +import { MediaSourcesModule } from 'src/media-sources/media-sources.module'; @Module({ - imports: [SourceProvidersModule], + imports: [forwardRef(() => MediaSourcesModule), SourceProvidersModule], providers: [...userProviders, UsersService], controllers: [UsersController], exports: [UsersService], diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index dfa8abb..463bcc0 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,7 +1,8 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { MediaSourcesService } from 'src/media-sources/media-sources.service'; import { SourceProvidersService } from 'src/source-providers/source-providers.service'; import { Repository } from 'typeorm'; -import { CreateUserDto, UpdateUserDto } from './user.dto'; +import { CreateUserDto, UpdateUserDto, UserDto } from './user.dto'; import { User } from './user.entity'; import { USER_REPOSITORY } from './user.providers'; @@ -16,8 +17,9 @@ export class UsersService { constructor( @Inject(USER_REPOSITORY) private readonly userRepository: Repository, - @Inject(SourceProvidersService) private readonly sourceProvidersService: SourceProvidersService, + @Inject(forwardRef(() => MediaSourcesService)) + private readonly mediaSourcesService: MediaSourcesService, ) {} // Finds @@ -148,6 +150,35 @@ export class UsersService { return adminCount === 0; } + async getUserDto(options: { user: User; caller?: User }): Promise { + const { user, caller = user } = options; + + const mediaSources = await Promise.all( + user.mediaSources?.map((m) => + this.mediaSourcesService.getMediaSourceDto({ mediaSource: m }), + ) ?? [], + ); + + const out = { + ...user, + // id: entity.id, + // name: entity.name, + // isAdmin: entity.isAdmin, + // settings: entity.settings, + // onboardingDone: entity.onboardingDone, + // mediaSources: entity.mediaSources, + password: '', + profilePicture: + 'data:image;base64,' + user.profilePicture?.toString('base64'), + mediaSources, + // pluginSettings: entity.pluginSettings, + }; + + delete out.password; + + return out; + } + private async filterMediaSources(user: User): Promise { const providers = await this.sourceProvidersService.getProviders(); diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index 538638c..d0c2f1b 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -208,8 +208,39 @@ export interface PaginatedResponseDto { itemsPerPage: number; } -export interface IndexItemDto { +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 { id: string; + tmdbId: string; + tmdbItem: TmdbItemDto; } export interface VideoStreamPropertyDto { @@ -537,37 +568,12 @@ export interface BulkUpdatePlayStateDto { playStates: UpdatePlayStateDto[]; } -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 LibraryItemDto { tmdbId: string; mediaType: 'Movie' | 'Series' | 'Episode'; playStates?: PlayStateDto[]; createdAt: string; - 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[]; + tmdbItem: TmdbItemDto; watched?: boolean; } @@ -1350,7 +1356,7 @@ export class Api extends HttpClient this.request< PaginatedResponseDto & { - items: IndexItemDto[]; + items: CatalogueItemDto[]; }, any >({ @@ -1370,7 +1376,7 @@ export class Api extends HttpClient this.request< PaginatedResponseDto & { - items: IndexItemDto[]; + items: CatalogueItemDto[]; }, any >({ diff --git a/src/lib/pages/LibraryPage/CatalogueTab.svelte b/src/lib/pages/LibraryPage/CatalogueTab.svelte index 51dfd98..7acaa59 100644 --- a/src/lib/pages/LibraryPage/CatalogueTab.svelte +++ b/src/lib/pages/LibraryPage/CatalogueTab.svelte @@ -10,11 +10,11 @@ $: items = reiverrApi.sources.getMovieCatalogue(source.id).then((r) => r.data.items); - + {#await items then items} - {#each items as item} - + {#each items.map((i) => i.tmdbItem) as item} + {/each} {/await} diff --git a/src/lib/pages/LibraryPage/LibraryPage.svelte b/src/lib/pages/LibraryPage/LibraryPage.svelte index 17da254..6c1ebbd 100644 --- a/src/lib/pages/LibraryPage/LibraryPage.svelte +++ b/src/lib/pages/LibraryPage/LibraryPage.svelte @@ -7,6 +7,7 @@ import { sources } from '$lib/stores/user.store'; import classNames from 'classnames'; import MyListTab from './MyListTab.svelte'; + import CatalogueTab from './CatalogueTab.svelte'; const tab = useTabs(0, { remount: true }); @@ -48,9 +49,7 @@ {#each catalogues as catalogue, index} - - {catalogue.name} - + {/each} diff --git a/src/lib/pages/LibraryPage/MyListTab.svelte b/src/lib/pages/LibraryPage/MyListTab.svelte index 4ed8e1c..ac097bf 100644 --- a/src/lib/pages/LibraryPage/MyListTab.svelte +++ b/src/lib/pages/LibraryPage/MyListTab.svelte @@ -46,11 +46,13 @@ for (const item of items) { const releaseDate = new Date( - item.release_date || (item.watched && (item.next_episode_to_air as any)?.air_date) || 0 + item.tmdbItem.release_date || + (item.watched && (item.tmdbItem.next_episode_to_air as any)?.air_date) || + 0 ); const hasFutureReleases = item.watched - ? item.seasons?.some((s) => s.air_date === null) - : item.last_air_date === null; + ? item.tmdbItem.seasons?.some((s) => s.air_date === null) + : item.tmdbItem.last_air_date === null; if (viewSettings.separateUpcoming && (releaseDate > new Date() || hasFutureReleases)) { categorizedItems.upcoming.push(item); @@ -63,14 +65,14 @@ categorizedItems.upcoming.sort((a, b) => { const aReleaseDate = new Date( - a.release_date || - a.next_episode_to_air?.air_date || + a.tmdbItem.release_date || + a.tmdbItem.next_episode_to_air?.air_date || new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 20 ); const bReleaseDate = new Date( - b.release_date || - (b.next_episode_to_air as any)?.air_date || + b.tmdbItem.release_date || + (b.tmdbItem.next_episode_to_air as any)?.air_date || new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 20 ); @@ -98,18 +100,18 @@ const aCreatedAt = a.createdAt; const bCreatedAt = b.createdAt; - const aReleaseDate = a.release_date || ''; - const bReleaseDate = b.release_date || ''; + const aReleaseDate = a.tmdbItem.release_date || ''; + const bReleaseDate = b.tmdbItem.release_date || ''; - const aFirstAirDate = a.first_air_date || aReleaseDate; - const bFirstAirDate = b.first_air_date || bReleaseDate; + const aFirstAirDate = a.tmdbItem.first_air_date || aReleaseDate; + const bFirstAirDate = b.tmdbItem.first_air_date || bReleaseDate; - const aLastAirDate = a.last_air_date || aFirstAirDate || aReleaseDate; - const bLastAirDate = b.last_air_date || bFirstAirDate || bReleaseDate; + const aLastAirDate = a.tmdbItem.last_air_date || aFirstAirDate || aReleaseDate; + const bLastAirDate = b.tmdbItem.last_air_date || bFirstAirDate || bReleaseDate; - const aTitle = a.title || a.name || ''; + const aTitle = a.tmdbItem.title || a.tmdbItem.name || ''; - const bTitle = b.title || b.name || ''; + const bTitle = b.tmdbItem.title || b.tmdbItem.name || ''; const direction = viewSettings.sortDirection === 'asc' ? 1 : -1; if (viewSettings.sortBy === 'date-added') { @@ -167,7 +169,11 @@ > {#key viewSettingsKey} {#each $libraryItemsCategorized.upcoming as item (item.tmdbId)} - + {/each} {/key} @@ -180,7 +186,7 @@ {#key viewSettingsKey} {#each $libraryItemsCategorized.main as item, index (item.tmdbId)}