diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 35c2c042..3508a198 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -298,6 +298,12 @@ export async function getEntries({ episodeNumber: sql`${episodeNumber}`, name: sql`${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." }, diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 24d202b9..5afcf324 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -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: { diff --git a/api/src/controllers/profiles/nextup.ts b/api/src/controllers/profiles/nextup.ts index 2ecba848..e48ead3b 100644 --- a/api/src/controllers/profiles/nextup.ts +++ b/api/src/controllers/profiles/nextup.ts @@ -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: { diff --git a/api/src/controllers/profiles/watchlist.ts b/api/src/controllers/profiles/watchlist.ts index baa7c988..b1ac2f9c 100644 --- a/api/src/controllers/profiles/watchlist.ts +++ b/api/src/controllers/profiles/watchlist.ts @@ -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: { diff --git a/api/src/controllers/seasons.ts b/api/src/controllers/seasons.ts index 5e1aec62..05392eac 100644 --- a/api/src/controllers/seasons.ts +++ b/api/src/controllers/seasons.ts @@ -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" }, diff --git a/api/src/controllers/shows/collections.ts b/api/src/controllers/shows/collections.ts index babc4846..31f20ae1 100644 --- a/api/src/controllers/shows/collections.ts +++ b/api/src/controllers/shows/collections.ts @@ -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" }, diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 5104f25d..f2e17d47 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -340,7 +340,7 @@ export async function getShows({ pk: showTranslations.pk, similarity: sql`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) diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts index 7c23a926..177fcf9b 100644 --- a/api/src/controllers/shows/movies.ts +++ b/api/src/controllers/shows/movies.ts @@ -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" }, diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index 3672d35e..94ca43e4 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -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" }, diff --git a/api/src/controllers/shows/shows.ts b/api/src/controllers/shows/shows.ts index 13a1f3c7..7e5b55ab 100644 --- a/api/src/controllers/shows/shows.ts +++ b/api/src/controllers/shows/shows.ts @@ -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" }, diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index 2d113f21..43a9419d 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -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: { diff --git a/api/src/controllers/studios.ts b/api/src/controllers/studios.ts index 7b090403..1380e287 100644 --- a/api/src/controllers/studios.ts +++ b/api/src/controllers/studios.ts @@ -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." }, diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 25255266..9c2837bb 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -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" }, diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts index 07f32215..844c8dcd 100644 --- a/api/src/models/utils/keyset-paginate.ts +++ b/api/src/models/utils/keyset-paginate.ts @@ -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"); }; diff --git a/api/src/models/utils/page.ts b/api/src/models/utils/page.ts index a84539df..ed22a77b 100644 --- a/api/src/models/utils/page.ts +++ b/api/src/models/utils/page.ts @@ -21,6 +21,7 @@ export const createPage = ( { url, sort, + query, limit, headers, }: { @@ -28,6 +29,7 @@ export const createPage = ( sort: Sort; limit: number; headers?: Record; + query?: string; }, ) => { const uri = new URL(url); @@ -52,7 +54,10 @@ export const createPage = ( // 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 };