mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-26 18:55:12 +02:00
feat: catalogue server side filters
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user