diff --git a/backend/src/media-sources/media-sources.controller.ts b/backend/src/media-sources/media-sources.controller.ts index bec3c82..5c069a5 100644 --- a/backend/src/media-sources/media-sources.controller.ts +++ b/backend/src/media-sources/media-sources.controller.ts @@ -69,9 +69,8 @@ export class ServiceOwnershipValidator implements CanActivate { if (!sourceId) return true; - const mediaSource = await this.mediaSourcesService.findMediaSource( - sourceId, - ); + const mediaSource = + await this.mediaSourcesService.findMediaSource(sourceId); if (!mediaSource) throw new NotFoundException('Source not found'); @@ -114,7 +113,12 @@ export class MediaSourcesController { pagination, ); - return catalogue ?? { items: [], total: 0, itemsPerPage: 0, page: 0 }; + return { + items: catalogue?.items ?? [], + itemsPerPage: catalogue?.itemsPerPage ?? pagination.itemsPerPage, + page: catalogue?.page ?? pagination.page, + total: catalogue?.total ?? 0, + }; } @Get(':sourceId/catalogue/episodes') @@ -303,9 +307,8 @@ export class MediaSourcesController { @GetAuthToken() token: string, ) { const sourceId = params.sourceId; - const mediaSource = await this.mediaSourcesService.findMediaSource( - sourceId, - ); + const mediaSource = + await this.mediaSourcesService.findMediaSource(sourceId); if (!mediaSource) throw new NotFoundException('Source not found'); @@ -370,9 +373,8 @@ export class MediaSourcesController { } async getConnection(sourceId: string) { - const mediaSource = await this.mediaSourcesService.findMediaSource( - sourceId, - ); + const mediaSource = + await this.mediaSourcesService.findMediaSource(sourceId); if (!mediaSource.pluginId || !mediaSource.enabled) { throw new BadRequestException('Source not configured'); diff --git a/backend/src/metadata/metadata.entity.ts b/backend/src/metadata/metadata.entity.ts index c2dfd3f..9f61886 100644 --- a/backend/src/metadata/metadata.entity.ts +++ b/backend/src/metadata/metadata.entity.ts @@ -2,11 +2,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { Column, Entity, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; import { TmdbMovieFull, TmdbSeriesFull } from './tmdb/tmdb.dto'; import { TMDB_CACHE_TTL } from 'src/consts'; +import { LibraryItem } from 'src/user-data/library/library.entity'; @Entity() export class MovieMetadata { @@ -18,10 +20,26 @@ export class MovieMetadata { @Column({ unique: true }) tmdbId: string; + // + @ApiProperty({ required: false, type: 'object' }) @Column('json') tmdbMovie: TmdbMovieFull; + @ApiProperty({ required: false, type: 'string' }) + @Column({ nullable: true }) + name?: string; + + @ApiProperty({ required: false, type: 'string' }) + @Column({ nullable: true }) + releaseDate?: Date; + + // + + @ApiProperty({ type: [LibraryItem], required: false }) + @OneToMany(() => LibraryItem, (libraryItem) => libraryItem.seriesMetadata) + libraryItems?: LibraryItem[]; + @ApiProperty({ type: 'string' }) @UpdateDateColumn() updatedAt: Date; @@ -67,10 +85,30 @@ export class SeriesMetadata { @Column({ unique: true }) tmdbId: string; + // + @ApiProperty({ required: false, type: 'object' }) @Column('json') tmdbSeries: TmdbSeriesFull; + @ApiProperty({ required: false, type: 'string' }) + @Column({ nullable: true }) + name?: string; + + @ApiProperty({ required: false, type: 'string' }) + @Column({ nullable: true }) + firstReleaseDate?: Date; + + @ApiProperty({ required: false, type: 'string' }) + @Column({ nullable: true }) + lastReleaseDate?: Date; + + // + + @ApiProperty({ type: [LibraryItem], required: false }) + @OneToMany(() => LibraryItem, (libraryItem) => libraryItem.seriesMetadata) + libraryItems?: LibraryItem[]; + @ApiProperty({ type: 'string' }) @UpdateDateColumn() updatedAt: Date; diff --git a/backend/src/metadata/metadata.service.ts b/backend/src/metadata/metadata.service.ts index b2b13a4..b4c80b7 100644 --- a/backend/src/metadata/metadata.service.ts +++ b/backend/src/metadata/metadata.service.ts @@ -44,6 +44,10 @@ export class MetadataService { if (tmdbMovie) { movie.tmdbMovie = tmdbMovie; movie.updatedAt = new Date(); + movie.name = tmdbMovie.title; + movie.releaseDate = tmdbMovie.release_date + ? new Date(tmdbMovie.release_date) + : undefined; } await this.movieRepository.upsert(movie, { @@ -79,6 +83,13 @@ export class MetadataService { if (tmdbSeries) { series.tmdbSeries = tmdbSeries; series.updatedAt = new Date(); + series.firstReleaseDate = tmdbSeries.first_air_date + ? new Date(tmdbSeries.first_air_date) + : undefined; + series.lastReleaseDate = tmdbSeries.last_air_date + ? new Date(tmdbSeries.last_air_date) + : undefined; + series.name = tmdbSeries.name; } await this.seriesRepository.upsert(series, { diff --git a/backend/src/user-data/library/library.controller.ts b/backend/src/user-data/library/library.controller.ts index 5010d09..afbc922 100644 --- a/backend/src/user-data/library/library.controller.ts +++ b/backend/src/user-data/library/library.controller.ts @@ -21,7 +21,12 @@ import { SuccessResponseDto, } from 'src/common/common.dto'; import { LibraryItemDto } from './library.dto'; -import { LibraryService } from './library.service'; +import { + LibraryService, + LibrarySortBy, + MyListFilter as MyListFilter, + SortByDirection, +} from './library.service'; @ApiTags('users') @Controller('users/:userId/library') @@ -29,25 +34,32 @@ import { LibraryService } from './library.service'; export class LibraryController { constructor(private libraryService: LibraryService) {} - @Get() + @Get('my-list') + @ApiQuery({ name: 'filter', enum: MyListFilter, required: false }) + @ApiQuery({ name: 'sortBy', enum: LibrarySortBy, required: false }) + @ApiQuery({ name: 'direction', enum: SortByDirection, required: false }) @PaginatedApiOkResponse(LibraryItemDto) async getLibraryItems( @GetPaginationParams() pagination: PaginationParamsDto, @Param('userId') userId: string, + @Query('filter', new ParseEnumPipe(MyListFilter, { optional: true })) + filter?: MyListFilter, + @Query('sortBy', new ParseEnumPipe(LibrarySortBy, { optional: true })) + sortBy?: LibrarySortBy, + @Query('direction', new ParseEnumPipe(SortByDirection, { optional: true })) + direction?: SortByDirection, ): Promise> { // const user = await this.userService.findOne(userId); - const items = await this.libraryService.getLibraryItemDtos( + const items = await this.libraryService.getMyListDtos({ userId, pagination, - ); + filter, + sortBy, + direction, + }); - return { - items, - itemsPerPage: pagination.itemsPerPage, - page: pagination.page, - total: items.length, - }; + return items; } @Put('tmdb/:tmdbId') diff --git a/backend/src/user-data/library/library.entity.ts b/backend/src/user-data/library/library.entity.ts index fd3b78a..911bbac 100644 --- a/backend/src/user-data/library/library.entity.ts +++ b/backend/src/user-data/library/library.entity.ts @@ -15,6 +15,7 @@ import { } from 'typeorm'; import { PlayState } from '../play-state/play-state.entity'; import { PlayStateDto } from '../play-state/play-state.dto'; +import { MovieMetadata, SeriesMetadata } from 'src/metadata/metadata.entity'; @Entity() @Unique(['tmdbId', 'userId']) @@ -31,6 +32,22 @@ export class LibraryItem { @Column() mediaType: MediaType; + @ApiProperty({ required: false, type: MovieMetadata }) + @ManyToOne(() => MovieMetadata, { + createForeignKeyConstraints: false, + nullable: true, + }) + @JoinColumn({ name: 'tmdbId', referencedColumnName: 'tmdbId' }) + movieMetadata?: MovieMetadata; + + @ApiProperty({ required: false, type: SeriesMetadata }) + @ManyToOne(() => SeriesMetadata, { + createForeignKeyConstraints: false, + nullable: true, + }) + @JoinColumn({ name: 'tmdbId', referencedColumnName: 'tmdbId' }) + seriesMetadata?: SeriesMetadata; + @ApiProperty({ required: true, type: 'string' }) @PrimaryColumn() userId: string; diff --git a/backend/src/user-data/library/library.service.ts b/backend/src/user-data/library/library.service.ts index fee8c33..90d7be6 100644 --- a/backend/src/user-data/library/library.service.ts +++ b/backend/src/user-data/library/library.service.ts @@ -1,11 +1,39 @@ import { Inject, Injectable } from '@nestjs/common'; -import { MediaType, PaginationParamsDto } from 'src/common/common.dto'; +import { + MediaType, + PaginatedResponseDto, + PaginationParamsDto, +} from 'src/common/common.dto'; import { MetadataService } from 'src/metadata/metadata.service'; -import { Repository } from 'typeorm'; +import { IsNull, Not, Repository } from 'typeorm'; import { LibraryItemDto } from './library.dto'; import { LibraryItem } from './library.entity'; import { USER_LIBRARY_REPOSITORY } from './library.providers'; +export enum SortByDirection { + Asc = 'asc', + Desc = 'desc', +} + +export enum LibrarySortBy { + DateAdded = 'dateAdded', + Name = 'name', + FirstReleaseDate = 'firstReleaseDate', + LastReleaseDate = 'lastReleaseDate', +} + +export enum MyListFilter { + Movie = 'movie', + Series = 'series', +} + +export enum CatalogueFilter { + All = 'all', + Movies = 'movies', + Series = 'series', + Unavailable = 'unavailable', +} + @Injectable() export class LibraryService { constructor( @@ -14,14 +42,13 @@ export class LibraryService { private readonly metadataService: MetadataService, ) {} - async getLibraryItemDtos( - userId: string, - pagination: PaginationParamsDto, - ): Promise { - const items = await this.getLibraryItems(userId, pagination); + async getMyListDtos( + ...args: Parameters + ): Promise> { + const paginatedItems = await this.getMyList(...args); - return Promise.all( - items.map(async (item) => { + const items = await Promise.all( + paginatedItems.items.map(async (item) => { const seriesMetadata = item.mediaType === MediaType.Series ? await this.metadataService.getSeriesByTmdbId(item.tmdbId) @@ -38,21 +65,95 @@ export class LibraryService { }); }), ); + + return { ...paginatedItems, items }; } - private async getLibraryItems( - userId: string, - pagination: PaginationParamsDto, - ): Promise { - return this.libraryRepository.find({ - where: { userId }, + /** TODO: decouple librayItem and movie/seriesItem */ + private async getMyList(options: { + userId: string; + pagination: PaginationParamsDto; + filter?: MyListFilter; + sortBy?: LibrarySortBy; + direction?: SortByDirection; + }): Promise> { + const { + userId, + pagination, + filter, + sortBy, + direction = SortByDirection.Desc, + } = options; + + const directon = direction === SortByDirection.Asc ? 'ASC' : 'DESC'; + + const order = { + [LibrarySortBy.DateAdded]: { createdAt: directon } as const, + [LibrarySortBy.Name]: { + seriesMetadata: { + name: directon, + }, + movieMetadata: { + name: directon, + }, + } as const, + [LibrarySortBy.FirstReleaseDate]: { + movieMetadata: { + releaseDate: directon, + }, + seriesMetadata: { + firstReleaseDate: directon, + }, + } as const, + [LibrarySortBy.LastReleaseDate]: { + movieMetadata: { + releaseDate: directon, + }, + seriesMetadata: { + lastReleaseDate: directon, + }, + } as const, + }[sortBy]; + + const mediaType = filter + ? filter === MyListFilter.Movie + ? MediaType.Movie + : MediaType.Series + : undefined; + + const [items, total] = await this.libraryRepository.findAndCount({ relations: { playStates: true, + seriesMetadata: true, + movieMetadata: true, }, - // TODO: Implement pagination - // take: pagination.itemsPerPage, - // skip: pagination.itemsPerPage * (pagination.page - 1), + select: { + seriesMetadata: { + firstReleaseDate: true, + lastReleaseDate: true, + name: true, + }, + movieMetadata: { + releaseDate: true, + name: true, + }, + }, + + where: { + userId, + ...(mediaType ? { mediaType } : {}), + }, + order, + take: pagination.itemsPerPage, + skip: pagination.itemsPerPage * (pagination.page - 1), }); + + return { + items, + total, + itemsPerPage: pagination.itemsPerPage, + page: pagination.page, + }; } async findByTmdbId( diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index e06d993..538638c 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -81,6 +81,27 @@ export interface PlayState { lastPlayedAt: string; } +export interface MovieMetadata { + id?: string; + tmdbId: string; + tmdbMovie?: object; + name?: string; + releaseDate?: string; + libraryItems?: any[][]; + updatedAt: string; +} + +export interface SeriesMetadata { + id?: string; + tmdbId: string; + tmdbSeries?: object; + name?: string; + firstReleaseDate?: string; + lastReleaseDate?: string; + libraryItems?: any[][]; + updatedAt: string; +} + export interface PlayStateDto { id: string; tmdbId: number; @@ -107,6 +128,8 @@ export interface LibraryItem { id?: string; tmdbId: string; mediaType: 'Movie' | 'Series' | 'Episode'; + movieMetadata?: MovieMetadata; + seriesMetadata?: SeriesMetadata; userId: string; user?: string; playStates?: PlayStateDto[]; @@ -1028,17 +1051,26 @@ export class Api extends HttpClient + getLibraryItems: ( + userId: string, + query?: { + filter?: 'movie' | 'series'; + sortBy?: 'dateAdded' | 'name' | 'firstReleaseDate' | 'lastReleaseDate'; + direction?: 'asc' | 'desc'; + }, + params: RequestParams = {} + ) => this.request< PaginatedResponseDto & { items: LibraryItemDto[]; }, any >({ - path: `/api/users/${userId}/library`, + path: `/api/users/${userId}/library/my-list`, method: 'GET', + query: query, format: 'json', ...params }), diff --git a/src/lib/components/StackRouter/StackRouter.ts b/src/lib/components/StackRouter/StackRouter.ts index 6991300..3fc37e7 100644 --- a/src/lib/components/StackRouter/StackRouter.ts +++ b/src/lib/components/StackRouter/StackRouter.ts @@ -1,4 +1,4 @@ -import { type ComponentType } from 'svelte'; +import { getContext, hasContext, setContext, type ComponentType } from 'svelte'; import { derived, get, writable } from 'svelte/store'; import LibraryPage from '../../pages/LibraryPage/LibraryPage.svelte'; import ManagePage from '../../pages/ManagePage/ManagePage.svelte'; @@ -16,6 +16,7 @@ import { modalStack } from '../Modal/modal.store'; import NetworkPage from '$lib/pages/CollectionPages/NetworkPage.svelte'; import ListPage from '$lib/pages/CollectionPages/ListPage.svelte'; import CompanyPage from '$lib/pages/CollectionPages/CompanyPage.svelte'; +import { useRegistrar } from '$lib/selectable'; interface Page { id: symbol; @@ -324,5 +325,45 @@ export const stackRouter = useStackRouter({ // // } // } as const); +function useStackRouterControls() { + const topSelectable = useRegistrar(); + + function handleGoBack() { + const selectable = get(topSelectable); + if (selectable && get(selectable.focusIndex) === 0) { + history.back(); + } else { + selectable?.focusChild(0, { cycleTo: true }) || selectable?.focus({ cycleTo: true }); + } + } + + function handleGoToTop() { + const selectable = get(topSelectable); + if (topSelectable) { + selectable?.focusChild(0, { cycleTo: true }) || selectable?.focus({ cycleTo: true }); + } else handleGoBack(); + } + + return { + handleGoBack, + handleGoToTop, + registrar: topSelectable.registrar + }; +} + +const STACK_ROUTER_CONTROLS = Symbol('STACK_ROUTER_CONTROLS'); + +export function createStackRouterControls() { + const store = useStackRouterControls(); + setContext(STACK_ROUTER_CONTROLS, store); + return store; +} + +export function getStackRouterControls(): ReturnType { + if (hasContext(STACK_ROUTER_CONTROLS)) return getContext(STACK_ROUTER_CONTROLS); + console.error('[StackRouterControls] Not found'); + return { handleGoBack: () => {}, handleGoToTop: () => {}, registrar: () => () => {} }; +} + export const navigate = stackRouter.navigate; export const back = stackRouter.back; diff --git a/src/lib/components/StackRouter/StackRouterPage.svelte b/src/lib/components/StackRouter/StackRouterPage.svelte index d6cd7c1..28da5a7 100644 --- a/src/lib/components/StackRouter/StackRouterPage.svelte +++ b/src/lib/components/StackRouter/StackRouterPage.svelte @@ -5,28 +5,13 @@ import { fade } from 'svelte/transition'; import Container from '../Container.svelte'; import { focusSidebar } from '../Sidebar/sidebar'; + import { createStackRouterControls } from './StackRouter'; export let hasSidebar = true; export let hidden = false; // Top element, that when focused and back is pressed, will exit the modal - const topSelectable = useRegistrar(); - - function handleGoBack() { - const selectable = get(topSelectable); - if (selectable && get(selectable.focusIndex) === 0) { - history.back(); - } else { - selectable?.focusChild(0, { cycleTo: true }) || selectable?.focus({ cycleTo: true }); - } - } - - function handleGoToTop() { - const selectable = get(topSelectable); - if (topSelectable) { - selectable?.focusChild(0, { cycleTo: true }) || selectable?.focus({ cycleTo: true }); - } else handleGoBack(); - } + const { handleGoBack, handleGoToTop, registrar } = createStackRouterControls(); - + diff --git a/src/lib/pages/LibraryPage/CatalogueTab.svelte b/src/lib/pages/LibraryPage/CatalogueTab.svelte new file mode 100644 index 0000000..51dfd98 --- /dev/null +++ b/src/lib/pages/LibraryPage/CatalogueTab.svelte @@ -0,0 +1,21 @@ + + + + {#await items then items} + + {#each items as item} + + {/each} + + {/await} + diff --git a/src/lib/pages/LibraryPage/LibraryPage.svelte b/src/lib/pages/LibraryPage/LibraryPage.svelte index dbf6a9e..0d5a82f 100644 --- a/src/lib/pages/LibraryPage/LibraryPage.svelte +++ b/src/lib/pages/LibraryPage/LibraryPage.svelte @@ -1,218 +1,57 @@ - - {#if !$isLoading} -
- - - category.set('all')}>All - category.set('series')}> - Series - - category.set('movies')}> - Movies - - - - - (didMount = true)} - focusedChild + + + + - {#if $libraryItemsCategorized.main.length + $libraryItemsCategorized.upcoming.length + $libraryItemsCategorized.watched.length} - {#if $libraryItemsCategorized.upcoming.length} -
- - {#key viewSettingsKey} - {#each $libraryItemsCategorized.upcoming as item (item.tmdbId)} - - {/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} + My List + + + {#each catalogues as catalogue, index} + + + {catalogue.name} + + + {/each} +
+ + + + + {#each catalogues as catalogue, index} + + + {catalogue.name} + + + {/each} + diff --git a/src/lib/pages/LibraryPage/MyListTab.svelte b/src/lib/pages/LibraryPage/MyListTab.svelte new file mode 100644 index 0000000..6d18708 --- /dev/null +++ b/src/lib/pages/LibraryPage/MyListTab.svelte @@ -0,0 +1,220 @@ + + + + {#if !$isLoading} +
+ + + category.set('all')}>All + category.set('series')}> + Series + + category.set('movies')}> + Movies + + + + + (didMount = true)} + focusedChild + > + {#if $libraryItemsCategorized.main.length + $libraryItemsCategorized.upcoming.length + $libraryItemsCategorized.watched.length} + {#if $libraryItemsCategorized.upcoming.length} +
+ + {#key viewSettingsKey} + {#each $libraryItemsCategorized.upcoming as item (item.tmdbId)} + + {/each} + {/key} + +
+ {/if} + {#if $libraryItemsCategorized.main.length} +
+
My List
+ + {#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/stores/data.store.ts b/src/lib/stores/data.store.ts index e29bab7..70dfc91 100644 --- a/src/lib/stores/data.store.ts +++ b/src/lib/stores/data.store.ts @@ -119,6 +119,7 @@ export function useRequestsStore, TResponse>( if (subscribers?.length === 0 && options.persistant !== true) { requests.delete(JSON.stringify(args)); + console.log('deleting request', args); } } };