feat: Add to library, my library

This commit is contained in:
Aleksi Lassila
2024-12-14 03:55:17 +02:00
parent 1b50d24161
commit dfffd01e7c
27 changed files with 867 additions and 201 deletions

View File

@@ -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<PaginatedResponseDto<LibraryItemDto>> {
// 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<SuccessResponseDto> {
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<SuccessResponseDto> {
const deleteAction = await this.libraryService.deleteByTmdbId(
userId,
tmdbId,
);
return {
success: deleteAction.affected > 0,
};
}
}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -1,4 +0,0 @@
import { Module } from '@nestjs/common';
@Module({})
export class LibraryModule {}

View File

@@ -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<LibraryItem>,
) {}
async getLibraryItems(userId: string): Promise<LibraryItem[]> {
return this.libraryRepository.find({ where: { userId } });
}
async findByTmdbId(userId: string, tmdbId: string): Promise<LibraryItem | null> {
return this.libraryRepository.findOne({ where: { userId, tmdbId } });
}
async findOrCreateByTmdbId(
userId: string,
tmdbId: string,
): Promise<LibraryItem> {
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 });
}
}

View File

@@ -0,0 +1,4 @@
import { Controller } from '@nestjs/common';
@Controller('play-state')
export class PlayStateController {}

View File

@@ -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;
}

View File

@@ -1,4 +0,0 @@
import { Module } from '@nestjs/common';
@Module({})
export class PlayStateModule {}

View File

@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class PlayStateService {}

View File

@@ -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<UserDto> {

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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],
},
];

View File

@@ -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<UserDto[]> {
async findAllUsers(@GetAuthUser() callerUser: User): Promise<UserDto[]> {
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<UserDto> {
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<UserDto> {
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<MovieUserDataDto> {
const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId);
return {
inLibrary: !!libraryItem,
};
}
}

View File

@@ -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 {}