From 2748f512a1edafb66e1437594c2a33b4ff72d234 Mon Sep 17 00:00:00 2001 From: Joosep Alviste Date: Sun, 13 Aug 2023 22:52:16 +0300 Subject: [PATCH] feat(api): delete series if it has been deleted from TMDB This requires some refactoring of the TMDB request making as we want to differentiate between a not found response and if parsing fails. If the parsing fails, then that is not enough cause to delete the series as there might just be some random breaking change that we would need to handle. In the future, we should probably send a Sentry error or something when parsing fails. Related to #72 --- .../src/features/series/__tests__/scopes.ts | 4 +- .../series/__tests__/series.service.test.ts | 48 +++++++++++++++++++ .../src/features/series/series.repository.ts | 10 ++++ .../api/src/features/series/series.service.ts | 22 ++++++++- apps/api/src/features/tmdb/tmdb.schemas.ts | 10 ++++ apps/api/src/features/tmdb/tmdb.service.ts | 38 ++++++++------- apps/api/src/features/tmdb/types.ts | 3 ++ 7 files changed, 113 insertions(+), 22 deletions(-) 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