diff --git a/backend/src/metadata/metadata.controller.ts b/backend/src/metadata/metadata.controller.ts deleted file mode 100644 index 957bc3d..0000000 --- a/backend/src/metadata/metadata.controller.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Controller } from '@nestjs/common'; -import { MetadataService } from './metadata.service'; - -// @UseGuards(OptionalAccessControl) -@Controller() -export class MetadataController { - constructor(private mediaService: MetadataService) {} - - // @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/metadata/metadata.entity.ts b/backend/src/metadata/metadata.entity.ts index a2a3303..4512090 100644 --- a/backend/src/metadata/metadata.entity.ts +++ b/backend/src/metadata/metadata.entity.ts @@ -6,6 +6,7 @@ import { UpdateDateColumn, } from 'typeorm'; import { TmdbMovieFull, TmdbSeriesFull } from './tmdb/tmdb.dto'; +import { TMDB_CACHE_TTL } from 'src/consts'; @Entity() export class Movie { @@ -21,6 +22,7 @@ export class Movie { @Column('json') tmdbMovie: TmdbMovieFull; + @ApiProperty({ type: 'string' }) @UpdateDateColumn() updatedAt: Date; } @@ -39,6 +41,33 @@ export class Series { @Column('json') tmdbSeries: TmdbSeriesFull; + @ApiProperty({ type: 'string' }) @UpdateDateColumn() updatedAt: Date; + + isStale() { + console.log(this.updatedAt); + if (!this.updatedAt) return true; + + console.log( + new Date().getTime() - this.updatedAt.getTime(), + TMDB_CACHE_TTL, + ); + if (new Date().getTime() - this.updatedAt.getTime() > TMDB_CACHE_TTL) + return true; + + console.log( + 'Checking if series is stale', + this.tmdbSeries.name, + this.tmdbSeries.next_episode_to_air?.air_date, + ); + if ( + this.tmdbSeries?.next_episode_to_air?.air_date && + new Date() > new Date(this.tmdbSeries.next_episode_to_air.air_date) + ) { + return true; + } + + return false; + } } diff --git a/backend/src/metadata/metadata.module.ts b/backend/src/metadata/metadata.module.ts index e3d1d9f..eb045a9 100644 --- a/backend/src/metadata/metadata.module.ts +++ b/backend/src/metadata/metadata.module.ts @@ -1,13 +1,11 @@ import { Module } from '@nestjs/common'; import { DatabaseModule } from 'src/database/database.module'; -import { MetadataController } from './metadata.controller'; import { metadataProviders } from './metadata.providers'; import { MetadataService } from './metadata.service'; import { TmdbModule } from './tmdb/tmdb.module'; @Module({ imports: [TmdbModule], - controllers: [MetadataController], providers: [...metadataProviders, MetadataService], exports: [MetadataService], }) diff --git a/backend/src/metadata/metadata.service.ts b/backend/src/metadata/metadata.service.ts index 6c2e575..3801904 100644 --- a/backend/src/metadata/metadata.service.ts +++ b/backend/src/metadata/metadata.service.ts @@ -5,6 +5,7 @@ import { MOVIE_REPOSITORY, SERIES_REPOSITORY } from './metadata.providers'; import { TMDB_CACHE_TTL } from 'src/consts'; import { TMDB_API, TmdbApi } from './tmdb/tmdb.providers'; import { TmdbMovieFull } from './tmdb/tmdb.dto'; +import { TmdbService } from './tmdb/tmdb.service'; @Injectable() export class MetadataService { @@ -17,8 +18,15 @@ export class MetadataService { @Inject(SERIES_REPOSITORY) private seriesRepository: Repository, + + private readonly tmdbService: TmdbService, ) {} + async clearMetadataCache() { + await this.movieRepository.clear(); + await this.seriesRepository.clear(); + } + async getMovieByTmdbId(tmdbId: string): Promise { let movie = await this.movieRepository.findOne({ where: { tmdbId } }); @@ -39,6 +47,8 @@ export class MetadataService { movie.tmdbMovie = tmdbMovie; } + await this.movieRepository.save(movie); + return movie; } @@ -54,20 +64,14 @@ export class MetadataService { series.tmdbId = tmdbId; } - if ( - !series.updatedAt || - new Date().getTime() - series.updatedAt.getTime() > TMDB_CACHE_TTL - ) { - const tmdbSeries = await this.tmdbApi.v3 - .tvSeriesDetails(Number(tmdbId)) - .then((r) => r.data) - .catch((e) => { - console.error('could not get metadata for series', tmdbId, e); - return e; - }); - series.tmdbSeries = tmdbSeries; + if (series.isStale()) { + console.log('getting metadata for series', tmdbId); + const tmdbSeries = await this.tmdbService.getFullSeries(Number(tmdbId)); + if (tmdbSeries) series.tmdbSeries = tmdbSeries; } + await this.seriesRepository.save(series); + return series; } } diff --git a/backend/src/metadata/tmdb/tmdb.controller.ts b/backend/src/metadata/tmdb/tmdb.controller.ts index 9e110fe..0c3082c 100644 --- a/backend/src/metadata/tmdb/tmdb.controller.ts +++ b/backend/src/metadata/tmdb/tmdb.controller.ts @@ -1,27 +1,20 @@ +import { + Cache, + CACHE_MANAGER +} from '@nestjs/cache-manager'; import { All, Controller, - Get, Inject, - Next, Param, Req, Res, - UseGuards, - UseInterceptors, + UseGuards } from '@nestjs/common'; +import { Request, Response } from 'express'; import { GetAuthUser, UserAccessControl } from 'src/auth/auth.guard'; -import { TMDB_CACHE_TTL, TMDB_API_KEY } from 'src/consts'; +import { TMDB_API_KEY, TMDB_CACHE_TTL } from 'src/consts'; import { User } from 'src/users/user.entity'; -import { Readable } from 'stream'; -import { NextFunction, Request, Response } from 'express'; -import { MetadataService } from '../metadata.service'; -import { - Cache, - CACHE_MANAGER, - CacheInterceptor, - CacheTTL, -} from '@nestjs/cache-manager'; @UseGuards(UserAccessControl) @Controller('tmdb') diff --git a/backend/src/metadata/tmdb/tmdb.module.ts b/backend/src/metadata/tmdb/tmdb.module.ts index 30d9a08..284c406 100644 --- a/backend/src/metadata/tmdb/tmdb.module.ts +++ b/backend/src/metadata/tmdb/tmdb.module.ts @@ -4,14 +4,15 @@ import { TMDB_CACHE_TTL } from 'src/consts'; import { UsersModule } from 'src/users/users.module'; import { TmdbController } from './tmdb.controller'; import { tmdbProviders } from './tmdb.providers'; +import { TmdbService } from './tmdb.service'; @Module({ imports: [ UsersModule, CacheModule.register({ ttl: TMDB_CACHE_TTL, max: 10_000 }), ], - providers: [...tmdbProviders], - exports: [...tmdbProviders], + providers: [...tmdbProviders, TmdbService], + exports: [...tmdbProviders, TmdbService], controllers: [TmdbController], }) export class TmdbModule {} diff --git a/backend/src/metadata/tmdb/tmdb.service.ts b/backend/src/metadata/tmdb/tmdb.service.ts new file mode 100644 index 0000000..734b7f1 --- /dev/null +++ b/backend/src/metadata/tmdb/tmdb.service.ts @@ -0,0 +1,23 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { TmdbSeriesFull } from './tmdb.dto'; +import { TMDB_API, TmdbApi } from './tmdb.providers'; + +@Injectable() +export class TmdbService { + constructor( + @Inject(TMDB_API) + private tmdbApi: TmdbApi, + ) {} + + async getFullSeries(tmdbId: number): Promise { + const tmdbSeries = await this.tmdbApi.v3 + .tvSeriesDetails(Number(tmdbId)) + .then((r) => r.data); + // .catch((e) => { + // console.error('could not get metadata for series', tmdbId, e); + // return e; + // }); + + return tmdbSeries; + } +} diff --git a/backend/src/user-data/library/library.controller.ts b/backend/src/user-data/library/library.controller.ts index d11a9c6..5010d09 100644 --- a/backend/src/user-data/library/library.controller.ts +++ b/backend/src/user-data/library/library.controller.ts @@ -37,7 +37,7 @@ export class LibraryController { ): Promise> { // const user = await this.userService.findOne(userId); - const items = await this.libraryService.getLibraryItemsWithMetadata( + const items = await this.libraryService.getLibraryItemDtos( userId, pagination, ); diff --git a/backend/src/user-data/library/library.dto.ts b/backend/src/user-data/library/library.dto.ts index e8e5cae..b9893d8 100644 --- a/backend/src/user-data/library/library.dto.ts +++ b/backend/src/user-data/library/library.dto.ts @@ -1,22 +1,20 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, PickType } from '@nestjs/swagger'; import { MovieDto } from 'src/metadata/metadata.dto'; -import { PlayStateDto } from '../play-state/play-state.dto'; -import { MediaType } from 'src/common/common.dto'; import { Series } from 'src/metadata/metadata.entity'; +import { LibraryItem } from './library.entity'; -export class LibraryItemDto { - @ApiProperty() - tmdbId: string; - - @ApiProperty({ enum: MediaType }) - mediaType: MediaType; - - @ApiProperty({ type: [PlayStateDto], required: false }) - playStates?: PlayStateDto[]; - +export class LibraryItemDto extends PickType(LibraryItem, [ + 'tmdbId', + 'mediaType', + 'playStates', + 'createdAt', +]) { @ApiProperty({ type: MovieDto, required: false }) movieMetadata?: MovieDto; @ApiProperty({ type: Series, required: false }) seriesMetadata?: Series; + + @ApiProperty({ required: false }) + watched?: boolean; } diff --git a/backend/src/user-data/library/library.entity.ts b/backend/src/user-data/library/library.entity.ts index f041159..fd3b78a 100644 --- a/backend/src/user-data/library/library.entity.ts +++ b/backend/src/user-data/library/library.entity.ts @@ -3,6 +3,7 @@ import { MediaType } from 'src/common/common.dto'; import { User } from 'src/users/user.entity'; import { Column, + CreateDateColumn, Entity, JoinColumn, ManyToOne, @@ -10,8 +11,10 @@ import { PrimaryColumn, PrimaryGeneratedColumn, Unique, + UpdateDateColumn, } from 'typeorm'; import { PlayState } from '../play-state/play-state.entity'; +import { PlayStateDto } from '../play-state/play-state.dto'; @Entity() @Unique(['tmdbId', 'userId']) @@ -20,7 +23,7 @@ export class LibraryItem { @PrimaryGeneratedColumn('uuid') id: string; - @ApiProperty({ required: true, type: 'number' }) + @ApiProperty({ required: true }) @Column({ unique: true }) tmdbId: string; @@ -37,6 +40,16 @@ export class LibraryItem { @JoinColumn({ name: 'userId' }) user: User; + @ApiProperty({ type: [PlayStateDto], required: false }) @OneToMany(() => PlayState, (playState) => playState.libraryItem) playStates?: PlayState[]; + + /** @deprecated */ + @ApiProperty({ type: 'string' }) + @UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + updatedAt: Date; + + @ApiProperty({ type: 'string' }) + @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + createdAt: Date; } diff --git a/backend/src/user-data/library/library.service.ts b/backend/src/user-data/library/library.service.ts index 94bbe76..6c0540b 100644 --- a/backend/src/user-data/library/library.service.ts +++ b/backend/src/user-data/library/library.service.ts @@ -14,7 +14,7 @@ export class LibraryService { private readonly metadataService: MetadataService, ) {} - async getLibraryItemsWithMetadata( + async getLibraryItemDtos( userId: string, pagination: PaginationParamsDto, ): Promise { @@ -22,16 +22,37 @@ export class LibraryService { return Promise.all( items.map(async (item) => { + const seriesMetadata = + item.mediaType === MediaType.Series + ? await this.metadataService.getSeriesByTmdbId(item.tmdbId) + : undefined; + let watched = false; + + 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, - seriesMetadata: - item.mediaType === MediaType.Series - ? await this.metadataService.getSeriesByTmdbId(item.tmdbId) - : undefined, + seriesMetadata, }; }), ); diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index 346a13a..a6190f5 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -81,12 +81,37 @@ export interface PlayState { lastPlayedAt: string; } -export interface LibraryItem { - id?: string; +export interface PlayStateDto { + id: string; tmdbId: number; mediaType: 'Movie' | 'Series' | 'Episode'; userId: 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; + /** Last time the user played this media */ + lastPlayedAt: string; +} + +export interface LibraryItem { + id?: string; + tmdbId: string; + mediaType: 'Movie' | 'Series' | 'Episode'; + userId: string; user?: string; + playStates?: PlayStateDto[]; + updatedAt: string; + createdAt: string; } export interface UserDto { @@ -439,28 +464,6 @@ export interface UpdateOrCreateMediaSourceDto { priority?: number; } -export interface PlayStateDto { - id: string; - tmdbId: number; - mediaType: 'Movie' | 'Series' | 'Episode'; - userId: 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; - /** Last time the user played this media */ - lastPlayedAt: string; -} - export interface MovieUserDataDto { tmdbId: string; inLibrary: boolean; @@ -498,20 +501,24 @@ export interface MovieDto { id?: string; tmdbId: string; tmdbMovie?: object; + updatedAt: string; } export interface Series { id?: string; tmdbId: string; tmdbSeries?: object; + updatedAt: string; } export interface LibraryItemDto { tmdbId: string; mediaType: 'Movie' | 'Series' | 'Episode'; playStates?: PlayStateDto[]; + createdAt: string; movieMetadata?: MovieDto; seriesMetadata?: Series; + watched?: boolean; } export interface SuccessResponseDto { diff --git a/src/lib/components/Card/Card.svelte b/src/lib/components/Card/Card.svelte index 444da12..22fe473 100644 --- a/src/lib/components/Card/Card.svelte +++ b/src/lib/components/Card/Card.svelte @@ -52,6 +52,7 @@ {/if} { if (tmdbId || tvdbId) navigate(`/${type}/${tmdbId || tvdbId}`); diff --git a/src/lib/components/CardGrid.svelte b/src/lib/components/CardGrid.svelte index efa1062..b97a85f 100644 --- a/src/lib/components/CardGrid.svelte +++ b/src/lib/components/CardGrid.svelte @@ -48,6 +48,7 @@ /> + import { createEventDispatcher } from 'svelte'; + import Container from './Container.svelte'; + import classNames from 'classnames'; + import { Check } from 'radix-icons-svelte'; + + type Option = { + label: string; + value?: string; + disabled?: boolean; + }; + + export let options: Option[] = []; + export let selected: string | undefined = undefined; + export let name: string | undefined = undefined; + export let style: 'primary' | 'secondary' = 'secondary'; + + const dispatch = createEventDispatcher<{ + select: string; + }>(); + + +
+ {#if name} +

{name}

+ {/if} + + {#each options as option, index} + {@const first = index === 0} + {@const last = index === options.length - 1} + {#if !first} +
+ {/if} + dispatch('select', option.value ?? option.label)} + let:hasFocus + > +
+ {option.label} + {#if selected === option.value} + + {/if} +
+
+ {/each} + +
diff --git a/src/lib/components/StackRouter/StackRouter.ts b/src/lib/components/StackRouter/StackRouter.ts index f46256b..74fd195 100644 --- a/src/lib/components/StackRouter/StackRouter.ts +++ b/src/lib/components/StackRouter/StackRouter.ts @@ -1,6 +1,6 @@ import { type ComponentType } from 'svelte'; import { derived, get, writable } from 'svelte/store'; -import LibraryPage from '../../pages/LibraryPage.svelte'; +import LibraryPage from '../../pages/LibraryPage/LibraryPage.svelte'; import ManagePage from '../../pages/ManagePage/ManagePage.svelte'; import MoviesHomePage from '../../pages/MoviesHomePage.svelte'; import PageNotFound from '../../pages/PageNotFound.svelte'; diff --git a/src/lib/components/Toggle.svelte b/src/lib/components/Toggle.svelte index 906a32c..69f8b95 100644 --- a/src/lib/components/Toggle.svelte +++ b/src/lib/components/Toggle.svelte @@ -7,6 +7,7 @@ }>(); export let checked: boolean; + export let label: string | undefined = undefined; let input: HTMLInputElement; const handleChange = (e: Event) => { @@ -17,22 +18,32 @@ }; - { - e.detail.options.setFocusedElement = input; - }} - on:clickOrSelect={() => input?.click()} -> - -
+ + {#if label} + + {/if} + + + { + e.detail.options.setFocusedElement = input; + }} + on:clickOrSelect={() => input?.click()} + > + +
- + /> + +
diff --git a/src/lib/pages/LibraryPage.svelte b/src/lib/pages/LibraryPage.svelte deleted file mode 100644 index f068de1..0000000 --- a/src/lib/pages/LibraryPage.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - {#await $libraryItems then items} -
-
-
My Library
-
- - {#each items as item} - - {/each} - -
- {/await} -
diff --git a/src/lib/pages/LibraryPage/LibraryPage.svelte b/src/lib/pages/LibraryPage/LibraryPage.svelte new file mode 100644 index 0000000..c4a349d --- /dev/null +++ b/src/lib/pages/LibraryPage/LibraryPage.svelte @@ -0,0 +1,218 @@ + + + + {#if !$isLoading} +
+
+
+ +
+ {#if $libraryItemsCategorized.main.length + $libraryItemsCategorized.upcoming.length + $libraryItemsCategorized.watched.length} + {#if $libraryItemsCategorized.upcoming.length} +
+
+
Upcoming
+
+
+ + {#key viewSettingsKey} + {#each $libraryItemsCategorized.upcoming as item} + + {/each} + {/key} + +
+ {/if} + {#if $libraryItemsCategorized.main.length} +
+
My Library
+ + {#key viewSettingsKey} + {#each $libraryItemsCategorized.main as item, index (item.tmdbId)} + + {/each} + {/key} + +
+ {/if} + {#if $libraryItemsCategorized.watched.length} +
+
Watched
+ + {#key viewSettingsKey} + {#each $libraryItemsCategorized.watched as item (item.tmdbId)} + + {/each} + {/key} + +
+ {/if} + {:else} + + You haven't added anything to your library + + {/if} +
+ {/if} + diff --git a/src/lib/pages/LibraryPage/OptionsDialog.LibraryPage.svelte b/src/lib/pages/LibraryPage/OptionsDialog.LibraryPage.svelte new file mode 100644 index 0000000..b046910 --- /dev/null +++ b/src/lib/pages/LibraryPage/OptionsDialog.LibraryPage.svelte @@ -0,0 +1,70 @@ + + + +

+ View Options + +

+ + updateSortBy(sortBy)} + /> + + updateSortByDirection(direction)} + /> + +
+ + libraryViewSettings.update((settings) => ({ + ...settings, + separateUpcoming: !separateUpcoming + }))} + /> + + + libraryViewSettings.update((settings) => ({ + ...settings, + separateWatched: !separateWatched + }))} + /> +
+
diff --git a/src/lib/stores/localstorage.store.ts b/src/lib/stores/localstorage.store.ts index 95af4a1..7243f09 100644 --- a/src/lib/stores/localstorage.store.ts +++ b/src/lib/stores/localstorage.store.ts @@ -3,16 +3,25 @@ import { get, writable } from 'svelte/store'; export function createLocalStorageStore(key: string, defaultValue: T) { const store = writable(JSON.parse(localStorage.getItem(key) || 'null') || defaultValue); + function writeValue(value: T) { + const strigified = JSON.stringify(value); + if (strigified === JSON.stringify(defaultValue)) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, strigified); + } + } + return { subscribe: store.subscribe, get: () => get(store), set: (value: T) => { - localStorage.setItem(key, JSON.stringify(value)); + writeValue(value); store.set(value); }, update: (updater: (value: T) => T) => { const newValue = updater(get(store)); - localStorage.setItem(key, JSON.stringify(newValue)); + writeValue(newValue); store.set(newValue); }, remove: () => { @@ -41,3 +50,20 @@ export const localSettings = createLocalStorageStore<{ checkForUpdates: true, skippedVersion: '' }); + +export type LibraryViewSettings = { + sortBy: 'date-added' | 'title' | 'first-release-date' | 'last-release-date'; + sortDirection: 'asc' | 'desc'; + separateUpcoming: boolean; + separateWatched: boolean; +}; + +export const libraryViewSettings = createLocalStorageStore( + 'library-view-settings', + { + sortBy: 'last-release-date', + sortDirection: 'desc', + separateUpcoming: true, + separateWatched: true + } +);