mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-03-09 19:33:38 -04:00
Split video controller in 3
This commit is contained in:
parent
c11260c767
commit
1cfbe931c2
@ -8,13 +8,15 @@ import { nextup } from "./controllers/profiles/nextup";
|
||||
import { watchlistH } from "./controllers/profiles/watchlist";
|
||||
import { seasonsH } from "./controllers/seasons";
|
||||
import { seed } from "./controllers/seed";
|
||||
import { videosWriteH } from "./controllers/seed/videos";
|
||||
import { collections } from "./controllers/shows/collections";
|
||||
import { movies } from "./controllers/shows/movies";
|
||||
import { series } from "./controllers/shows/series";
|
||||
import { showsH } from "./controllers/shows/shows";
|
||||
import { staffH } from "./controllers/staff";
|
||||
import { studiosH } from "./controllers/studios";
|
||||
import { videosReadH, videosWriteH } from "./controllers/videos";
|
||||
import { videosMetadata } from "./controllers/video-metadata";
|
||||
import { videosReadH } from "./controllers/videos";
|
||||
import { dbRaw } from "./db";
|
||||
import { KError } from "./models/error";
|
||||
import { appWs } from "./websockets";
|
||||
@ -124,7 +126,8 @@ export const handlers = new Elysia({ prefix })
|
||||
.use(watchlistH)
|
||||
.use(historyH)
|
||||
.use(nextup)
|
||||
.use(videosReadH),
|
||||
.use(videosReadH)
|
||||
.use(videosMetadata),
|
||||
)
|
||||
.guard(
|
||||
{
|
||||
|
||||
419
api/src/controllers/seed/videos.ts
Normal file
419
api/src/controllers/seed/videos.ts
Normal file
@ -0,0 +1,419 @@
|
||||
import { and, eq, gt, ne, notExists, or, sql } from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { auth } from "~/auth";
|
||||
import { db, type Transaction } from "~/db";
|
||||
import { entries, entryVideoJoin, shows, videos } from "~/db/schema";
|
||||
import {
|
||||
conflictUpdateAllExcept,
|
||||
isUniqueConstraint,
|
||||
sqlarr,
|
||||
unnest,
|
||||
unnestValues,
|
||||
} from "~/db/utils";
|
||||
import { KError } from "~/models/error";
|
||||
import { bubbleVideo } from "~/models/examples";
|
||||
import { isUuid } from "~/models/utils";
|
||||
import { Guess, SeedVideo, Video } from "~/models/video";
|
||||
import { comment } from "~/utils";
|
||||
import { computeVideoSlug } from "./insert/entries";
|
||||
import { updateAvailableCount, updateAvailableSince } from "./insert/shows";
|
||||
|
||||
async function linkVideos(
|
||||
tx: Transaction,
|
||||
links: {
|
||||
video: number;
|
||||
entry: Omit<SeedVideo["for"], "movie" | "serie"> & {
|
||||
movie?: { id?: string; slug?: string };
|
||||
serie?: { id?: string; slug?: string };
|
||||
};
|
||||
}[],
|
||||
) {
|
||||
if (!links.length) return {};
|
||||
|
||||
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 renderVid = alias(videos, "renderVid");
|
||||
const hasRenderingQ = or(
|
||||
gt(
|
||||
sql`dense_rank() over (partition by ${entriesQ.pk} order by ${videos.rendering})`,
|
||||
1,
|
||||
),
|
||||
sql`exists(${tx
|
||||
.select()
|
||||
.from(entryVideoJoin)
|
||||
.innerJoin(renderVid, eq(renderVid.pk, entryVideoJoin.videoPk))
|
||||
.where(
|
||||
and(
|
||||
eq(entryVideoJoin.entryPk, entriesQ.pk),
|
||||
ne(renderVid.rendering, videos.rendering),
|
||||
),
|
||||
)})`,
|
||||
)!;
|
||||
|
||||
const ret = await tx
|
||||
.insert(entryVideoJoin)
|
||||
.select(
|
||||
tx
|
||||
.selectDistinctOn([entriesQ.pk, videos.pk], {
|
||||
entryPk: entriesQ.pk,
|
||||
videoPk: videos.pk,
|
||||
slug: computeVideoSlug(entriesQ.slug, hasRenderingQ),
|
||||
})
|
||||
.from(
|
||||
unnest(links, "j", {
|
||||
video: "integer",
|
||||
entry: "jsonb",
|
||||
}),
|
||||
)
|
||||
.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}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.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,
|
||||
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 }[]>,
|
||||
);
|
||||
|
||||
const entriesPk = [...new Set(ret.map((x) => x.entryPk))];
|
||||
await updateAvailableCount(
|
||||
tx,
|
||||
tx
|
||||
.selectDistinct({ pk: entries.showPk })
|
||||
.from(entries)
|
||||
.where(eq(entries.pk, sql`any(${sqlarr(entriesPk)})`)),
|
||||
);
|
||||
await updateAvailableSince(tx, entriesPk);
|
||||
|
||||
return entr;
|
||||
}
|
||||
|
||||
const CreatedVideo = t.Object({
|
||||
id: t.String({ format: "uuid" }),
|
||||
path: t.String({ examples: [bubbleVideo.path] }),
|
||||
guess: t.Omit(Guess, ["history"]),
|
||||
entries: t.Array(
|
||||
t.Object({
|
||||
slug: t.String({ format: "slug", examples: ["bubble-v2"] }),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const videosWriteH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
||||
.model({
|
||||
video: Video,
|
||||
"created-videos": t.Array(CreatedVideo),
|
||||
error: t.Object({}),
|
||||
})
|
||||
.use(auth)
|
||||
.post(
|
||||
"",
|
||||
async ({ body, status }) => {
|
||||
if (body.length === 0) {
|
||||
return status(422, { status: 422, message: "No videos" });
|
||||
}
|
||||
return await db.transaction(async (tx) => {
|
||||
let vids: { pk: number; id: string; path: string; guess: Guess }[] = [];
|
||||
try {
|
||||
vids = await tx
|
||||
.insert(videos)
|
||||
.select(unnestValues(body, videos))
|
||||
.onConflictDoUpdate({
|
||||
target: [videos.path],
|
||||
set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]),
|
||||
})
|
||||
.returning({
|
||||
pk: videos.pk,
|
||||
id: videos.id,
|
||||
path: videos.path,
|
||||
guess: videos.guess,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isUniqueConstraint(e)) throw e;
|
||||
return status(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 [];
|
||||
return x.for.map((e) => ({
|
||||
video: vids.find((v) => v.path === x.path)!.pk,
|
||||
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 status(
|
||||
201,
|
||||
vids.map((x) => ({
|
||||
id: x.id,
|
||||
path: x.path,
|
||||
guess: x.guess,
|
||||
entries: [],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const links = await linkVideos(tx, vidEntries);
|
||||
|
||||
return status(
|
||||
201,
|
||||
vids.map((x) => ({
|
||||
id: x.id,
|
||||
path: x.path,
|
||||
guess: x.guess,
|
||||
entries: links[x.pk] ?? [],
|
||||
})),
|
||||
);
|
||||
});
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: comment`
|
||||
Create videos in bulk.
|
||||
Duplicated videos will simply be ignored.
|
||||
|
||||
If a videos has a \`guess\` field, it will be used to automatically register the video under an existing
|
||||
movie or entry.
|
||||
`,
|
||||
},
|
||||
body: t.Array(SeedVideo),
|
||||
response: {
|
||||
201: t.Array(CreatedVideo),
|
||||
409: {
|
||||
...KError,
|
||||
description:
|
||||
"Invalid rendering specified. (conflicts with an existing video)",
|
||||
},
|
||||
422: KError,
|
||||
},
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"",
|
||||
async ({ body }) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
const vids = tx.$with("vids").as(
|
||||
tx
|
||||
.delete(videos)
|
||||
.where(eq(videos.path, sql`any(${sqlarr(body)})`))
|
||||
.returning({ pk: videos.pk, path: videos.path }),
|
||||
);
|
||||
|
||||
const deletedJoin = await tx
|
||||
.with(vids)
|
||||
.select({ entryPk: entryVideoJoin.entryPk, path: vids.path })
|
||||
.from(entryVideoJoin)
|
||||
.rightJoin(vids, eq(vids.pk, entryVideoJoin.videoPk));
|
||||
|
||||
const delEntries = await tx
|
||||
.update(entries)
|
||||
.set({ availableSince: null })
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
entries.pk,
|
||||
sql`any(${sqlarr(
|
||||
deletedJoin.filter((x) => x.entryPk).map((x) => x.entryPk!),
|
||||
)})`,
|
||||
),
|
||||
notExists(
|
||||
tx
|
||||
.select()
|
||||
.from(entryVideoJoin)
|
||||
.where(eq(entries.pk, entryVideoJoin.entryPk)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning({ show: entries.showPk });
|
||||
|
||||
await updateAvailableCount(
|
||||
tx,
|
||||
delEntries.map((x) => x.show),
|
||||
false,
|
||||
);
|
||||
|
||||
return [...new Set(deletedJoin.map((x) => x.path))];
|
||||
});
|
||||
},
|
||||
{
|
||||
detail: { description: "Delete videos in bulk." },
|
||||
body: t.Array(
|
||||
t.String({
|
||||
description: "Path of the video to delete",
|
||||
examples: [bubbleVideo.path],
|
||||
}),
|
||||
),
|
||||
response: { 200: t.Array(t.String()) },
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/link",
|
||||
async ({ body, status }) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
const vids = await tx
|
||||
.select({ pk: videos.pk, id: videos.id, path: videos.path })
|
||||
.from(videos)
|
||||
.where(eq(videos.id, sql`any(${sqlarr(body.map((x) => x.id))})`));
|
||||
const lVids = body.flatMap((x) => {
|
||||
return x.for.map((e) => ({
|
||||
video: vids.find((v) => v.id === x.id)!.pk,
|
||||
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 links = await linkVideos(tx, lVids);
|
||||
return status(
|
||||
201,
|
||||
vids.map((x) => ({
|
||||
id: x.id,
|
||||
path: x.path,
|
||||
entries: links[x.pk] ?? [],
|
||||
})),
|
||||
);
|
||||
});
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Link existing videos to existing entries",
|
||||
},
|
||||
body: t.Array(
|
||||
t.Object({
|
||||
id: t.String({
|
||||
description: "Id of the video",
|
||||
format: "uuid",
|
||||
}),
|
||||
for: t.Array(SeedVideo.properties.for.items),
|
||||
}),
|
||||
),
|
||||
response: {
|
||||
201: t.Array(
|
||||
t.Object({
|
||||
id: t.String({ format: "uuid" }),
|
||||
path: t.String({ examples: ["/video/made in abyss s1e13.mkv"] }),
|
||||
entries: t.Array(
|
||||
t.Object({
|
||||
slug: t.String({
|
||||
format: "slug",
|
||||
examples: ["made-in-abyss-s1e13"],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
422: KError,
|
||||
},
|
||||
},
|
||||
);
|
||||
188
api/src/controllers/video-metadata.ts
Normal file
188
api/src/controllers/video-metadata.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { auth } from "~/auth";
|
||||
import { db } from "~/db";
|
||||
import { entryVideoJoin, videos } from "~/db/schema";
|
||||
import { KError } from "~/models/error";
|
||||
import { isUuid } from "~/models/utils";
|
||||
import { Video } from "~/models/video";
|
||||
|
||||
export const videosMetadata = new Elysia({
|
||||
prefix: "/videos",
|
||||
tags: ["videos"],
|
||||
})
|
||||
.model({
|
||||
video: Video,
|
||||
error: t.Object({}),
|
||||
})
|
||||
.use(auth)
|
||||
.get(
|
||||
":id/info",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
return redirect(`/video/${path}/info`);
|
||||
},
|
||||
{
|
||||
detail: { description: "Get a video's metadata informations" },
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to retrieve.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/info](?api=transcoder#tag/metadata/get/:path/info) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/thumbnails.vtt",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
return redirect(`/video/${path}/thumbnails.vtt`);
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Get redirected to the direct stream of the video",
|
||||
},
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to watch.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/direct",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
const filename = path.substring(path.lastIndexOf("/") + 1);
|
||||
return redirect(`/video/${path}/direct/${filename}`);
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Get redirected to the direct stream of the video",
|
||||
},
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to watch.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/master.m3u8",
|
||||
async ({ params: { id }, request, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
const query = request.url.substring(request.url.indexOf("?"));
|
||||
return redirect(`/video/${path}/master.m3u8${query}`);
|
||||
},
|
||||
{
|
||||
detail: { description: "Get redirected to the master.m3u8 of the video" },
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to watch.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/master.m3u8](?api=transcoder#tag/metadata/get/:path/master.m3u8) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -7,7 +7,6 @@ import {
|
||||
lt,
|
||||
max,
|
||||
min,
|
||||
ne,
|
||||
notExists,
|
||||
or,
|
||||
sql,
|
||||
@ -15,7 +14,7 @@ import {
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { auth } from "~/auth";
|
||||
import { db, type Transaction } from "~/db";
|
||||
import { db } from "~/db";
|
||||
import {
|
||||
entries,
|
||||
entryVideoJoin,
|
||||
@ -28,19 +27,14 @@ import {
|
||||
import { watchlist } from "~/db/schema/watchlist";
|
||||
import {
|
||||
coalesce,
|
||||
conflictUpdateAllExcept,
|
||||
getColumns,
|
||||
isUniqueConstraint,
|
||||
jsonbAgg,
|
||||
jsonbBuildObject,
|
||||
jsonbObjectAgg,
|
||||
sqlarr,
|
||||
unnest,
|
||||
unnestValues,
|
||||
} from "~/db/utils";
|
||||
import { Entry } from "~/models/entry";
|
||||
import { KError } from "~/models/error";
|
||||
import { bubbleVideo } from "~/models/examples";
|
||||
import { Progress } from "~/models/history";
|
||||
import { Movie, type MovieStatus } from "~/models/movie";
|
||||
import { Serie } from "~/models/serie";
|
||||
@ -58,178 +52,14 @@ import {
|
||||
sortToSql,
|
||||
} from "~/models/utils";
|
||||
import { desc as description } from "~/models/utils/descriptions";
|
||||
import { Guess, Guesses, SeedVideo, Video } from "~/models/video";
|
||||
import { Guesses, Video } from "~/models/video";
|
||||
import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
|
||||
import { comment } from "~/utils";
|
||||
import {
|
||||
entryProgressQ,
|
||||
entryVideosQ,
|
||||
getEntryTransQ,
|
||||
mapProgress,
|
||||
} from "./entries";
|
||||
import { computeVideoSlug } from "./seed/insert/entries";
|
||||
import {
|
||||
updateAvailableCount,
|
||||
updateAvailableSince,
|
||||
} from "./seed/insert/shows";
|
||||
|
||||
async function linkVideos(
|
||||
tx: Transaction,
|
||||
links: {
|
||||
video: number;
|
||||
entry: Omit<SeedVideo["for"], "movie" | "serie"> & {
|
||||
movie?: { id?: string; slug?: string };
|
||||
serie?: { id?: string; slug?: string };
|
||||
};
|
||||
}[],
|
||||
) {
|
||||
if (!links.length) return {};
|
||||
|
||||
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 renderVid = alias(videos, "renderVid");
|
||||
const hasRenderingQ = or(
|
||||
gt(
|
||||
sql`dense_rank() over (partition by ${entriesQ.pk} order by ${videos.rendering})`,
|
||||
1,
|
||||
),
|
||||
sql`exists(${tx
|
||||
.select()
|
||||
.from(entryVideoJoin)
|
||||
.innerJoin(renderVid, eq(renderVid.pk, entryVideoJoin.videoPk))
|
||||
.where(
|
||||
and(
|
||||
eq(entryVideoJoin.entryPk, entriesQ.pk),
|
||||
ne(renderVid.rendering, videos.rendering),
|
||||
),
|
||||
)})`,
|
||||
)!;
|
||||
|
||||
const ret = await tx
|
||||
.insert(entryVideoJoin)
|
||||
.select(
|
||||
tx
|
||||
.selectDistinctOn([entriesQ.pk, videos.pk], {
|
||||
entryPk: entriesQ.pk,
|
||||
videoPk: videos.pk,
|
||||
slug: computeVideoSlug(entriesQ.slug, hasRenderingQ),
|
||||
})
|
||||
.from(
|
||||
unnest(links, "j", {
|
||||
video: "integer",
|
||||
entry: "jsonb",
|
||||
}),
|
||||
)
|
||||
.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}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.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,
|
||||
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 }[]>,
|
||||
);
|
||||
|
||||
const entriesPk = [...new Set(ret.map((x) => x.entryPk))];
|
||||
await updateAvailableCount(
|
||||
tx,
|
||||
tx
|
||||
.selectDistinct({ pk: entries.showPk })
|
||||
.from(entries)
|
||||
.where(eq(entries.pk, sql`any(${sqlarr(entriesPk)})`)),
|
||||
);
|
||||
await updateAvailableSince(tx, entriesPk);
|
||||
|
||||
return entr;
|
||||
}
|
||||
|
||||
const CreatedVideo = t.Object({
|
||||
id: t.String({ format: "uuid" }),
|
||||
path: t.String({ examples: [bubbleVideo.path] }),
|
||||
guess: t.Omit(Guess, ["history"]),
|
||||
entries: t.Array(
|
||||
t.Object({
|
||||
slug: t.String({ format: "slug", examples: ["bubble-v2"] }),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const videoRelations = {
|
||||
slugs: () => {
|
||||
@ -564,177 +394,7 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/info",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
return redirect(`/video/${path}/info`);
|
||||
},
|
||||
{
|
||||
detail: { description: "Get a video's metadata informations" },
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to retrieve.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/info](?api=transcoder#tag/metadata/get/:path/info) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/thumbnails.vtt",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
return redirect(`/video/${path}/thumbnails.vtt`);
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Get redirected to the direct stream of the video",
|
||||
},
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to watch.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/direct",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
const filename = path.substring(path.lastIndexOf("/") + 1);
|
||||
return redirect(`/video/${path}/direct/${filename}`);
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Get redirected to the direct stream of the video",
|
||||
},
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to watch.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/master.m3u8",
|
||||
async ({ params: { id }, request, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
const query = request.url.substring(request.url.indexOf("?"));
|
||||
return redirect(`/video/${path}/master.m3u8${query}`);
|
||||
},
|
||||
{
|
||||
detail: { description: "Get redirected to the master.m3u8 of the video" },
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to watch.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/master.m3u8](?api=transcoder#tag/metadata/get/:path/master.m3u8) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"",
|
||||
"guesses",
|
||||
async () => {
|
||||
const years = db.$with("years").as(
|
||||
db
|
||||
@ -853,244 +513,3 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const videosWriteH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
||||
.model({
|
||||
video: Video,
|
||||
"created-videos": t.Array(CreatedVideo),
|
||||
error: t.Object({}),
|
||||
})
|
||||
.use(auth)
|
||||
.post(
|
||||
"",
|
||||
async ({ body, status }) => {
|
||||
if (body.length === 0) {
|
||||
return status(422, { status: 422, message: "No videos" });
|
||||
}
|
||||
return await db.transaction(async (tx) => {
|
||||
let vids: { pk: number; id: string; path: string; guess: Guess }[] = [];
|
||||
try {
|
||||
vids = await tx
|
||||
.insert(videos)
|
||||
.select(unnestValues(body, videos))
|
||||
.onConflictDoUpdate({
|
||||
target: [videos.path],
|
||||
set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]),
|
||||
})
|
||||
.returning({
|
||||
pk: videos.pk,
|
||||
id: videos.id,
|
||||
path: videos.path,
|
||||
guess: videos.guess,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isUniqueConstraint(e)) throw e;
|
||||
return status(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 [];
|
||||
return x.for.map((e) => ({
|
||||
video: vids.find((v) => v.path === x.path)!.pk,
|
||||
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 status(
|
||||
201,
|
||||
vids.map((x) => ({
|
||||
id: x.id,
|
||||
path: x.path,
|
||||
guess: x.guess,
|
||||
entries: [],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const links = await linkVideos(tx, vidEntries);
|
||||
|
||||
return status(
|
||||
201,
|
||||
vids.map((x) => ({
|
||||
id: x.id,
|
||||
path: x.path,
|
||||
guess: x.guess,
|
||||
entries: links[x.pk] ?? [],
|
||||
})),
|
||||
);
|
||||
});
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: comment`
|
||||
Create videos in bulk.
|
||||
Duplicated videos will simply be ignored.
|
||||
|
||||
If a videos has a \`guess\` field, it will be used to automatically register the video under an existing
|
||||
movie or entry.
|
||||
`,
|
||||
},
|
||||
body: t.Array(SeedVideo),
|
||||
response: {
|
||||
201: t.Array(CreatedVideo),
|
||||
409: {
|
||||
...KError,
|
||||
description:
|
||||
"Invalid rendering specified. (conflicts with an existing video)",
|
||||
},
|
||||
422: KError,
|
||||
},
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"",
|
||||
async ({ body }) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
const vids = tx.$with("vids").as(
|
||||
tx
|
||||
.delete(videos)
|
||||
.where(eq(videos.path, sql`any(${sqlarr(body)})`))
|
||||
.returning({ pk: videos.pk, path: videos.path }),
|
||||
);
|
||||
|
||||
const deletedJoin = await tx
|
||||
.with(vids)
|
||||
.select({ entryPk: entryVideoJoin.entryPk, path: vids.path })
|
||||
.from(entryVideoJoin)
|
||||
.rightJoin(vids, eq(vids.pk, entryVideoJoin.videoPk));
|
||||
|
||||
const delEntries = await tx
|
||||
.update(entries)
|
||||
.set({ availableSince: null })
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
entries.pk,
|
||||
sql`any(${sqlarr(
|
||||
deletedJoin.filter((x) => x.entryPk).map((x) => x.entryPk!),
|
||||
)})`,
|
||||
),
|
||||
notExists(
|
||||
tx
|
||||
.select()
|
||||
.from(entryVideoJoin)
|
||||
.where(eq(entries.pk, entryVideoJoin.entryPk)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning({ show: entries.showPk });
|
||||
|
||||
await updateAvailableCount(
|
||||
tx,
|
||||
delEntries.map((x) => x.show),
|
||||
false,
|
||||
);
|
||||
|
||||
return [...new Set(deletedJoin.map((x) => x.path))];
|
||||
});
|
||||
},
|
||||
{
|
||||
detail: { description: "Delete videos in bulk." },
|
||||
body: t.Array(
|
||||
t.String({
|
||||
description: "Path of the video to delete",
|
||||
examples: [bubbleVideo.path],
|
||||
}),
|
||||
),
|
||||
response: { 200: t.Array(t.String()) },
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/link",
|
||||
async ({ body, status }) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
const vids = await tx
|
||||
.select({ pk: videos.pk, id: videos.id, path: videos.path })
|
||||
.from(videos)
|
||||
.where(eq(videos.id, sql`any(${sqlarr(body.map((x) => x.id))})`));
|
||||
const lVids = body.flatMap((x) => {
|
||||
return x.for.map((e) => ({
|
||||
video: vids.find((v) => v.id === x.id)!.pk,
|
||||
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 links = await linkVideos(tx, lVids);
|
||||
return status(
|
||||
201,
|
||||
vids.map((x) => ({
|
||||
id: x.id,
|
||||
path: x.path,
|
||||
entries: links[x.pk] ?? [],
|
||||
})),
|
||||
);
|
||||
});
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Link existing videos to existing entries",
|
||||
},
|
||||
body: t.Array(
|
||||
t.Object({
|
||||
id: t.String({
|
||||
description: "Id of the video",
|
||||
format: "uuid",
|
||||
}),
|
||||
for: t.Array(SeedVideo.properties.for.items),
|
||||
}),
|
||||
),
|
||||
response: {
|
||||
201: t.Array(
|
||||
t.Object({
|
||||
id: t.String({ format: "uuid" }),
|
||||
path: t.String({ examples: ["/video/made in abyss s1e13.mkv"] }),
|
||||
entries: t.Array(
|
||||
t.Object({
|
||||
slug: t.String({
|
||||
format: "slug",
|
||||
examples: ["made-in-abyss-s1e13"],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
422: KError,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@ -18,9 +18,9 @@ export const createVideo = async (video: SeedVideo | SeedVideo[]) => {
|
||||
return [resp, body] as const;
|
||||
};
|
||||
|
||||
export const getVideos = async () => {
|
||||
export const getGuesses = async () => {
|
||||
const resp = await handlers.handle(
|
||||
new Request(buildUrl("videos"), {
|
||||
new Request(buildUrl("videos/guesses"), {
|
||||
method: "GET",
|
||||
headers: await getJwtHeaders(),
|
||||
}),
|
||||
|
||||
@ -2,7 +2,7 @@ import { db, migrate } from "~/db";
|
||||
import { profiles, shows } from "~/db/schema";
|
||||
import { bubble, madeInAbyss } from "~/models/examples";
|
||||
import { setupLogging } from "../src/logtape";
|
||||
import { createMovie, createSerie, createVideo, getVideos } from "./helpers";
|
||||
import { createMovie, createSerie, createVideo, getGuesses } from "./helpers";
|
||||
|
||||
// test file used to run manually using `bun tests/manual.ts`
|
||||
// run those before running this script
|
||||
@ -54,7 +54,7 @@ const [resp, body] = await createVideo([
|
||||
},
|
||||
]);
|
||||
console.log(body);
|
||||
const [___, ret] = await getVideos();
|
||||
const [___, ret] = await getGuesses();
|
||||
console.log(JSON.stringify(ret, undefined, 4));
|
||||
|
||||
process.exit(0);
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
createSerie,
|
||||
createVideo,
|
||||
deleteVideo,
|
||||
getVideos,
|
||||
getGuesses,
|
||||
linkVideos,
|
||||
} from "tests/helpers";
|
||||
import { expectStatus } from "tests/utils";
|
||||
@ -108,7 +108,7 @@ beforeAll(async () => {
|
||||
|
||||
describe("Video get/deletion", () => {
|
||||
it("Get current state", async () => {
|
||||
const [resp, body] = await getVideos();
|
||||
const [resp, body] = await getGuesses();
|
||||
expectStatus(resp, body).toBe(200);
|
||||
expect(body.guesses).toMatchObject({
|
||||
mia: {
|
||||
@ -145,7 +145,7 @@ describe("Video get/deletion", () => {
|
||||
});
|
||||
expectStatus(resp, body).toBe(201);
|
||||
|
||||
[resp, body] = await getVideos();
|
||||
[resp, body] = await getGuesses();
|
||||
expectStatus(resp, body).toBe(200);
|
||||
expect(body.guesses).toMatchObject({
|
||||
mia: {
|
||||
@ -187,7 +187,7 @@ describe("Video get/deletion", () => {
|
||||
});
|
||||
expectStatus(resp, body).toBe(201);
|
||||
|
||||
[resp, body] = await getVideos();
|
||||
[resp, body] = await getGuesses();
|
||||
expectStatus(resp, body).toBe(200);
|
||||
expect(body.guesses).toMatchObject({
|
||||
mia: {
|
||||
|
||||
@ -49,7 +49,7 @@ class KyooClient(metaclass=Singleton):
|
||||
)
|
||||
|
||||
async def get_videos_info(self) -> VideoInfo:
|
||||
async with self._client.get("videos") as r:
|
||||
async with self._client.get("videos/guesses") as r:
|
||||
await self.raise_for_status(r)
|
||||
return VideoInfo(**await r.json())
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user