mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
Create /news (#839)
This commit is contained in:
commit
458eb2c387
1
api/drizzle/0015_news.sql
Normal file
1
api/drizzle/0015_news.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "kyoo"."entries" ADD COLUMN "available_since" timestamp with time zone;
|
1493
api/drizzle/meta/0015_snapshot.json
Normal file
1493
api/drizzle/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -106,6 +106,13 @@
|
|||||||
"when": 1741601145901,
|
"when": 1741601145901,
|
||||||
"tag": "0014_staff",
|
"tag": "0014_staff",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1741623934941,
|
||||||
|
"tag": "0015_news",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -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 { Elysia, t } from "elysia";
|
||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import {
|
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({
|
async function getEntries({
|
||||||
after,
|
after,
|
||||||
limit,
|
limit,
|
||||||
@ -304,7 +317,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
|
|||||||
limit,
|
limit,
|
||||||
after,
|
after,
|
||||||
query,
|
query,
|
||||||
sort: sort as any,
|
sort: sort,
|
||||||
filter: and(
|
filter: and(
|
||||||
eq(entries.showPk, serie.pk),
|
eq(entries.showPk, serie.pk),
|
||||||
eq(entries.kind, "extra"),
|
eq(entries.kind, "extra"),
|
||||||
@ -355,7 +368,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
|
|||||||
limit,
|
limit,
|
||||||
after,
|
after,
|
||||||
query,
|
query,
|
||||||
sort: sort as any,
|
sort: sort,
|
||||||
filter: and(eq(entries.kind, "unknown"), filter),
|
filter: and(eq(entries.kind, "unknown"), filter),
|
||||||
languages: ["extra"],
|
languages: ["extra"],
|
||||||
})) as UnknownEntry[];
|
})) as UnknownEntry[];
|
||||||
@ -382,4 +395,44 @@ export const entriesH = new Elysia({ tags: ["series"] })
|
|||||||
},
|
},
|
||||||
tags: ["videos"],
|
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"],
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
@ -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 { db } from "~/db";
|
||||||
import {
|
import {
|
||||||
entries,
|
entries,
|
||||||
@ -6,10 +6,11 @@ import {
|
|||||||
entryVideoJoin,
|
entryVideoJoin,
|
||||||
videos,
|
videos,
|
||||||
} from "~/db/schema";
|
} 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 type { SeedEntry as SEntry, SeedExtra as SExtra } from "~/models/entry";
|
||||||
import { processOptImage } from "../images";
|
import { processOptImage } from "../images";
|
||||||
import { guessNextRefresh } from "../refresh";
|
import { guessNextRefresh } from "../refresh";
|
||||||
|
import { updateAvailableCount } from "./shows";
|
||||||
|
|
||||||
type SeedEntry = SEntry & {
|
type SeedEntry = SEntry & {
|
||||||
video?: undefined;
|
video?: undefined;
|
||||||
@ -41,8 +42,9 @@ const generateSlug = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const insertEntries = async (
|
export const insertEntries = async (
|
||||||
show: { pk: number; slug: string },
|
show: { pk: number; slug: string; kind: "movie" | "serie" | "collection" },
|
||||||
items: (SeedEntry | SeedExtra)[],
|
items: (SeedEntry | SeedExtra)[],
|
||||||
|
onlyExtras = false,
|
||||||
) => {
|
) => {
|
||||||
if (!items) return [];
|
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: [] }));
|
return retEntries.map((x) => ({ id: x.id, slug: x.slug, videos: [] }));
|
||||||
|
}
|
||||||
|
|
||||||
const retVideos = await db
|
const retVideos = await db.transaction(async (tx) => {
|
||||||
.insert(entryVideoJoin)
|
const ret = await tx
|
||||||
.select(
|
.insert(entryVideoJoin)
|
||||||
db
|
.select(
|
||||||
.select({
|
db
|
||||||
entryPk: sql<number>`vids.entryPk::integer`.as("entry"),
|
.select({
|
||||||
videoPk: videos.pk,
|
entryPk: sql<number>`vids.entryPk::integer`.as("entry"),
|
||||||
slug: computeVideoSlug(
|
videoPk: videos.pk,
|
||||||
sql`vids.entrySlug::text`,
|
slug: computeVideoSlug(
|
||||||
sql`vids.needRendering::boolean`,
|
sql`vids.entrySlug::text`,
|
||||||
),
|
sql`vids.needRendering::boolean`,
|
||||||
})
|
),
|
||||||
.from(values(vids).as("vids"))
|
})
|
||||||
.innerJoin(videos, eq(videos.id, sql`vids.videoId::uuid`)),
|
.from(values(vids).as("vids"))
|
||||||
)
|
.innerJoin(videos, eq(videos.id, sql`vids.videoId::uuid`)),
|
||||||
.onConflictDoNothing()
|
)
|
||||||
.returning({
|
.onConflictDoNothing()
|
||||||
slug: entryVideoJoin.slug,
|
.returning({
|
||||||
entryPk: entryVideoJoin.entryPk,
|
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) => ({
|
return retEntries.map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
|
@ -60,6 +60,7 @@ async function insertBaseShow(
|
|||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
pk: shows.pk,
|
pk: shows.pk,
|
||||||
|
kind: shows.kind,
|
||||||
id: shows.id,
|
id: shows.id,
|
||||||
slug: shows.slug,
|
slug: shows.slug,
|
||||||
// https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667
|
// 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.
|
// if at this point ret is still undefined, we could not reconciliate.
|
||||||
// simply bail and let the caller handle this.
|
// simply bail and let the caller handle this.
|
||||||
const [{ pk, id }] = await db
|
const [{ pk, id, kind }] = await db
|
||||||
.select({ pk: shows.pk, id: shows.id })
|
.select({ pk: shows.pk, id: shows.id, kind: shows.kind })
|
||||||
.from(shows)
|
.from(shows)
|
||||||
.where(eq(shows.slug, show.slug))
|
.where(eq(shows.slug, show.slug))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return {
|
return {
|
||||||
status: 409 as const,
|
status: 409 as const,
|
||||||
|
kind,
|
||||||
pk,
|
pk,
|
||||||
id,
|
id,
|
||||||
slug: show.slug,
|
slug: show.slug,
|
||||||
@ -95,10 +97,11 @@ async function insertBaseShow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAvailableCount(
|
export async function updateAvailableCount(
|
||||||
|
tx: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0],
|
||||||
showPks: number[],
|
showPks: number[],
|
||||||
updateEntryCount = true,
|
updateEntryCount = true,
|
||||||
) {
|
) {
|
||||||
return await db
|
return await tx
|
||||||
.update(shows)
|
.update(shows)
|
||||||
.set({
|
.set({
|
||||||
availableCount: sql`${db
|
availableCount: sql`${db
|
||||||
|
@ -105,7 +105,6 @@ export const seedMovie = async (
|
|||||||
videos,
|
videos,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
await updateAvailableCount([show.pk], false);
|
|
||||||
|
|
||||||
const retStudios = await insertStudios(studios, show.pk);
|
const retStudios = await insertStudios(studios, show.pk);
|
||||||
const retStaff = await insertStaff(staff, show.pk);
|
const retStaff = await insertStaff(staff, show.pk);
|
||||||
|
@ -131,8 +131,8 @@ export const seedSerie = async (
|
|||||||
const retExtras = await insertEntries(
|
const retExtras = await insertEntries(
|
||||||
show,
|
show,
|
||||||
(extras ?? []).map((x) => ({ ...x, kind: "extra", extraKind: x.kind })),
|
(extras ?? []).map((x) => ({ ...x, kind: "extra", extraKind: x.kind })),
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
await updateAvailableCount([show.pk]);
|
|
||||||
|
|
||||||
const retStudios = await insertStudios(studios, show.pk);
|
const retStudios = await insertStudios(studios, show.pk);
|
||||||
const retStaff = await insertStaff(staff, show.pk);
|
const retStaff = await insertStaff(staff, show.pk);
|
||||||
|
@ -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 { Elysia, t } from "elysia";
|
||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import { entries, entryVideoJoin, shows, videos } from "~/db/schema";
|
import { entries, entryVideoJoin, shows, videos } from "~/db/schema";
|
||||||
|
import { sqlarr } from "~/db/utils";
|
||||||
import { bubbleVideo } from "~/models/examples";
|
import { bubbleVideo } from "~/models/examples";
|
||||||
|
import { Page } from "~/models/utils";
|
||||||
import { SeedVideo, Video } from "~/models/video";
|
import { SeedVideo, Video } from "~/models/video";
|
||||||
import { comment } from "~/utils";
|
import { comment } from "~/utils";
|
||||||
import { computeVideoSlug } from "./seed/insert/entries";
|
import { computeVideoSlug } from "./seed/insert/entries";
|
||||||
|
import { updateAvailableCount } from "./seed/insert/shows";
|
||||||
|
|
||||||
const CreatedVideo = t.Object({
|
const CreatedVideo = t.Object({
|
||||||
id: t.String({ format: "uuid" }),
|
id: t.String({ format: "uuid" }),
|
||||||
@ -23,9 +27,6 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
|||||||
"created-videos": t.Array(CreatedVideo),
|
"created-videos": t.Array(CreatedVideo),
|
||||||
error: t.Object({}),
|
error: t.Object({}),
|
||||||
})
|
})
|
||||||
.get("/:id", () => "hello" as unknown as Video, {
|
|
||||||
response: { 200: "video" },
|
|
||||||
})
|
|
||||||
.post(
|
.post(
|
||||||
"",
|
"",
|
||||||
async ({ body, error }) => {
|
async ({ body, error }) => {
|
||||||
@ -115,8 +116,6 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
|||||||
// return error(201, ret as any);
|
// return error(201, ret as any);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body: t.Array(SeedVideo),
|
|
||||||
response: { 201: t.Array(CreatedVideo) },
|
|
||||||
detail: {
|
detail: {
|
||||||
description: comment`
|
description: comment`
|
||||||
Create videos in bulk.
|
Create videos in bulk.
|
||||||
@ -126,5 +125,67 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
|||||||
movie or entry.
|
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() },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -74,6 +74,7 @@ export const entries = schema.table(
|
|||||||
updatedAt: timestamp({ withTimezone: true, mode: "string" })
|
updatedAt: timestamp({ withTimezone: true, mode: "string" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.$onUpdate(() => sql`now()`),
|
.$onUpdate(() => sql`now()`),
|
||||||
|
availableSince: timestamp({ withTimezone: true, mode: "string" }),
|
||||||
nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(),
|
nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
|
@ -129,3 +129,28 @@ export const getUnknowns = async (opts: {
|
|||||||
const body = await resp.json();
|
const body = await resp.json();
|
||||||
return [resp, body] as const;
|
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;
|
||||||
|
};
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
import { beforeAll, describe, expect, it } from "bun:test";
|
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 { expectStatus } from "tests/utils";
|
||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import { shows, videos } from "~/db/schema";
|
import { shows, videos } from "~/db/schema";
|
||||||
@ -48,6 +54,21 @@ describe("Get entries", () => {
|
|||||||
part: madeInAbyssVideo.part,
|
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", () => {
|
describe("Get extra", () => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user