feat: catalogue server side filters

This commit is contained in:
Aleksi Lassila
2025-03-28 11:43:28 +02:00
parent 5c926d6147
commit aedb7168c4
24 changed files with 603 additions and 483 deletions

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Controller,
Delete,
Get,
@@ -9,7 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger';
import { UserAccessControl } from 'src/auth/auth.guard';
import { GetAuthToken, UserAccessControl } from 'src/auth/auth.guard';
import {
GetPaginationParams,
PaginatedApiOkResponse,
@@ -20,38 +21,43 @@ import {
PaginationParamsDto,
SuccessResponseDto,
} from 'src/common/common.dto';
import { LibraryItemDto } from './library.dto';
import { MediaSourcesService } from 'src/users/media-sources/media-sources.service';
import {
LibraryService,
LibrarySortBy,
MyListFilter as MyListFilter,
CatalogueFilter,
LibraryItemDto,
MyListSortBy,
MyListFilter,
SortByDirection,
} from './library.service';
} from './library.dto';
import { LibraryService } from './library.service';
@ApiTags('users')
@ApiTags('library')
@Controller('users/:userId/library')
@UseGuards(UserAccessControl)
export class LibraryController {
constructor(private libraryService: LibraryService) {}
constructor(
private libraryService: LibraryService,
private mediaSourceService: MediaSourcesService,
) {}
@Get('my-list')
@ApiQuery({ name: 'filter', enum: MyListFilter, required: false })
@ApiQuery({ name: 'sortBy', enum: LibrarySortBy, required: false })
@ApiQuery({ name: 'sortBy', enum: MyListSortBy, required: false })
@ApiQuery({ name: 'direction', enum: SortByDirection, required: false })
@PaginatedApiOkResponse(LibraryItemDto)
async getLibraryItems(
async getMyList(
@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('sortBy', new ParseEnumPipe(MyListSortBy, { optional: true }))
sortBy?: MyListSortBy,
@Query('direction', new ParseEnumPipe(SortByDirection, { optional: true }))
direction?: SortByDirection,
): Promise<PaginatedResponseDto<LibraryItemDto>> {
// const user = await this.userService.findOne(userId);
const items = await this.libraryService.getMyListDtos({
const response = await this.libraryService.getMyList({
userId,
pagination,
filter,
@@ -59,6 +65,40 @@ export class LibraryController {
direction,
});
return {
...response,
items: await Promise.all(
response.items.map((i) =>
this.libraryService.getLibraryItemDto({
...i,
mediaType: i.mediaType === MediaType.Movie ? 'movie' : 'series',
}),
),
),
};
}
@Get('catalogue/:sourceId')
@PaginatedApiOkResponse(LibraryItemDto)
async getCatalogue(
@GetPaginationParams() pagination: PaginationParamsDto,
@Param('userId') userId: string,
@Param('sourceId') sourceId: string,
@GetAuthToken() token: string,
@Query('filter', new ParseEnumPipe(CatalogueFilter, { optional: true }))
filter: CatalogueFilter = CatalogueFilter.All,
): Promise<PaginatedResponseDto<LibraryItemDto>> {
const items = this.libraryService.getCatalogueItems({
sourceId,
token,
pagination,
filter,
});
if (!items) {
throw new BadRequestException();
}
return items;
}

View File

@@ -1,73 +1,40 @@
import { ApiProperty, PickType } from '@nestjs/swagger';
import { MediaType } from 'src/common/common.dto';
import { MovieMetadata, SeriesMetadata } from 'src/metadata/metadata.entity';
import { TmdbItemDto } from 'src/metadata/tmdb/tmdb.dto';
import { LibraryItem } from './library.entity';
export enum SortByDirection {
Asc = 'asc',
Desc = 'desc',
}
export enum MyListSortBy {
DateAdded = 'dateAdded',
Name = 'name',
FirstReleaseDate = 'firstReleaseDate',
LastReleaseDate = 'lastReleaseDate',
}
export enum MyListFilter {
Movie = 'movie',
Series = 'series',
All = 'all',
}
export enum CatalogueFilter {
All = 'all',
Movies = 'movies',
Series = 'series',
Missing = 'missing',
}
export class LibraryItemDto extends PickType(LibraryItem, [
'tmdbId',
'mediaType',
'playStates',
'createdAt',
]) {
@ApiProperty()
tmdbItem: TmdbItemDto;
@ApiProperty({ required: false })
watched?: boolean;
static create(options: {
libraryItem: LibraryItem;
movieMetadata?: MovieMetadata;
seriesMetadata?: SeriesMetadata;
}): LibraryItemDto {
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,
tmdbItem: {
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,
},
};
}
}

View File

@@ -3,9 +3,11 @@ import { MetadataModule } from 'src/metadata/metadata.module';
import { LibraryController } from './library.controller';
import { libraryProviders } from './library.providers';
import { LibraryService } from './library.service';
import { UsersModule } from 'src/users/users.module';
import { SourceProvidersModule } from 'src/source-providers/source-providers.module';
@Module({
imports: [MetadataModule],
imports: [UsersModule, MetadataModule, SourceProvidersModule],
providers: [...libraryProviders, LibraryService],
controllers: [LibraryController],
exports: [LibraryService],

View File

@@ -6,33 +6,17 @@ import {
} from 'src/common/common.dto';
import { MetadataService } from 'src/metadata/metadata.service';
import { Repository } from 'typeorm';
import { LibraryItemDto } from './library.dto';
import {
LibraryItemDto,
MyListSortBy,
MyListFilter,
SortByDirection,
} 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',
}
import { SourceProvidersService } from 'src/source-providers/source-providers.service';
import { MediaSourcesService } from 'src/users/media-sources/media-sources.service';
import { PlayState } from '../play-state/play-state.entity';
@Injectable()
export class LibraryService {
@@ -40,41 +24,15 @@ export class LibraryService {
@Inject(USER_LIBRARY_REPOSITORY)
private readonly libraryRepository: Repository<LibraryItem>,
private readonly metadataService: MetadataService,
private readonly mediaSourceService: MediaSourcesService,
) {}
async getMyListDtos(
...args: Parameters<LibraryService['getMyList']>
): Promise<PaginatedResponseDto<LibraryItemDto>> {
const paginatedItems = await this.getMyList(...args);
const items = await Promise.all(
paginatedItems.items.map(async (item) => {
const seriesMetadata =
item.mediaType === MediaType.Series
? await this.metadataService.getSeriesByTmdbId(item.tmdbId)
: undefined;
const movieMetadata =
item.mediaType === MediaType.Movie
? await this.metadataService.getMovieByTmdbId(item.tmdbId)
: undefined;
return LibraryItemDto.create({
libraryItem: item,
seriesMetadata,
movieMetadata,
});
}),
);
return { ...paginatedItems, items };
}
/** TODO: decouple librayItem and movie/seriesItem */
private async getMyList(options: {
async getMyList(options: {
userId: string;
pagination: PaginationParamsDto;
filter?: MyListFilter;
sortBy?: LibrarySortBy;
sortBy?: MyListSortBy;
direction?: SortByDirection;
}): Promise<PaginatedResponseDto<LibraryItem>> {
const {
@@ -88,8 +46,8 @@ export class LibraryService {
const directon = direction === SortByDirection.Asc ? 'ASC' : 'DESC';
const order = {
[LibrarySortBy.DateAdded]: { createdAt: directon } as const,
[LibrarySortBy.Name]: {
[MyListSortBy.DateAdded]: { createdAt: directon } as const,
[MyListSortBy.Name]: {
seriesMetadata: {
name: directon,
},
@@ -97,7 +55,7 @@ export class LibraryService {
name: directon,
},
} as const,
[LibrarySortBy.FirstReleaseDate]: {
[MyListSortBy.FirstReleaseDate]: {
movieMetadata: {
releaseDate: directon,
},
@@ -105,7 +63,7 @@ export class LibraryService {
firstReleaseDate: directon,
},
} as const,
[LibrarySortBy.LastReleaseDate]: {
[MyListSortBy.LastReleaseDate]: {
movieMetadata: {
releaseDate: directon,
},
@@ -156,6 +114,115 @@ export class LibraryService {
};
}
async getCatalogueItems<T extends object = object>(options: {
sourceId: string;
token: string;
pagination: PaginationParamsDto;
filter?: 'all' | 'movies' | 'series' | 'missing';
}): Promise<PaginatedResponseDto<LibraryItemDto> | undefined> {
const { sourceId, token, pagination, filter = 'all' } = options;
const connection = await this.mediaSourceService.getConnection(sourceId);
if (!connection) return;
const combined = connection.provider.catalogueProvider.getCatalogue;
const movies = connection.provider.catalogueProvider.getMovieCatalogue;
const series = connection.provider.catalogueProvider.getSeriesCatalogue;
const missing = connection.provider.catalogueProvider.getMissingInCatalogue;
if (filter === 'all' && combined) {
const response = await combined(
{
userId: connection.mediaSource.userId,
settings: connection.mediaSource.pluginSettings,
sourceId: connection.mediaSource.id,
token,
},
pagination,
);
return {
...response,
items: await Promise.all(
response.items.map(async (item) => this.getLibraryItemDto(item)),
),
};
} else if (filter === 'movies' && movies) {
const response = await movies(
{
userId: connection.mediaSource.userId,
settings: connection.mediaSource.pluginSettings,
sourceId: connection.mediaSource.id,
token,
},
pagination,
);
return {
...response,
items: await Promise.all(
response.items.map(async (item) => this.getLibraryItemDto(item)),
),
};
} else if (filter === 'series' && series) {
const response = await series(
{
userId: connection.mediaSource.userId,
settings: connection.mediaSource.pluginSettings,
sourceId: connection.mediaSource.id,
token,
},
pagination,
);
return {
...response,
items: await Promise.all(
response.items.map(async (item) => this.getLibraryItemDto(item)),
),
};
} else if (filter === 'missing' && missing) {
const tmdbIdToMyListItem: Record<string, LibraryItem> = {};
const myListItems = await this.getMyList({
pagination: {
itemsPerPage: 500,
page: 1,
},
userId: connection.mediaSource.userId,
filter: MyListFilter.All,
sortBy: MyListSortBy.DateAdded,
}).then((res) => res.items);
myListItems.forEach((i) => {
tmdbIdToMyListItem[i.tmdbId] = i;
});
const response = await missing(
{
userId: connection.mediaSource.userId,
settings: connection.mediaSource.pluginSettings,
sourceId: connection.mediaSource.id,
token,
},
pagination,
tmdbIdToMyListItem,
);
return {
...response,
items: await Promise.all(
response.items.map(async (item) =>
this.getLibraryItemDto({
...item,
mediaType:
item.mediaType === MediaType.Movie ? 'movie' : 'series',
}),
),
),
};
}
}
async findByTmdbId(
userId: string,
tmdbId: string,
@@ -185,4 +252,72 @@ export class LibraryService {
async deleteByTmdbId(userId: string, tmdbId: string) {
return await this.libraryRepository.delete({ userId, tmdbId });
}
async getLibraryItemDto(options: {
tmdbId: string;
mediaType: 'series' | 'movie';
playStates?: PlayState[];
}): Promise<LibraryItemDto> {
const { tmdbId, mediaType, playStates } = options;
const seriesMetadata =
mediaType === 'series'
? await this.metadataService.getSeriesByTmdbId(tmdbId)
: undefined;
const movieMetadata =
mediaType === 'movie'
? await this.metadataService.getMovieByTmdbId(tmdbId)
: undefined;
if (!movieMetadata && !seriesMetadata) {
throw new Error(
'At least one of movieMetadata or seriesMetadata must be provided',
);
}
let watched = false;
if (mediaType === 'movie') {
watched = playStates?.some((state) => state.watched) ?? false;
} else if (
mediaType === 'series' &&
seriesMetadata?.tmdbSeries?.last_episode_to_air
) {
const { season_number: season, episode_number: episode } =
seriesMetadata?.tmdbSeries.last_episode_to_air;
watched =
playStates?.some(
(state) =>
state.season === season &&
state.episode === episode &&
state.watched,
) ?? false;
}
const libraryItem: LibraryItemDto = {
tmdbId,
mediaType: mediaType === 'movie' ? MediaType.Movie : MediaType.Series,
watched,
playStates,
tmdbItem: {
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,
},
};
return libraryItem;
}
}