diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 21683381..c6e3485f 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -1,5 +1,5 @@ -import { and, desc, eq, sql } from "drizzle-orm"; -import { Elysia, t } from "elysia"; +import { and, eq, sql } from "drizzle-orm"; +import { Elysia, redirect, t } from "elysia"; import { KError } from "~/models/error"; import { comment } from "~/utils"; import { db } from "../db"; @@ -95,14 +95,12 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) return error(404, { status: 404, message: "Movie not found", - details: undefined, }); } if (!ret.translation) { return error(422, { status: 422, message: "Accept-Language header could not be satisfied.", - details: undefined, }); } 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( "", async ({ @@ -163,7 +196,6 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }) => { const langs = processLanguages(languages); const [transQ, transCol] = getTranslationQuery(langs, true); - // TODO: Add sql indexes on sort keys const items = await db @@ -177,7 +209,12 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .innerJoin(transQ, eq(shows.pk, transQ.pk)) .where(and(filter, keysetPaginate({ table: shows, after, sort }))) .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, ) .limit(limit); @@ -188,7 +225,6 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) detail: { description: "Get all movies" }, query: t.Object({ sort: Sort(["slug", "rating", "airDate", "createdAt", "nextRefresh"], { - // TODO: Add random remap: { airDate: "startAir" }, default: ["slug"], description: "How to sort the query", diff --git a/api/src/controllers/seed/index.ts b/api/src/controllers/seed/index.ts index 8a5dac11..9926f37d 100644 --- a/api/src/controllers/seed/index.ts +++ b/api/src/controllers/seed/index.ts @@ -17,8 +17,9 @@ export const seed = new Elysia() const err = validateTranslations(body.translations); if (err) return error(400, err); - const { status, ...ret } = await seedMovie(body); - return error(status, ret); + const ret = await seedMovie(body); + if (ret.status === 422) return error(422, ret); + return error(ret.status, ret); }, { body: "seed-movie", diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 45b3b8a4..396fcd27 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -13,6 +13,7 @@ import { conflictUpdateAllExcept } from "~/db/schema/utils"; import type { SeedMovie } from "~/models/movie"; import { processOptImage } from "./images"; import { guessNextRefresh } from "./refresh"; +import { KErrorT } from "~/models/error"; type Show = typeof shows.$inferInsert; type ShowTrans = typeof showTranslations.$inferInsert; @@ -30,8 +31,19 @@ export type SeedMovieResponse = typeof SeedMovieResponse.static; export const seedMovie = async ( seed: SeedMovie, ): 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 ret = await db.transaction(async (tx) => { diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts index 6bcba223..44e96a89 100644 --- a/api/src/models/utils/keyset-paginate.ts +++ b/api/src/models/utils/keyset-paginate.ts @@ -1,5 +1,5 @@ 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 = Record; @@ -24,7 +24,7 @@ export const keysetPaginate = < sort, after, }: { - table: Table<"pk" | Sort[number]["key"]>; + table: Table<"pk" | Sort["sort"][number]["key"]>; after: string | undefined; sort: Sort; }) => { @@ -35,11 +35,28 @@ export const keysetPaginate = < 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 // PERF: See https://use-the-index-luke.com/sql/partial-results/fetch-next-page#sb-equivalent-logic let where = 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; where = or( where, @@ -62,7 +79,7 @@ export const keysetPaginate = < export const generateAfter = (cursor: any, sort: Sort) => { const ret = [ - ...sort.map((by) => cursor[by.remmapedKey ?? by.key]), + ...sort.sort.map((by) => cursor[by.remmapedKey ?? by.key]), cursor.pk, ]; return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64url"); diff --git a/api/src/models/utils/page.ts b/api/src/models/utils/page.ts index daad081e..c7a18d1b 100644 --- a/api/src/models/utils/page.ts +++ b/api/src/models/utils/page.ts @@ -18,12 +18,17 @@ export const createPage = ( { url, sort, limit }: { url: string; sort: Sort; limit: number }, ) => { 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. // 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. if (items.length === limit && limit > 0) { - const uri = new URL(url); uri.searchParams.set("after", generateAfter(items[items.length - 1], sort)); next = uri.toString(); } diff --git a/api/src/models/utils/sort.ts b/api/src/models/utils/sort.ts index 650ffb79..83e22d39 100644 --- a/api/src/models/utils/sort.ts +++ b/api/src/models/utils/sort.ts @@ -4,10 +4,13 @@ export type Sort< T extends string[], Remap extends Partial>, > = { - key: Exclude | NonNullable; - remmapedKey?: keyof Remap; - desc: boolean; -}[]; + sort: { + key: Exclude | NonNullable; + remmapedKey?: keyof Remap; + desc: boolean; + }[]; + random?: { seed: number }; +}; export type NonEmptyArray = [T, ...T[]]; @@ -29,9 +32,13 @@ export const Sort = < t .Transform( t.Array( - t.UnionEnum([ - ...values, - ...values.map((x: T[number]) => `-${x}` as const), + t.Union([ + t.Literal("random"), + 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 @@ -42,12 +49,21 @@ export const Sort = < ), ) .Decode((sort): Sort => { - return 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, desc }; - }); + const random = sort.find((x) => x.startsWith("random")); + if (random) { + const seed = random.includes(":") + ? Number.parseInt(random.substring("random:".length)) + : 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, desc }; + }), + }; }) .Encode(() => { throw new Error("Encode not supported for sort"); diff --git a/api/tests/movies/get-all-movies.test.ts b/api/tests/movies/get-all-movies.test.ts index 09adc457..d7f28a4e 100644 --- a/api/tests/movies/get-all-movies.test.ts +++ b/api/tests/movies/get-all-movies.test.ts @@ -6,7 +6,9 @@ import { shows } from "~/db/schema"; import { bubble } from "~/models/examples"; import { dune1984 } from "~/models/examples/dune-1984"; 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 () => { await db.delete(shows); @@ -120,4 +122,81 @@ describe("Get all movies", () => { 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); + }); + }); }); diff --git a/api/tests/movies/seed-movies.test.ts b/api/tests/movies/seed-movies.test.ts index cb32428a..827072d3 100644 --- a/api/tests/movies/seed-movies.test.ts +++ b/api/tests/movies/seed-movies.test.ts @@ -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 (part)", async () => {}); test.todo("Create correct video slug (rendering)", async () => {});