Add /videos/:id?with=next,previous route (#1019)

This commit is contained in:
Zoe Roux 2025-07-19 18:05:11 +02:00 committed by GitHub
commit d5a747f40d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 784 additions and 58 deletions

View File

@ -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<number>`${order}`,
seasonNumber: sql<number>`${seasonNumber}`,
episodeNumber: sql<number>`${episodeNumber}`,
name: sql<string>`${name}`,
name: sql<string>`${transQ.name}`,
})
.from(entries)
.innerJoin(transQ, eq(entries.pk, transQ.pk))

View File

@ -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,

View File

@ -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,
@ -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.
@ -109,7 +101,7 @@ export const nextup = new Elysia({ tags: ["profiles"] })
order: sql<number>`${order}`,
seasonNumber: sql<number>`${seasonNumber}`,
episodeNumber: sql<number>`${episodeNumber}`,
name: sql<string>`${name}`,
name: sql<string>`${transQ.name}`,
})
.from(entries)
.innerJoin(watchlist, eq(watchlist.nextEntry, entries.pk))

View File

@ -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,

View File

@ -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, mapProgress } from "../entries";
import {
entryProgressQ,
entryVideosQ,
getEntryTransQ,
mapProgress,
} from "../entries";
export const watchStatusQ = db
.select({
@ -147,7 +151,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 +189,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<Entry>({
...getColumns(entries),
...transCol,
...getColumns(transQ),
number: entries.episodeNumber,
videos: entryVideosQ.videos,
progress: mapProgress({ aliased: false }),
@ -217,21 +213,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<Entry>({
...getColumns(entries),
...transCol,
...getColumns(transQ),
number: entries.episodeNumber,
videos: entryVideosQ.videos,
progress: mapProgress({ aliased: false }),

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -1,22 +1,43 @@
import { and, eq, notExists, or, sql } from "drizzle-orm";
import {
and,
desc,
eq,
gt,
isNotNull,
lt,
max,
min,
notExists,
or,
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, 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 +45,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 +202,237 @@ 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<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"')`,
}),
),
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<Entry>({
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),
// take the first part available
vids.part,
// 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 () => {

View File

@ -124,7 +124,7 @@ export const jsonbObjectAgg = <T>(
>`jsonb_object_agg(${sql.join([key, value], sql.raw(","))})`;
};
export const jsonbAgg = <T>(val: SQL<T>) => {
export const jsonbAgg = <T>(val: SQL<T> | SQLWrapper) => {
return sql<T[]>`jsonb_agg(${val})`;
};

View File

@ -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: [
{

View File

@ -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)),
}),
]);

View File

@ -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<Rel[P]>["_"]["selectedFields"] extends {
[key: string]: infer TValue;
}

View File

@ -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"), {

View File

@ -0,0 +1,397 @@
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);
let [ret, _] = await createSerie(madeInAbyss);
expect(ret.status).toBe(201);
await db.delete(videos);
[ret, _] = 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&3.mkv",
rendering: "mia-s2e2",
part: null,
version: 1,
guess: {
title: "Made in abyss",
kind: "episode",
episodes: [
{ season: 2, episode: 2 },
{ season: 2, episode: 3 },
],
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: 1,
guess: {
title: "Made in abyss",
kind: "episode",
episodes: [{ season: 2, episode: 4 }],
from: "guessit",
history: [],
},
for: [{ serie: madeInAbyss.slug, season: 2, episode: 4 }],
},
]);
expect(ret.status).toBe(201);
});
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,
}),
},
});
});
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",
entry: expect.objectContaining({
slug: "made-in-abyss-s2e2",
seasonNumber: 2,
episodeNumber: 2,
}),
},
});
});
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",
entry: expect.objectContaining({
slug: "made-in-abyss-s2e2",
seasonNumber: 2,
episodeNumber: 2,
}),
},
});
});
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,
});
});
});