mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
feat(api): random sort (#771)
This commit is contained in:
commit
4d0a6e5223
@ -1,5 +1,5 @@
|
|||||||
import { and, desc, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { Elysia, t } from "elysia";
|
import { Elysia, redirect, t } from "elysia";
|
||||||
import { KError } from "~/models/error";
|
import { KError } from "~/models/error";
|
||||||
import { comment } from "~/utils";
|
import { comment } from "~/utils";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
@ -95,14 +95,12 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
|||||||
return error(404, {
|
return error(404, {
|
||||||
status: 404,
|
status: 404,
|
||||||
message: "Movie not found",
|
message: "Movie not found",
|
||||||
details: undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!ret.translation) {
|
if (!ret.translation) {
|
||||||
return error(422, {
|
return error(422, {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: "Accept-Language header could not be satisfied.",
|
message: "Accept-Language header could not be satisfied.",
|
||||||
details: undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
set.headers["content-language"] = ret.translation.language;
|
set.headers["content-language"] = ret.translation.language;
|
||||||
@ -154,6 +152,41 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.get(
|
||||||
|
"random",
|
||||||
|
async ({ error, redirect }) => {
|
||||||
|
const [movie] = await db
|
||||||
|
.select({ id: shows.id })
|
||||||
|
.from(shows)
|
||||||
|
.where(eq(shows.kind, "movie"))
|
||||||
|
.orderBy(sql`random()`)
|
||||||
|
.limit(1);
|
||||||
|
if (!movie)
|
||||||
|
return error(404, {
|
||||||
|
status: 404,
|
||||||
|
message: "No movies in the database",
|
||||||
|
});
|
||||||
|
return redirect(`/movies/${movie.id}`);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
detail: {
|
||||||
|
description: "Get a random movie",
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
302: t.Void({
|
||||||
|
description:
|
||||||
|
"Redirected to the [/movies/{id}](#tag/movies/GET/movies/{id}) route.",
|
||||||
|
}),
|
||||||
|
404: {
|
||||||
|
...KError,
|
||||||
|
description: "No movie found with the given id or slug.",
|
||||||
|
examples: [
|
||||||
|
{ status: 404, message: "Movie not found", details: undefined },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
.get(
|
.get(
|
||||||
"",
|
"",
|
||||||
async ({
|
async ({
|
||||||
@ -163,7 +196,6 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
|||||||
}) => {
|
}) => {
|
||||||
const langs = processLanguages(languages);
|
const langs = processLanguages(languages);
|
||||||
const [transQ, transCol] = getTranslationQuery(langs, true);
|
const [transQ, transCol] = getTranslationQuery(langs, true);
|
||||||
|
|
||||||
// TODO: Add sql indexes on sort keys
|
// TODO: Add sql indexes on sort keys
|
||||||
|
|
||||||
const items = await db
|
const items = await db
|
||||||
@ -177,7 +209,12 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
|||||||
.innerJoin(transQ, eq(shows.pk, transQ.pk))
|
.innerJoin(transQ, eq(shows.pk, transQ.pk))
|
||||||
.where(and(filter, keysetPaginate({ table: shows, after, sort })))
|
.where(and(filter, keysetPaginate({ table: shows, after, sort })))
|
||||||
.orderBy(
|
.orderBy(
|
||||||
...sort.map((x) => (x.desc ? sql`${shows[x.key]} desc nulls last` : shows[x.key])),
|
...(sort.random
|
||||||
|
? [sql`md5(${sort.random.seed} || ${shows.pk})`]
|
||||||
|
: []),
|
||||||
|
...sort.sort.map((x) =>
|
||||||
|
x.desc ? sql`${shows[x.key]} desc nulls last` : shows[x.key],
|
||||||
|
),
|
||||||
shows.pk,
|
shows.pk,
|
||||||
)
|
)
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
@ -188,7 +225,6 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
|||||||
detail: { description: "Get all movies" },
|
detail: { description: "Get all movies" },
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
sort: Sort(["slug", "rating", "airDate", "createdAt", "nextRefresh"], {
|
sort: Sort(["slug", "rating", "airDate", "createdAt", "nextRefresh"], {
|
||||||
// TODO: Add random
|
|
||||||
remap: { airDate: "startAir" },
|
remap: { airDate: "startAir" },
|
||||||
default: ["slug"],
|
default: ["slug"],
|
||||||
description: "How to sort the query",
|
description: "How to sort the query",
|
||||||
|
@ -17,8 +17,9 @@ export const seed = new Elysia()
|
|||||||
const err = validateTranslations(body.translations);
|
const err = validateTranslations(body.translations);
|
||||||
if (err) return error(400, err);
|
if (err) return error(400, err);
|
||||||
|
|
||||||
const { status, ...ret } = await seedMovie(body);
|
const ret = await seedMovie(body);
|
||||||
return error(status, ret);
|
if (ret.status === 422) return error(422, ret);
|
||||||
|
return error(ret.status, ret);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body: "seed-movie",
|
body: "seed-movie",
|
||||||
|
@ -13,6 +13,7 @@ import { conflictUpdateAllExcept } from "~/db/schema/utils";
|
|||||||
import type { SeedMovie } from "~/models/movie";
|
import type { SeedMovie } from "~/models/movie";
|
||||||
import { processOptImage } from "./images";
|
import { processOptImage } from "./images";
|
||||||
import { guessNextRefresh } from "./refresh";
|
import { guessNextRefresh } from "./refresh";
|
||||||
|
import { KErrorT } from "~/models/error";
|
||||||
|
|
||||||
type Show = typeof shows.$inferInsert;
|
type Show = typeof shows.$inferInsert;
|
||||||
type ShowTrans = typeof showTranslations.$inferInsert;
|
type ShowTrans = typeof showTranslations.$inferInsert;
|
||||||
@ -30,8 +31,19 @@ export type SeedMovieResponse = typeof SeedMovieResponse.static;
|
|||||||
export const seedMovie = async (
|
export const seedMovie = async (
|
||||||
seed: SeedMovie,
|
seed: SeedMovie,
|
||||||
): Promise<
|
): Promise<
|
||||||
SeedMovieResponse & { status: "Created" | "OK" | "Conflict" }
|
| (SeedMovieResponse & { status: "Created" | "OK" | "Conflict" })
|
||||||
|
| { status: 422; message: string }
|
||||||
> => {
|
> => {
|
||||||
|
if (seed.slug === "random") {
|
||||||
|
if (!seed.airDate) {
|
||||||
|
return {
|
||||||
|
status: 422,
|
||||||
|
message: "`random` is a reserved slug. Use something else.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
seed.slug = `random-${getYear(seed.airDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
const { translations, videos: vids, ...bMovie } = seed;
|
const { translations, videos: vids, ...bMovie } = seed;
|
||||||
|
|
||||||
const ret = await db.transaction(async (tx) => {
|
const ret = await db.transaction(async (tx) => {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import type { NonEmptyArray, Sort } from "./sort";
|
import type { NonEmptyArray, Sort } from "./sort";
|
||||||
import { eq, or, type Column, and, gt, lt, isNull } from "drizzle-orm";
|
import { eq, or, type Column, and, gt, lt, isNull, sql } from "drizzle-orm";
|
||||||
|
|
||||||
type Table<Name extends string> = Record<Name, Column>;
|
type Table<Name extends string> = Record<Name, Column>;
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ export const keysetPaginate = <
|
|||||||
sort,
|
sort,
|
||||||
after,
|
after,
|
||||||
}: {
|
}: {
|
||||||
table: Table<"pk" | Sort<T, Remap>[number]["key"]>;
|
table: Table<"pk" | Sort<T, Remap>["sort"][number]["key"]>;
|
||||||
after: string | undefined;
|
after: string | undefined;
|
||||||
sort: Sort<T, Remap>;
|
sort: Sort<T, Remap>;
|
||||||
}) => {
|
}) => {
|
||||||
@ -35,11 +35,28 @@ export const keysetPaginate = <
|
|||||||
|
|
||||||
const pkSort = { key: "pk" as const, desc: false };
|
const pkSort = { key: "pk" as const, desc: false };
|
||||||
|
|
||||||
|
if (sort.random) {
|
||||||
|
return or(
|
||||||
|
gt(
|
||||||
|
sql`md5(${sort.random.seed} || ${table[pkSort.key]})`,
|
||||||
|
sql`md5(${sort.random.seed} || ${cursor[0]})`,
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
sql`md5(${sort.random.seed} || ${table[pkSort.key]})`,
|
||||||
|
sql`md5(${sort.random.seed} || ${cursor[0]})`,
|
||||||
|
),
|
||||||
|
gt(table[pkSort.key], cursor[0]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Add an outer query >= for perf
|
// TODO: Add an outer query >= for perf
|
||||||
// PERF: See https://use-the-index-luke.com/sql/partial-results/fetch-next-page#sb-equivalent-logic
|
// PERF: See https://use-the-index-luke.com/sql/partial-results/fetch-next-page#sb-equivalent-logic
|
||||||
let where = undefined;
|
let where = undefined;
|
||||||
let previous = undefined;
|
let previous = undefined;
|
||||||
for (const [i, by] of [...sort, pkSort].entries()) {
|
|
||||||
|
for (const [i, by] of [...sort.sort, pkSort].entries()) {
|
||||||
const cmp = by.desc ? lt : gt;
|
const cmp = by.desc ? lt : gt;
|
||||||
where = or(
|
where = or(
|
||||||
where,
|
where,
|
||||||
@ -62,7 +79,7 @@ export const keysetPaginate = <
|
|||||||
|
|
||||||
export const generateAfter = (cursor: any, sort: Sort<any, any>) => {
|
export const generateAfter = (cursor: any, sort: Sort<any, any>) => {
|
||||||
const ret = [
|
const ret = [
|
||||||
...sort.map((by) => cursor[by.remmapedKey ?? by.key]),
|
...sort.sort.map((by) => cursor[by.remmapedKey ?? by.key]),
|
||||||
cursor.pk,
|
cursor.pk,
|
||||||
];
|
];
|
||||||
return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64url");
|
return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64url");
|
||||||
|
@ -18,12 +18,17 @@ export const createPage = <T>(
|
|||||||
{ url, sort, limit }: { url: string; sort: Sort<any, any>; limit: number },
|
{ url, sort, limit }: { url: string; sort: Sort<any, any>; limit: number },
|
||||||
) => {
|
) => {
|
||||||
let next: string | null = null;
|
let next: string | null = null;
|
||||||
|
const uri = new URL(url);
|
||||||
|
|
||||||
|
if (sort.random) {
|
||||||
|
uri.searchParams.set("sort", `random:${sort.random.seed}`);
|
||||||
|
url = uri.toString();
|
||||||
|
}
|
||||||
|
|
||||||
// we can't know for sure if there's a next page when the current page is full.
|
// we can't know for sure if there's a next page when the current page is full.
|
||||||
// maybe the next page is empty, this is a bit weird but it allows us to handle pages
|
// maybe the next page is empty, this is a bit weird but it allows us to handle pages
|
||||||
// without making a new request to the db so it's fine.
|
// without making a new request to the db so it's fine.
|
||||||
if (items.length === limit && limit > 0) {
|
if (items.length === limit && limit > 0) {
|
||||||
const uri = new URL(url);
|
|
||||||
uri.searchParams.set("after", generateAfter(items[items.length - 1], sort));
|
uri.searchParams.set("after", generateAfter(items[items.length - 1], sort));
|
||||||
next = uri.toString();
|
next = uri.toString();
|
||||||
}
|
}
|
||||||
|
@ -4,10 +4,13 @@ export type Sort<
|
|||||||
T extends string[],
|
T extends string[],
|
||||||
Remap extends Partial<Record<T[number], string>>,
|
Remap extends Partial<Record<T[number], string>>,
|
||||||
> = {
|
> = {
|
||||||
key: Exclude<T[number], keyof Remap> | NonNullable<Remap[keyof Remap]>;
|
sort: {
|
||||||
remmapedKey?: keyof Remap;
|
key: Exclude<T[number], keyof Remap> | NonNullable<Remap[keyof Remap]>;
|
||||||
desc: boolean;
|
remmapedKey?: keyof Remap;
|
||||||
}[];
|
desc: boolean;
|
||||||
|
}[];
|
||||||
|
random?: { seed: number };
|
||||||
|
};
|
||||||
|
|
||||||
export type NonEmptyArray<T> = [T, ...T[]];
|
export type NonEmptyArray<T> = [T, ...T[]];
|
||||||
|
|
||||||
@ -29,9 +32,13 @@ export const Sort = <
|
|||||||
t
|
t
|
||||||
.Transform(
|
.Transform(
|
||||||
t.Array(
|
t.Array(
|
||||||
t.UnionEnum([
|
t.Union([
|
||||||
...values,
|
t.Literal("random"),
|
||||||
...values.map((x: T[number]) => `-${x}` as const),
|
t.TemplateLiteral("random:${number}"),
|
||||||
|
t.UnionEnum([
|
||||||
|
...values,
|
||||||
|
...values.map((x: T[number]) => `-${x}` as const),
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
{
|
{
|
||||||
// TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia
|
// TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia
|
||||||
@ -42,12 +49,21 @@ export const Sort = <
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.Decode((sort): Sort<T, Remap> => {
|
.Decode((sort): Sort<T, Remap> => {
|
||||||
return sort.map((x) => {
|
const random = sort.find((x) => x.startsWith("random"));
|
||||||
const desc = x[0] === "-";
|
if (random) {
|
||||||
const key = (desc ? x.substring(1) : x) as T[number];
|
const seed = random.includes(":")
|
||||||
if (key in remap) return { key: remap[key]!, remmapedKey: key, desc };
|
? Number.parseInt(random.substring("random:".length))
|
||||||
return { key: key as Exclude<typeof key, keyof Remap>, desc };
|
: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||||
});
|
return { random: { seed }, sort: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sort: sort.map((x) => {
|
||||||
|
const desc = x[0] === "-";
|
||||||
|
const key = (desc ? x.substring(1) : x) as T[number];
|
||||||
|
if (key in remap) return { key: remap[key]!, remmapedKey: key, desc };
|
||||||
|
return { key: key as Exclude<typeof key, keyof Remap>, desc };
|
||||||
|
}),
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.Encode(() => {
|
.Encode(() => {
|
||||||
throw new Error("Encode not supported for sort");
|
throw new Error("Encode not supported for sort");
|
||||||
|
@ -6,7 +6,9 @@ import { shows } from "~/db/schema";
|
|||||||
import { bubble } from "~/models/examples";
|
import { bubble } from "~/models/examples";
|
||||||
import { dune1984 } from "~/models/examples/dune-1984";
|
import { dune1984 } from "~/models/examples/dune-1984";
|
||||||
import { dune } from "~/models/examples/dune-2021";
|
import { dune } from "~/models/examples/dune-2021";
|
||||||
import { getMovies, movieApp } from "./movies-helper";
|
import { getMovie, getMovies, movieApp } from "./movies-helper";
|
||||||
|
import type { Movie } from "~/models/movie";
|
||||||
|
import { isUuid } from "~/models/utils";
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await db.delete(shows);
|
await db.delete(shows);
|
||||||
@ -120,4 +122,81 @@ describe("Get all movies", () => {
|
|||||||
next: null,
|
next: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Random sort", () => {
|
||||||
|
it("No limit, compare order with same seeds", async () => {
|
||||||
|
// First query
|
||||||
|
const [resp1, body1] = await getMovies({
|
||||||
|
sort: "random:100",
|
||||||
|
});
|
||||||
|
expectStatus(resp1, body1).toBe(200);
|
||||||
|
const items1: Movie[] = body1.items;
|
||||||
|
const items1Ids = items1.map(({ id }) => id);
|
||||||
|
|
||||||
|
// Second query
|
||||||
|
const [resp2, body2] = await getMovies({
|
||||||
|
sort: "random:100",
|
||||||
|
});
|
||||||
|
expectStatus(resp2, body2).toBe(200);
|
||||||
|
const items2: Movie[] = body2.items;
|
||||||
|
const items2Ids = items2.map(({ id }) => id);
|
||||||
|
|
||||||
|
expect(items1Ids).toEqual(items2Ids);
|
||||||
|
});
|
||||||
|
it("Limit 1, pages 1 and 2 ", async () => {
|
||||||
|
// First query fetches all
|
||||||
|
// use the result to know what is expected
|
||||||
|
let [resp, body] = await getMovies({
|
||||||
|
sort: "random:1234",
|
||||||
|
});
|
||||||
|
expectStatus(resp, body).toBe(200);
|
||||||
|
let items: Movie[] = body.items;
|
||||||
|
const expectedIds = items.map(({ id }) => id);
|
||||||
|
|
||||||
|
// Get First Page
|
||||||
|
[resp, body] = await getMovies({
|
||||||
|
sort: "random:1234",
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
expectStatus(resp, body).toBe(200);
|
||||||
|
items = body.items;
|
||||||
|
expect(items.length).toBe(1);
|
||||||
|
expect(items[0].id).toBe(expectedIds[0]);
|
||||||
|
// Get Second Page
|
||||||
|
resp = await movieApp.handle(new Request(body.next));
|
||||||
|
body = await resp.json();
|
||||||
|
|
||||||
|
expectStatus(resp, body).toBe(200);
|
||||||
|
items = body.items;
|
||||||
|
expect(items.length).toBe(1);
|
||||||
|
expect(items[0].id).toBe(expectedIds[1]);
|
||||||
|
});
|
||||||
|
it("Limit 1, pages 1 and 2, no seed ", async () => {
|
||||||
|
const [resp, body] = await getMovies({
|
||||||
|
sort: "random",
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
expectStatus(resp, body).toBe(200);
|
||||||
|
|
||||||
|
const resp2 = await movieApp.handle(new Request(body.next));
|
||||||
|
const body2 = await resp2.json();
|
||||||
|
expectStatus(resp2, body).toBe(200);
|
||||||
|
|
||||||
|
expect(body2.items.length).toBe(1);
|
||||||
|
expect(body.items.map((x: Movie) => x.slug)).not.toContain(
|
||||||
|
body2.items[0].slug,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Get /random", async () => {
|
||||||
|
const resp = await movieApp.handle(
|
||||||
|
new Request("http://localhost/movies/random"),
|
||||||
|
);
|
||||||
|
expect(resp.status).toBe(302);
|
||||||
|
const location = resp.headers.get("location")!;
|
||||||
|
expect(location).toStartWith("/movies/");
|
||||||
|
const id = location.substring("/movies/".length);
|
||||||
|
expect(isUuid(id)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -192,6 +192,16 @@ describe("Movie seeding", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Refuses random as a slug", async () => {
|
||||||
|
const [resp, body] = await createMovie({ ...bubble, slug: "random", airDate: null });
|
||||||
|
expectStatus(resp, body).toBe(422);
|
||||||
|
});
|
||||||
|
it("Refuses random as a slug but fallback w/ airDate", async () => {
|
||||||
|
const [resp, body] = await createMovie({ ...bubble, slug: "random" });
|
||||||
|
expectStatus(resp, body).toBe(201);
|
||||||
|
expect(body.slug).toBe("random-2022");
|
||||||
|
});
|
||||||
|
|
||||||
test.todo("Create correct video slug (version)", async () => {});
|
test.todo("Create correct video slug (version)", async () => {});
|
||||||
test.todo("Create correct video slug (part)", async () => {});
|
test.todo("Create correct video slug (part)", async () => {});
|
||||||
test.todo("Create correct video slug (rendering)", async () => {});
|
test.todo("Create correct video slug (rendering)", async () => {});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user