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,
"tag": "0019_nextup",
"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 {
conflictUpdateAllExcept,
isUniqueConstraint,
jsonbBuildObject,
jsonbObjectAgg,
values,
@ -161,7 +162,9 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
"",
async ({ body, error }) => {
return await db.transaction(async (tx) => {
const vids = await tx
let vids: { pk: number; id: string; path: string }[] = [];
try {
vids = await tx
.insert(videos)
.values(body)
.onConflictDoUpdate({
@ -173,6 +176,19 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
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) => {
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({
slug: entryVideoJoin.slug,
entryPk: entryVideoJoin.entryPk,
@ -340,7 +360,14 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
`,
},
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(

View File

@ -6,6 +6,7 @@ import {
primaryKey,
text,
timestamp,
unique,
uuid,
varchar,
} from "drizzle-orm/pg-core";
@ -34,6 +35,9 @@ export const videos = schema.table(
(t) => [
check("part_pos", sql`${t.part} >= 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})`;
};
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",
part: null,
version: 1,
rendering: "oeunhtoeuth",
rendering: "oeunhtoeuthoeu",
guess: { title: "bubble", from: "test" },
},
{

View File

@ -50,7 +50,7 @@ describe("Video seeding", () => {
guess: { title: "mia", season: [1], episode: [13], from: "test" },
part: null,
path: "/video/mia s1e13.mkv",
rendering: "sha",
rendering: "sha2",
version: 1,
for: [{ slug: `${madeInAbyss.slug}-s1e13` }],
});
@ -82,7 +82,7 @@ describe("Video seeding", () => {
guess: { title: "bubble", from: "test" },
part: null,
path: "/video/bubble.mkv",
rendering: "sha",
rendering: "sha3",
version: 1,
for: [{ movie: bubble.slug }],
});
@ -114,7 +114,7 @@ describe("Video seeding", () => {
guess: { title: "bubble", from: "test" },
part: null,
path: "/video/bubble.mkv",
rendering: "sha",
rendering: "sha4",
version: 1,
for: [{ movie: bubble.slug }],
});
@ -221,7 +221,7 @@ describe("Video seeding", () => {
guess: { title: "mia", season: [0], episode: [3], from: "test" },
part: null,
path: "/video/mia 13.5.mkv",
rendering: "notehu",
rendering: "notehu2",
version: 1,
for: [
{
@ -266,7 +266,7 @@ describe("Video seeding", () => {
},
part: null,
path: "/video/mia s1e13 [tmdb=72636].mkv",
rendering: "notehu",
rendering: "notehu3",
version: 1,
for: [
{
@ -295,7 +295,7 @@ describe("Video seeding", () => {
expect(body[0].entries).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");
});
@ -343,6 +343,22 @@ describe("Video seeding", () => {
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 () => {
const [resp, body] = await createVideo({
guess: {
@ -513,7 +529,7 @@ describe("Video seeding", () => {
},
part: null,
path: "/video/mia s1e13 & s2e1 [tmdb=72636].mkv",
rendering: "notehu",
rendering: "notehu5",
version: 1,
for: [
{ serie: madeInAbyss.slug, season: 1, episode: 13 },