import { Inject, Injectable } from '@nestjs/common'; import { MediaType, PaginatedResponseDto, PaginationDto, } from 'src/common/common.dto'; import { MetadataService } from 'src/metadata/metadata.service'; import { MediaSourcesService } from 'src/users/media-sources/media-sources.service'; import { Brackets, Repository } from 'typeorm'; import { PlayState } from '../play-state/play-state.entity'; import { USER_PLAY_STATE_REPOSITORY } from '../play-state/play-state.providers'; import { CatalogueTypeFilter, LibraryItemDto, MyListOrder, MyListStatusFilter, MyListTypeFilter, OrderDirection, } from './library.dto'; import { LibraryItem } from './library.entity'; import { USER_LIBRARY_REPOSITORY } from './library.providers'; @Injectable() export class LibraryService { constructor( @Inject(USER_LIBRARY_REPOSITORY) private readonly libraryRepository: Repository, @Inject(USER_PLAY_STATE_REPOSITORY) private readonly playStateRepository: Repository, private readonly metadataService: MetadataService, private readonly mediaSourceService: MediaSourcesService, ) { // Make sure library items are cached this.libraryRepository.find().then((r) => r.forEach((i) => { if (i.mediaType === 'movie') { this.metadataService.getMovieByTmdbId(i.tmdbId); } else if (i.mediaType === 'series') { this.metadataService.getSeriesByTmdbId(i.tmdbId); } }), ); } /** TODO: decouple librayItem and movie/seriesItem */ async getMyList(options: { userId: string; pagination: PaginationDto; type?: MyListTypeFilter; status?: MyListStatusFilter; order?: MyListOrder; direction?: OrderDirection; }): Promise> { const { userId, pagination, type, status, order, direction = OrderDirection.Desc, } = options; const mediaType = type ? type === MyListTypeFilter.Movies ? MediaType.Movie : MediaType.Series : undefined; let builder = this.libraryRepository .createQueryBuilder('libraryItem') .leftJoinAndSelect('libraryItem.playStates', 'playStates') .leftJoinAndSelect('libraryItem.movieMetadata', 'movieMetadata') .leftJoinAndSelect('libraryItem.seriesMetadata', 'seriesMetadata') .addSelect('libraryItem.createdAt', 'createdAt') .addSelect(['libraryItem.createdAt', 'libraryItem.updatedAt']) .where('libraryItem.userId = :userId', { userId }); if (mediaType) { builder = builder.andWhere('libraryItem.mediaType = :mediaType', { mediaType, }); } const watched = new Brackets((qb) => qb .where( "(libraryItem.mediaType = 'movie' AND playStates.watched = true)", ) .orWhere( new Brackets((qb) => qb .where("libraryItem.mediaType = 'series'") .andWhere( 'playStates.watched = true AND playStates.season = seriesMetadata.lastSeasonNumber AND playStates.episode = seriesMetadata.lastEpisodeNumber', ), ), ), ); const upcoming = new Brackets((qb) => qb .where(watched) .andWhere( new Brackets((qb) => qb .where('seriesMetadata.nextReleaseDate > date("now")') .orWhere('movieMetadata.releaseDate > date("now")'), ), ), ); const watchedAndNotUpcoming = new Brackets((qb) => qb .where(watched) .andWhere( new Brackets((qb) => qb .where('seriesMetadata.nextReleaseDate < date("now")') .orWhere('movieMetadata.releaseDate < date("now")') .orWhere( '(seriesMetadata.nextReleaseDate IS NULL AND movieMetadata.releaseDate IS NULL)', ), ), ), ); if (status === MyListStatusFilter.Upcoming) { builder = builder.andWhere(upcoming); } else if (status === MyListStatusFilter.Watched) { builder = builder.andWhere(watchedAndNotUpcoming); } else if ( status === MyListStatusFilter.Unwatched || status === MyListStatusFilter.ContinueWatching ) { builder = builder.andWhere( new Brackets((qb) => qb .where( "(libraryItem.mediaType = 'movie' AND playStates.watched = false)", ) .orWhere( "(libraryItem.mediaType = 'movie' AND playStates.watched IS NULL)", ) .orWhere( new Brackets((qb) => qb .where("libraryItem.mediaType = 'series'") .andWhere( 'playStates.watched = false AND playStates.season = seriesMetadata.lastSeasonNumber AND playStates.episode = seriesMetadata.lastEpisodeNumber', ), ), ) .orWhere( new Brackets((qb) => qb.where("libraryItem.mediaType = 'series'").andWhere( `NOT EXISTS ( select 1 from play_state playStates LEFT JOIN movie_metadata movieMetadata on playStates.tmdbId = movieMetadata.tmdbId LEFT JOIN series_metadata seriesMetadata on playStates.tmdbId = seriesMetadata.tmdbId where playStates.tmdbId = libraryItem.tmdbId AND playStates.userId = libraryItem.userId AND seriesMetadata.lastEpisodeNumber = playStates.episode AND seriesMetadata.lastSeasonNumber = playStates.season )`, ), ), ), ), ); if (status === MyListStatusFilter.ContinueWatching) { builder = builder.andWhere( "libraryItem.lastPlayedAt IS NOT NULL AND libraryItem.lastPlayedAt > date('now', '-1 month')", ); } } const DIRECTION = direction === OrderDirection.Asc ? 'ASC' : 'DESC'; if (order === MyListOrder.Name) { builder.addOrderBy('seriesMetadata.name', DIRECTION); builder.addOrderBy('movieMetadata.name', DIRECTION); } else if (order === MyListOrder.DateAdded) { builder.addOrderBy('libraryItem.createdAt', DIRECTION); } else if (order === MyListOrder.FirstReleaseDate) { builder.addOrderBy('movieMetadata.releaseDate', DIRECTION); builder.addOrderBy('seriesMetadata.firstReleaseDate', DIRECTION); } else if (order === MyListOrder.LastReleaseDate) { builder.addOrderBy('movieMetadata.releaseDate', DIRECTION); builder.addOrderBy('seriesMetadata.lastReleaseDate', DIRECTION); } else if (order === MyListOrder.LastPlayed) { builder.addOrderBy('libraryItem.lastPlayedAt', DIRECTION); } const [items, total] = await builder .take(pagination.itemsPerPage) .skip(pagination.itemsPerPage * (pagination.page - 1)) .getManyAndCount(); // console.log(builder.getQuery()); return { items: await Promise.all( items.map((item) => this.getLibraryItemDto(item)), ), total, itemsPerPage: pagination.itemsPerPage, page: pagination.page, }; } async getCatalogueItems(options: { sourceId: string; userId: string; token: string; pagination: PaginationDto; type?: CatalogueTypeFilter; order?: string; direction?: string; }): Promise | undefined> { const { sourceId, userId, token, pagination, type = CatalogueTypeFilter.All, order, direction, } = options; const connection = await this.mediaSourceService.getConnection({ sourceId, token, userId, }); if (!connection) { console.error( `No connection found for sourceId: ${sourceId}. Please check your media source configuration.`, ); throw new Error('No connection found'); } const combined = connection.provider.getCatalogue; const movies = connection.provider.getMovieCatalogue; const series = connection.provider.getSeriesCatalogue; const missing = connection.provider.getMissingInCatalogue; if (type === CatalogueTypeFilter.All && combined) { const response = await combined({ pagination, order, direction, }); return { ...response, items: await Promise.all( response.items.map(async (item) => this.getLibraryItemDto(item)), ), }; } else if (type === CatalogueTypeFilter.Movies && movies) { const response = await movies({ pagination, order, direction, }); return { ...response, items: await Promise.all( response.items.map(async (item) => this.getLibraryItemDto(item)), ), }; } else if (type === CatalogueTypeFilter.Series && series) { const response = await series({ pagination, order, direction, }); return { ...response, items: await Promise.all( response.items.map(async (item) => this.getLibraryItemDto(item)), ), }; } else if (type === CatalogueTypeFilter.Missing && missing) { const tmdbIdToMyListItem: Record = {}; const myListItems = await this.getMyList({ pagination: { itemsPerPage: 500, page: 1, }, userId: connection.mediaSource.userId, type: MyListTypeFilter.All, status: MyListStatusFilter.All, order: MyListOrder.DateAdded, }).then((res) => res.items); myListItems.forEach((i) => { tmdbIdToMyListItem[i.tmdbId] = i; }); const response = await missing({ pagination, myListItems: tmdbIdToMyListItem, order, direction, }); return response; } throw new Error( `No catalogue provider found for type: ${type}. Please check your media source configuration.`, ); } async findByTmdbId( userId: string, tmdbId: string, ): Promise { return this.libraryRepository.findOne({ where: { userId, tmdbId } }); } async findOrCreateByTmdbId( userId: string, tmdbId: string, mediaType: MediaType, ): Promise { let libraryItem = await this.findByTmdbId(userId, tmdbId); if (!libraryItem) { libraryItem = this.libraryRepository.create({ userId, tmdbId, mediaType, }); const lastPlayedAt = await this.playStateRepository .find({ where: { userId, tmdbId, }, }) .then((states) => states.map((s) => new Date(s.lastPlayedAt).getTime() || 0), ) .then((dates) => Math.max(...dates)); if (lastPlayedAt) { libraryItem.lastPlayedAt = new Date(lastPlayedAt); } await this.libraryRepository.save(libraryItem); } return libraryItem; } async deleteByTmdbId(userId: string, tmdbId: string) { return await this.libraryRepository.delete({ userId, tmdbId }); } async getLibraryItemDto(options: { tmdbId: string; mediaType: 'series' | 'movie'; playStates?: PlayState[]; }): Promise { 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, lastPlayState: playStates ? playStates[playStates.length - 1] : undefined, 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; } }