Add unique constraint on [rendering, version, part]

This commit is contained in:
Zoe Roux 2025-05-02 17:29:51 +02:00
parent 466b67afe5
commit 45e769828b
No known key found for this signature in database
8 changed files with 1935 additions and 21 deletions

View File

@ -0,0 +1,5 @@
ALTER TABLE "kyoo"."entries" ALTER COLUMN "kind" SET DATA TYPE text;--> statement-breakpoint
DROP TYPE "kyoo"."entry_type";--> statement-breakpoint
CREATE TYPE "kyoo"."entry_type" AS ENUM('episode', 'movie', 'special', 'extra');--> statement-breakpoint
ALTER TABLE "kyoo"."entries" ALTER COLUMN "kind" SET DATA TYPE "kyoo"."entry_type" USING "kind"::"kyoo"."entry_type";--> statement-breakpoint
ALTER TABLE "kyoo"."videos" ADD CONSTRAINT "rendering_unique" UNIQUE NULLS NOT DISTINCT("rendering","part","version");

File diff suppressed because it is too large Load Diff

View File

@ -141,6 +141,13 @@
"when": 1744120518941, "when": 1744120518941,
"tag": "0019_nextup", "tag": "0019_nextup",
"breakpoints": true "breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1746198322219,
"tag": "0020_video_unique",
"breakpoints": true
} }
] ]
} }

View File

@ -5,6 +5,7 @@ import { db } from "~/db";
import { entries, entryVideoJoin, shows, videos } from "~/db/schema"; import { entries, entryVideoJoin, shows, videos } from "~/db/schema";
import { import {
conflictUpdateAllExcept, conflictUpdateAllExcept,
isUniqueConstraint,
jsonbBuildObject, jsonbBuildObject,
jsonbObjectAgg, jsonbObjectAgg,
values, values,
@ -161,18 +162,33 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
"", "",
async ({ body, error }) => { async ({ body, error }) => {
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const vids = await tx let vids: { pk: number; id: string; path: string }[] = [];
.insert(videos) try {
.values(body) vids = await tx
.onConflictDoUpdate({ .insert(videos)
target: [videos.path], .values(body)
set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), .onConflictDoUpdate({
}) target: [videos.path],
.returning({ set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]),
pk: videos.pk, })
id: videos.id, .returning({
path: videos.path, pk: videos.pk,
id: videos.id,
path: videos.path,
});
} catch (e) {
if (!isUniqueConstraint(e))
throw e;
return error(409, {
status: 409,
message: comment`
Invalid rendering. A video with the same (rendering, part, version) combo
(but with a different path) already exists in db.
rendering should be computed by the sha of your path (excluding only the version & part numbers)
`,
}); });
}
const vidEntries = body.flatMap((x) => { const vidEntries = body.flatMap((x) => {
if (!x.for) return []; if (!x.for) return [];
@ -305,7 +321,11 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
), ),
), ),
) )
.onConflictDoNothing() .onConflictDoUpdate({
target: [entryVideoJoin.entryPk, entryVideoJoin.videoPk],
// this is basically a `.onConflictDoNothing()` but we want `returning` to give us the existing data
set: { entryPk: sql`excluded.entry_pk` },
})
.returning({ .returning({
slug: entryVideoJoin.slug, slug: entryVideoJoin.slug,
entryPk: entryVideoJoin.entryPk, entryPk: entryVideoJoin.entryPk,
@ -340,7 +360,14 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
`, `,
}, },
body: t.Array(SeedVideo), body: t.Array(SeedVideo),
response: { 201: t.Array(CreatedVideo) }, response: {
201: t.Array(CreatedVideo),
409: {
...KError,
description:
"Invalid rendering specified. (conflicts with an existing video)",
},
},
}, },
) )
.delete( .delete(

View File

@ -6,6 +6,7 @@ import {
primaryKey, primaryKey,
text, text,
timestamp, timestamp,
unique,
uuid, uuid,
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
@ -34,6 +35,9 @@ export const videos = schema.table(
(t) => [ (t) => [
check("part_pos", sql`${t.part} >= 0`), check("part_pos", sql`${t.part} >= 0`),
check("version_pos", sql`${t.version} >= 0`), check("version_pos", sql`${t.version} >= 0`),
unique("rendering_unique")
.on(t.rendering, t.part, t.version)
.nullsNotDistinct(),
], ],
); );

View File

@ -142,3 +142,7 @@ export const jsonbBuildObject = <T>(select: JsonFields) => {
); );
return sql<T>`jsonb_build_object(${query})`; return sql<T>`jsonb_build_object(${query})`;
}; };
export const isUniqueConstraint = (e: unknown): boolean => {
return typeof e === "object" && e != null && "code" in e && e.code === "23505";
};

View File

@ -384,7 +384,7 @@ describe("Movie seeding", () => {
path: "/video/bubble3.mkv", path: "/video/bubble3.mkv",
part: null, part: null,
version: 1, version: 1,
rendering: "oeunhtoeuth", rendering: "oeunhtoeuthoeu",
guess: { title: "bubble", from: "test" }, guess: { title: "bubble", from: "test" },
}, },
{ {

View File

@ -50,7 +50,7 @@ describe("Video seeding", () => {
guess: { title: "mia", season: [1], episode: [13], from: "test" }, guess: { title: "mia", season: [1], episode: [13], from: "test" },
part: null, part: null,
path: "/video/mia s1e13.mkv", path: "/video/mia s1e13.mkv",
rendering: "sha", rendering: "sha2",
version: 1, version: 1,
for: [{ slug: `${madeInAbyss.slug}-s1e13` }], for: [{ slug: `${madeInAbyss.slug}-s1e13` }],
}); });
@ -82,7 +82,7 @@ describe("Video seeding", () => {
guess: { title: "bubble", from: "test" }, guess: { title: "bubble", from: "test" },
part: null, part: null,
path: "/video/bubble.mkv", path: "/video/bubble.mkv",
rendering: "sha", rendering: "sha3",
version: 1, version: 1,
for: [{ movie: bubble.slug }], for: [{ movie: bubble.slug }],
}); });
@ -114,7 +114,7 @@ describe("Video seeding", () => {
guess: { title: "bubble", from: "test" }, guess: { title: "bubble", from: "test" },
part: null, part: null,
path: "/video/bubble.mkv", path: "/video/bubble.mkv",
rendering: "sha", rendering: "sha4",
version: 1, version: 1,
for: [{ movie: bubble.slug }], for: [{ movie: bubble.slug }],
}); });
@ -221,7 +221,7 @@ describe("Video seeding", () => {
guess: { title: "mia", season: [0], episode: [3], from: "test" }, guess: { title: "mia", season: [0], episode: [3], from: "test" },
part: null, part: null,
path: "/video/mia 13.5.mkv", path: "/video/mia 13.5.mkv",
rendering: "notehu", rendering: "notehu2",
version: 1, version: 1,
for: [ for: [
{ {
@ -266,7 +266,7 @@ describe("Video seeding", () => {
}, },
part: null, part: null,
path: "/video/mia s1e13 [tmdb=72636].mkv", path: "/video/mia s1e13 [tmdb=72636].mkv",
rendering: "notehu", rendering: "notehu3",
version: 1, version: 1,
for: [ for: [
{ {
@ -295,7 +295,7 @@ describe("Video seeding", () => {
expect(body[0].entries).toBeArrayOfSize(1); expect(body[0].entries).toBeArrayOfSize(1);
expect(vid!.evj).toBeArrayOfSize(1); expect(vid!.evj).toBeArrayOfSize(1);
expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13-notehu"); expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13-notehu3");
expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-s1e13"); expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-s1e13");
}); });
@ -343,6 +343,22 @@ describe("Video seeding", () => {
expect(vid!.evj[0].entry.slug).toBe("bubble"); expect(vid!.evj[0].entry.slug).toBe("bubble");
}); });
it("Different path, same sha", async () => {
const [resp, body] = await createVideo({
guess: { title: "bubble", from: "test" },
part: null,
path: "/video/bubble invalid-sha.mkv",
rendering: "sha",
version: 1,
for: [{ movie: bubble.slug }],
});
// conflict with existing video, message will contain an explanation on how to fix this
expectStatus(resp, body).toBe(409);
expect(body.message).toBeString();
});
it("Two for the same entry", async () => { it("Two for the same entry", async () => {
const [resp, body] = await createVideo({ const [resp, body] = await createVideo({
guess: { guess: {
@ -513,7 +529,7 @@ describe("Video seeding", () => {
}, },
part: null, part: null,
path: "/video/mia s1e13 & s2e1 [tmdb=72636].mkv", path: "/video/mia s1e13 & s2e1 [tmdb=72636].mkv",
rendering: "notehu", rendering: "notehu5",
version: 1, version: 1,
for: [ for: [
{ serie: madeInAbyss.slug, season: 1, episode: 13 }, { serie: madeInAbyss.slug, season: 1, episode: 13 },