mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-31 14:33:50 -04:00
Add /videos/:id?with=next,previous route (#1019)
This commit is contained in:
commit
d5a747f40d
@ -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))
|
||||
|
@ -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,
|
||||
|
@ -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))
|
||||
|
@ -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,
|
||||
|
@ -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 }),
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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 () => {
|
||||
|
@ -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})`;
|
||||
};
|
||||
|
||||
|
@ -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: [
|
||||
{
|
||||
|
@ -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)),
|
||||
}),
|
||||
]);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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"), {
|
||||
|
397
api/tests/videos/get-id.test.ts
Normal file
397
api/tests/videos/get-id.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user