From 616c7140d301e79e54c44b7f9e6212e5fd14b9f6 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 19 Jul 2025 11:57:26 +0200 Subject: [PATCH 1/9] Fix firstEntry type in api response --- api/src/models/serie.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index 61902a46..4e7fa182 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -81,7 +81,7 @@ export const FullSerie = t.Intersect([ t.Object({ translations: t.Optional(TranslationRecord(SerieTranslation)), studios: t.Optional(t.Array(Studio)), - firstEntry: t.Optional(Entry), + firstEntry: t.Optional(t.Nullable(Entry)), nextEntry: t.Optional(t.Nullable(Entry)), }), ]); From 8c8a974054b2d549af446ddcb5574be3f270c021 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 19 Jul 2025 15:26:48 +0200 Subject: [PATCH 2/9] Add helper function for entries' translations --- api/src/controllers/entries.ts | 25 +++++++++++++---------- api/src/controllers/profiles/nextup.ts | 14 +++---------- api/src/controllers/shows/logic.ts | 28 ++++++-------------------- 3 files changed, 23 insertions(+), 44 deletions(-) diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index c59fa656..74f54ce2 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -141,6 +141,17 @@ export const entryVideosQ = db .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); +export const getEntryTransQ = (languages: string[]) => { + return db + .selectDistinctOn([entryTranslations.pk]) + .from(entryTranslations) + .orderBy( + entryTranslations.pk, + sql`array_position(${sqlarr(languages)}, ${entryTranslations.language})`, + ) + .as("entry_t"); +}; + export const mapProgress = ({ aliased }: { aliased: boolean }) => { const { time, percent, playedDate, videoId } = getColumns(entryProgressQ); const ret = { @@ -174,15 +185,7 @@ export async function getEntries({ userId: string; progressQ?: typeof entryProgressQ; }): Promise<(Entry | Extra)[]> { - const transQ = db - .selectDistinctOn([entryTranslations.pk]) - .from(entryTranslations) - .orderBy( - entryTranslations.pk, - sql`array_position(${sqlarr(languages)}, ${entryTranslations.language})`, - ) - .as("t"); - const { pk, name, ...transCol } = getColumns(transQ); + const transQ = getEntryTransQ(languages); const { kind, @@ -196,7 +199,7 @@ export async function getEntries({ return await db .select({ ...entryCol, - ...transCol, + ...getColumns(transQ), videos: entryVideosQ.videos, progress: mapProgress({ aliased: true }), // specials don't have an `episodeNumber` but a `number` field. @@ -212,7 +215,7 @@ export async function getEntries({ order: sql`${order}`, seasonNumber: sql`${seasonNumber}`, episodeNumber: sql`${episodeNumber}`, - name: sql`${name}`, + name: sql`${transQ.name}`, }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) diff --git a/api/src/controllers/profiles/nextup.ts b/api/src/controllers/profiles/nextup.ts index 78cd6433..bbf64686 100644 --- a/api/src/controllers/profiles/nextup.ts +++ b/api/src/controllers/profiles/nextup.ts @@ -22,6 +22,7 @@ import { entryFilters, entryProgressQ, entryVideosQ, + getEntryTransQ, mapProgress, } from "../entries"; @@ -73,16 +74,7 @@ export const nextup = new Elysia({ tags: ["profiles"] }) jwt: { sub }, }) => { const langs = processLanguages(languages); - - const transQ = db - .selectDistinctOn([entryTranslations.pk]) - .from(entryTranslations) - .orderBy( - entryTranslations.pk, - sql`array_position(${sqlarr(langs)}, ${entryTranslations.language})`, - ) - .as("t"); - const { pk, name, ...transCol } = getColumns(transQ); + const transQ = getEntryTransQ(langs); const { externalId, @@ -97,7 +89,7 @@ export const nextup = new Elysia({ tags: ["profiles"] }) const items = await db .select({ ...entryCol, - ...transCol, + ...getColumns(transQ), videos: entryVideosQ.videos, progress: mapProgress({ aliased: true }), // specials don't have an `episodeNumber` but a `number` field. diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 4a789e0f..e2a498bb 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -36,7 +36,7 @@ import { } from "~/models/utils"; import type { EmbeddedVideo } from "~/models/video"; import { WatchlistStatus } from "~/models/watchlist"; -import { entryProgressQ, entryVideosQ, mapProgress } from "../entries"; +import { entryProgressQ, entryVideosQ, getEntryTransQ, mapProgress } from "../entries"; export const watchStatusQ = db .select({ @@ -147,7 +147,7 @@ const showRelations = { ).as("json"), }) .from(studios) - .leftJoin(studioTransQ, eq(studios.pk, studioTransQ.pk)) + .innerJoin(studioTransQ, eq(studios.pk, studioTransQ.pk)) .where( exists( db @@ -185,21 +185,13 @@ const showRelations = { .as("videos"); }, firstEntry: ({ languages }: { languages: string[] }) => { - const transQ = db - .selectDistinctOn([entryTranslations.pk]) - .from(entryTranslations) - .orderBy( - entryTranslations.pk, - sql`array_position(${sqlarr(languages)}, ${entryTranslations.language})`, - ) - .as("t"); - const { pk, ...transCol } = getColumns(transQ); + const transQ = getEntryTransQ(languages); return db .select({ firstEntry: jsonbBuildObject({ ...getColumns(entries), - ...transCol, + ...getColumns(transQ), number: entries.episodeNumber, videos: entryVideosQ.videos, progress: mapProgress({ aliased: false }), @@ -217,21 +209,13 @@ const showRelations = { .as("firstEntry"); }, nextEntry: ({ languages }: { languages: string[] }) => { - const transQ = db - .selectDistinctOn([entryTranslations.pk]) - .from(entryTranslations) - .orderBy( - entryTranslations.pk, - sql`array_position(${sqlarr(languages)}, ${entryTranslations.language})`, - ) - .as("t"); - const { pk, ...transCol } = getColumns(transQ); + const transQ = getEntryTransQ(languages); return db .select({ nextEntry: jsonbBuildObject({ ...getColumns(entries), - ...transCol, + ...getColumns(transQ), number: entries.episodeNumber, videos: entryVideosQ.videos, progress: mapProgress({ aliased: false }), From 5379536db2cbf9e6f5fb4b37b52ffd2f1f0aadac Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 19 Jul 2025 15:27:02 +0200 Subject: [PATCH 3/9] Add `/videos/:id?with=next,previous` route --- api/src/controllers/videos.ts | 265 +++++++++++++++++++++++++++++- api/src/db/utils.ts | 2 +- api/src/models/utils/relations.ts | 2 +- 3 files changed, 263 insertions(+), 6 deletions(-) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 8a1c9d15..6e869b55 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -1,22 +1,50 @@ -import { and, eq, notExists, or, sql } from "drizzle-orm"; -import { Elysia, t } from "elysia"; -import { db, type Transaction } from "~/db"; -import { entries, entryVideoJoin, shows, videos } from "~/db/schema"; import { + and, + desc, + eq, + gt, + isNotNull, + lt, + max, + min, + notExists, + or, + type SQL, + sql, +} from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; +import { Elysia, t } from "elysia"; +import { auth } from "~/auth"; +import { db, type Transaction } from "~/db"; +import { + entries, + entryTranslations, + entryVideoJoin, + shows, + videos, +} from "~/db/schema"; +import { + coalesce, conflictUpdateAllExcept, + getColumns, isUniqueConstraint, + jsonbAgg, jsonbBuildObject, jsonbObjectAgg, sqlarr, values, } from "~/db/utils"; +import { Entry } from "~/models/entry"; import { KError } from "~/models/error"; import { bubbleVideo } from "~/models/examples"; import { + AcceptLanguage, + buildRelations, createPage, isUuid, keysetPaginate, Page, + processLanguages, type Resource, Sort, sortToSql, @@ -24,6 +52,12 @@ import { import { desc as description } from "~/models/utils/descriptions"; import { Guess, Guesses, SeedVideo, Video } from "~/models/video"; import { comment } from "~/utils"; +import { + entryProgressQ, + entryVideosQ, + getEntryTransQ, + mapProgress, +} from "./entries"; import { computeVideoSlug } from "./seed/insert/entries"; import { updateAvailableCount, @@ -175,12 +209,235 @@ const CreatedVideo = t.Object({ ), }); +const videoRelations = { + slugs: () => { + return db + .select({ + slugs: coalesce(jsonbAgg(entryVideoJoin.slug), sql`'[]'::jsonb`).as( + "slugs", + ), + }) + .from(entryVideoJoin) + .where(eq(entryVideoJoin.videoPk, videos.pk)) + .as("slugs"); + }, + entries: ({ languages }: { languages: string[] }) => { + const transQ = getEntryTransQ(languages); + + return db + .select({ + json: coalesce( + jsonbAgg( + jsonbBuildObject({ + ...getColumns(entries), + ...getColumns(transQ), + number: entries.episodeNumber, + videos: entryVideosQ.videos, + progress: mapProgress({ aliased: false }), + createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, + updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, + }), + ), + sql`'[]'::jsonb`, + ).as("json"), + }) + .from(entries) + .innerJoin(transQ, eq(entries.pk, transQ.pk)) + .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) + .crossJoinLateral(entryVideosQ) + .innerJoin(entryVideoJoin, eq(entryVideoJoin.entryPk, entries.pk)) + .where(eq(entryVideoJoin.videoPk, videos.pk)) + .as("entries"); + }, + previous: ({ languages }: { languages: string[] }) => { + return getNextVideoEntry({ languages, prev: true }); + }, + next: getNextVideoEntry, +}; + +function getNextVideoEntry({ + languages, + prev = false, +}: { + languages: string[]; + prev?: boolean; +}) { + const transQ = getEntryTransQ(languages); + + // tables we use two times in the query bellow + const vids = alias(videos, `vid_${prev ? "prev" : "next"}`); + const entr = alias(entries, `entr_${prev ? "prev" : "next"}`); + const evj = alias(entryVideoJoin, `evj_${prev ? "prev" : "next"}`); + return db + .select({ + json: jsonbBuildObject({ + video: entryVideoJoin.slug, + entry: { + ...getColumns(entries), + ...getColumns(transQ), + number: entries.episodeNumber, + videos: entryVideosQ.videos, + progress: mapProgress({ aliased: false }), + createdAt: sql`to_char(${entries.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, + updatedAt: sql`to_char(${entries.updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, + }, + }), + }) + .from(entries) + .innerJoin(transQ, eq(entries.pk, transQ.pk)) + .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) + .crossJoinLateral(entryVideosQ) + .leftJoin(entryVideoJoin, eq(entries.pk, entryVideoJoin.entryPk)) + .innerJoin(vids, eq(vids.pk, entryVideoJoin.videoPk)) + .where( + and( + // either way it needs to be of the same show + eq( + entries.showPk, + db + .select({ showPk: entr.showPk }) + .from(entr) + .innerJoin(evj, eq(evj.entryPk, entr.pk)) + .where(eq(evj.videoPk, videos.pk)) + .limit(1), + ), + or( + // either the next entry + (prev ? lt : gt)( + entries.order, + db + .select({ order: (prev ? min : max)(entr.order) }) + .from(entr) + .innerJoin(evj, eq(evj.entryPk, entr.pk)) + .where(eq(evj.videoPk, videos.pk)), + ), + // or the second part of the current entry + and( + isNotNull(videos.part), + eq(vids.rendering, videos.rendering), + eq(vids.part, sql`${videos.part} ${sql.raw(prev ? "-" : "+")} 1`), + ), + ), + ), + ) + .orderBy( + prev ? desc(entries.order) : entries.order, + // prefer next part of the current entry over next entry + eq(vids.rendering, videos.rendering), + // always prefer latest version of video + desc(vids.version), + ) + .limit(1) + .as("next"); +} + export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .model({ video: Video, "created-videos": t.Array(CreatedVideo), error: t.Object({}), }) + .use(auth) + .get( + ":id", + async ({ + params: { id }, + query: { with: relations }, + headers: { "accept-language": langs }, + jwt: { sub }, + status, + }) => { + const languages = processLanguages(langs); + + // make an alias so entry video join is not usable on subqueries + const evj = alias(entryVideoJoin, "evj"); + + const [video] = await db + .select({ + ...getColumns(videos), + ...buildRelations( + ["slugs", "entries", ...relations], + videoRelations, + { + languages, + }, + ), + }) + .from(videos) + .leftJoin(evj, eq(videos.pk, evj.videoPk)) + .where(isUuid(id) ? eq(videos.id, id) : eq(evj.slug, id)) + .limit(1) + .execute({ userId: sub }); + if (!video) { + return status(404, { + status: 404, + message: `No video found with id or slug '${id}'`, + }); + } + return video; + }, + { + detail: { + description: "Get a video & it's related entries", + }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the video to retrieve.", + example: "made-in-abyss-s1e13", + }), + }), + query: t.Object({ + with: t.Array(t.UnionEnum(["previous", "next"]), { + default: [], + description: "Include related entries in the response.", + }), + }), + headers: t.Object( + { + "accept-language": AcceptLanguage(), + }, + { additionalProperties: true }, + ), + response: { + 200: t.Composite([ + Video, + t.Object({ + slugs: t.Array( + t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }), + ), + entries: t.Array(Entry), + previous: t.Optional( + t.Nullable( + t.Object({ + video: t.String({ + format: "slug", + examples: ["made-in-abyss-s1e12"], + }), + entry: Entry, + }), + ), + ), + next: t.Optional( + t.Nullable( + t.Object({ + video: t.String({ + format: "slug", + examples: ["made-in-abyss-dawn-of-the-deep-soul"], + }), + entry: Entry, + }), + ), + ), + }), + ]), + 404: { + ...KError, + description: "No video found with the given id or slug.", + }, + 422: KError, + }, + }, + ) .get( "", async () => { diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index 5add5b95..ecb737d8 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -124,7 +124,7 @@ export const jsonbObjectAgg = ( >`jsonb_object_agg(${sql.join([key, value], sql.raw(","))})`; }; -export const jsonbAgg = (val: SQL) => { +export const jsonbAgg = (val: SQL | SQLWrapper) => { return sql`jsonb_agg(${val})`; }; diff --git a/api/src/models/utils/relations.ts b/api/src/models/utils/relations.ts index 443cdf30..e4201fc6 100644 --- a/api/src/models/utils/relations.ts +++ b/api/src/models/utils/relations.ts @@ -15,7 +15,7 @@ export const buildRelations = < return Object.fromEntries( enabled.map((x) => [x, sql`${relations[x](params!)}`]), ) as { - [P in R]?: SQL< + [P in R]: SQL< ReturnType["_"]["selectedFields"] extends { [key: string]: infer TValue; } From fcac650322d5374ffb65e91481d7bfe7157c5082 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 19 Jul 2025 15:53:07 +0200 Subject: [PATCH 4/9] Fix swagger's href --- api/src/controllers/images.ts | 2 +- api/src/controllers/profiles/nextup.ts | 4 ++-- api/src/controllers/shows/collections.ts | 2 +- api/src/controllers/shows/logic.ts | 8 ++++++-- api/src/controllers/shows/movies.ts | 2 +- api/src/controllers/shows/series.ts | 2 +- api/src/controllers/staff.ts | 2 +- api/src/controllers/studios.ts | 2 +- api/src/controllers/videos.ts | 9 +-------- 9 files changed, 15 insertions(+), 18 deletions(-) diff --git a/api/src/controllers/images.ts b/api/src/controllers/images.ts index 07c54e10..2f690be9 100644 --- a/api/src/controllers/images.ts +++ b/api/src/controllers/images.ts @@ -166,7 +166,7 @@ export const imagesH = new Elysia({ tags: ["images"] }) response: { 302: t.Void({ description: - "Redirected to the [/images/{id}](#tag/images/GET/images/{id}) route.", + "Redirected to the [/images/{id}](#tag/images/get/api/images/{id}) route.", }), 404: { ...KError, diff --git a/api/src/controllers/profiles/nextup.ts b/api/src/controllers/profiles/nextup.ts index bbf64686..d305fac4 100644 --- a/api/src/controllers/profiles/nextup.ts +++ b/api/src/controllers/profiles/nextup.ts @@ -2,9 +2,9 @@ import { and, eq, sql } from "drizzle-orm"; import Elysia, { t } from "elysia"; import { auth } from "~/auth"; import { db } from "~/db"; -import { entries, entryTranslations } from "~/db/schema"; +import { entries } from "~/db/schema"; import { watchlist } from "~/db/schema/watchlist"; -import { getColumns, sqlarr } from "~/db/utils"; +import { getColumns } from "~/db/utils"; import { Entry } from "~/models/entry"; import { AcceptLanguage, diff --git a/api/src/controllers/shows/collections.ts b/api/src/controllers/shows/collections.ts index 34ddf4fd..b53cbe4e 100644 --- a/api/src/controllers/shows/collections.ts +++ b/api/src/controllers/shows/collections.ts @@ -130,7 +130,7 @@ export const collections = new Elysia({ response: { 302: t.Void({ description: - "Redirected to the [/collections/{id}](#tag/collections/GET/collections/{id}) route.", + "Redirected to the [/collections/{id}](#tag/collections/get/api/collections/{id}) route.", }), 404: { ...KError, diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index e2a498bb..51d4b985 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -2,7 +2,6 @@ import { and, eq, exists, ne, type SQL, sql } from "drizzle-orm"; import { db } from "~/db"; import { entries, - entryTranslations, entryVideoJoin, profiles, showStudioJoin, @@ -36,7 +35,12 @@ import { } from "~/models/utils"; import type { EmbeddedVideo } from "~/models/video"; import { WatchlistStatus } from "~/models/watchlist"; -import { entryProgressQ, entryVideosQ, getEntryTransQ, mapProgress } from "../entries"; +import { + entryProgressQ, + entryVideosQ, + getEntryTransQ, + mapProgress, +} from "../entries"; export const watchStatusQ = db .select({ diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts index 9f619b34..e47c7bd5 100644 --- a/api/src/controllers/shows/movies.ts +++ b/api/src/controllers/shows/movies.ts @@ -120,7 +120,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) response: { 302: t.Void({ description: - "Redirected to the [/movies/{id}](#tag/movies/GET/movies/{id}) route.", + "Redirected to the [/movies/{id}](#tag/movies/get/api/movies/{id}) route.", }), 404: { ...KError, diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index 52b6de52..853d13b3 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -123,7 +123,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) response: { 302: t.Void({ description: - "Redirected to the [/series/{id}](#tag/series/GET/series/{id}) route.", + "Redirected to the [/series/{id}](#tag/series/get/api/series/{id}) route.", }), 404: { ...KError, diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index a094249e..699e45b2 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -175,7 +175,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) response: { 302: t.Void({ description: - "Redirected to the [/staff/{id}](#tag/staff/GET/staff/{id}) route.", + "Redirected to the [/staff/{id}](#tag/staff/get/api/staff/{id}) route.", }), 404: { ...KError, diff --git a/api/src/controllers/studios.ts b/api/src/controllers/studios.ts index 65841ede..81097886 100644 --- a/api/src/controllers/studios.ts +++ b/api/src/controllers/studios.ts @@ -215,7 +215,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) response: { 302: t.Void({ description: - "Redirected to the [/studios/{id}](#tag/studios/GET/studios/{id}) route.", + "Redirected to the [/studios/{id}](#tag/studios/get/api/studios/{id}) route.", }), 404: { ...KError, diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 6e869b55..dacdc9e0 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -9,20 +9,13 @@ import { min, notExists, or, - type SQL, sql, } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { Elysia, t } from "elysia"; import { auth } from "~/auth"; import { db, type Transaction } from "~/db"; -import { - entries, - entryTranslations, - entryVideoJoin, - shows, - videos, -} from "~/db/schema"; +import { entries, entryVideoJoin, shows, videos } from "~/db/schema"; import { coalesce, conflictUpdateAllExcept, From dc0c412edacc9d3ca6a63d65a0bc4b0e8c5d3a00 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 19 Jul 2025 16:40:31 +0200 Subject: [PATCH 5/9] fixup! Add helper function for entries' translations --- api/src/controllers/profiles/nextup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/controllers/profiles/nextup.ts b/api/src/controllers/profiles/nextup.ts index d305fac4..afc2fff0 100644 --- a/api/src/controllers/profiles/nextup.ts +++ b/api/src/controllers/profiles/nextup.ts @@ -101,7 +101,7 @@ export const nextup = new Elysia({ tags: ["profiles"] }) order: sql`${order}`, seasonNumber: sql`${seasonNumber}`, episodeNumber: sql`${episodeNumber}`, - name: sql`${name}`, + name: sql`${transQ.name}`, }) .from(entries) .innerJoin(watchlist, eq(watchlist.nextEntry, entries.pk)) From f99a144bc45d6ee9e19b5d8bdf3b07614a9ccc99 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 19 Jul 2025 17:40:33 +0200 Subject: [PATCH 6/9] Add tests for `/videos/:id` --- api/src/models/examples/made-in-abyss.ts | 75 +++++++++ api/tests/helpers/videos-helper.ts | 19 +++ api/tests/videos/get-id.test.ts | 204 +++++++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 api/tests/videos/get-id.test.ts diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts index fae77b19..ea3ade33 100644 --- a/api/src/models/examples/made-in-abyss.ts +++ b/api/src/models/examples/made-in-abyss.ts @@ -266,6 +266,81 @@ export const madeInAbyss = { }, }, }, + { + kind: "episode", + order: 15, + seasonNumber: 2, + episodeNumber: 2, + translations: { + en: { + name: " Resurrection Festival ", + description: + "Riko and Reg find out more about their past but the question still remains, who or what exactly is Reg?", + }, + }, + runtime: 23, + airDate: "2022-07-06", + thumbnail: + "https://artworks.thetvdb.com/banners/episodes/326109/6174129.jpg", + externalId: { + themoviedatabase: { + serieId: "72636", + season: 2, + episode: 2, + link: "https://www.themoviedb.org/tv/72636/season/2/episode/2", + }, + }, + }, + { + kind: "episode", + order: 16, + seasonNumber: 2, + episodeNumber: 3, + translations: { + en: { + name: "Departure", + description: + "Reg goes on his first cave raid! Meanwhile, Riko makes preparations to go into the abyss to find her mother.", + }, + }, + runtime: 23, + airDate: "2022-07-06", + thumbnail: + "https://artworks.thetvdb.com/banners/episodes/326109/6180539.jpg", + externalId: { + themoviedatabase: { + serieId: "72636", + season: 2, + episode: 3, + link: "https://www.themoviedb.org/tv/72636/season/2/episode/4", + }, + }, + }, + { + kind: "episode", + order: 17, + seasonNumber: 2, + episodeNumber: 4, + translations: { + en: { + name: "The Edge of the Abyss", + description: + "Riko and Reg start their adventure into the Abyss, while there they run into an unexpected familiar face.", + }, + }, + runtime: 23, + airDate: "2022-07-06", + thumbnail: + "https://artworks.thetvdb.com/banners/episodes/326109/6180540.jpg", + externalId: { + themoviedatabase: { + serieId: "72636", + season: 2, + episode: 4, + link: "https://www.themoviedb.org/tv/72636/season/2/episode/4", + }, + }, + }, ], extras: [ { diff --git a/api/tests/helpers/videos-helper.ts b/api/tests/helpers/videos-helper.ts index d21a94dd..c301d3a4 100644 --- a/api/tests/helpers/videos-helper.ts +++ b/api/tests/helpers/videos-helper.ts @@ -29,6 +29,25 @@ export const getVideos = async () => { return [resp, body] as const; }; +export const getVideo = async ( + id: string, + { langs, ...query }: { langs?: string; with?: string[] }, +) => { + const resp = await handlers.handle( + new Request(buildUrl(`videos/${id}`, query), { + method: "GET", + headers: langs + ? { + "Accept-Language": langs, + ...(await getJwtHeaders()), + } + : await getJwtHeaders(), + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; + export const deleteVideo = async (paths: string[]) => { const resp = await handlers.handle( new Request(buildUrl("videos"), { diff --git a/api/tests/videos/get-id.test.ts b/api/tests/videos/get-id.test.ts new file mode 100644 index 00000000..33fb147e --- /dev/null +++ b/api/tests/videos/get-id.test.ts @@ -0,0 +1,204 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { createSerie, createVideo, getVideo } from "tests/helpers"; +import { expectStatus } from "tests/utils"; +import { db } from "~/db"; +import { shows, videos } from "~/db/schema"; +import { madeInAbyss } from "~/models/examples"; + +beforeAll(async () => { + await db.delete(shows); + const [ret, _] = await createSerie(madeInAbyss); + expect(ret.status).toBe(201); + await db.delete(videos); + + await createVideo([ + { + path: "/video/Made in abyss S01E13.mkv", + rendering: "mia13", + part: null, + version: 1, + guess: { + title: "Made in abyss", + episodes: [{ season: 1, episode: 13 }], + kind: "episode", + from: "guessit", + history: [], + }, + for: [{ serie: madeInAbyss.slug, season: 1, episode: 13 }], + }, + { + path: "/video/Made in abyss movie.mkv", + rendering: "mia-movie", + part: null, + version: 1, + guess: { + title: "Made in abyss", + kind: "movie", + from: "guessit", + history: [], + }, + // TODO: i feel like there's a better way than that. we need to make this api better + for: [{ serie: madeInAbyss.slug, order: 13.5 }], + }, + { + path: "/video/Made in abyss s2e1 p1.mkv", + rendering: "mia-s2e1", + part: 1, + version: 1, + guess: { + title: "Made in abyss", + kind: "episode", + episodes: [{ season: 2, episode: 1 }], + from: "guessit", + history: [], + }, + for: [{ serie: madeInAbyss.slug, season: 2, episode: 1 }], + }, + { + path: "/video/Made in abyss s2e1 p2.mkv", + rendering: "mia-s2e1", + part: 2, + version: 1, + guess: { + title: "Made in abyss", + kind: "episode", + episodes: [{ season: 2, episode: 1 }], + from: "guessit", + history: [], + }, + for: [{ serie: madeInAbyss.slug, season: 2, episode: 1 }], + }, + { + path: "/video/Made in abyss s2e1 p2 v2.mkv", + rendering: "mia-s2e1", + part: 2, + version: 2, + guess: { + title: "Made in abyss", + kind: "episode", + episodes: [{ season: 2, episode: 1 }], + from: "guessit", + history: [], + }, + for: [{ serie: madeInAbyss.slug, season: 2, episode: 1 }], + }, + { + path: "/video/Made in abyss s2e2.mkv", + rendering: "mia-s2e2", + part: null, + version: 2, + guess: { + title: "Made in abyss", + kind: "episode", + episodes: [{ season: 2, episode: 2 }], + from: "guessit", + history: [], + }, + for: [ + { serie: madeInAbyss.slug, season: 2, episode: 2 }, + { serie: madeInAbyss.slug, season: 2, episode: 3 }, + ], + }, + { + path: "/video/Made in abyss s2e4.mkv", + rendering: "mia-s2e4", + part: null, + version: 2, + guess: { + title: "Made in abyss", + kind: "episode", + episodes: [{ season: 2, episode: 4 }], + from: "guessit", + history: [], + }, + for: [{ serie: madeInAbyss.slug, season: 2, episode: 4 }], + }, + ]); +}); + +describe("Get videos", () => { + it("Invalid slug", async () => { + const [resp, body] = await getVideo("sotneuhn", { langs: "en" }); + expectStatus(resp, body).toBe(404); + expect(body).toMatchObject({ + status: 404, + message: expect.any(String), + }); + }); + + it("Get video", async () => { + const [resp, body] = await getVideo("made-in-abyss-s1e13", { langs: "en" }); + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + id: expect.any(String), + path: "/video/Made in abyss S01E13.mkv", + rendering: "mia13", + part: null, + version: 1, + guess: { + title: "Made in abyss", + episodes: [{ season: 1, episode: 13 }], + kind: "episode", + from: "guessit", + history: [], + }, + slugs: ["made-in-abyss-s1e13"], + }); + }); + + it("Get video with null previous", async () => { + const [resp, body] = await getVideo("made-in-abyss-s1e13", { + langs: "en", + with: ["previous"], + }); + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + id: expect.any(String), + path: "/video/Made in abyss S01E13.mkv", + rendering: "mia13", + part: null, + version: 1, + guess: { + title: "Made in abyss", + episodes: [{ season: 1, episode: 13 }], + kind: "episode", + from: "guessit", + history: [], + }, + slugs: ["made-in-abyss-s1e13"], + previous: null, + }); + }); + + it("Get video with movie next", async () => { + const [resp, body] = await getVideo("made-in-abyss-s1e13", { + langs: "en", + with: ["previous", "next"], + }); + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + id: expect.any(String), + path: "/video/Made in abyss S01E13.mkv", + rendering: "mia13", + part: null, + version: 1, + guess: { + title: "Made in abyss", + episodes: [{ season: 1, episode: 13 }], + kind: "episode", + from: "guessit", + history: [], + }, + slugs: ["made-in-abyss-s1e13"], + previous: null, + next: { + video: "made-in-abyss-dawn-of-the-deep-soul", + entry: expect.objectContaining({ + slug: "made-in-abyss-dawn-of-the-deep-soul", + name: "Made in Abyss: Dawn of the Deep Soul", + order: 13.5, + }), + }, + }); + }); +}); From 28702b9cdbdca9340979139d7c3e5d1e8a5f7073 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 19 Jul 2025 17:55:13 +0200 Subject: [PATCH 7/9] Test `/videos/id` with multi-part episodes --- api/src/controllers/videos.ts | 2 + api/tests/videos/get-id.test.ts | 87 ++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index dacdc9e0..f88a6deb 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -317,6 +317,8 @@ function getNextVideoEntry({ prev ? desc(entries.order) : entries.order, // prefer next part of the current entry over next entry eq(vids.rendering, videos.rendering), + // take the first part available + vids.part, // always prefer latest version of video desc(vids.version), ) diff --git a/api/tests/videos/get-id.test.ts b/api/tests/videos/get-id.test.ts index 33fb147e..a753d179 100644 --- a/api/tests/videos/get-id.test.ts +++ b/api/tests/videos/get-id.test.ts @@ -7,11 +7,11 @@ import { madeInAbyss } from "~/models/examples"; beforeAll(async () => { await db.delete(shows); - const [ret, _] = await createSerie(madeInAbyss); + let [ret, _] = await createSerie(madeInAbyss); expect(ret.status).toBe(201); await db.delete(videos); - await createVideo([ + [ret, _] = await createVideo([ { path: "/video/Made in abyss S01E13.mkv", rendering: "mia13", @@ -114,6 +114,7 @@ beforeAll(async () => { for: [{ serie: madeInAbyss.slug, season: 2, episode: 4 }], }, ]); + expect(ret.status).toBe(201); }); describe("Get videos", () => { @@ -201,4 +202,86 @@ describe("Get videos", () => { }, }); }); + + it("Get video with multi-part next", async () => { + const [resp, body] = await getVideo("made-in-abyss-dawn-of-the-deep-soul", { + langs: "en", + with: ["previous", "next"], + }); + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + path: "/video/Made in abyss movie.mkv", + slugs: ["made-in-abyss-dawn-of-the-deep-soul"], + previous: { + video: "made-in-abyss-s1e13", + entry: expect.objectContaining({ + slug: "made-in-abyss-s1e13", + order: 13, + }), + }, + next: { + video: "made-in-abyss-s2e1-p1", + entry: expect.objectContaining({ + slug: "made-in-abyss-s2e1", + seasonNumber: 2, + episodeNumber: 1, + }), + }, + }); + }); + + it("Get first part", async () => { + const [resp, body] = await getVideo("made-in-abyss-s2e1-p1", { + langs: "en", + with: ["previous", "next"], + }); + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + path: "/video/Made in abyss s2e1 p1.mkv", + slugs: ["made-in-abyss-s2e1-p1"], + previous: { + video: "made-in-abyss-dawn-of-the-deep-soul", + entry: expect.objectContaining({ + slug: "made-in-abyss-dawn-of-the-deep-soul", + order: 13.5, + }), + }, + next: { + video: "made-in-abyss-s2e1-p2-v2", + entry: expect.objectContaining({ + slug: "made-in-abyss-s2e1", + seasonNumber: 2, + episodeNumber: 1, + }), + }, + }); + }); + + it("Get second part", async () => { + const [resp, body] = await getVideo("made-in-abyss-s2e1-p2-v2", { + langs: "en", + with: ["previous", "next"], + }); + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + path: "/video/Made in abyss s2e1 p2 v2.mkv", + slugs: ["made-in-abyss-s2e1-p2-v2"], + previous: { + video: "made-in-abyss-s2e1-p1", + entry: expect.objectContaining({ + slug: "made-in-abyss-s2e1", + seasonNumber: 2, + episodeNumber: 1, + }), + }, + next: { + video: "made-in-abyss-s2e2-v2", + entry: expect.objectContaining({ + slug: "made-in-abyss-s2e2", + seasonNumber: 2, + episodeNumber: 2, + }), + }, + }); + }); }); From e0d5754519451a4d7f131cc74df4dd445bbbaf8f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 19 Jul 2025 17:57:19 +0200 Subject: [PATCH 8/9] Test multi-versions in `/videos/:id` --- api/tests/videos/get-id.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/api/tests/videos/get-id.test.ts b/api/tests/videos/get-id.test.ts index a753d179..66bf8e5f 100644 --- a/api/tests/videos/get-id.test.ts +++ b/api/tests/videos/get-id.test.ts @@ -284,4 +284,32 @@ describe("Get videos", () => { }, }); }); + + it("Get v1", async () => { + const [resp, body] = await getVideo("made-in-abyss-s2e1-p2", { + langs: "en", + with: ["previous", "next"], + }); + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + path: "/video/Made in abyss s2e1 p2.mkv", + slugs: ["made-in-abyss-s2e1-p2"], + previous: { + video: "made-in-abyss-s2e1-p1", + entry: expect.objectContaining({ + slug: "made-in-abyss-s2e1", + seasonNumber: 2, + episodeNumber: 1, + }), + }, + next: { + video: "made-in-abyss-s2e2-v2", + entry: expect.objectContaining({ + slug: "made-in-abyss-s2e2", + seasonNumber: 2, + episodeNumber: 2, + }), + }, + }); + }); }); From 335b003b7cb4aab6ae491611bf962216dadc4482 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 19 Jul 2025 18:03:05 +0200 Subject: [PATCH 9/9] Test multi entry video with `/videos/:id` --- api/tests/videos/get-id.test.ts | 94 ++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 6 deletions(-) diff --git a/api/tests/videos/get-id.test.ts b/api/tests/videos/get-id.test.ts index 66bf8e5f..d974822b 100644 --- a/api/tests/videos/get-id.test.ts +++ b/api/tests/videos/get-id.test.ts @@ -83,14 +83,17 @@ beforeAll(async () => { for: [{ serie: madeInAbyss.slug, season: 2, episode: 1 }], }, { - path: "/video/Made in abyss s2e2.mkv", + path: "/video/Made in abyss s2e2&3.mkv", rendering: "mia-s2e2", part: null, - version: 2, + version: 1, guess: { title: "Made in abyss", kind: "episode", - episodes: [{ season: 2, episode: 2 }], + episodes: [ + { season: 2, episode: 2 }, + { season: 2, episode: 3 }, + ], from: "guessit", history: [], }, @@ -103,7 +106,7 @@ beforeAll(async () => { path: "/video/Made in abyss s2e4.mkv", rendering: "mia-s2e4", part: null, - version: 2, + version: 1, guess: { title: "Made in abyss", kind: "episode", @@ -275,7 +278,7 @@ describe("Get videos", () => { }), }, next: { - video: "made-in-abyss-s2e2-v2", + video: "made-in-abyss-s2e2", entry: expect.objectContaining({ slug: "made-in-abyss-s2e2", seasonNumber: 2, @@ -303,7 +306,7 @@ describe("Get videos", () => { }), }, next: { - video: "made-in-abyss-s2e2-v2", + video: "made-in-abyss-s2e2", entry: expect.objectContaining({ slug: "made-in-abyss-s2e2", seasonNumber: 2, @@ -312,4 +315,83 @@ describe("Get videos", () => { }, }); }); + + it("Get multi entry video", async () => { + const [resp, body] = await getVideo("made-in-abyss-s2e2", { + langs: "en", + with: ["previous", "next"], + }); + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + path: "/video/Made in abyss s2e2&3.mkv", + slugs: ["made-in-abyss-s2e2", "made-in-abyss-s2e3"], + previous: { + // when going to the prev episode, go to the first part of it + video: "made-in-abyss-s2e1-p1", + entry: expect.objectContaining({ + slug: "made-in-abyss-s2e1", + seasonNumber: 2, + episodeNumber: 1, + }), + }, + next: { + video: "made-in-abyss-s2e4", + entry: expect.objectContaining({ + slug: "made-in-abyss-s2e4", + seasonNumber: 2, + episodeNumber: 4, + }), + }, + }); + }); + + it("Get multi entry video (ep 2)", async () => { + const [resp, body] = await getVideo("made-in-abyss-s2e3", { + langs: "en", + with: ["previous", "next"], + }); + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + path: "/video/Made in abyss s2e2&3.mkv", + slugs: ["made-in-abyss-s2e2", "made-in-abyss-s2e3"], + previous: { + // when going to the prev episode, go to the first part of it + video: "made-in-abyss-s2e1-p1", + entry: expect.objectContaining({ + slug: "made-in-abyss-s2e1", + seasonNumber: 2, + episodeNumber: 1, + }), + }, + next: { + video: "made-in-abyss-s2e4", + entry: expect.objectContaining({ + slug: "made-in-abyss-s2e4", + seasonNumber: 2, + episodeNumber: 4, + }), + }, + }); + }); + + it("Get last ep with next=null", async () => { + const [resp, body] = await getVideo("made-in-abyss-s2e4", { + langs: "en", + with: ["previous", "next"], + }); + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + path: "/video/Made in abyss s2e4.mkv", + slugs: ["made-in-abyss-s2e4"], + previous: { + video: "made-in-abyss-s2e3", + entry: expect.objectContaining({ + slug: "made-in-abyss-s2e3", + seasonNumber: 2, + episodeNumber: 3, + }), + }, + next: null, + }); + }); });