diff --git a/apps/api/src/features/series/__tests__/scopes.ts b/apps/api/src/features/series/__tests__/scopes.ts index 2733aad0..b913cf56 100644 --- a/apps/api/src/features/series/__tests__/scopes.ts +++ b/apps/api/src/features/series/__tests__/scopes.ts @@ -2,9 +2,9 @@ import nock from 'nock' import { config } from '@/config' import { - type TMDbSeries, type TMDbSeason, type TMDbSearchResponse, + type TMDbSeriesResponse, } from '@/features/tmdb' export const mockTMDbSearchRequest = ( @@ -29,7 +29,7 @@ export const mockTMDbSearchRequest = ( export const mockTMDbDetailsRequest = ( tmdbId: number, - response: TMDbSeries, + response: TMDbSeriesResponse, ) => { return nock(config.tmdb.url) .get(`/3/tv/${tmdbId}`) diff --git a/apps/api/src/features/series/__tests__/series.service.test.ts b/apps/api/src/features/series/__tests__/series.service.test.ts index 729ed23d..b0874b27 100644 --- a/apps/api/src/features/series/__tests__/series.service.test.ts +++ b/apps/api/src/features/series/__tests__/series.service.test.ts @@ -13,6 +13,7 @@ import { createSeenEpisodesForUser, createSeriesWithEpisodesAndSeasons, } from '@/test/testUtils' +import { type LiterallyAnything } from '@/types/utils' import { episodeFactory } from '../episode.factory' import { seasonFactory } from '../season.factory' @@ -265,6 +266,53 @@ describe('features/series/series.service', () => { tmdbId: series.tmdbId, }) }) + + it('deletes a series if it has been deleted in TMDB', async () => { + const series = await seriesFactory.create() + + mockTMDbDetailsRequest(series.tmdbId, { + success: false, + status_code: 34, + status_message: 'The resource you requested could not be found.', + }) + + const returnedSeries = await syncSeriesDetails({ + ctx: createContext(), + tmdbId: series.tmdbId, + }) + + expect(returnedSeries).toBe(null) + + const seriesInDb = await db + .selectFrom('series') + .where('id', '=', series.id) + .selectAll() + .executeTakeFirst() + expect(seriesInDb).toBe(undefined) + }) + + it('does not delete the series if parsing the TMDB response fails', async () => { + const series = await seriesFactory.create() + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + mockTMDbDetailsRequest(series.tmdbId, { + not: 'correct', + } as LiterallyAnything) + + const returnedSeries = await syncSeriesDetails({ + ctx: createContext(), + tmdbId: series.tmdbId, + }) + + expect(returnedSeries).toBe(null) + + const seriesInDb = await db + .selectFrom('series') + .where('id', '=', series.id) + .selectAll() + .executeTakeFirst() + expect(seriesInDb).not.toBeFalsy() + }) }) describe('findStatusForSeries', () => { diff --git a/apps/api/src/features/series/series.repository.ts b/apps/api/src/features/series/series.repository.ts index 3fcd21ab..59f25813 100644 --- a/apps/api/src/features/series/series.repository.ts +++ b/apps/api/src/features/series/series.repository.ts @@ -120,3 +120,13 @@ export const updateOneByTMDbId = ({ .returningAll() .executeTakeFirst() } + +export const deleteOne = ({ + ctx, + tmdbId, +}: { + ctx: DBContext + tmdbId: number +}) => { + return ctx.db.deleteFrom('series').where('tmdbId', '=', tmdbId).execute() +} diff --git a/apps/api/src/features/series/series.service.ts b/apps/api/src/features/series/series.service.ts index d0dd327c..2d90d1c0 100644 --- a/apps/api/src/features/series/series.service.ts +++ b/apps/api/src/features/series/series.service.ts @@ -185,6 +185,9 @@ export const syncSeasonsAndEpisodes = async ({ * Update the details of the series with the given TMDB ID from the TMDB API. * This also syncs the seasons and episodes from TMDB, saving them into the * database if needed. + * + * If the series does not exist on TMDB, then it will be deleted from the + * database. */ export const syncSeriesDetails = async ({ ctx, @@ -194,10 +197,20 @@ export const syncSeriesDetails = async ({ tmdbId: number }) => { const { + parsed, + found, series: newSeries, totalSeasons, seasons, } = await tmdbService.fetchSeriesDetails({ tmdbId }) + if (!parsed) { + return null + } + + if (!found) { + await seriesRepository.deleteOne({ ctx, tmdbId }) + return null + } const savedSeries = await seriesRepository.updateOneByTMDbId({ ctx, @@ -209,7 +222,7 @@ export const syncSeriesDetails = async ({ }, }) if (!savedSeries) { - throw new NotFoundError() + return null } if (totalSeasons) { @@ -242,7 +255,12 @@ export const getSeriesByIdAndFetchDetailsFromTMDB = async ({ return series } - return await syncSeriesDetails({ ctx, tmdbId: series.tmdbId }) + const syncedSeries = await syncSeriesDetails({ ctx, tmdbId: series.tmdbId }) + if (!syncedSeries) { + throw new NotFoundError() + } + + return series } export const updateSeriesStatusForUser = async ({ diff --git a/apps/api/src/features/tmdb/tmdb.schemas.ts b/apps/api/src/features/tmdb/tmdb.schemas.ts index 84d4982c..66613719 100644 --- a/apps/api/src/features/tmdb/tmdb.schemas.ts +++ b/apps/api/src/features/tmdb/tmdb.schemas.ts @@ -7,6 +7,12 @@ export const tmdbStatusSchema = z.enum([ 'Canceled', ]) +export const tmdbSeriesNotFoundSchema = z.object({ + success: z.literal(false), + status_code: z.literal(34), + status_message: z.string(), +}) + export const tmdbSeriesSchema = z.object({ id: z.number(), name: z.string(), @@ -30,6 +36,10 @@ export const tmdbSeriesSchema = z.object({ ), }) +export const tmdbSeriesResponseSchema = tmdbSeriesSchema.or( + tmdbSeriesNotFoundSchema, +) + export const tmdbSearchSeriesSchema = tmdbSeriesSchema.pick({ id: true, overview: true, diff --git a/apps/api/src/features/tmdb/tmdb.service.ts b/apps/api/src/features/tmdb/tmdb.service.ts index bd0ceb20..083a498e 100644 --- a/apps/api/src/features/tmdb/tmdb.service.ts +++ b/apps/api/src/features/tmdb/tmdb.service.ts @@ -9,7 +9,7 @@ import { log } from '@/lib/logger' import { tmdbSeasonSchema, - tmdbSeriesSchema, + tmdbSeriesResponseSchema, tmdbSeriesSearchResponseSchema, } from './tmdb.schemas' import { type TMDbSeries, type TMDbSearchSeries } from './types' @@ -33,7 +33,7 @@ const makeTMDbRequest = async ( const json = (await res.json()) as T try { - return schema.parse(json) + return { parsed: true, response: schema.parse(json) } } catch (e) { log.warn( { @@ -44,7 +44,7 @@ const makeTMDbRequest = async ( 'TMDB API response did not match the schema.', ) - return null + return { parsed: false, response: null } } } @@ -79,34 +79,36 @@ export const searchSeries = async ({ }: { keyword: string }): Promise[]> => { - const seriesSearchResponse = await makeTMDbRequest( + const { response } = await makeTMDbRequest( 'search/tv', { query: keyword }, tmdbSeriesSearchResponseSchema, ) - if (!seriesSearchResponse) { + if (!response) { // No result found or other error return [] } - return seriesSearchResponse.results.map(parseSeriesFromTMDbResponse) + return response.results.map(parseSeriesFromTMDbResponse) } export const fetchSeriesDetails = async ({ tmdbId }: { tmdbId: number }) => { - const seriesDetails = await makeTMDbRequest( + const { parsed, response } = await makeTMDbRequest( `tv/${tmdbId}`, { append_to_response: 'external_ids' }, - tmdbSeriesSchema, + tmdbSeriesResponseSchema, ) - if (!seriesDetails) { - throw new NotFoundError() + if (!response || 'success' in response) { + return { parsed, found: false, series: null, totalSeasons: 0, seasons: [] } } return { - series: parseSeriesFromTMDbResponse(seriesDetails), - totalSeasons: seriesDetails.number_of_seasons, - seasons: seriesDetails.seasons.map( + found: true, + parsed: true, + series: parseSeriesFromTMDbResponse(response), + totalSeasons: response.number_of_seasons, + seasons: response.seasons.map( (season): Omit, 'seriesId'> => ({ tmdbId: season.id, number: season.season_number, @@ -123,19 +125,19 @@ export const fetchEpisodesForSeason = async ({ tmdbId: number seasonNumber: number }) => { - const season = await makeTMDbRequest( + const { response } = await makeTMDbRequest( `tv/${tmdbId}/season/${seasonNumber}`, {}, tmdbSeasonSchema, ) - if (!season) { + if (!response) { throw new NotFoundError() } return { - seasonNumber: season.season_number, - seasonTitle: season.name, - episodes: season.episodes.map((episode) => ({ + seasonNumber: response.season_number, + seasonTitle: response.name, + episodes: response.episodes.map((episode) => ({ tmdbId: episode.id, number: episode.episode_number, title: episode.name, diff --git a/apps/api/src/features/tmdb/types.ts b/apps/api/src/features/tmdb/types.ts index 9bd0cdc2..7807da07 100644 --- a/apps/api/src/features/tmdb/types.ts +++ b/apps/api/src/features/tmdb/types.ts @@ -6,6 +6,7 @@ import { type tmdbEpisodeSchema, type tmdbSeasonSchema, type tmdbSeriesSearchResponseSchema, + type tmdbSeriesResponseSchema, } from './tmdb.schemas' export type TMDbSearchResponse = z.infer @@ -14,6 +15,8 @@ export type TMDbSearchSeries = z.infer export type TMDbSeries = z.infer +export type TMDbSeriesResponse = z.infer + export type TMDbSeason = z.infer export type TMDbEpisode = z.infer