Fix pagination with search

This commit is contained in:
Zoe Roux
2026-05-19 11:22:57 +02:00
parent df01512a93
commit 42bae68f71
15 changed files with 126 additions and 77 deletions
+11 -9
View File
@@ -298,6 +298,12 @@ export async function getEntries({
episodeNumber: sql<number>`${episodeNumber}`,
name: sql<string>`${transQ.name}`,
__similarity: query
? sql`word_similarity(${query}::text, concat(${entries.episodeNumber}, ' ', ${transQ.name}))`.as(
"__similarity",
)
: sql`false`,
...buildRelations(relations, entryRelations, {
languages,
preferOriginal,
@@ -313,15 +319,11 @@ export async function getEntries({
query
? sql`concat(${entries.episodeNumber}, ' ', ${transQ.name}) %> ${query}::text`
: undefined,
keysetPaginate({ after, sort }),
keysetPaginate({ after, sort, query }),
),
)
.orderBy(
...(query
? [
sql`word_similarity(${query}::text, concat(${entries.episodeNumber}, ' ', ${transQ.name})) desc`,
]
: sortToSql(sort)),
...(query ? [desc(sql`__similarity`)] : sortToSql(sort)),
entries.pk,
)
.limit(limit)
@@ -405,7 +407,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
});
}
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get entries of a serie" },
@@ -501,7 +503,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
userId: sub,
})) as Extra[];
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get extras of a serie" },
@@ -559,7 +561,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
preferOriginal: settings.preferOriginal,
})) as (Entry & { show: Show })[];
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get new movies/episodes added recently." },
+2 -2
View File
@@ -391,7 +391,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
relations: ["show"],
})) as (Entry & { show: Show })[];
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: {
@@ -438,7 +438,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
progressQ: historyProgressQ,
})) as Entry[];
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: {
+9 -6
View File
@@ -1,4 +1,4 @@
import { and, eq, isNotNull, lt, or, sql } from "drizzle-orm";
import { and, eq, isNotNull, lt, or, sql, desc as sqlDesc } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { auth } from "~/auth";
import { db } from "~/db";
@@ -93,6 +93,11 @@ export const nextup = new Elysia({ tags: ["profiles"] })
.select({
...entryCol,
...getColumns(transQ),
__similarity: query
? sql`word_similarity(${query}::text, ${transQ.name})`.as(
"__similarity",
)
: sql`false`,
videos: entryVideosQ.videos,
progress: mapProgress({ aliased: true }),
// specials don't have an `episodeNumber` but a `number` field.
@@ -126,19 +131,17 @@ export const nextup = new Elysia({ tags: ["profiles"] })
),
filter,
query ? sql`${transQ.name} %> ${query}::text` : undefined,
keysetPaginate({ after, sort }),
keysetPaginate({ after, sort, query }),
),
)
.orderBy(
...(query
? [sql`word_similarity(${query}::text, ${transQ.name}) desc`]
: sortToSql(sort)),
...(query ? [sqlDesc(sql`__similarity`)] : sortToSql(sort)),
entries.pk,
)
.limit(limit)
.execute({ userId: sub });
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: {
+2 -2
View File
@@ -157,7 +157,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
relations: ["nextEntry"],
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get all movies/series in your watchlist" },
@@ -210,7 +210,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
relations: ["nextEntry"],
userId: uInfo.id,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: {
+9 -6
View File
@@ -1,4 +1,4 @@
import { and, eq, type SQL, sql } from "drizzle-orm";
import { and, eq, type SQL, sql, desc as sqlDesc } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { db } from "~/db";
import { seasons, seasonTranslations, shows } from "~/db/schema";
@@ -83,6 +83,11 @@ export async function getSeasons({
.select({
...getColumns(seasons),
...transCol,
__similarity: query
? sql`word_similarity(${query}::text, ${transQ.name})`.as(
"__similarity",
)
: sql`false`,
})
.from(seasons)
.leftJoin(transQ, eq(seasons.pk, transQ.pk))
@@ -90,13 +95,11 @@ export async function getSeasons({
and(
filter,
query ? sql`${transQ.name} %> ${query}::text` : undefined,
keysetPaginate({ after, sort }),
keysetPaginate({ after, sort, query }),
),
)
.orderBy(
...(query
? [sql`word_similarity(${query}::text, ${transQ.name}) desc`]
: sortToSql(sort)),
...(query ? [sqlDesc(sql`__similarity`)] : sortToSql(sort)),
seasons.pk,
)
.limit(limit);
@@ -144,7 +147,7 @@ export const seasonsH = new Elysia({ tags: ["series"] })
languages: langs,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get seasons of a serie" },
+4 -4
View File
@@ -167,7 +167,7 @@ export const collections = new Elysia({
preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get all collections" },
@@ -268,7 +268,7 @@ export const collections = new Elysia({
preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get all movies in a collection" },
@@ -325,7 +325,7 @@ export const collections = new Elysia({
preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get all series in a collection" },
@@ -378,7 +378,7 @@ export const collections = new Elysia({
preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get all series & movies in a collection" },
+5 -5
View File
@@ -340,7 +340,7 @@ export async function getShows({
pk: showTranslations.pk,
similarity:
sql<number>`max(word_similarity(${query ?? ""}::text, ${showTranslations.name}))`.as(
"similarity",
"__similarity",
),
})
.from(showTranslations)
@@ -397,6 +397,8 @@ export async function getShows({
watchStatus: getColumns(watchStatusQ),
__similarity: searchQ.similarity,
...buildRelations(relations, showRelations, {
languages,
preferOriginal,
@@ -413,13 +415,11 @@ export async function getShows({
and(
filter,
query ? sql`${searchQ.pk} is not null` : undefined,
keysetPaginate({ after, sort }),
keysetPaginate({ after, sort, query }),
),
)
.orderBy(
...(query
? [desc(searchQ.similarity)]
: sortToSql(sort)),
...(query ? [desc(searchQ.similarity)] : sortToSql(sort)),
shows.pk,
)
.limit(limit)
+1 -1
View File
@@ -161,7 +161,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get all movies" },
+1 -1
View File
@@ -173,7 +173,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] })
preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get all series" },
+1 -1
View File
@@ -122,7 +122,7 @@ export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] })
preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get all movies/series/collections" },
+27 -18
View File
@@ -1,4 +1,4 @@
import { and, eq, type SQL, sql } from "drizzle-orm";
import { and, eq, type SQL, sql, desc as sqlDesc } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { auth } from "~/auth";
import { prefix } from "~/base";
@@ -93,6 +93,9 @@ async function getStaffRoles({
.select({
...getColumns(roles),
staff: getColumns(staff),
__similarity: query
? sql`word_similarity(${query}::text, ${staff.name})`.as("__similarity")
: sql`false`,
})
.from(roles)
.innerJoin(staff, eq(roles.staffPk, staff.pk))
@@ -100,13 +103,11 @@ async function getStaffRoles({
and(
filter,
query ? sql`${staff.name} %> ${query}::text` : undefined,
keysetPaginate({ sort, after }),
keysetPaginate({ sort, after, query }),
),
)
.orderBy(
...(query
? [sql`word_similarity(${query}::text, ${staff.name}) desc`]
: sortToSql(sort)),
...(query ? [sqlDesc(sql`__similarity`)] : sortToSql(sort)),
staff.pk,
)
.limit(limit);
@@ -232,6 +233,11 @@ export const staffH = new Elysia({ tags: ["staff"] })
const items = await db
.select({
...getColumns(roles),
__similarity: query
? sql`word_similarity(${query}::text, ${transQ.name})`.as(
"__similarity",
)
: sql`false`,
show: {
...getColumns(shows),
...getColumns(transQ),
@@ -259,17 +265,15 @@ export const staffH = new Elysia({ tags: ["staff"] })
eq(roles.staffPk, member.pk),
filter,
query ? sql`${transQ.name} %> ${query}::text` : undefined,
keysetPaginate({ after, sort }),
keysetPaginate({ after, sort, query }),
),
)
.orderBy(
...(query
? [sql`word_similarity(${query}::text, ${transQ.name}) desc`]
: sortToSql(sort)),
...(query ? [sqlDesc(sql`__similarity`)] : sortToSql(sort)),
roles.showPk,
)
.limit(limit);
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: {
@@ -319,22 +323,27 @@ export const staffH = new Elysia({ tags: ["staff"] })
headers,
}) => {
const items = await db
.select()
.select({
...getColumns(staff),
__similarity: query
? sql`word_similarity(${query}::text, ${staff.name})`.as(
"__similarity",
)
: sql`false`,
})
.from(staff)
.where(
and(
query ? sql`${staff.name} %> ${query}::text` : undefined,
keysetPaginate({ after, sort }),
keysetPaginate({ after, sort, query }),
),
)
.orderBy(
...(query
? [sql`word_similarity(${query}::text, ${staff.name}) desc`]
: sortToSql(sort)),
...(query ? [sqlDesc(sql`__similarity`)] : sortToSql(sort)),
staff.pk,
)
.limit(limit);
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: {
@@ -391,7 +400,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
sort,
filter: and(eq(roles.showPk, movie.pk), filter),
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: {
@@ -459,7 +468,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
sort,
filter: and(eq(roles.showPk, serie.pk), filter),
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: {
+12 -9
View File
@@ -1,4 +1,4 @@
import { and, eq, exists, type SQL, sql } from "drizzle-orm";
import { and, eq, exists, type SQL, sql, desc as sqlDesc } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { auth } from "~/auth";
import { prefix } from "~/base";
@@ -101,6 +101,11 @@ export async function getStudios({
.select({
...getColumns(studios),
...getColumns(transQ),
__similarity: query
? sql`word_similarity(${query}::text, ${transQ.name})`.as(
"__similarity",
)
: sql`false`,
...buildRelations(relations, studioRelations),
})
.from(studios)
@@ -112,13 +117,11 @@ export async function getStudios({
and(
filter,
query ? sql`${transQ.name} %> ${query}::text` : undefined,
keysetPaginate({ after, sort }),
keysetPaginate({ after, sort, query }),
),
)
.orderBy(
...(query
? [sql`word_similarity(${query}::text, ${transQ.name}) desc`]
: sortToSql(sort)),
...(query ? [sqlDesc(sql`__similarity`)] : sortToSql(sort)),
studios.pk,
)
.limit(limit);
@@ -243,7 +246,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
sort,
languages: langs,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get all studios" },
@@ -342,7 +345,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get all series & movies made by a studio." },
@@ -404,7 +407,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get all movies made by a studio." },
@@ -466,7 +469,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "Get all series made by a studio." },
+23 -10
View File
@@ -326,6 +326,11 @@ export async function getVideos({
.with(...cte)
.select({
...getColumns(videos),
__similarity: query
? sql`word_similarity(${query}::text, ${videos.path})`.as(
"__similarity",
)
: sql`false`,
...buildRelations(["slugs", "progress", ...relations], videoRelations, {
languages,
preferOriginal,
@@ -336,13 +341,11 @@ export async function getVideos({
and(
filter,
query ? sql`${videos.path} %> ${query}::text` : undefined,
keysetPaginate({ after, sort }),
keysetPaginate({ after, sort, query }),
),
)
.orderBy(
...(query
? [sql`word_similarity(${query}::text, ${videos.path}) desc`]
: sortToSql(sort)),
...(query ? [desc(sql`__similarity`)] : sortToSql(sort)),
videos.pk,
)
.limit(limit)
@@ -482,7 +485,7 @@ export const videosReadH = new Elysia({ tags: ["videos"] })
preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: {
@@ -596,7 +599,14 @@ export const videosReadH = new Elysia({ tags: ["videos"] })
request: { url },
}) => {
const ret = await db
.select()
.select({
...getColumns(videos),
__similarity: query
? sql`greatest(word_similarity(${query}::text, ${videos.path}), word_similarity(${query}::text, ${videos.guess}->>'title'))`.as(
"__similarity",
)
: sql`false`,
})
.from(videos)
.where(
and(
@@ -612,12 +622,15 @@ export const videosReadH = new Elysia({ tags: ["videos"] })
sql`${videos.guess}->>'title' %> ${query}::text`,
)
: undefined,
keysetPaginate({ after, sort }),
keysetPaginate({ after, sort, query }),
),
)
.orderBy(...(query ? [] : sortToSql(sort)), videos.pk)
.orderBy(
...(query ? [desc(sql`__similarity`)] : sortToSql(sort)),
videos.pk,
)
.limit(limit);
return createPage(ret, { url, sort, limit, headers });
return createPage(ret, { url, sort, limit, headers, query });
},
{
detail: { description: "Get unknown/unmatched videos." },
@@ -719,7 +732,7 @@ export const videosReadH = new Elysia({ tags: ["videos"] })
(x) =>
(x as unknown as typeof entries.$inferSelect).showPk === serie.pk,
);
return createPage(items, { url, sort, limit, headers });
return createPage(items, { url, sort, limit, headers, query });
},
{
detail: { description: "List videos of a serie" },
+13 -2
View File
@@ -19,9 +19,11 @@ type After = (string | number | boolean | Date | undefined)[];
export const keysetPaginate = ({
sort,
after,
query,
}: {
sort: Sort | undefined;
after: string | undefined;
query?: string;
}) => {
if (!after || !sort) return undefined;
const cursor: After = JSON.parse(
@@ -35,6 +37,13 @@ export const keysetPaginate = ({
desc: false,
};
if (query) {
return or(
lt(sql`__similarity`, cursor[0]),
and(eq(sql`__similarity`, cursor[0]), gt(sort.tablePk, cursor[1])),
);
}
if (sort.random) {
return or(
gt(
@@ -76,7 +85,9 @@ export const keysetPaginate = ({
return where;
};
export const generateAfter = (cursor: any, sort: Sort) => {
const ret = [...sort.sort.map((by) => by.accessor(cursor)), cursor.pk];
export const generateAfter = (cursor: any, sort: Sort, query?: string) => {
const ret = query
? [cursor.__similarity, cursor.pk]
: [...sort.sort.map((by) => by.accessor(cursor)), cursor.pk];
return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64url");
};
+6 -1
View File
@@ -21,6 +21,7 @@ export const createPage = <T>(
{
url,
sort,
query,
limit,
headers,
}: {
@@ -28,6 +29,7 @@ export const createPage = <T>(
sort: Sort;
limit: number;
headers?: Record<string, string | undefined>;
query?: string;
},
) => {
const uri = new URL(url);
@@ -52,7 +54,10 @@ export const createPage = <T>(
// 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) {
uri.searchParams.set("after", generateAfter(items[items.length - 1], sort));
uri.searchParams.set(
"after",
generateAfter(items[items.length - 1], sort, query),
);
next = uri.toString();
}
return { items, this: current, next };