From 466b67afe5ed2c9b200dfe12f616ebc0a1b9091b Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 2 May 2025 12:47:27 +0200 Subject: [PATCH] Put `POST /videos` in a transaction, handle dups --- api/src/controllers/videos.ts | 316 ++++++++++++++++--------------- api/src/models/video.ts | 7 +- api/tests/videos/scanner.test.ts | 172 +++++++++++++++-- 3 files changed, 326 insertions(+), 169 deletions(-) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 855aedcf..676b32b4 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -160,166 +160,174 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .post( "", async ({ body, error }) => { - const vids = await db - .insert(videos) - .values(body) - .onConflictDoUpdate({ - target: [videos.path], - set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), - }) - .returning({ - pk: videos.pk, - id: videos.id, - path: videos.path, + return await db.transaction(async (tx) => { + const vids = await tx + .insert(videos) + .values(body) + .onConflictDoUpdate({ + target: [videos.path], + set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), + }) + .returning({ + pk: videos.pk, + id: videos.id, + path: videos.path, + }); + + const vidEntries = body.flatMap((x) => { + if (!x.for) return []; + return x.for.map((e) => ({ + video: vids.find((v) => v.path === x.path)!.pk, + path: x.path, + entry: { + ...e, + movie: + "movie" in e + ? isUuid(e.movie) + ? { id: e.movie } + : { slug: e.movie } + : undefined, + serie: + "serie" in e + ? isUuid(e.serie) + ? { id: e.serie } + : { slug: e.serie } + : undefined, + }, + })); }); - const vidEntries = body.flatMap((x) => { - if (!x.for) return []; - return x.for.map((e) => ({ - video: vids.find((v) => v.path === x.path)!.pk, - path: x.path, - entry: { - ...e, - movie: - "movie" in e - ? isUuid(e.movie) - ? { id: e.movie } - : { slug: e.movie } - : undefined, - serie: - "serie" in e - ? isUuid(e.serie) - ? { id: e.serie } - : { slug: e.serie } - : undefined, - }, - })); - }); + if (!vidEntries.length) { + return error( + 201, + vids.map((x) => ({ id: x.id, path: x.path, entries: [] })), + ); + } - if (!vidEntries.length) { + const entriesQ = tx + .select({ + pk: entries.pk, + id: entries.id, + slug: entries.slug, + kind: entries.kind, + seasonNumber: entries.seasonNumber, + episodeNumber: entries.episodeNumber, + order: entries.order, + showId: sql`${shows.id}`.as("showId"), + showSlug: sql`${shows.slug}`.as("showSlug"), + externalId: entries.externalId, + }) + .from(entries) + .innerJoin(shows, eq(entries.showPk, shows.pk)) + .as("entriesQ"); + + const hasRenderingQ = tx + .select() + .from(entryVideoJoin) + .where(eq(entryVideoJoin.entryPk, entriesQ.pk)); + + const ret = await tx + .insert(entryVideoJoin) + .select( + tx + .selectDistinctOn([entriesQ.pk, videos.pk], { + entryPk: entriesQ.pk, + videoPk: videos.pk, + slug: computeVideoSlug( + entriesQ.slug, + sql`exists(${hasRenderingQ})`, + ), + }) + .from( + values(vidEntries, { + video: "integer", + entry: "jsonb", + }).as("j"), + ) + .innerJoin(videos, eq(videos.pk, sql`j.video`)) + .innerJoin( + entriesQ, + or( + and( + sql`j.entry ? 'slug'`, + eq(entriesQ.slug, sql`j.entry->>'slug'`), + ), + and( + sql`j.entry ? 'movie'`, + or( + eq( + entriesQ.showId, + sql`(j.entry #>> '{movie, id}')::uuid`, + ), + eq(entriesQ.showSlug, sql`j.entry #>> '{movie, slug}'`), + ), + eq(entriesQ.kind, "movie"), + ), + and( + sql`j.entry ? 'serie'`, + or( + eq( + entriesQ.showId, + sql`(j.entry #>> '{serie, id}')::uuid`, + ), + eq(entriesQ.showSlug, sql`j.entry #>> '{serie, slug}'`), + ), + or( + and( + sql`j.entry ?& array['season', 'episode']`, + eq( + entriesQ.seasonNumber, + sql`(j.entry->>'season')::integer`, + ), + eq( + entriesQ.episodeNumber, + sql`(j.entry->>'episode')::integer`, + ), + ), + and( + sql`j.entry ? 'order'`, + eq(entriesQ.order, sql`(j.entry->>'order')::float`), + ), + and( + sql`j.entry ? 'special'`, + eq( + entriesQ.episodeNumber, + sql`(j.entry->>'special')::integer`, + ), + eq(entriesQ.kind, "special"), + ), + ), + ), + and( + sql`j.entry ? 'externalId'`, + sql`j.entry->'externalId' <@ ${entriesQ.externalId}`, + ), + ), + ), + ) + .onConflictDoNothing() + .returning({ + slug: entryVideoJoin.slug, + entryPk: entryVideoJoin.entryPk, + videoPk: entryVideoJoin.videoPk, + }); + const entr = ret.reduce( + (acc, x) => { + acc[x.videoPk] ??= []; + acc[x.videoPk].push({ slug: x.slug }); + return acc; + }, + {} as Record, + ); return error( 201, - vids.map((x) => ({ id: x.id, path: x.path, entries: [] })), + vids.map((x) => ({ + id: x.id, + path: x.path, + entries: entr[x.pk] ?? [], + })), ); - } - - const entriesQ = db - .select({ - pk: entries.pk, - id: entries.id, - slug: entries.slug, - kind: entries.kind, - seasonNumber: entries.seasonNumber, - episodeNumber: entries.episodeNumber, - order: entries.order, - showId: sql`${shows.id}`.as("showId"), - showSlug: sql`${shows.slug}`.as("showSlug"), - externalId: entries.externalId, - }) - .from(entries) - .innerJoin(shows, eq(entries.showPk, shows.pk)) - .as("entriesQ"); - - const hasRenderingQ = db - .select() - .from(entryVideoJoin) - .where(eq(entryVideoJoin.entryPk, entriesQ.pk)); - - const ret = await db - .insert(entryVideoJoin) - .select( - db - .select({ - entryPk: entriesQ.pk, - videoPk: videos.pk, - slug: computeVideoSlug( - entriesQ.slug, - sql`exists(${hasRenderingQ})`, - ), - }) - .from( - values(vidEntries, { - video: "integer", - entry: "jsonb", - }).as("j"), - ) - .innerJoin(videos, eq(videos.pk, sql`j.video`)) - .innerJoin( - entriesQ, - or( - and( - sql`j.entry ? 'slug'`, - eq(entriesQ.slug, sql`j.entry->>'slug'`), - ), - and( - sql`j.entry ? 'movie'`, - or( - eq(entriesQ.showId, sql`(j.entry #>> '{movie, id}')::uuid`), - eq(entriesQ.showSlug, sql`j.entry #>> '{movie, slug}'`), - ), - eq(entriesQ.kind, "movie"), - ), - and( - sql`j.entry ? 'serie'`, - or( - eq(entriesQ.showId, sql`(j.entry #>> '{serie, id}')::uuid`), - eq(entriesQ.showSlug, sql`j.entry #>> '{serie, slug}'`), - ), - or( - and( - sql`j.entry ?& array['season', 'episode']`, - eq( - entriesQ.seasonNumber, - sql`(j.entry->>'season')::integer`, - ), - eq( - entriesQ.episodeNumber, - sql`(j.entry->>'episode')::integer`, - ), - ), - and( - sql`j.entry ? 'order'`, - eq(entriesQ.order, sql`(j.entry->>'order')::float`), - ), - and( - sql`j.entry ? 'special'`, - eq( - entriesQ.episodeNumber, - sql`(j.entry->>'special')::integer`, - ), - eq(entriesQ.kind, "special"), - ), - ), - ), - and( - sql`j.entry ? 'externalId'`, - sql`j.entry->'externalId' <@ ${entriesQ.externalId}`, - ), - ), - ), - ) - .onConflictDoNothing() - .returning({ - slug: entryVideoJoin.slug, - entryPk: entryVideoJoin.entryPk, - videoPk: entryVideoJoin.videoPk, - }); - const entr = ret.reduce( - (acc, x) => { - acc[x.videoPk] ??= []; - acc[x.videoPk].push({ slug: x.slug }); - return acc; - }, - {} as Record, - ); - return error( - 201, - vids.map((x) => ({ - id: x.id, - path: x.path, - entries: entr[x.pk] ?? [], - })), - ); + }); }, { detail: { diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 15799dd8..15ce5fab 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,4 +1,4 @@ -import { PatternString } from "@sinclair/typebox"; +import { PatternStringExact } from "@sinclair/typebox"; import { t } from "elysia"; import { type Prettify, comment } from "~/utils"; import { ExtraType } from "./entry/extra"; @@ -9,12 +9,13 @@ const ExternalIds = t.Record( t.String(), t.Omit( t.Union([ - EpisodeId.patternProperties[PatternString], - ExternalId().patternProperties[PatternString], + EpisodeId.patternProperties[PatternStringExact], + ExternalId().patternProperties[PatternStringExact], ]), ["link"], ), ); +type ExternalIds = typeof ExternalIds.static; export const Guess = t.Recursive((Self) => t.Object( diff --git a/api/tests/videos/scanner.test.ts b/api/tests/videos/scanner.test.ts index 82624adc..989aa833 100644 --- a/api/tests/videos/scanner.test.ts +++ b/api/tests/videos/scanner.test.ts @@ -109,6 +109,38 @@ describe("Video seeding", () => { expect(vid!.evj[0].entry.slug).toBe(bubble.slug); }); + it("Conflicting path", async () => { + const [resp, body] = await createVideo({ + guess: { title: "bubble", from: "test" }, + part: null, + path: "/video/bubble.mkv", + rendering: "sha", + version: 1, + for: [{ movie: bubble.slug }], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble.mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe(bubble.slug); + expect(vid!.evj[0].entry.slug).toBe(bubble.slug); + }); + it("With season/episode", async () => { const [resp, body] = await createVideo({ guess: { title: "mia", season: [2], episode: [1], from: "test" }, @@ -229,7 +261,7 @@ describe("Video seeding", () => { episode: [3], from: "test", externalId: { - themoviedb: { serieId: "72636", season: 1, episode: 13 }, + themoviedatabase: { serieId: "72636", season: 1, episode: 13 }, }, }, part: null, @@ -239,7 +271,7 @@ describe("Video seeding", () => { for: [ { externalId: { - themoviedb: { serieId: "72636", season: 1, episode: 13 }, + themoviedatabase: { serieId: "72636", season: 1, episode: 13 }, }, }, ], @@ -273,7 +305,7 @@ describe("Video seeding", () => { title: "bubble", from: "test", externalId: { - themoviedb: { dataId: "912598", season: 1, episode: 13 }, + themoviedatabase: { dataId: "912598" }, }, }, part: null, @@ -283,7 +315,7 @@ describe("Video seeding", () => { for: [ { externalId: { - themoviedb: { serieId: "912598", season: 1, episode: 13 }, + themoviedatabase: { dataId: "912598" }, }, }, ], @@ -317,18 +349,18 @@ describe("Video seeding", () => { title: "bubble", from: "test", externalId: { - themoviedb: { dataId: "912598", season: 1, episode: 13 }, + themoviedatabase: { dataId: "912598" }, }, }, part: null, - path: "/video/bubble [tmdb=912598].mkv", - rendering: "cwhtn", + path: "/video/bubble ue [tmdb=912598].mkv", + rendering: "aoeubnht", version: 1, for: [ { movie: "bubble" }, { externalId: { - themoviedb: { serieId: "912598", season: 1, episode: 13 }, + themoviedatabase: { dataId: "912598" }, }, }, ], @@ -346,13 +378,13 @@ describe("Video seeding", () => { }); expect(vid).not.toBeNil(); - expect(vid!.path).toBe("/video/bubble [tmdb=912598].mkv"); + expect(vid!.path).toBe("/video/bubble ue [tmdb=912598].mkv"); expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); expect(body[0].entries).toBeArrayOfSize(1); expect(vid!.evj).toBeArrayOfSize(1); - expect(vid!.evj[0].slug).toBe("bubble-cwhtn"); + expect(vid!.evj[0].slug).toBe("bubble-aoeubnht"); expect(vid!.evj[0].entry.slug).toBe("bubble"); }); @@ -363,7 +395,7 @@ describe("Video seeding", () => { title: "bubble", from: "test", externalId: { - themoviedb: { dataId: "912598", season: 1, episode: 13 }, + themoviedatabase: { dataId: "912598" }, }, }, part: null, @@ -374,7 +406,7 @@ describe("Video seeding", () => { { movie: "bubble" }, { externalId: { - themoviedb: { serieId: "912598", season: 1, episode: 13 }, + themoviedatabase: { dataId: "912598" }, }, }, ], @@ -401,4 +433,120 @@ describe("Video seeding", () => { expect(vid!.evj[0].slug).toBe("bubble"); expect(vid!.evj[0].entry.slug).toBe("bubble"); }); + + it("Multi part", async () => { + await db.delete(videos); + const [resp, body] = await createVideo([ + { + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + part: 1, + path: "/video/bubble p1 [tmdb=912598].mkv", + rendering: "cwhtn", + version: 1, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + ], + }, + { + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + part: 2, + path: "/video/bubble p2 [tmdb=912598].mkv", + rendering: "cwhtn", + version: 1, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + ], + }, + ]); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(2); + expect(body[0].id).toBeString(); + expect(body[1].id).toBeString(); + expect(body[0].entries).toBeArrayOfSize(1); + expect(body[1].entries).toBeArrayOfSize(1); + + const entr = (await db.query.entries.findFirst({ + where: eq(entries.slug, bubble.slug), + with: { + evj: { with: { video: true } }, + }, + }))!; + + expect(entr.evj).toBeArrayOfSize(2); + expect(entr.evj[0].video.path).toBe("/video/bubble p1 [tmdb=912598].mkv"); + + expect(entr.evj[0].slug).toBe("bubble-p1"); + expect(entr.evj[1].slug).toBe("bubble-p2"); + }); + + it("Multi entry", async () => { + await db.delete(videos); + const [resp, body] = await createVideo({ + guess: { + title: "mia", + season: [1, 2], + episode: [13, 1], + from: "test", + }, + part: null, + path: "/video/mia s1e13 & s2e1 [tmdb=72636].mkv", + rendering: "notehu", + version: 1, + for: [ + { serie: madeInAbyss.slug, season: 1, episode: 13 }, + { + externalId: { + themoviedatabase: { serieId: "72636", season: 1, episode: 13 }, + }, + }, + { serie: madeInAbyss.slug, season: 2, episode: 1 }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia s1e13 & s2e1 [tmdb=72636].mkv"); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(2); + expect(vid!.evj).toBeArrayOfSize(2); + + expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13"); + expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-s1e13"); + expect(vid!.evj[1].slug).toBe("made-in-abyss-s2e1"); + expect(vid!.evj[1].entry.slug).toBe("made-in-abyss-s2e1"); + }); });