diff --git a/backend/package-lock.json b/backend/package-lock.json index a0f4bd8..4ae1b36 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -26,7 +26,7 @@ }, "devDependencies": { "@nestjs/cli": "^10.0.0", - "@nestjs/schematics": "^10.0.0", + "@nestjs/schematics": "^10.1.3", "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", @@ -1865,26 +1865,115 @@ } }, "node_modules/@nestjs/schematics": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.1.tgz", - "integrity": "sha512-o4lfCnEeIkfJhGBbLZxTuVWcGuqDCFwg5OrvpgRUBM7vI/vONvKKiB5riVNpO+JqXoH0I42NNeDb0m4V5RREig==", + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", + "integrity": "sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==", "dev": true, + "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.1.2", - "@angular-devkit/schematics": "17.1.2", - "comment-json": "4.2.3", - "jsonc-parser": "3.2.1", + "@angular-devkit/core": "17.3.11", + "@angular-devkit/schematics": "17.3.11", + "comment-json": "4.2.5", + "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, "peerDependencies": { "typescript": ">=4.8.2" } }, - "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", + "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.1", + "picomatch": "4.0.1", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core/node_modules/jsonc-parser": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.11.tgz", + "integrity": "sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "17.3.11", + "jsonc-parser": "3.2.1", + "magic-string": "0.30.8", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics/node_modules/jsonc-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nestjs/schematics/node_modules/magic-string": { + "version": "0.30.8", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", + "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@nestjs/schematics/node_modules/picomatch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", + "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, "node_modules/@nestjs/serve-static": { "version": "4.0.1", @@ -3122,7 +3211,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/array-union": { "version": "2.1.0", @@ -3998,10 +4088,11 @@ } }, "node_modules/comment-json": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.3.tgz", - "integrity": "sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz", + "integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==", "dev": true, + "license": "MIT", "dependencies": { "array-timsort": "^1.0.3", "core-util-is": "^1.0.3", @@ -5627,6 +5718,7 @@ "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -8658,6 +8750,7 @@ "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10" } diff --git a/backend/package.json b/backend/package.json index 293e9c5..0c9cfec 100644 --- a/backend/package.json +++ b/backend/package.json @@ -43,7 +43,7 @@ }, "devDependencies": { "@nestjs/cli": "^10.0.0", - "@nestjs/schematics": "^10.0.0", + "@nestjs/schematics": "^10.1.3", "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", @@ -87,4 +87,4 @@ "semi": true, "singleQuote": true } -} +} \ No newline at end of file diff --git a/backend/src/auth/auth.guard.ts b/backend/src/auth/auth.guard.ts index f7b34c7..3efe017 100644 --- a/backend/src/auth/auth.guard.ts +++ b/backend/src/auth/auth.guard.ts @@ -12,7 +12,7 @@ import { User } from '../users/user.entity'; import { UsersService } from '../users/users.service'; import { Request } from 'express'; -export const GetUser = createParamDecorator( +export const GetAuthUser = createParamDecorator( (data: unknown, ctx: ExecutionContext): User => { const request = ctx.switchToHttp().getRequest(); return request.user; @@ -40,7 +40,7 @@ function extractTokenFromRequest(request: Request): string | undefined { } @Injectable() -export class AuthGuard implements CanActivate { +export class UserAccessControl implements CanActivate { constructor( private jwtService: JwtService, private userService: UsersService, @@ -63,22 +63,31 @@ export class AuthGuard implements CanActivate { secret: JWT_SECRET, }, ); + + let user: User; if (payload.sub) { - request['user'] = await this.userService.findOne(payload.sub); + user = await this.userService.findOne(payload.sub); + request['user'] = user; } - if (!request['user']) { + if (!user) { + throw new UnauthorizedException(); + } + + const targetUser = request.params.userId; + if (targetUser && targetUser !== user.id && user.isAdmin === false) { throw new UnauthorizedException(); } } catch { throw new UnauthorizedException(); } + return true; } } @Injectable() -export class OptionalAuthGuard implements CanActivate { +export class OptionalAccessControl implements CanActivate { constructor( private jwtService: JwtService, private userService: UsersService, diff --git a/backend/src/common/common.dto.ts b/backend/src/common/common.dto.ts index 15e6f0e..528b4cb 100644 --- a/backend/src/common/common.dto.ts +++ b/backend/src/common/common.dto.ts @@ -22,3 +22,8 @@ export class PaginationParamsDto implements PaginationParams { @ApiProperty() itemsPerPage: number; } + +export class SuccessResponseDto { + @ApiProperty() + success: boolean; +} \ No newline at end of file diff --git a/backend/src/media/media.controller.ts b/backend/src/media/media.controller.ts new file mode 100644 index 0000000..818f528 --- /dev/null +++ b/backend/src/media/media.controller.ts @@ -0,0 +1,62 @@ +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { + GetAuthUser, + OptionalAccessControl, + UserAccessControl, +} from 'src/auth/auth.guard'; +import { + GetPaginationParams, + PaginatedApiOkResponse, +} from 'src/common/common.decorator'; +import { PaginationParamsDto } from 'src/common/common.dto'; +import { LibraryService } from 'src/users/library/library.service'; +import { User } from 'src/users/user.entity'; +import { MovieDto } from './media.dto'; +import { MediaService } from './media.service'; + +// @UseGuards(OptionalAccessControl) +@Controller() +export class MediaController { + constructor(private mediaService: MediaService) {} + + @ApiTags('movies') + @Get('movies/tmdb/:tmdbId') + @ApiOkResponse({ type: MovieDto }) + async getMovieByTmdbId( + @GetAuthUser() user: User, + @Param('tmdbId') tmdbId: string, + ): Promise { + // let userData: MovieDto['userData']; + + // if (user) { + // const libraryItem = await this.libraryService.findByTmdbId( + // user.id, + // tmdbId, + // ); + + // userData = { + // inLibrary: !!libraryItem, + // }; + // } + + return this.mediaService.getMovieByTmdbId(tmdbId); + } + + // @ApiTags('movies') + // @Get('movies/library') + // @PaginatedApiOkResponse(MovieDto) + // @UseGuards(UserAccessControl) + // async getLibraryMovies( + // @GetAuthUser() user: User, + // @GetPaginationParams() pagination: PaginationParamsDto, + // ): Promise { + // const libraryItems = await this.libraryService.getLibraryItems(user.id); + + // const items = await Promise.all( + // libraryItems.map((item) => this.mediaService.getm), + // ); + + // return {}; + // } +} diff --git a/backend/src/media/media.dto.ts b/backend/src/media/media.dto.ts new file mode 100644 index 0000000..f9684e4 --- /dev/null +++ b/backend/src/media/media.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +// export class MovieUserDataDto { +// @ApiProperty() +// inLibrary: boolean; +// } + +export class MovieDto { + // tmdbData: any; + // @ApiProperty({ type: MovieUserDataDto, required: false }) + // userData?: MovieUserDataDto; +} diff --git a/backend/src/media/media.module.ts b/backend/src/media/media.module.ts index acf2a0a..abf719c 100644 --- a/backend/src/media/media.module.ts +++ b/backend/src/media/media.module.ts @@ -1,4 +1,11 @@ import { Module } from '@nestjs/common'; +import { MediaController } from './media.controller'; +import { MediaService } from './media.service'; -@Module({}) +@Module({ + imports: [], + controllers: [MediaController], + providers: [MediaService], + exports: [MediaService], +}) export class MediaModule {} diff --git a/backend/src/media/media.service.ts b/backend/src/media/media.service.ts new file mode 100644 index 0000000..e2543fd --- /dev/null +++ b/backend/src/media/media.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class MediaService { + async getMovieByTmdbId(tmdbId: string): Promise { + // throw new Error('Method not implemented.'); + + return {}; + } + + async getBulkMoviesByTmdbIds(tmdbIds: string[]): Promise { + return []; + } +} diff --git a/backend/src/source-plugins/source-plugins.controller.ts b/backend/src/source-plugins/source-plugins.controller.ts index ca0bbe7..7fb449f 100644 --- a/backend/src/source-plugins/source-plugins.controller.ts +++ b/backend/src/source-plugins/source-plugins.controller.ts @@ -19,7 +19,11 @@ import { import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { SourcePlugin, SourcePluginError } from 'plugins/plugin-types'; -import { AuthGuard, GetAuthToken, GetUser } from 'src/auth/auth.guard'; +import { + UserAccessControl, + GetAuthToken, + GetAuthUser, +} from 'src/auth/auth.guard'; import { GetPaginationParams, PaginatedApiOkResponse, @@ -61,7 +65,7 @@ export class ValidateSourcePluginPipe implements PipeTransform { } @Controller() -@UseGuards(AuthGuard) +@UseGuards(UserAccessControl) export class SourcesController { constructor( private sourcesService: SourcePluginsService, @@ -89,7 +93,7 @@ export class SourcesController { }) async getSourceSettingsTemplate( @Param('sourceId') sourceId: string, - @GetUser() callerUser: User, + @GetAuthUser() callerUser: User, ): Promise { const plugin = this.sourcesService.getPlugin(sourceId); @@ -110,7 +114,7 @@ export class SourcesController { type: ValidationResponseDto, }) async validateSourceSettings( - @GetUser() callerUser: User, + @GetAuthUser() callerUser: User, @Param('sourceId') sourceId: string, @Body() settings: PluginSettingsDto, ): Promise { @@ -129,7 +133,7 @@ export class SourcesController { type: SourcePluginCapabilitiesDto, }) async getSourceCapabilities( - @GetUser() user: User, + @GetAuthUser() user: User, @Param('sourceId', ValidateSourcePluginPipe) plugin: SourcePlugin, @GetAuthToken() token: string, ): Promise { @@ -152,7 +156,7 @@ export class SourcesController { @Get('sources/:sourceId/index/movies') @PaginatedApiOkResponse(IndexItemDto) async getSourceMovieIndex( - @GetUser() user: User, + @GetAuthUser() user: User, @Param('sourceId', ValidateSourcePluginPipe) plugin: SourcePlugin, @GetAuthToken() token: string, @GetPaginationParams() pagination: PaginationParamsDto, @@ -184,7 +188,7 @@ export class SourcesController { async getMovieStreams( @Param('tmdbId') tmdbId: string, @Param('sourceId') sourceId: string, - @GetUser() user: User, + @GetAuthUser() user: User, @GetAuthToken() token: string, ): Promise { const plugin = this.sourcesService.getPlugin(sourceId); @@ -199,11 +203,10 @@ export class SourcesController { throw new BadRequestException('Source configuration not found'); } - const streams = await plugin - .getMovieStreams(tmdbId, { - settings, - token, - }) + const streams = await plugin.getMovieStreams(tmdbId, { + settings, + token, + }); return { streams, @@ -249,7 +252,7 @@ export class SourcesController { @Param('tmdbId') tmdbId: string, @Param('sourceId') sourceId: string, @Query('key') key: string, - @GetUser() user: User, + @GetAuthUser() user: User, @GetAuthToken() token: string, @Body() config: PlaybackConfigDto, ): Promise { @@ -288,7 +291,7 @@ export class SourcesController { @Param() params: any, @Req() req: Request, @Res() res: Response, - @GetUser() user: User, + @GetAuthUser() user: User, ) { const sourceId = params.sourceId; const settings = this.userSourcesService.getSourceSettings(user, sourceId); diff --git a/backend/src/users/library/library.controller.ts b/backend/src/users/library/library.controller.ts new file mode 100644 index 0000000..b4c56ac --- /dev/null +++ b/backend/src/users/library/library.controller.ts @@ -0,0 +1,79 @@ +import { Controller, Delete, Get, Param, Put, UseGuards } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { UserAccessControl } from 'src/auth/auth.guard'; +import { LibraryService } from './library.service'; +import { + PaginatedResponseDto, + PaginationParamsDto, + SuccessResponseDto, +} from 'src/common/common.dto'; +import { LibraryItemDto } from './library.dto'; +import { + GetPaginationParams, + PaginatedApiOkResponse, +} from 'src/common/common.decorator'; +import { UsersService } from '../users.service'; +import { MediaService } from 'src/media/media.service'; + +@ApiTags('users') +@Controller('users/:userId/library') +@UseGuards(UserAccessControl) +export class LibraryController { + constructor( + private userService: UsersService, + private libraryService: LibraryService, + ) {} + + @Get() + @PaginatedApiOkResponse(LibraryItemDto) + async getLibraryItems( + @GetPaginationParams() pagination: PaginationParamsDto, + @Param('userId') userId: string, + ): Promise> { + // const user = await this.userService.findOne(userId); + + const items = await this.libraryService.getLibraryItems(userId); + + return { + items, + itemsPerPage: pagination.itemsPerPage, + page: pagination.page, + total: items.length, + }; + } + + @Put('tmdb/:tmdbId') + @ApiOkResponse({ + description: 'Library item added', + type: SuccessResponseDto, + }) + async addLibraryItem( + @Param('userId') userId: string, + @Param('tmdbId') tmdbId: string, + ): Promise { + const item = await this.libraryService.findOrCreateByTmdbId(userId, tmdbId); + + return { + success: !!item, + }; + } + + @Delete('tmdb/:tmdbId') + @ApiOkResponse({ + description: 'Library item removed', + type: SuccessResponseDto, + }) + async removeLibraryItem( + @Param('userId') userId: string, + @Param('tmdbId') tmdbId: string, + ): Promise { + const deleteAction = await this.libraryService.deleteByTmdbId( + userId, + tmdbId, + ); + + return { + success: deleteAction.affected > 0, + }; + } +} diff --git a/backend/src/users/library/library.dto.ts b/backend/src/users/library/library.dto.ts new file mode 100644 index 0000000..814fdc6 --- /dev/null +++ b/backend/src/users/library/library.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { MovieDto } from 'src/media/media.dto'; + +export class LibraryItemDto { + @ApiProperty() + tmdbId: string; + + @ApiProperty({ type: MovieDto, required: false }) + metadata?: MovieDto; // TODO +} diff --git a/backend/src/users/library/library.entity.ts b/backend/src/users/library/library.entity.ts new file mode 100644 index 0000000..77e69b7 --- /dev/null +++ b/backend/src/users/library/library.entity.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryColumn, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from '../user.entity'; + +@Entity() +export class LibraryItem { + @ApiProperty({ required: false, type: 'string' }) + @PrimaryGeneratedColumn('uuid') + id: string; + + @ApiProperty({ required: true, type: 'number' }) + @Column({ unique: true }) + tmdbId: string; + + @ApiProperty({ required: true, type: 'string' }) + @PrimaryColumn() + userId: string; + + @ApiProperty({ required: false, type: 'string' }) + @ManyToOne(() => User, (user) => user.libraryItems, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; +} diff --git a/backend/src/users/library/library.module.ts b/backend/src/users/library/library.module.ts deleted file mode 100644 index 8c2805d..0000000 --- a/backend/src/users/library/library.module.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Module } from '@nestjs/common'; - -@Module({}) -export class LibraryModule {} diff --git a/backend/src/users/library/library.service.ts b/backend/src/users/library/library.service.ts new file mode 100644 index 0000000..ccf44c6 --- /dev/null +++ b/backend/src/users/library/library.service.ts @@ -0,0 +1,38 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { USER_LIBRARY_REPOSITORY } from '../user.providers'; +import { Repository } from 'typeorm'; +import { LibraryItem } from './library.entity'; + +@Injectable() +export class LibraryService { + constructor( + @Inject(USER_LIBRARY_REPOSITORY) + private readonly libraryRepository: Repository, + ) {} + + async getLibraryItems(userId: string): Promise { + return this.libraryRepository.find({ where: { userId } }); + } + + async findByTmdbId(userId: string, tmdbId: string): Promise { + return this.libraryRepository.findOne({ where: { userId, tmdbId } }); + } + + async findOrCreateByTmdbId( + userId: string, + tmdbId: string, + ): Promise { + let libraryItem = await this.findByTmdbId(userId, tmdbId); + + if (!libraryItem) { + libraryItem = this.libraryRepository.create({ userId, tmdbId }); + await this.libraryRepository.save(libraryItem); + } + + return libraryItem; + } + + async deleteByTmdbId(userId: string, tmdbId: string) { + return await this.libraryRepository.delete({ userId, tmdbId }); + } +} diff --git a/backend/src/users/play-state/play-state.controller.ts b/backend/src/users/play-state/play-state.controller.ts new file mode 100644 index 0000000..fc47446 --- /dev/null +++ b/backend/src/users/play-state/play-state.controller.ts @@ -0,0 +1,4 @@ +import { Controller } from '@nestjs/common'; + +@Controller('play-state') +export class PlayStateController {} diff --git a/backend/src/users/play-state/play-state.entity.ts b/backend/src/users/play-state/play-state.entity.ts new file mode 100644 index 0000000..ee83246 --- /dev/null +++ b/backend/src/users/play-state/play-state.entity.ts @@ -0,0 +1,58 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryColumn, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; +import { User } from '../user.entity'; + +@Entity() +@Unique(['tmdbId', 'userId', 'season', 'episode']) +export class PlayState { + @ApiProperty({ required: false, type: 'string' }) + @PrimaryGeneratedColumn('uuid') + id: string; + + @ApiProperty({ required: true, type: 'number' }) + @Column({ unique: true }) + tmdbId: string; + + @ApiProperty({ required: true, type: 'string' }) + @Column() + userId: string; + + @ApiProperty({ required: true, type: 'string' }) + @ManyToOne(() => User, (user) => user.playStates, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @ApiProperty({ required: false, type: 'number' }) + @PrimaryColumn({ default: 0 }) + season: number = 0; + + @ApiProperty({ required: false, type: 'number' }) + @PrimaryColumn({ default: 0 }) + episode: number = 0; + + @ApiProperty({ + required: false, + type: 'boolean', + default: false, + description: 'Whether the user has watched this media', + }) + @Column({ default: false }) + watched: boolean = false; + + @ApiProperty({ + required: false, + default: false, + example: 0.5, + description: 'A number between 0 and 1', + }) + @Column({ default: 0 }) + progress: number = 0; +} diff --git a/backend/src/users/play-state/play-state.module.ts b/backend/src/users/play-state/play-state.module.ts deleted file mode 100644 index 350d9e5..0000000 --- a/backend/src/users/play-state/play-state.module.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Module } from '@nestjs/common'; - -@Module({}) -export class PlayStateModule {} diff --git a/backend/src/users/play-state/play-state.service.ts b/backend/src/users/play-state/play-state.service.ts new file mode 100644 index 0000000..10d2668 --- /dev/null +++ b/backend/src/users/play-state/play-state.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class PlayStateService {} diff --git a/backend/src/users/user-sources/user-sources.controller.ts b/backend/src/users/user-sources/user-sources.controller.ts index 58116b97..5c73e7d 100644 --- a/backend/src/users/user-sources/user-sources.controller.ts +++ b/backend/src/users/user-sources/user-sources.controller.ts @@ -11,7 +11,7 @@ import { UseGuards, } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { AuthGuard, GetUser } from 'src/auth/auth.guard'; +import { UserAccessControl, GetAuthUser } from 'src/auth/auth.guard'; import { UsersService } from '../users.service'; import { User } from '../user.entity'; import { UserDto } from '../user.dto'; @@ -23,7 +23,7 @@ import { @ApiTags('users') @Controller('users/:userId/sources') -@UseGuards(AuthGuard) +@UseGuards(UserAccessControl) export class UserSourcesController { constructor( private usersService: UsersService, @@ -33,7 +33,7 @@ export class UserSourcesController { @Put(':sourceId') @ApiOkResponse({ description: 'Source updated', type: UserDto }) async updateSource( - @GetUser() callerUser: User, + @GetAuthUser() callerUser: User, @Param('sourceId') sourceId: string, @Param('userId') userId: string, @Body() sourceDto: CreateSourceDto, @@ -64,7 +64,7 @@ export class UserSourcesController { @Delete(':sourceId') @ApiOkResponse({ description: 'Source deleted', type: UserDto }) async deleteSource( - @GetUser() callerUser: User, + @GetAuthUser() callerUser: User, @Param('sourceId') sourceId: string, @Param('userId') userId: string, ): Promise { diff --git a/backend/src/users/user.dto.ts b/backend/src/users/user.dto.ts index bd0224a..4b76e57 100644 --- a/backend/src/users/user.dto.ts +++ b/backend/src/users/user.dto.ts @@ -9,17 +9,23 @@ export class UserDto extends OmitType(User, [ profilePicture: string | null; static fromEntity(entity: User, caller: User = entity): UserDto { - return { - id: entity.id, - name: entity.name, - isAdmin: entity.isAdmin, - settings: entity.settings, - onboardingDone: entity.onboardingDone, + 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, - mediaSources: entity.mediaSources, }; + + delete out.password; + + return out; } } @@ -50,3 +56,8 @@ export class UpdateUserDto extends PartialType( } export class SignInDto extends PickType(User, ['name', 'password'] as const) {} + +export class MovieUserDataDto { + @ApiProperty() + inLibrary: boolean; +} diff --git a/backend/src/users/user.entity.ts b/backend/src/users/user.entity.ts index 2ed13b1..e0a0271 100644 --- a/backend/src/users/user.entity.ts +++ b/backend/src/users/user.entity.ts @@ -3,11 +3,14 @@ import { Entity, ManyToOne, OneToMany, + OneToOne, PrimaryColumn, PrimaryGeneratedColumn, } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; import { MediaSource } from 'src/users/user-sources/user-source.entity'; +import { LibraryItem } from './library/library.entity'; +import { PlayState } from './play-state/play-state.entity'; export class SonarrSettings { @ApiProperty({ required: true }) @@ -143,4 +146,12 @@ export class User { @ApiProperty({ required: false, type: MediaSource, isArray: true }) @OneToMany(() => MediaSource, (mediaSource) => mediaSource.user) mediaSources: MediaSource[]; + + @ApiProperty({ required: false, type: PlayState, isArray: true }) + @OneToMany(() => PlayState, (playState) => playState.user) + playStates: PlayState[]; + + @ApiProperty({ required: false, type: LibraryItem, isArray: true }) + @OneToMany(() => LibraryItem, (library) => library.user) + libraryItems: LibraryItem[]; } diff --git a/backend/src/users/user.providers.ts b/backend/src/users/user.providers.ts index 6144583..ce21985 100644 --- a/backend/src/users/user.providers.ts +++ b/backend/src/users/user.providers.ts @@ -2,9 +2,13 @@ import { DataSource } from 'typeorm'; import { User } from './user.entity'; import { DATA_SOURCE } from '../database/database.providers'; import { MediaSource } from './user-sources/user-source.entity'; +import { PlayState } from './play-state/play-state.entity'; +import { LibraryItem } from './library/library.entity'; export const USER_REPOSITORY = 'USER_REPOSITORY'; export const USER_SOURCE_REPOSITORY = 'USER_SOURCE_REPOSITORY'; +export const USER_LIBRARY_REPOSITORY = 'USER_LIBRARY_REPOSITORY'; +export const USER_PLAY_STATE_REPOSITORY = 'USER_PLAY_STATE_REPOSITORY'; export const userProviders = [ { @@ -18,4 +22,15 @@ export const userProviders = [ dataSource.getRepository(MediaSource), inject: [DATA_SOURCE], }, + { + provide: USER_PLAY_STATE_REPOSITORY, + useFactory: (dataSource: DataSource) => dataSource.getRepository(PlayState), + inject: [DATA_SOURCE], + }, + { + provide: USER_LIBRARY_REPOSITORY, + useFactory: (dataSource: DataSource) => + dataSource.getRepository(LibraryItem), + inject: [DATA_SOURCE], + }, ]; diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index fec1c61..a440f14 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -15,16 +15,29 @@ import { UseGuards, } from '@nestjs/common'; import { UserServiceError, UsersService } from './users.service'; -import { AuthGuard, GetUser, OptionalAuthGuard } from '../auth/auth.guard'; +import { + UserAccessControl, + GetAuthUser, + OptionalAccessControl, +} from '../auth/auth.guard'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { CreateUserDto, UpdateUserDto, UserDto } from './user.dto'; +import { + CreateUserDto, + MovieUserDataDto, + UpdateUserDto, + UserDto, +} from './user.dto'; import { User } from './user.entity'; import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator'; +import { LibraryService } from './library/library.service'; @ApiTags('users') @Controller('users') export class UsersController { - constructor(private usersService: UsersService) {} + constructor( + private usersService: UsersService, + private libraryService: LibraryService, + ) {} // @UseGuards(AuthGuard) // @Get() @@ -40,14 +53,14 @@ export class UsersController { // return UserDto.fromEntity(user); // } - @UseGuards(AuthGuard) + @UseGuards(UserAccessControl) @Get('') @ApiOkResponse({ description: 'All users found', type: UserDto, isArray: true, }) - async findAllUsers(@GetUser() callerUser: User): Promise { + async findAllUsers(@GetAuthUser() callerUser: User): Promise { if (!callerUser.isAdmin) { throw new UnauthorizedException(); } @@ -57,13 +70,13 @@ export class UsersController { return users.map((user) => UserDto.fromEntity(user)); } - @UseGuards(AuthGuard) + @UseGuards(UserAccessControl) @Get(':id') @ApiOkResponse({ description: 'User found', type: UserDto }) @ApiException(() => NotFoundException, { description: 'User not found' }) async findUserById( @Param('id') id: string, - @GetUser() callerUser: User, + @GetAuthUser() callerUser: User, ): Promise { if (!callerUser.isAdmin && callerUser.id !== id) { throw new NotFoundException(); @@ -84,7 +97,7 @@ export class UsersController { // return this.userService.noPreviousAdmins(); // } - @UseGuards(OptionalAuthGuard) + @UseGuards(OptionalAccessControl) @Post() @ApiOkResponse({ description: 'User created', type: UserDto }) @ApiException(() => UnauthorizedException, { description: 'Unauthorized' }) @@ -92,7 +105,7 @@ export class UsersController { async createUser( @Body() userCreateDto: CreateUserDto, - @GetUser() callerUser: User | undefined, + @GetAuthUser() callerUser: User | undefined, ) { const canCreateUser = (await this.usersService.noPreviousAdmins()) || callerUser?.isAdmin; @@ -108,14 +121,14 @@ export class UsersController { return UserDto.fromEntity(user); } - @UseGuards(AuthGuard) + @UseGuards(UserAccessControl) @Put(':id') @ApiOkResponse({ description: 'User updated', type: UserDto }) @ApiException(() => NotFoundException, { description: 'User not found' }) async updateUser( @Param('id') id: string, @Body() updateUserDto: UpdateUserDto, - @GetUser() callerUser: User, + @GetAuthUser() callerUser: User, ): Promise { if ((!callerUser.isAdmin && callerUser.id !== id) || !id) { throw new NotFoundException(); @@ -135,15 +148,32 @@ export class UsersController { return UserDto.fromEntity(updated); } - @UseGuards(AuthGuard) + @UseGuards(UserAccessControl) @Delete(':id') @ApiOkResponse({ description: 'User deleted' }) @ApiException(() => NotFoundException, { description: 'User not found' }) - async deleteUser(@Param('id') id: string, @GetUser() callerUser: User) { + async deleteUser(@Param('id') id: string, @GetAuthUser() callerUser: User) { if ((!callerUser.isAdmin && callerUser.id !== id) || !id) { throw new NotFoundException(); } await this.usersService.remove(id); } + + @UseGuards(UserAccessControl) + @Get(':userId/user-data/movie/tmdb/:tmdbId') + @ApiOkResponse({ + description: 'User movie data found', + type: MovieUserDataDto, + }) + async getUserMovieData( + @Param('userId') userId: string, + @Param('tmdbId') tmdbId: string, + ): Promise { + const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId); + + return { + inLibrary: !!libraryItem, + }; + } } diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 022aa62..e9ad568 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -3,15 +3,28 @@ import { UsersService } from './users.service'; import { userProviders } from './user.providers'; import { UsersController } from './users.controller'; import { DatabaseModule } from '../database/database.module'; -import { LibraryModule } from './library/library.module'; -import { PlayStateModule } from './play-state/play-state.module'; import { UserSourcesService } from './user-sources/user-sources.service'; import { UserSourcesController } from './user-sources/user-sources.controller'; +import { LibraryService } from './library/library.service'; +import { PlayStateService } from './play-state/play-state.service'; +import { LibraryController } from './library/library.controller'; +import { PlayStateController } from './play-state/play-state.controller'; @Module({ - imports: [DatabaseModule, LibraryModule, PlayStateModule], - providers: [...userProviders, UsersService, UserSourcesService], - controllers: [UsersController, UserSourcesController], - exports: [UsersService, UserSourcesService], + imports: [DatabaseModule], + providers: [ + ...userProviders, + UsersService, + UserSourcesService, + LibraryService, + PlayStateService, + ], + controllers: [ + UsersController, + UserSourcesController, + LibraryController, + PlayStateController, + ], + exports: [UsersService, UserSourcesService, LibraryService], }) export class UsersModule {} diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index 788b8da..1441fb6 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -56,6 +56,33 @@ export interface MediaSource { pluginSettings?: object; } +export interface PlayState { + id?: string; + tmdbId: number; + userId: string; + user: string; + season?: number; + episode?: number; + /** + * Whether the user has watched this media + * @default false + */ + watched?: boolean; + /** + * A number between 0 and 1 + * @default false + * @example 0.5 + */ + progress?: number; +} + +export interface LibraryItem { + id?: string; + tmdbId: number; + userId: string; + user?: string; +} + export interface UserDto { id: string; name: string; @@ -63,6 +90,8 @@ export interface UserDto { onboardingDone?: boolean; settings: Settings; mediaSources?: MediaSource[]; + playStates?: PlayState[]; + libraryItems?: LibraryItem[]; profilePicture: string; } @@ -83,6 +112,10 @@ export interface UpdateUserDto { oldPassword?: string; } +export interface MovieUserDataDto { + inLibrary: boolean; +} + export interface CreateSourceDto { pluginSettings?: object; /** @default false */ @@ -91,6 +124,23 @@ export interface CreateSourceDto { adminControlled?: boolean; } +export interface PaginatedResponseDto { + total: number; + page: number; + itemsPerPage: number; +} + +export type MovieDto = object; + +export interface LibraryItemDto { + tmdbId: string; + metadata?: MovieDto; +} + +export interface SuccessResponseDto { + success: boolean; +} + export interface SignInDto { name: string; password: string; @@ -127,12 +177,6 @@ export interface SourcePluginCapabilitiesDto { deletion: boolean; } -export interface PaginatedResponseDto { - total: number; - page: number; - itemsPerPage: number; -} - export interface IndexItemDto { id: string; } @@ -684,6 +728,21 @@ export class Api extends HttpClient + this.request({ + path: `/api/users/${userId}/user-data/movie/tmdb/${tmdbId}`, + method: 'GET', + format: 'json', + ...params + }), + /** * No description * @@ -719,6 +778,56 @@ export class Api extends HttpClient + this.request< + PaginatedResponseDto & { + items: LibraryItemDto[]; + }, + any + >({ + path: `/api/users/${userId}/library`, + method: 'GET', + format: 'json', + ...params + }), + + /** + * No description + * + * @tags users + * @name AddLibraryItem + * @request PUT:/api/users/{userId}/library/tmdb/{tmdbId} + */ + addLibraryItem: (userId: string, tmdbId: string, params: RequestParams = {}) => + this.request({ + path: `/api/users/${userId}/library/tmdb/${tmdbId}`, + method: 'PUT', + 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 }) }; api = { @@ -761,94 +870,22 @@ export class Api extends HttpClient - this.request({ - path: `/api/sources`, - method: 'GET', - format: 'json', - ...params - }), - - /** - * No description - * - * @tags sources - * @name GetSourceSettingsTemplate - * @request GET:/api/sources/{sourceId}/settings/template - */ - getSourceSettingsTemplate: (sourceId: string, params: RequestParams = {}) => - this.request({ - path: `/api/sources/${sourceId}/settings/template`, - method: 'GET', - format: 'json', - ...params - }), - - /** - * No description - * - * @tags sources - * @name ValidateSourceSettings - * @request POST:/api/sources/{sourceId}/settings/validate - */ - validateSourceSettings: ( - sourceId: string, - data: PluginSettingsDto, - params: RequestParams = {} - ) => - this.request({ - path: `/api/sources/${sourceId}/settings/validate`, - method: 'POST', - body: data, - type: ContentType.Json, - format: 'json', - ...params - }), - - /** - * No description - * - * @tags sources - * @name GetSourceCapabilities - * @request GET:/api/sources/{sourceId}/capabilities - */ - getSourceCapabilities: (sourceId: string, params: RequestParams = {}) => - this.request({ - path: `/api/sources/${sourceId}/capabilities`, - method: 'GET', - format: 'json', - ...params - }), - - /** - * No description - * - * @tags sources - * @name GetSourceMovieIndex - * @request GET:/api/sources/{sourceId}/index/movies - */ - getSourceMovieIndex: (sourceId: string, params: RequestParams = {}) => - this.request< - PaginatedResponseDto & { - items: IndexItemDto[]; - }, - any - >({ - path: `/api/sources/${sourceId}/index/movies`, - method: 'GET', - format: 'json', - ...params - }) - }; movies = { + /** + * No description + * + * @tags movies + * @name GetMovieByTmdbId + * @request GET:/api/movies/tmdb/{tmdbId} + */ + getMovieByTmdbId: (tmdbId: string, params: RequestParams = {}) => + this.request({ + path: `/api/movies/tmdb/${tmdbId}`, + method: 'GET', + format: 'json', + ...params + }), + /** * No description * @@ -1002,4 +1039,91 @@ export class Api extends HttpClient + this.request({ + path: `/api/sources`, + method: 'GET', + format: 'json', + ...params + }), + + /** + * No description + * + * @tags sources + * @name GetSourceSettingsTemplate + * @request GET:/api/sources/{sourceId}/settings/template + */ + getSourceSettingsTemplate: (sourceId: string, params: RequestParams = {}) => + this.request({ + path: `/api/sources/${sourceId}/settings/template`, + method: 'GET', + format: 'json', + ...params + }), + + /** + * No description + * + * @tags sources + * @name ValidateSourceSettings + * @request POST:/api/sources/{sourceId}/settings/validate + */ + validateSourceSettings: ( + sourceId: string, + data: PluginSettingsDto, + params: RequestParams = {} + ) => + this.request({ + path: `/api/sources/${sourceId}/settings/validate`, + method: 'POST', + body: data, + type: ContentType.Json, + format: 'json', + ...params + }), + + /** + * No description + * + * @tags sources + * @name GetSourceCapabilities + * @request GET:/api/sources/{sourceId}/capabilities + */ + getSourceCapabilities: (sourceId: string, params: RequestParams = {}) => + this.request({ + path: `/api/sources/${sourceId}/capabilities`, + method: 'GET', + format: 'json', + ...params + }), + + /** + * No description + * + * @tags sources + * @name GetSourceMovieIndex + * @request GET:/api/sources/{sourceId}/index/movies + */ + getSourceMovieIndex: (sourceId: string, params: RequestParams = {}) => + this.request< + PaginatedResponseDto & { + items: IndexItemDto[]; + }, + any + >({ + path: `/api/sources/${sourceId}/index/movies`, + method: 'GET', + format: 'json', + ...params + }) + }; } diff --git a/src/lib/pages/LibraryPage.svelte b/src/lib/pages/LibraryPage.svelte index 1199d8f..750d701 100644 --- a/src/lib/pages/LibraryPage.svelte +++ b/src/lib/pages/LibraryPage.svelte @@ -15,11 +15,18 @@ import Carousel from '../components/Carousel/Carousel.svelte'; import DetachedPage from '../components/DetachedPage/DetachedPage.svelte'; import { scrollIntoView } from '../selectable'; - import { reiverrApiNew, sources } from '../stores/user.store'; + import { reiverrApiNew, sources, user } from '../stores/user.store'; let availableTmdbItems: (TmdbMovie2 | TmdbSeries2)[] = []; + const libraryItems = reiverrApiNew.users + .getLibraryItems($user?.id as string) + .then((r) => + Promise.all(r.data.items.map((i) => tmdbApi.getTmdbMovie(Number(i.tmdbId)))).then( + (i) => i.filter((i) => !!i) as TmdbMovie2[] + ) + ); - $: indexableSources = $sources.filter((s) => s.capabilities.indexing).map(s => s.source) + $: indexableSources = $sources.filter((s) => s.capabilities.indexing).map((s) => s.source); async function updateAvailableItems() { const initialUpdate = availableTmdbItems.length === 0; @@ -85,7 +92,7 @@ - {#await Promise.all([sonarrDownloads, radarrDownloads]) then [sonarrDownloads, radarrDownloads]} + + {#await libraryItems then items} +
+
+
My Library
+
+ + + {#each items as item} + + {/each} + + +
+ {/await}
diff --git a/src/lib/pages/MoviePage/MoviePage.svelte b/src/lib/pages/MoviePage/MoviePage.svelte index b13a4bf..1b88ad8 100644 --- a/src/lib/pages/MoviePage/MoviePage.svelte +++ b/src/lib/pages/MoviePage/MoviePage.svelte @@ -1,50 +1,45 @@ @@ -218,6 +227,15 @@ {/if} + {#if !$inLibrary} + + {:else} + + {/if}