mirror of
				https://github.com/zoriya/Kyoo.git
				synced 2025-11-03 19:17:16 -05: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,
 | 
			
		||||
			"tag": "0014_staff",
 | 
			
		||||
			"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 { 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"],
 | 
			
		||||
		},
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
 | 
			
		||||
@ -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() },
 | 
			
		||||
		},
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
@ -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) => [
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -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", () => {
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user