Put POST /videos in a transaction, handle dups

This commit is contained in:
Zoe Roux 2025-05-02 12:47:27 +02:00
parent 379765b28f
commit 466b67afe5
No known key found for this signature in database
3 changed files with 326 additions and 169 deletions

View File

@ -160,166 +160,174 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
.post( .post(
"", "",
async ({ body, error }) => { async ({ body, error }) => {
const vids = await db return await db.transaction(async (tx) => {
.insert(videos) const vids = await tx
.values(body) .insert(videos)
.onConflictDoUpdate({ .values(body)
target: [videos.path], .onConflictDoUpdate({
set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), target: [videos.path],
}) set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]),
.returning({ })
pk: videos.pk, .returning({
id: videos.id, pk: videos.pk,
path: videos.path, 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 (!vidEntries.length) {
if (!x.for) return []; return error(
return x.for.map((e) => ({ 201,
video: vids.find((v) => v.path === x.path)!.pk, vids.map((x) => ({ id: x.id, path: x.path, entries: [] })),
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) { 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<number, { slug: string }[]>,
);
return error( return error(
201, 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<number, { slug: string }[]>,
);
return error(
201,
vids.map((x) => ({
id: x.id,
path: x.path,
entries: entr[x.pk] ?? [],
})),
);
}, },
{ {
detail: { detail: {

View File

@ -1,4 +1,4 @@
import { PatternString } from "@sinclair/typebox"; import { PatternStringExact } from "@sinclair/typebox";
import { t } from "elysia"; import { t } from "elysia";
import { type Prettify, comment } from "~/utils"; import { type Prettify, comment } from "~/utils";
import { ExtraType } from "./entry/extra"; import { ExtraType } from "./entry/extra";
@ -9,12 +9,13 @@ const ExternalIds = t.Record(
t.String(), t.String(),
t.Omit( t.Omit(
t.Union([ t.Union([
EpisodeId.patternProperties[PatternString], EpisodeId.patternProperties[PatternStringExact],
ExternalId().patternProperties[PatternString], ExternalId().patternProperties[PatternStringExact],
]), ]),
["link"], ["link"],
), ),
); );
type ExternalIds = typeof ExternalIds.static;
export const Guess = t.Recursive((Self) => export const Guess = t.Recursive((Self) =>
t.Object( t.Object(

View File

@ -109,6 +109,38 @@ describe("Video seeding", () => {
expect(vid!.evj[0].entry.slug).toBe(bubble.slug); 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 () => { it("With season/episode", async () => {
const [resp, body] = await createVideo({ const [resp, body] = await createVideo({
guess: { title: "mia", season: [2], episode: [1], from: "test" }, guess: { title: "mia", season: [2], episode: [1], from: "test" },
@ -229,7 +261,7 @@ describe("Video seeding", () => {
episode: [3], episode: [3],
from: "test", from: "test",
externalId: { externalId: {
themoviedb: { serieId: "72636", season: 1, episode: 13 }, themoviedatabase: { serieId: "72636", season: 1, episode: 13 },
}, },
}, },
part: null, part: null,
@ -239,7 +271,7 @@ describe("Video seeding", () => {
for: [ for: [
{ {
externalId: { externalId: {
themoviedb: { serieId: "72636", season: 1, episode: 13 }, themoviedatabase: { serieId: "72636", season: 1, episode: 13 },
}, },
}, },
], ],
@ -273,7 +305,7 @@ describe("Video seeding", () => {
title: "bubble", title: "bubble",
from: "test", from: "test",
externalId: { externalId: {
themoviedb: { dataId: "912598", season: 1, episode: 13 }, themoviedatabase: { dataId: "912598" },
}, },
}, },
part: null, part: null,
@ -283,7 +315,7 @@ describe("Video seeding", () => {
for: [ for: [
{ {
externalId: { externalId: {
themoviedb: { serieId: "912598", season: 1, episode: 13 }, themoviedatabase: { dataId: "912598" },
}, },
}, },
], ],
@ -317,18 +349,18 @@ describe("Video seeding", () => {
title: "bubble", title: "bubble",
from: "test", from: "test",
externalId: { externalId: {
themoviedb: { dataId: "912598", season: 1, episode: 13 }, themoviedatabase: { dataId: "912598" },
}, },
}, },
part: null, part: null,
path: "/video/bubble [tmdb=912598].mkv", path: "/video/bubble ue [tmdb=912598].mkv",
rendering: "cwhtn", rendering: "aoeubnht",
version: 1, version: 1,
for: [ for: [
{ movie: "bubble" }, { movie: "bubble" },
{ {
externalId: { externalId: {
themoviedb: { serieId: "912598", season: 1, episode: 13 }, themoviedatabase: { dataId: "912598" },
}, },
}, },
], ],
@ -346,13 +378,13 @@ describe("Video seeding", () => {
}); });
expect(vid).not.toBeNil(); 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(vid!.guess).toMatchObject({ title: "bubble", from: "test" });
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("bubble-cwhtn"); expect(vid!.evj[0].slug).toBe("bubble-aoeubnht");
expect(vid!.evj[0].entry.slug).toBe("bubble"); expect(vid!.evj[0].entry.slug).toBe("bubble");
}); });
@ -363,7 +395,7 @@ describe("Video seeding", () => {
title: "bubble", title: "bubble",
from: "test", from: "test",
externalId: { externalId: {
themoviedb: { dataId: "912598", season: 1, episode: 13 }, themoviedatabase: { dataId: "912598" },
}, },
}, },
part: null, part: null,
@ -374,7 +406,7 @@ describe("Video seeding", () => {
{ movie: "bubble" }, { movie: "bubble" },
{ {
externalId: { 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].slug).toBe("bubble");
expect(vid!.evj[0].entry.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");
});
}); });