From 1172c74ba26c4068bace7957ac2921e34c69e589 Mon Sep 17 00:00:00 2001 From: Kaarel Part Date: Sun, 5 Apr 2026 14:39:43 +0100 Subject: [PATCH 1/4] Increase watch history item array limit from 1000 to 5000 --- server/routes/users/[id]/watch-history/[tmdbid]/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routes/users/[id]/watch-history/[tmdbid]/index.ts b/server/routes/users/[id]/watch-history/[tmdbid]/index.ts index 9c965d7..30fc750 100644 --- a/server/routes/users/[id]/watch-history/[tmdbid]/index.ts +++ b/server/routes/users/[id]/watch-history/[tmdbid]/index.ts @@ -57,7 +57,7 @@ export default defineEventHandler(async event => { // Accept single object (normal playback) or array (e.g. user import) const bodySchema = z.union([ watchHistoryItemSchema, - z.array(watchHistoryItemSchema).max(1000), + z.array(watchHistoryItemSchema).max(5000), ]); const parsed = bodySchema.parse(body); const items = Array.isArray(parsed) ? parsed : [parsed]; From 9c8a7c3e8c948b3978a709a622640b6d19b09288 Mon Sep 17 00:00:00 2001 From: Kaarel Part Date: Sun, 5 Apr 2026 14:51:24 +0100 Subject: [PATCH 2/4] Refactor watch history upsert logic to use transactions and improve error handling --- .../[id]/watch-history/[tmdbid]/index.ts | 132 +++++++++++------- 1 file changed, 81 insertions(+), 51 deletions(-) diff --git a/server/routes/users/[id]/watch-history/[tmdbid]/index.ts b/server/routes/users/[id]/watch-history/[tmdbid]/index.ts index 30fc750..33c0611 100644 --- a/server/routes/users/[id]/watch-history/[tmdbid]/index.ts +++ b/server/routes/users/[id]/watch-history/[tmdbid]/index.ts @@ -24,6 +24,7 @@ const watchHistoryItemSchema = z.object({ // 13th July 2021 - movie-web epoch const minEpoch = 1626134400000; +const movieHistoryId = '\n'; function defaultAndCoerceDateTime(dateTime: string | undefined) { const epoch = dateTime ? new Date(dateTime).getTime() : Date.now(); @@ -52,62 +53,81 @@ export default defineEventHandler(async event => { } if (method === 'PUT') { - const body = await readBody(event); - - // Accept single object (normal playback) or array (e.g. user import) - const bodySchema = z.union([ - watchHistoryItemSchema, - z.array(watchHistoryItemSchema).max(5000), - ]); - const parsed = bodySchema.parse(body); - const items = Array.isArray(parsed) ? parsed : [parsed]; - try { - - const upsertPromises = items.map(validatedBody => { - const itemTmdbId = items.length === 1 ? tmdbId : (validatedBody.tmdbId ?? tmdbId); - const watchedAt = defaultAndCoerceDateTime(validatedBody.watchedAt); - const now = new Date(); - - // Normalize IDs for movies (use '\n' instead of null to satisfy unique constraint) - const normSeasonId = validatedBody.meta.type === 'movie' ? '\n' : validatedBody.seasonId ?? null; - const normEpisodeId = validatedBody.meta.type === 'movie' ? '\n' : validatedBody.episodeId ?? null; - - const data = { - duration: parseFloat(validatedBody.duration), - watched: parseFloat(validatedBody.watched), - watched_at: watchedAt, - completed: validatedBody.completed, - meta: validatedBody.meta, - updated_at: now, - }; - - return prisma.watch_history.upsert({ - where: { - tmdb_id_user_id_season_id_episode_id: { + const body = await readBody(event); + + // Accept single object (normal playback) or array (e.g. user import) + const bodySchema = z.union([ + watchHistoryItemSchema, + z.array(watchHistoryItemSchema).max(5000), + ]); + const parsed = bodySchema.parse(body); + const items = Array.isArray(parsed) ? parsed : [parsed]; + + const transactionResults = await prisma.$transaction(async tx => { + const results = []; + + for (const validatedBody of items) { + const itemTmdbId = items.length === 1 ? tmdbId : (validatedBody.tmdbId ?? tmdbId); + const watchedAt = defaultAndCoerceDateTime(validatedBody.watchedAt); + const now = new Date(); + + // Normalize IDs for movies so the unique key stays stable. + const normSeasonId = + validatedBody.meta.type === 'movie' ? movieHistoryId : (validatedBody.seasonId ?? null); + const normEpisodeId = + validatedBody.meta.type === 'movie' + ? movieHistoryId + : (validatedBody.episodeId ?? null); + + const data = { + duration: parseFloat(validatedBody.duration), + watched: parseFloat(validatedBody.watched), + watched_at: watchedAt, + completed: validatedBody.completed, + meta: validatedBody.meta, + updated_at: now, + }; + + const existingItem = await tx.watch_history.findFirst({ + where: { tmdb_id: itemTmdbId, user_id: userId, season_id: normSeasonId, episode_id: normEpisodeId, }, - }, - update: data, - create: { - id: uuidv7(), - tmdb_id: itemTmdbId, - user_id: userId, - season_id: normSeasonId, - episode_id: normEpisodeId, - season_number: validatedBody.seasonNumber ?? null, - episode_number: validatedBody.episodeNumber ?? null, - ...data, - }, - }); + }); + + if (existingItem) { + results.push( + await tx.watch_history.update({ + where: { id: existingItem.id }, + data, + }) + ); + continue; + } + + results.push( + await tx.watch_history.create({ + data: { + id: uuidv7(), + tmdb_id: itemTmdbId, + user_id: userId, + season_id: normSeasonId, + episode_id: normEpisodeId, + season_number: validatedBody.seasonNumber ?? null, + episode_number: validatedBody.episodeNumber ?? null, + ...data, + }, + }) + ); + } + + return results; }); - if (upsertPromises.length === 0) return { success: true, count: 0, items: [] }; - - const transactionResults = await prisma.$transaction(upsertPromises); + if (transactionResults.length === 0) return { success: true, count: 0, items: [] }; const results = transactionResults.map(watchHistoryItem => ({ success: true, @@ -126,9 +146,19 @@ export default defineEventHandler(async event => { updatedAt: watchHistoryItem.updated_at.toISOString(), })); - return results.length === 1 ? results[0] : { success: true, count: results.length, items: results }; - } catch (dbError) { - console.error('Database error:', dbError); + return results.length === 1 + ? results[0] + : { success: true, count: results.length, items: results }; + } catch (error) { + if (error instanceof z.ZodError) { + throw createError({ + statusCode: 400, + message: 'Invalid watch history data', + cause: error.errors, + }); + } + + console.error('Database error:', error); throw createError({ statusCode: 500, message: 'Failed to save watch history', From 4ebbf6adaaf0e46e2e9d07613e727a32457e0b12 Mon Sep 17 00:00:00 2001 From: Kaarel Part Date: Tue, 7 Apr 2026 13:49:19 +0100 Subject: [PATCH 3/4] Fix item TMDB ID assignment logic in watch history handler --- server/routes/users/[id]/watch-history/[tmdbid]/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routes/users/[id]/watch-history/[tmdbid]/index.ts b/server/routes/users/[id]/watch-history/[tmdbid]/index.ts index 33c0611..644c4cb 100644 --- a/server/routes/users/[id]/watch-history/[tmdbid]/index.ts +++ b/server/routes/users/[id]/watch-history/[tmdbid]/index.ts @@ -68,7 +68,7 @@ export default defineEventHandler(async event => { const results = []; for (const validatedBody of items) { - const itemTmdbId = items.length === 1 ? tmdbId : (validatedBody.tmdbId ?? tmdbId); + const itemTmdbId = validatedBody.tmdbId; const watchedAt = defaultAndCoerceDateTime(validatedBody.watchedAt); const now = new Date(); From 55d4f73145768786015173f82c201cfe0359a1d4 Mon Sep 17 00:00:00 2001 From: Kaarel Part Date: Tue, 7 Apr 2026 13:50:23 +0100 Subject: [PATCH 4/4] Add validation for tmdbId mismatch in watch history import --- server/routes/users/[id]/watch-history/[tmdbid]/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/routes/users/[id]/watch-history/[tmdbid]/index.ts b/server/routes/users/[id]/watch-history/[tmdbid]/index.ts index 644c4cb..4fd398b 100644 --- a/server/routes/users/[id]/watch-history/[tmdbid]/index.ts +++ b/server/routes/users/[id]/watch-history/[tmdbid]/index.ts @@ -64,6 +64,14 @@ export default defineEventHandler(async event => { const parsed = bodySchema.parse(body); const items = Array.isArray(parsed) ? parsed : [parsed]; + // Guard against route/body mismatches (e.g. /watch-history/import for single writes) + if (items.length === 1 && tmdbId && tmdbId !== items[0].tmdbId) { + throw createError({ + statusCode: 400, + message: 'tmdbId in URL does not match request body', + }); + } + const transactionResults = await prisma.$transaction(async tx => { const results = [];