Create /news (#839)

This commit is contained in:
Zoe Roux 2025-03-10 18:58:13 +01:00 committed by GitHub
commit 458eb2c387
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1726 additions and 39 deletions

View File

@ -0,0 +1 @@
ALTER TABLE "kyoo"."entries" ADD COLUMN "available_since" timestamp with time zone;

File diff suppressed because it is too large Load Diff

View File

@ -106,6 +106,13 @@
"when": 1741601145901,
"tag": "0014_staff",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1741623934941,
"tag": "0015_news",
"breakpoints": true
}
]
}

View File

@ -1,4 +1,4 @@
import { type SQL, and, eq, ne, sql } from "drizzle-orm";
import { type SQL, and, eq, isNotNull, ne, sql } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { db } from "~/db";
import {
@ -93,6 +93,19 @@ const extraSort = Sort(
},
);
const newsSort: Sort = {
tablePk: entries.pk,
sort: [
{
sql: entries.availableSince,
// in the news query we already filter nulls out
isNullable: false,
accessor: (x) => x.availableSince,
desc: false,
},
],
};
async function getEntries({
after,
limit,
@ -304,7 +317,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
limit,
after,
query,
sort: sort as any,
sort: sort,
filter: and(
eq(entries.showPk, serie.pk),
eq(entries.kind, "extra"),
@ -355,7 +368,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
limit,
after,
query,
sort: sort as any,
sort: sort,
filter: and(eq(entries.kind, "unknown"), filter),
languages: ["extra"],
})) as UnknownEntry[];
@ -382,4 +395,44 @@ export const entriesH = new Elysia({ tags: ["series"] })
},
tags: ["videos"],
},
)
.get(
"/news",
async ({ query: { limit, after, query, filter }, request: { url } }) => {
const sort = newsSort;
const items = (await getEntries({
limit,
after,
query,
sort,
filter: and(
isNotNull(entries.availableSince),
ne(entries.kind, "unknown"),
ne(entries.kind, "extra"),
filter,
),
languages: ["extra"],
})) as Entry[];
return createPage(items, { url, sort, limit });
},
{
detail: { description: "Get new movies/episodes added recently." },
query: t.Object({
filter: t.Optional(Filter({ def: entryFilters })),
query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({
minimum: 1,
maximum: 250,
default: 50,
description: "Max page size.",
}),
after: t.Optional(t.String({ description: desc.after })),
}),
response: {
200: Page(Entry),
422: KError,
},
tags: ["videos"],
},
);

View File

@ -1,4 +1,4 @@
import { type Column, type SQL, eq, sql } from "drizzle-orm";
import { type Column, type SQL, and, eq, isNull, sql } from "drizzle-orm";
import { db } from "~/db";
import {
entries,
@ -6,10 +6,11 @@ import {
entryVideoJoin,
videos,
} from "~/db/schema";
import { conflictUpdateAllExcept, values } from "~/db/utils";
import { conflictUpdateAllExcept, sqlarr, values } from "~/db/utils";
import type { SeedEntry as SEntry, SeedExtra as SExtra } from "~/models/entry";
import { processOptImage } from "../images";
import { guessNextRefresh } from "../refresh";
import { updateAvailableCount } from "./shows";
type SeedEntry = SEntry & {
video?: undefined;
@ -41,8 +42,9 @@ const generateSlug = (
};
export const insertEntries = async (
show: { pk: number; slug: string },
show: { pk: number; slug: string; kind: "movie" | "serie" | "collection" },
items: (SeedEntry | SeedExtra)[],
onlyExtras = false,
) => {
if (!items) return [];
@ -135,29 +137,50 @@ export const insertEntries = async (
}));
});
if (vids.length === 0)
if (vids.length === 0) {
// we have not added videos but we need to update the `entriesCount`
if (show.kind === "serie" && !onlyExtras)
await updateAvailableCount(db, [show.pk], true);
return retEntries.map((x) => ({ id: x.id, slug: x.slug, videos: [] }));
}
const retVideos = await db
.insert(entryVideoJoin)
.select(
db
.select({
entryPk: sql<number>`vids.entryPk::integer`.as("entry"),
videoPk: videos.pk,
slug: computeVideoSlug(
sql`vids.entrySlug::text`,
sql`vids.needRendering::boolean`,
),
})
.from(values(vids).as("vids"))
.innerJoin(videos, eq(videos.id, sql`vids.videoId::uuid`)),
)
.onConflictDoNothing()
.returning({
slug: entryVideoJoin.slug,
entryPk: entryVideoJoin.entryPk,
});
const retVideos = await db.transaction(async (tx) => {
const ret = await tx
.insert(entryVideoJoin)
.select(
db
.select({
entryPk: sql<number>`vids.entryPk::integer`.as("entry"),
videoPk: videos.pk,
slug: computeVideoSlug(
sql`vids.entrySlug::text`,
sql`vids.needRendering::boolean`,
),
})
.from(values(vids).as("vids"))
.innerJoin(videos, eq(videos.id, sql`vids.videoId::uuid`)),
)
.onConflictDoNothing()
.returning({
slug: entryVideoJoin.slug,
entryPk: entryVideoJoin.entryPk,
});
if (!onlyExtras)
await updateAvailableCount(tx, [show.pk], show.kind === "serie");
const entriesPk = [...new Set(vids.map((x) => x.entryPk))];
await tx
.update(entries)
.set({ availableSince: sql`now()` })
.where(
and(
eq(entries.pk, sql`any(${sqlarr(entriesPk)})`),
isNull(entries.availableSince),
),
);
return ret;
});
return retEntries.map((entry) => ({
id: entry.id,

View File

@ -60,6 +60,7 @@ async function insertBaseShow(
})
.returning({
pk: shows.pk,
kind: shows.kind,
id: shows.id,
slug: shows.slug,
// https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667
@ -81,13 +82,14 @@ async function insertBaseShow(
// if at this point ret is still undefined, we could not reconciliate.
// simply bail and let the caller handle this.
const [{ pk, id }] = await db
.select({ pk: shows.pk, id: shows.id })
const [{ pk, id, kind }] = await db
.select({ pk: shows.pk, id: shows.id, kind: shows.kind })
.from(shows)
.where(eq(shows.slug, show.slug))
.limit(1);
return {
status: 409 as const,
kind,
pk,
id,
slug: show.slug,
@ -95,10 +97,11 @@ async function insertBaseShow(
}
export async function updateAvailableCount(
tx: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0],
showPks: number[],
updateEntryCount = true,
) {
return await db
return await tx
.update(shows)
.set({
availableCount: sql`${db

View File

@ -105,7 +105,6 @@ export const seedMovie = async (
videos,
},
]);
await updateAvailableCount([show.pk], false);
const retStudios = await insertStudios(studios, show.pk);
const retStaff = await insertStaff(staff, show.pk);

View File

@ -131,8 +131,8 @@ export const seedSerie = async (
const retExtras = await insertEntries(
show,
(extras ?? []).map((x) => ({ ...x, kind: "extra", extraKind: x.kind })),
true,
);
await updateAvailableCount([show.pk]);
const retStudios = await insertStudios(studios, show.pk);
const retStaff = await insertStaff(staff, show.pk);

View File

@ -1,11 +1,15 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, eq, exists, inArray, not, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { Elysia, t } from "elysia";
import { db } from "~/db";
import { entries, entryVideoJoin, shows, videos } from "~/db/schema";
import { sqlarr } from "~/db/utils";
import { bubbleVideo } from "~/models/examples";
import { Page } from "~/models/utils";
import { SeedVideo, Video } from "~/models/video";
import { comment } from "~/utils";
import { computeVideoSlug } from "./seed/insert/entries";
import { updateAvailableCount } from "./seed/insert/shows";
const CreatedVideo = t.Object({
id: t.String({ format: "uuid" }),
@ -23,9 +27,6 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
"created-videos": t.Array(CreatedVideo),
error: t.Object({}),
})
.get("/:id", () => "hello" as unknown as Video, {
response: { 200: "video" },
})
.post(
"",
async ({ body, error }) => {
@ -115,8 +116,6 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
// return error(201, ret as any);
},
{
body: t.Array(SeedVideo),
response: { 201: t.Array(CreatedVideo) },
detail: {
description: comment`
Create videos in bulk.
@ -126,5 +125,67 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
movie or entry.
`,
},
body: t.Array(SeedVideo),
response: { 201: t.Array(CreatedVideo) },
},
)
.delete(
"",
async ({ body }) => {
await db.transaction(async (tx) => {
const vids = tx.$with("vids").as(
tx
.delete(videos)
.where(eq(videos.path, sql`any(${body})`))
.returning({ pk: videos.pk }),
);
const evj = alias(entryVideoJoin, "evj");
const delEntries = tx.$with("del_entries").as(
tx
.with(vids)
.select({ entry: entryVideoJoin.entryPk })
.from(entryVideoJoin)
.where(
and(
inArray(entryVideoJoin.videoPk, tx.select().from(vids)),
not(
exists(
tx
.select()
.from(evj)
.where(
and(
eq(evj.entryPk, entryVideoJoin.entryPk),
not(inArray(evj.videoPk, db.select().from(vids))),
),
),
),
),
),
),
);
const delShows = await tx
.with(delEntries)
.update(entries)
.set({ availableSince: null })
.where(inArray(entries.pk, db.select().from(delEntries)))
.returning({ show: entries.showPk });
await updateAvailableCount(
tx,
delShows.map((x) => x.show),
false,
);
});
},
{
detail: { description: "Delete videos in bulk." },
body: t.Array(
t.String({
description: "Path of the video to delete",
examples: [bubbleVideo.path],
}),
),
response: { 204: t.Void() },
},
);

View File

@ -74,6 +74,7 @@ export const entries = schema.table(
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.$onUpdate(() => sql`now()`),
availableSince: timestamp({ withTimezone: true, mode: "string" }),
nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(),
},
(t) => [

View File

@ -129,3 +129,28 @@ export const getUnknowns = async (opts: {
const body = await resp.json();
return [resp, body] as const;
};
export const getNews = async ({
langs,
...opts
}: {
filter?: string;
limit?: number;
after?: string;
query?: string;
langs?: string;
preferOriginal?: boolean;
}) => {
const resp = await app.handle(
new Request(buildUrl("news", opts), {
method: "GET",
headers: langs
? {
"Accept-Language": langs,
}
: {},
}),
);
const body = await resp.json();
return [resp, body] as const;
};

View File

@ -1,5 +1,11 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { createSerie, createVideo, getEntries, getExtras } from "tests/helpers";
import {
createSerie,
createVideo,
getEntries,
getExtras,
getNews,
} from "tests/helpers";
import { expectStatus } from "tests/utils";
import { db } from "~/db";
import { shows, videos } from "~/db/schema";
@ -48,6 +54,21 @@ describe("Get entries", () => {
part: madeInAbyssVideo.part,
});
});
it("Get new videos", async () => {
const [resp, body] = await getNews({ langs: "en" });
expectStatus(resp, body).toBe(200);
expect(body.items).toBeArrayOfSize(1);
expect(body.items[0].slug).toBe("made-in-abyss-s1e13");
expect(body.items[0].videos).toBeArrayOfSize(1);
expect(body.items[0].videos[0]).toMatchObject({
path: madeInAbyssVideo.path,
slug: madeInAbyssVideo.slug,
version: madeInAbyssVideo.version,
rendering: madeInAbyssVideo.rendering,
part: madeInAbyssVideo.part,
});
});
});
describe("Get extra", () => {