From 80b4f8c01a4ee6180871269f7a052383b0475ef8 Mon Sep 17 00:00:00 2001 From: Aleksi Lassila Date: Fri, 21 Feb 2025 18:02:18 +0200 Subject: [PATCH] refactor: library items & metadata for performance --- .../1740149539330-rename-metadata.ts | 41 +++++ backend/src/metadata/dto/movie.dto.ts | 2 +- backend/src/metadata/metadata.dto.ts | 4 +- backend/src/metadata/metadata.entity.ts | 4 +- backend/src/metadata/metadata.providers.ts | 8 +- backend/src/metadata/metadata.service.ts | 14 +- .../user-data/library/library.controller.ts | 6 +- backend/src/user-data/library/library.dto.ts | 150 +++++++++++++++++- .../src/user-data/library/library.service.ts | 38 ++--- src/lib/apis/reiverr/reiverr.openapi.ts | 38 +++-- src/lib/components/Card/TmdbCard.svelte | 4 +- src/lib/pages/LibraryPage/LibraryPage.svelte | 72 +++------ src/lib/pages/MoviesHomePage.svelte | 2 +- src/lib/pages/SeriesHomePage.svelte | 2 +- src/lib/stores/data.store.ts | 111 ++++++++++--- 15 files changed, 363 insertions(+), 133 deletions(-) create mode 100644 backend/migrations/1740149539330-rename-metadata.ts diff --git a/backend/migrations/1740149539330-rename-metadata.ts b/backend/migrations/1740149539330-rename-metadata.ts new file mode 100644 index 0000000..73774b7 --- /dev/null +++ b/backend/migrations/1740149539330-rename-metadata.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameMetadata1740149539330 implements MigrationInterface { + name = 'RenameMetadata1740149539330'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "movie_metadata" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" varchar NOT NULL, "tmdbMovie" json NOT NULL, "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_c76bb822f86ef23ba7fddb9e626" UNIQUE ("tmdbId"))`, + ); + await queryRunner.query( + `CREATE TABLE "series_metadata" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" varchar NOT NULL, "tmdbSeries" json NOT NULL, "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_751986e5b93acdabc33a8d62cd9" UNIQUE ("tmdbId"))`, + ); + await queryRunner.query( + `CREATE TABLE "temporary_library_item" ("id" varchar NOT NULL, "tmdbId" varchar NOT NULL, "userId" varchar NOT NULL, "mediaType" varchar NOT NULL, "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_d1794fd0082c98017895ea6afa4" UNIQUE ("tmdbId"), CONSTRAINT "UQ_d1794fd0082c98017895ea6afa4" UNIQUE ("tmdbId", "userId"), CONSTRAINT "UQ_97081ef9b13ccb55daec682da1a" UNIQUE ("tmdbId", "userId"), CONSTRAINT "FK_44e2a69f2788510e190dd3ac5ec" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("id", "userId"))`, + ); + await queryRunner.query( + `INSERT INTO "temporary_library_item"("id", "tmdbId", "userId", "mediaType", "updatedAt", "createdAt") SELECT "id", "tmdbId", "userId", "mediaType", "updatedAt", "createdAt" FROM "library_item"`, + ); + await queryRunner.query(`DROP TABLE "library_item"`); + await queryRunner.query(`DROP TABLE "movie"`); + await queryRunner.query(`DROP TABLE "series"`); + await queryRunner.query( + `ALTER TABLE "temporary_library_item" RENAME TO "library_item"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "library_item" RENAME TO "temporary_library_item"`, + ); + await queryRunner.query( + `CREATE TABLE "library_item" ("id" varchar NOT NULL, "tmdbId" varchar NOT NULL, "userId" varchar NOT NULL, "mediaType" varchar NOT NULL, "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_d1794fd0082c98017895ea6afa4" UNIQUE ("tmdbId"), CONSTRAINT "UQ_d1794fd0082c98017895ea6afa4" UNIQUE ("tmdbId", "userId"), CONSTRAINT "FK_44e2a69f2788510e190dd3ac5ec" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("id", "userId"))`, + ); + await queryRunner.query( + `INSERT INTO "library_item"("id", "tmdbId", "userId", "mediaType", "updatedAt", "createdAt") SELECT "id", "tmdbId", "userId", "mediaType", "updatedAt", "createdAt" FROM "temporary_library_item"`, + ); + await queryRunner.query(`DROP TABLE "temporary_library_item"`); + await queryRunner.query(`DROP TABLE "series_metadata"`); + await queryRunner.query(`DROP TABLE "movie_metadata"`); + } +} diff --git a/backend/src/metadata/dto/movie.dto.ts b/backend/src/metadata/dto/movie.dto.ts index 61adecd..941bd29 100644 --- a/backend/src/metadata/dto/movie.dto.ts +++ b/backend/src/metadata/dto/movie.dto.ts @@ -1,4 +1,4 @@ -import { Movie } from '../metadata.entity'; +import { MovieMetadata } from '../metadata.entity'; // export class MovieDto extends Movie { // static FromEntity(entity: Movie): MovieDto {} diff --git a/backend/src/metadata/metadata.dto.ts b/backend/src/metadata/metadata.dto.ts index acb9706..66f3e90 100644 --- a/backend/src/metadata/metadata.dto.ts +++ b/backend/src/metadata/metadata.dto.ts @@ -1,12 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Movie } from './metadata.entity'; +import { MovieMetadata } from './metadata.entity'; // export class MovieUserDataDto { // @ApiProperty() // inLibrary: boolean; // } -export class MovieDto extends Movie { +export class MovieDto extends MovieMetadata { // tmdbData: any; // @ApiProperty({ type: MovieUserDataDto, required: false }) // userData?: MovieUserDataDto; diff --git a/backend/src/metadata/metadata.entity.ts b/backend/src/metadata/metadata.entity.ts index 77ee386..7c72994 100644 --- a/backend/src/metadata/metadata.entity.ts +++ b/backend/src/metadata/metadata.entity.ts @@ -9,7 +9,7 @@ import { TmdbMovieFull, TmdbSeriesFull } from './tmdb/tmdb.dto'; import { TMDB_CACHE_TTL } from 'src/consts'; @Entity() -export class Movie { +export class MovieMetadata { @ApiProperty({ required: false, type: 'string' }) @PrimaryGeneratedColumn('uuid') id: string; @@ -28,7 +28,7 @@ export class Movie { } @Entity() -export class Series { +export class SeriesMetadata { @ApiProperty({ required: false, type: 'string' }) @PrimaryGeneratedColumn('uuid') id: string; diff --git a/backend/src/metadata/metadata.providers.ts b/backend/src/metadata/metadata.providers.ts index 148ce5e..d8d20b7 100644 --- a/backend/src/metadata/metadata.providers.ts +++ b/backend/src/metadata/metadata.providers.ts @@ -1,5 +1,5 @@ import { DataSource } from 'typeorm'; -import { Movie, Series } from './metadata.entity'; +import { MovieMetadata, SeriesMetadata } from './metadata.entity'; import { DATA_SOURCE } from 'src/database/database.providers'; export const MOVIE_REPOSITORY = 'MOVIE_REPOSITORY'; @@ -8,12 +8,14 @@ export const SERIES_REPOSITORY = 'SERIES_REPOSITORY'; export const metadataProviders = [ { provide: MOVIE_REPOSITORY, - useFactory: (dataSource: DataSource) => dataSource.getRepository(Movie), + useFactory: (dataSource: DataSource) => + dataSource.getRepository(MovieMetadata), inject: [DATA_SOURCE], }, { provide: SERIES_REPOSITORY, - useFactory: (dataSource: DataSource) => dataSource.getRepository(Series), + useFactory: (dataSource: DataSource) => + dataSource.getRepository(SeriesMetadata), inject: [DATA_SOURCE], }, ]; diff --git a/backend/src/metadata/metadata.service.ts b/backend/src/metadata/metadata.service.ts index 8356e0f..a772f20 100644 --- a/backend/src/metadata/metadata.service.ts +++ b/backend/src/metadata/metadata.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { Repository } from 'typeorm'; -import { Movie, Series } from './metadata.entity'; +import { MovieMetadata, SeriesMetadata } from './metadata.entity'; import { MOVIE_REPOSITORY, SERIES_REPOSITORY } from './metadata.providers'; import { TMDB_CACHE_TTL } from 'src/consts'; import { TMDB_API, TmdbApi } from './tmdb/tmdb.providers'; @@ -16,10 +16,10 @@ export class MetadataService { private tmdbApi: TmdbApi, @Inject(MOVIE_REPOSITORY) - private movieRepository: Repository, + private movieRepository: Repository, @Inject(SERIES_REPOSITORY) - private seriesRepository: Repository, + private seriesRepository: Repository, private readonly tmdbService: TmdbService, ) {} @@ -29,11 +29,11 @@ export class MetadataService { await this.seriesRepository.clear(); } - async getMovieByTmdbId(tmdbId: string): Promise { + async getMovieByTmdbId(tmdbId: string): Promise { let movie = await this.movieRepository.findOne({ where: { tmdbId } }); if (!movie) { - movie = new Movie(); + movie = new MovieMetadata(); movie.tmdbId = tmdbId; } @@ -54,11 +54,11 @@ export class MetadataService { return []; } - async getSeriesByTmdbId(tmdbId: string): Promise { + async getSeriesByTmdbId(tmdbId: string): Promise { let series = await this.seriesRepository.findOne({ where: { tmdbId } }); if (!series) { - series = new Series(); + series = new SeriesMetadata(); series.tmdbId = tmdbId; } diff --git a/backend/src/user-data/library/library.controller.ts b/backend/src/user-data/library/library.controller.ts index 5010d09..21194b4 100644 --- a/backend/src/user-data/library/library.controller.ts +++ b/backend/src/user-data/library/library.controller.ts @@ -20,7 +20,7 @@ import { PaginationParamsDto, SuccessResponseDto, } from 'src/common/common.dto'; -import { LibraryItemDto } from './library.dto'; +import { LibraryItemDto, LibraryItemDto2 } from './library.dto'; import { LibraryService } from './library.service'; @ApiTags('users') @@ -30,11 +30,11 @@ export class LibraryController { constructor(private libraryService: LibraryService) {} @Get() - @PaginatedApiOkResponse(LibraryItemDto) + @PaginatedApiOkResponse(LibraryItemDto2) async getLibraryItems( @GetPaginationParams() pagination: PaginationParamsDto, @Param('userId') userId: string, - ): Promise> { + ): Promise> { // const user = await this.userService.findOne(userId); const items = await this.libraryService.getLibraryItemDtos( diff --git a/backend/src/user-data/library/library.dto.ts b/backend/src/user-data/library/library.dto.ts index b9893d8..93cd304 100644 --- a/backend/src/user-data/library/library.dto.ts +++ b/backend/src/user-data/library/library.dto.ts @@ -1,7 +1,13 @@ import { ApiProperty, PickType } from '@nestjs/swagger'; import { MovieDto } from 'src/metadata/metadata.dto'; -import { Series } from 'src/metadata/metadata.entity'; +import { MovieMetadata, SeriesMetadata } from 'src/metadata/metadata.entity'; import { LibraryItem } from './library.entity'; +import { + TmdbMovie, + TmdbMovieFull, + TmdbSeries, +} from 'src/metadata/tmdb/tmdb.dto'; +import { MediaType } from 'src/common/common.dto'; export class LibraryItemDto extends PickType(LibraryItem, [ 'tmdbId', @@ -12,9 +18,147 @@ export class LibraryItemDto extends PickType(LibraryItem, [ @ApiProperty({ type: MovieDto, required: false }) movieMetadata?: MovieDto; - @ApiProperty({ type: Series, required: false }) - seriesMetadata?: Series; + @ApiProperty({ type: SeriesMetadata, required: false }) + seriesMetadata?: SeriesMetadata; @ApiProperty({ required: false }) watched?: boolean; } + +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 LibraryItemDto2 + 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 + + @ApiProperty({ required: false }) + watched?: boolean; + + static create(options: { + libraryItem: LibraryItem; + movieMetadata?: MovieMetadata; + seriesMetadata?: SeriesMetadata; + }): LibraryItemDto2 { + const { libraryItem, movieMetadata, seriesMetadata } = options; + + if (!movieMetadata && !seriesMetadata) { + throw new Error( + 'At least one of movieMetadata or seriesMetadata must be provided', + ); + } + + let watched = false; + + if (libraryItem.mediaType === MediaType.Movie) { + watched = libraryItem.playStates?.some((state) => state.watched) ?? false; + } else if ( + libraryItem.mediaType === MediaType.Series && + seriesMetadata?.tmdbSeries?.last_episode_to_air + ) { + const { season_number: season, episode_number: episode } = + seriesMetadata?.tmdbSeries.last_episode_to_air; + watched = + libraryItem.playStates?.some( + (state) => + state.season === season && + state.episode === episode && + state.watched, + ) ?? false; + } + + return { + ...libraryItem, + watched, + id: movieMetadata?.tmdbMovie.id ?? seriesMetadata?.tmdbSeries.id, + poster_path: + movieMetadata?.tmdbMovie.poster_path ?? + seriesMetadata?.tmdbSeries.poster_path, + vote_average: + movieMetadata?.tmdbMovie.vote_average ?? + seriesMetadata?.tmdbSeries.vote_average, + title: movieMetadata?.tmdbMovie.title, + release_date: movieMetadata?.tmdbMovie.release_date, + runtime: movieMetadata?.tmdbMovie.runtime, + name: seriesMetadata?.tmdbSeries.name, + first_air_date: seriesMetadata?.tmdbSeries.first_air_date, + last_air_date: seriesMetadata?.tmdbSeries.last_air_date, + next_episode_to_air: seriesMetadata?.tmdbSeries.next_episode_to_air, + seasons: seriesMetadata?.tmdbSeries.seasons, + }; + } +} diff --git a/backend/src/user-data/library/library.service.ts b/backend/src/user-data/library/library.service.ts index a2c8f92..1c70e0d 100644 --- a/backend/src/user-data/library/library.service.ts +++ b/backend/src/user-data/library/library.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Repository } from 'typeorm'; import { LibraryItem } from './library.entity'; import { MediaType, PaginationParamsDto } from 'src/common/common.dto'; -import { LibraryItemDto } from './library.dto'; +import { LibraryItemDto, LibraryItemDto2 } from './library.dto'; import { MetadataService } from 'src/metadata/metadata.service'; import { USER_LIBRARY_REPOSITORY } from './library.providers'; @@ -17,7 +17,7 @@ export class LibraryService { async getLibraryItemDtos( userId: string, pagination: PaginationParamsDto, - ): Promise { + ): Promise { const items = await this.getLibraryItems(userId, pagination); return Promise.all( @@ -26,34 +26,16 @@ export class LibraryService { item.mediaType === MediaType.Series ? await this.metadataService.getSeriesByTmdbId(item.tmdbId) : undefined; - let watched = false; + const movieMetadata = + item.mediaType === MediaType.Movie + ? await this.metadataService.getMovieByTmdbId(item.tmdbId) + : undefined; - if (item.mediaType === MediaType.Movie) { - watched = item.playStates?.some((state) => state.watched) ?? false; - } else if ( - item.mediaType === MediaType.Series && - seriesMetadata.tmdbSeries?.last_episode_to_air - ) { - const { season_number: season, episode_number: episode } = - seriesMetadata.tmdbSeries.last_episode_to_air; - watched = - item.playStates?.some( - (state) => - state.season === season && - state.episode === episode && - state.watched, - ) ?? false; - } - - return { - ...item, - watched, - movieMetadata: - item.mediaType === MediaType.Movie - ? await this.metadataService.getMovieByTmdbId(item.tmdbId) - : undefined, + return LibraryItemDto2.create({ + libraryItem: item, seriesMetadata, - }; + movieMetadata, + }); }), ); } diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index c64ab47..d2316e7 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -514,27 +514,37 @@ export interface BulkUpdatePlayStateDto { playStates: UpdatePlayStateDto[]; } -export interface MovieDto { - id?: string; - tmdbId: string; - tmdbMovie?: object; - updatedAt: string; +export interface NextEpisodeToAir { + air_date?: string; } -export interface Series { - id?: string; - tmdbId: string; - tmdbSeries?: object; - updatedAt: 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 { +export interface LibraryItemDto2 { tmdbId: string; mediaType: 'Movie' | 'Series' | 'Episode'; playStates?: PlayStateDto[]; createdAt: string; - movieMetadata?: MovieDto; - seriesMetadata?: Series; + 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[]; watched?: boolean; } @@ -1023,7 +1033,7 @@ export class Api extends HttpClient this.request< PaginatedResponseDto & { - items: LibraryItemDto[]; + items: LibraryItemDto2[]; }, any >({ diff --git a/src/lib/components/Card/TmdbCard.svelte b/src/lib/components/Card/TmdbCard.svelte index fd94466..db0c8c3 100644 --- a/src/lib/components/Card/TmdbCard.svelte +++ b/src/lib/components/Card/TmdbCard.svelte @@ -5,7 +5,9 @@ import { TMDB_POSTER_SMALL } from '../../constants'; import type { TitleType } from '../../types'; - export let item: TmdbMovie2 | TmdbSeries2; + export let item: + | Pick + | Pick; export let progress = 0; let title = ''; let subtitle = ''; diff --git a/src/lib/pages/LibraryPage/LibraryPage.svelte b/src/lib/pages/LibraryPage/LibraryPage.svelte index 3fa334d..c936793 100644 --- a/src/lib/pages/LibraryPage/LibraryPage.svelte +++ b/src/lib/pages/LibraryPage/LibraryPage.svelte @@ -1,5 +1,5 @@