Implement admin video mapper (#1350)

This commit is contained in:
Zoe Roux 2026-03-11 12:59:01 +01:00 committed by GitHub
commit f4d3e86735
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 2054 additions and 801 deletions

View File

@ -8,13 +8,16 @@ import { nextup } from "./controllers/profiles/nextup";
import { watchlistH } from "./controllers/profiles/watchlist";
import { seasonsH } from "./controllers/seasons";
import { seed } from "./controllers/seed";
import { videoLinkH } from "./controllers/seed/video-links";
import { videosWriteH } from "./controllers/seed/videos";
import { collections } from "./controllers/shows/collections";
import { movies } from "./controllers/shows/movies";
import { series } from "./controllers/shows/series";
import { showsH } from "./controllers/shows/shows";
import { staffH } from "./controllers/staff";
import { studiosH } from "./controllers/studios";
import { videosReadH, videosWriteH } from "./controllers/videos";
import { videosMetadata } from "./controllers/video-metadata";
import { videosReadH } from "./controllers/videos";
import { dbRaw } from "./db";
import { KError } from "./models/error";
import { appWs } from "./websockets";
@ -124,7 +127,8 @@ export const handlers = new Elysia({ prefix })
.use(watchlistH)
.use(historyH)
.use(nextup)
.use(videosReadH),
.use(videosReadH)
.use(videosMetadata),
)
.guard(
{
@ -137,5 +141,5 @@ export const handlers = new Elysia({ prefix })
},
permissions: ["core.write"],
},
(app) => app.use(videosWriteH).use(seed),
(app) => app.use(videosWriteH).use(videoLinkH).use(seed),
);

View File

@ -0,0 +1,314 @@
import { and, eq, gt, ne, 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 { sqlarr, unnest } from "~/db/utils";
import { bubble } from "~/models/examples";
import { isUuid } from "~/models/utils";
import { SeedVideo } from "~/models/video";
import { computeVideoSlug } from "./insert/entries";
import { updateAvailableCount, updateAvailableSince } from "./insert/shows";
const LinkReq = t.Array(
t.Object({
id: t.String({
description: "Id of the video",
format: "uuid",
}),
for: t.Array(SeedVideo.properties.for.items),
}),
);
type LinkReq = typeof LinkReq.static;
const LinkRet = t.Array(
t.Object({
id: t.String({ format: "uuid" }),
path: t.String({ examples: ["/video/made in abyss s1e13.mkv"] }),
entries: t.Array(
t.Object({
slug: t.String({
format: "slug",
examples: ["made-in-abyss-s1e13"],
}),
}),
),
}),
);
type LinkRet = typeof LinkRet.static;
async function mapBody(tx: Transaction, body: LinkReq) {
const vids = await tx
.select({ pk: videos.pk, id: videos.id, path: videos.path })
.from(videos)
.where(eq(videos.id, sql`any(${sqlarr(body.map((x) => x.id))})`));
const mapped = body.flatMap((x) =>
x.for.map((e) => ({
video: vids.find((v) => v.id === x.id)!.pk,
entry: {
...e,
movie:
"movie" in e
? isUuid(e.movie)
? { id: e.movie }
: { slug: e.movie }
: undefined,
serie:
"serie" in e
? isUuid(e.serie)
? { id: e.serie }
: { slug: e.serie }
: undefined,
},
})),
);
return [vids, mapped] as const;
}
export async function linkVideos(
tx: Transaction,
links: {
video: number;
entry: Omit<SeedVideo["for"], "movie" | "serie"> & {
movie?: { id?: string; slug?: string };
serie?: { id?: string; slug?: string };
};
}[],
) {
if (!links.length) return {};
const entriesQ = tx
.select({
pk: entries.pk,
id: entries.id,
slug: entries.slug,
kind: entries.kind,
seasonNumber: entries.seasonNumber,
episodeNumber: entries.episodeNumber,
order: entries.order,
showId: sql`${shows.id}`.as("showId"),
showSlug: sql`${shows.slug}`.as("showSlug"),
externalId: entries.externalId,
})
.from(entries)
.innerJoin(shows, eq(entries.showPk, shows.pk))
.as("entriesQ");
const renderVid = alias(videos, "renderVid");
const hasRenderingQ = or(
gt(
sql`dense_rank() over (partition by ${entriesQ.pk} order by ${videos.rendering})`,
1,
),
sql`exists(${tx
.select()
.from(entryVideoJoin)
.innerJoin(renderVid, eq(renderVid.pk, entryVideoJoin.videoPk))
.where(
and(
eq(entryVideoJoin.entryPk, entriesQ.pk),
ne(renderVid.rendering, videos.rendering),
),
)})`,
)!;
const ret = await tx
.insert(entryVideoJoin)
.select(
tx
.selectDistinctOn([entriesQ.pk, videos.pk], {
entryPk: entriesQ.pk,
videoPk: videos.pk,
slug: computeVideoSlug(entriesQ.slug, hasRenderingQ),
})
.from(
unnest(links, "j", {
video: "integer",
entry: "jsonb",
}),
)
.innerJoin(videos, eq(videos.pk, sql`j.video`))
.innerJoin(
entriesQ,
or(
and(
sql`j.entry ? 'slug'`,
eq(entriesQ.slug, sql`j.entry->>'slug'`),
),
and(
sql`j.entry ? 'movie'`,
or(
eq(entriesQ.showId, sql`(j.entry #>> '{movie, id}')::uuid`),
eq(entriesQ.showSlug, sql`j.entry #>> '{movie, slug}'`),
),
eq(entriesQ.kind, "movie"),
),
and(
sql`j.entry ? 'serie'`,
or(
eq(entriesQ.showId, sql`(j.entry #>> '{serie, id}')::uuid`),
eq(entriesQ.showSlug, sql`j.entry #>> '{serie, slug}'`),
),
or(
and(
sql`j.entry ?& array['season', 'episode']`,
eq(entriesQ.seasonNumber, sql`(j.entry->>'season')::integer`),
eq(
entriesQ.episodeNumber,
sql`(j.entry->>'episode')::integer`,
),
),
and(
sql`j.entry ? 'order'`,
eq(entriesQ.order, sql`(j.entry->>'order')::float`),
),
and(
sql`j.entry ? 'special'`,
eq(
entriesQ.episodeNumber,
sql`(j.entry->>'special')::integer`,
),
eq(entriesQ.kind, "special"),
),
),
),
and(
sql`j.entry ? 'externalId'`,
sql`j.entry->'externalId' <@ ${entriesQ.externalId}`,
),
),
),
)
.onConflictDoUpdate({
target: [entryVideoJoin.entryPk, entryVideoJoin.videoPk],
// this is basically a `.onConflictDoNothing()` but we want `returning` to give us the existing data
set: { entryPk: sql`excluded.entry_pk` },
})
.returning({
slug: entryVideoJoin.slug,
entryPk: entryVideoJoin.entryPk,
videoPk: entryVideoJoin.videoPk,
});
const entr = ret.reduce(
(acc, x) => {
acc[x.videoPk] ??= [];
acc[x.videoPk].push({ slug: x.slug });
return acc;
},
{} as Record<number, { slug: string }[]>,
);
const entriesPk = [...new Set(ret.map((x) => x.entryPk))];
await updateAvailableCount(
tx,
tx
.selectDistinct({ pk: entries.showPk })
.from(entries)
.where(eq(entries.pk, sql`any(${sqlarr(entriesPk)})`)),
);
await updateAvailableSince(tx, entriesPk);
return entr;
}
export const videoLinkH = new Elysia({ prefix: "/videos", tags: ["videos"] })
.use(auth)
.post(
"/link",
async ({ body, status }) => {
return await db.transaction(async (tx) => {
const [vids, mapped] = await mapBody(tx, body);
const links = await linkVideos(tx, mapped);
return status(
201,
vids.map((x) => ({
id: x.id,
path: x.path,
entries: links[x.pk] ?? [],
})),
);
});
},
{
detail: {
description: "Link existing videos to existing entries",
},
body: LinkReq,
response: {
201: LinkRet,
},
},
)
.put(
"/link",
async ({ body, status }) => {
return await db.transaction(async (tx) => {
const [vids, mapped] = await mapBody(tx, body);
await tx
.delete(entryVideoJoin)
.where(
eq(
entryVideoJoin.videoPk,
sql`any(${sqlarr(vids.map((x) => x.pk))})`,
),
);
const links = await linkVideos(tx, mapped);
return status(
201,
vids.map((x) => ({
id: x.id,
path: x.path,
entries: links[x.pk] ?? [],
})),
);
});
},
{
detail: {
description:
"Override all links between the specified videos and entries.",
},
body: LinkReq,
response: {
201: LinkRet,
},
},
)
.delete(
"/link",
async ({ body }) => {
return await db.transaction(async (tx) => {
const ret = await tx
.delete(entryVideoJoin)
.where(eq(entryVideoJoin.slug, sql`any(${sqlarr(body)})`))
.returning({
slug: entryVideoJoin.slug,
entryPk: entryVideoJoin.entryPk,
});
const entriesPk = [...new Set(ret.map((x) => x.entryPk))];
await updateAvailableCount(
tx,
tx
.selectDistinct({ pk: entries.showPk })
.from(entries)
.where(eq(entries.pk, sql`any(${sqlarr(entriesPk)})`)),
);
await updateAvailableSince(tx, entriesPk);
return ret.map((x) => x.slug);
});
},
{
detail: {
description: "Delete links between an entry and a video by their slug",
},
body: t.Array(t.String({ format: "slug", examples: [bubble.slug] })),
response: {
200: t.Array(t.String({ format: "slug", examples: [bubble.slug] })),
},
},
);

View File

@ -0,0 +1,240 @@
import { and, eq, notExists, sql } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { auth } from "~/auth";
import { db } from "~/db";
import { entries, entryVideoJoin, videos } from "~/db/schema";
import {
conflictUpdateAllExcept,
isUniqueConstraint,
sqlarr,
unnestValues,
} from "~/db/utils";
import { KError } from "~/models/error";
import { bubbleVideo } from "~/models/examples";
import { isUuid } from "~/models/utils";
import { Guess, SeedVideo, Video } from "~/models/video";
import { comment } from "~/utils";
import { updateAvailableCount } from "./insert/shows";
import { linkVideos } from "./video-links";
const CreatedVideo = t.Object({
id: t.String({ format: "uuid" }),
path: t.String({ examples: [bubbleVideo.path] }),
guess: t.Omit(Guess, ["history"]),
entries: t.Array(
t.Object({
slug: t.String({ format: "slug", examples: ["bubble-v2"] }),
}),
),
});
async function createVideos(body: SeedVideo[], clearLinks: boolean) {
if (body.length === 0) {
return { status: 422, message: "No videos" } as const;
}
return await db.transaction(async (tx) => {
let vids: { pk: number; id: string; path: string; guess: Guess }[] = [];
try {
vids = await tx
.insert(videos)
.select(unnestValues(body, videos))
.onConflictDoUpdate({
target: [videos.path],
set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]),
})
.returning({
pk: videos.pk,
id: videos.id,
path: videos.path,
guess: videos.guess,
});
} catch (e) {
if (!isUniqueConstraint(e)) throw e;
return {
status: 409,
message: comment`
Invalid rendering. A video with the same (rendering, part, version) combo
(but with a different path) already exists in db.
rendering should be computed by the sha of your path (excluding only the version & part numbers)
`,
} as const;
}
const vidEntries = body.flatMap((x) => {
if (!x.for) return [];
return x.for.map((e) => ({
video: vids.find((v) => v.path === x.path)!.pk,
entry: {
...e,
movie:
"movie" in e
? isUuid(e.movie)
? { id: e.movie }
: { slug: e.movie }
: undefined,
serie:
"serie" in e
? isUuid(e.serie)
? { id: e.serie }
: { slug: e.serie }
: undefined,
},
}));
});
if (!vidEntries.length) {
return vids.map((x) => ({
id: x.id,
path: x.path,
guess: x.guess,
entries: [],
}));
}
if (clearLinks) {
await tx
.delete(entryVideoJoin)
.where(
eq(
entryVideoJoin.videoPk,
sql`any(${sqlarr(vids.map((x) => x.pk))})`,
),
);
}
const links = await linkVideos(tx, vidEntries);
return vids.map((x) => ({
id: x.id,
path: x.path,
guess: x.guess,
entries: links[x.pk] ?? [],
}));
});
}
export const videosWriteH = new Elysia({ prefix: "/videos", tags: ["videos"] })
.model({
video: Video,
"created-videos": t.Array(CreatedVideo),
error: t.Object({}),
})
.use(auth)
.post(
"",
async ({ body, status }) => {
const ret = await createVideos(body, false);
if ("status" in ret) return status(ret.status, ret);
return status(201, ret);
},
{
detail: {
description: comment`
Create videos in bulk.
Duplicated videos will simply be ignored.
The \`for\` field of each video can be used to link the video to an existing entry.
If the video was already registered, links will be merged (existing and new ones will be kept).
`,
},
body: t.Array(SeedVideo),
response: {
201: t.Array(CreatedVideo),
409: {
...KError,
description:
"Invalid rendering specified. (conflicts with an existing video)",
},
422: KError,
},
},
)
.put(
"",
async ({ body, status }) => {
const ret = await createVideos(body, true);
if ("status" in ret) return status(ret.status, ret);
return status(201, ret);
},
{
detail: {
description: comment`
Create videos in bulk.
Duplicated videos will simply be ignored.
The \`for\` field of each video can be used to link the video to an existing entry.
If the video was already registered, links will be overriden (existing will be removed and new ones will be created).
`,
},
body: t.Array(SeedVideo),
response: {
201: t.Array(CreatedVideo),
409: {
...KError,
description:
"Invalid rendering specified. (conflicts with an existing video)",
},
422: KError,
},
},
)
.delete(
"",
async ({ body }) => {
return await db.transaction(async (tx) => {
const vids = tx.$with("vids").as(
tx
.delete(videos)
.where(eq(videos.path, sql`any(${sqlarr(body)})`))
.returning({ pk: videos.pk, path: videos.path }),
);
const deletedJoin = await tx
.with(vids)
.select({ entryPk: entryVideoJoin.entryPk, path: vids.path })
.from(entryVideoJoin)
.rightJoin(vids, eq(vids.pk, entryVideoJoin.videoPk));
const delEntries = await tx
.update(entries)
.set({ availableSince: null })
.where(
and(
eq(
entries.pk,
sql`any(${sqlarr(
deletedJoin.filter((x) => x.entryPk).map((x) => x.entryPk!),
)})`,
),
notExists(
tx
.select()
.from(entryVideoJoin)
.where(eq(entries.pk, entryVideoJoin.entryPk)),
),
),
)
.returning({ show: entries.showPk });
await updateAvailableCount(
tx,
delEntries.map((x) => x.show),
false,
);
return [...new Set(deletedJoin.map((x) => x.path))];
});
},
{
detail: { description: "Delete videos in bulk." },
body: t.Array(
t.String({
description: "Path of the video to delete",
examples: [bubbleVideo.path],
}),
),
response: { 200: t.Array(t.String()) },
},
);

View File

@ -0,0 +1,188 @@
import { eq } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { auth } from "~/auth";
import { db } from "~/db";
import { entryVideoJoin, videos } from "~/db/schema";
import { KError } from "~/models/error";
import { isUuid } from "~/models/utils";
import { Video } from "~/models/video";
export const videosMetadata = new Elysia({
prefix: "/videos",
tags: ["videos"],
})
.model({
video: Video,
error: t.Object({}),
})
.use(auth)
.get(
":id/info",
async ({ params: { id }, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
return redirect(`/video/${path}/info`);
},
{
detail: { description: "Get a video's metadata informations" },
params: t.Object({
id: t.String({
description: "The id or slug of the video to retrieve.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/info](?api=transcoder#tag/metadata/get/:path/info) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get(
":id/thumbnails.vtt",
async ({ params: { id }, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
return redirect(`/video/${path}/thumbnails.vtt`);
},
{
detail: {
description: "Get redirected to the direct stream of the video",
},
params: t.Object({
id: t.String({
description: "The id or slug of the video to watch.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get(
":id/direct",
async ({ params: { id }, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
const filename = path.substring(path.lastIndexOf("/") + 1);
return redirect(`/video/${path}/direct/${filename}`);
},
{
detail: {
description: "Get redirected to the direct stream of the video",
},
params: t.Object({
id: t.String({
description: "The id or slug of the video to watch.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get(
":id/master.m3u8",
async ({ params: { id }, request, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
const query = request.url.substring(request.url.indexOf("?"));
return redirect(`/video/${path}/master.m3u8${query}`);
},
{
detail: { description: "Get redirected to the master.m3u8 of the video" },
params: t.Object({
id: t.String({
description: "The id or slug of the video to watch.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/master.m3u8](?api=transcoder#tag/metadata/get/:path/master.m3u8) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
);

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
import { relations, sql } from "drizzle-orm";
import {
check,
index,
integer,
jsonb,
primaryKey,
@ -52,7 +53,10 @@ export const entryVideoJoin = schema.table(
.references(() => videos.pk, { onDelete: "cascade" }),
slug: varchar({ length: 255 }).notNull().unique(),
},
(t) => [primaryKey({ columns: [t.entryPk, t.videoPk] })],
(t) => [
primaryKey({ columns: [t.entryPk, t.videoPk] }),
index("evj_video_pk").on(t.videoPk),
],
);
export const videosRelations = relations(videos, ({ many }) => ({

View File

@ -0,0 +1,46 @@
import { t } from "elysia";
import { Entry } from "./entry";
import { Progress } from "./history";
import { Movie } from "./movie";
import { Serie } from "./serie";
import { Video } from "./video";
export const FullVideo = t.Composite([
Video,
t.Object({
slugs: t.Array(
t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }),
),
progress: t.Optional(Progress),
entries: t.Array(t.Omit(Entry, ["videos", "progress"])),
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,
}),
),
),
show: t.Optional(
t.Union([
t.Composite([t.Object({ kind: t.Literal("movie") }), Movie]),
t.Composite([t.Object({ kind: t.Literal("serie") }), Serie]),
]),
),
}),
]);
export type FullVideo = typeof FullVideo.static;

View File

@ -13,20 +13,20 @@ export type Sort = {
random?: { seed: number };
};
export type SortVal =
| PgColumn
| {
sql: PgColumn;
accessor: (cursor: any) => unknown;
}
| {
sql: SQLWrapper;
isNullable: boolean;
accessor: (cursor: any) => unknown;
};
export const Sort = (
values: Record<
string,
| PgColumn
| {
sql: PgColumn;
accessor: (cursor: any) => unknown;
}
| {
sql: SQLWrapper;
isNullable: boolean;
accessor: (cursor: any) => unknown;
}
>,
values: Record<string, SortVal | SortVal[]>,
{
description = "How to sort the query",
default: def,
@ -65,26 +65,29 @@ export const Sort = (
}
return {
tablePk,
sort: sort.map((x) => {
sort: sort.flatMap((x) => {
const desc = x[0] === "-";
const key = desc ? x.substring(1) : x;
if ("getSQL" in values[key]) {
const process = (val: SortVal): Sort["sort"][0] => {
if ("getSQL" in val) {
return {
sql: val,
isNullable: !val.notNull,
accessor: (x) => x[key],
desc,
};
}
return {
sql: values[key],
isNullable: !values[key].notNull,
accessor: (x) => x[key],
sql: val.sql,
isNullable:
"isNullable" in val ? val.isNullable : !val.sql.notNull,
accessor: val.accessor,
desc,
};
}
return {
sql: values[key].sql,
isNullable:
"isNullable" in values[key]
? values[key].isNullable
: !values[key].sql.notNull,
accessor: values[key].accessor,
desc,
};
return Array.isArray(values[key])
? values[key].map(process)
: process(values[key]);
}),
};
})

View File

@ -33,7 +33,7 @@ export function uniq<T>(a: T[]): T[] {
return uniqBy(a, (x) => x as string);
}
export function uniqBy<T>(a: T[], key: (val: T) => string): T[] {
export function uniqBy<T>(a: T[], key: (val: T) => string | number): T[] {
const seen: Record<string, boolean> = {};
return a.filter((item) => {
const k = key(item);

View File

@ -18,9 +18,9 @@ export const createVideo = async (video: SeedVideo | SeedVideo[]) => {
return [resp, body] as const;
};
export const getVideos = async () => {
export const getGuesses = async () => {
const resp = await handlers.handle(
new Request(buildUrl("videos"), {
new Request(buildUrl("videos/guesses"), {
method: "GET",
headers: await getJwtHeaders(),
}),

View File

@ -2,7 +2,7 @@ import { db, migrate } from "~/db";
import { profiles, shows } from "~/db/schema";
import { bubble, madeInAbyss } from "~/models/examples";
import { setupLogging } from "../src/logtape";
import { createMovie, createSerie, createVideo, getVideos } from "./helpers";
import { createMovie, createSerie, createVideo, getGuesses } from "./helpers";
// test file used to run manually using `bun tests/manual.ts`
// run those before running this script
@ -54,7 +54,7 @@ const [resp, body] = await createVideo([
},
]);
console.log(body);
const [___, ret] = await getVideos();
const [___, ret] = await getGuesses();
console.log(JSON.stringify(ret, undefined, 4));
process.exit(0);

View File

@ -5,7 +5,7 @@ import {
createSerie,
createVideo,
deleteVideo,
getVideos,
getGuesses,
linkVideos,
} from "tests/helpers";
import { expectStatus } from "tests/utils";
@ -108,7 +108,7 @@ beforeAll(async () => {
describe("Video get/deletion", () => {
it("Get current state", async () => {
const [resp, body] = await getVideos();
const [resp, body] = await getGuesses();
expectStatus(resp, body).toBe(200);
expect(body.guesses).toMatchObject({
mia: {
@ -145,7 +145,7 @@ describe("Video get/deletion", () => {
});
expectStatus(resp, body).toBe(201);
[resp, body] = await getVideos();
[resp, body] = await getGuesses();
expectStatus(resp, body).toBe(200);
expect(body.guesses).toMatchObject({
mia: {
@ -187,7 +187,7 @@ describe("Video get/deletion", () => {
});
expectStatus(resp, body).toBe(201);
[resp, body] = await getVideos();
[resp, body] = await getGuesses();
expectStatus(resp, body).toBe(200);
expect(body.guesses).toMatchObject({
mia: {

View File

@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,

View File

@ -28,7 +28,8 @@
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off",
"noTemplateCurlyInString": "off"
"noTemplateCurlyInString": "off",
"noDuplicateCustomProperties": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
@ -44,16 +45,8 @@
"useSortedClasses": {
"level": "warn",
"options": {
"attributes": [
"classList"
],
"functions": [
"clsx",
"cva",
"cn",
"tw",
"tw.*"
]
"attributes": ["classList"],
"functions": ["clsx", "cva", "cn", "tw", "tw.*"]
}
}
}

View File

@ -13,6 +13,7 @@
"@legendapp/list": "zoriya/legend-list#build",
"@material-symbols/svg-400": "^0.40.2",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/native": "^7.1.8",
@ -474,6 +475,8 @@
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],

View File

@ -23,6 +23,7 @@
"@legendapp/list": "zoriya/legend-list#build",
"@material-symbols/svg-400": "^0.40.2",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/native": "^7.1.8",

View File

@ -44,7 +44,18 @@
"nextUp": "Next up",
"season": "Season {{number}}",
"multiVideos": "Multiples video files available",
"videosCount": "{{number}} videos"
"videosCount": "{{number}} videos",
"videos-map": "Edit video mappings"
},
"videos-map": {
"none": "NONE",
"add": "Add another video",
"delete": "Remove video from serie",
"validate": "Validate guess and add video to serie",
"no-guess": "This video was guessed to be part of this serie but we don't know which episode",
"related": "Added videos related to the title {{title}}",
"sort-path": "Path",
"sort-entry": "Episode order"
},
"browse": {
"mediatypekey": {

View File

@ -28,17 +28,6 @@ export default function Layout() {
},
headerTintColor: color as string,
}}
>
<Stack.Screen
name="info/[slug]"
options={{
presentation: "transparentModal",
headerShown: false,
contentStyle: {
backgroundColor: "transparent",
},
}}
/>
</Stack>
/>
);
}

View File

@ -0,0 +1,3 @@
import { VideosModal } from "~/ui/admin";
export default VideosModal;

View File

@ -3,7 +3,7 @@ import type { Entry } from "~/models";
export * from "./entry-box";
export * from "./entry-line";
export const entryDisplayNumber = (entry: Entry) => {
export const entryDisplayNumber = (entry: Partial<Entry>) => {
switch (entry.kind) {
case "episode":
return `S${entry.seasonNumber}:E${entry.episodeNumber}`;

View File

@ -1,50 +1,55 @@
import { z } from "zod/v4";
import { Entry } from "./entry";
import { Entry, Episode, MovieEntry, Special } from "./entry";
import { Extra } from "./extra";
import { Show } from "./show";
import { zdate } from "./utils/utils";
export const Guess = z.looseObject({
title: z.string(),
kind: z.enum(["episode", "movie", "extra"]).nullable().optional(),
extraKind: Extra.shape.kind.optional().nullable(),
years: z.array(z.int()).default([]),
episodes: z
.array(
z.object({
season: z.int().nullable(),
episode: z.int(),
}),
)
.default([]),
externalId: z.record(z.string(), z.string()).default({}),
// Name of the tool that made the guess
from: z.string(),
});
export const Video = z.object({
id: z.string(),
path: z.string(),
rendering: z.string(),
part: z.int().min(0).nullable(),
version: z.int().min(0).default(1),
guess: z.object({
title: z.string(),
kind: z.enum(["episode", "movie", "extra"]).nullable().optional(),
extraKind: Extra.shape.kind.optional().nullable(),
years: z.array(z.int()).default([]),
episodes: z
.array(
z.object({
season: z.int().nullable(),
episode: z.int(),
}),
)
.default([]),
externalId: z.record(z.string(), z.string()).default({}),
// Name of the tool that made the guess
from: z.string(),
// Adding that results in an infinite recursion
// get history() {
// return z.array(Video.shape.guess.omit({ history: true })).default([]);
// },
}),
guess: Guess.extend({ history: z.array(Guess).default([]) }),
createdAt: zdate(),
updatedAt: zdate(),
});
export const FullVideo = Video.extend({
slugs: z.array(z.string()),
progress: z.object({
percent: z.int().min(0).max(100),
time: z.int().min(0),
playedDate: zdate().nullable(),
videoId: z.string().nullable(),
}),
entries: z.array(Entry),
entries: z.array(
z.discriminatedUnion("kind", [
Episode.omit({ progress: true, videos: true }),
MovieEntry.omit({ progress: true, videos: true }),
Special.omit({ progress: true, videos: true }),
]),
),
progress: z.optional(
z.object({
percent: z.int().min(0).max(100),
time: z.int().min(0),
playedDate: zdate().nullable(),
videoId: z.string().nullable(),
}),
),
previous: z.object({ video: z.string(), entry: Entry }).nullable().optional(),
next: z.object({ video: z.string(), entry: Entry }).nullable().optional(),
show: Show.optional(),

View File

@ -1,10 +1,5 @@
import type { ComponentProps, ComponentType, Ref } from "react";
import {
type Falsy,
type Pressable,
type PressableProps,
View,
} from "react-native";
import { type Falsy, type PressableProps, View } from "react-native";
import { cn } from "~/utils";
import { Icon } from "./icons";
import { PressableFeedback } from "./links";
@ -20,18 +15,18 @@ export const Button = <AsProps = PressableProps>({
className,
...props
}: {
disabled?: boolean;
disabled?: boolean | null;
text?: string;
icon?: ComponentProps<typeof Icon>["icon"] | Falsy;
ricon?: ComponentProps<typeof Icon>["icon"] | Falsy;
ref?: Ref<typeof Pressable>;
ref?: Ref<View>;
className?: string;
as?: ComponentType<AsProps>;
} & AsProps) => {
const Container = as ?? PressableFeedback;
return (
<Container
ref={ref}
ref={ref as any}
disabled={disabled}
className={cn(
"flex-row items-center justify-center overflow-hidden",

View File

@ -0,0 +1,235 @@
import { Portal } from "@gorhom/portal";
import { LegendList } from "@legendapp/list";
import Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg";
import SearchIcon from "@material-symbols/svg-400/rounded/search-fill.svg";
import { type ComponentType, useMemo, useRef, useState } from "react";
import {
KeyboardAvoidingView,
Pressable,
type PressableProps,
TextInput,
View,
} from "react-native";
import { type QueryIdentifier, useInfiniteFetch } from "~/query/query";
import { cn } from "~/utils";
import { Icon, IconButton } from "./icons";
import { PressableFeedback } from "./links";
import { Skeleton } from "./skeleton";
import { P } from "./text";
type ComboBoxSingleProps<Data> = {
multiple?: false;
value: Data | null;
values?: never;
onValueChange: (item: Data | null) => void;
};
type ComboBoxMultiProps<Data> = {
multiple: true;
value?: never;
values: Data[];
onValueChange: (items: Data[]) => void;
};
type ComboBoxBaseProps<Data> = {
searchPlaceholder?: string;
query: (search: string) => QueryIdentifier<Data>;
getKey: (item: Data) => string;
getLabel: (item: Data) => string;
getSmallLabel?: (item: Data) => string;
placeholderCount?: number;
label?: string;
Trigger?: ComponentType<PressableProps>;
};
export type ComboBoxProps<Data> = ComboBoxBaseProps<Data> &
(ComboBoxSingleProps<Data> | ComboBoxMultiProps<Data>);
export const ComboBox = <Data,>({
label,
value,
values,
onValueChange,
query,
getLabel,
getSmallLabel,
getKey,
searchPlaceholder,
placeholderCount = 4,
multiple,
Trigger,
}: ComboBoxProps<Data>) => {
const [isOpen, setOpen] = useState(false);
const [search, setSearch] = useState("");
const inputRef = useRef<TextInput>(null);
const oldItems = useRef<Data[] | undefined>(undefined);
let { items, fetchNextPage, hasNextPage, isFetching } = useInfiniteFetch(
query(search),
);
if (items) oldItems.current = items;
items ??= oldItems.current;
const data = useMemo(() => {
const placeholders = [...Array(placeholderCount)].fill(null);
if (!items) return placeholders;
return isFetching ? [...items, ...placeholders] : items;
}, [items, isFetching, placeholderCount]);
const selectedKeys = useMemo(() => {
if (multiple) return new Set(values.map(getKey));
return new Set(value !== null ? [getKey(value)] : []);
}, [value, values, multiple, getKey]);
return (
<>
{Trigger ? (
<Trigger onPressIn={() => setOpen(true)} />
) : (
<PressableFeedback
onPressIn={() => setOpen(true)}
accessibilityLabel={label}
className={cn(
"flex-row items-center justify-center overflow-hidden",
"rounded-4xl border-3 border-accent p-1 outline-0",
"group focus-within:bg-accent hover:bg-accent",
)}
>
<View className="flex-row items-center px-6">
<P className="text-center group-focus-within:text-slate-200 group-hover:text-slate-200">
{(multiple ? !values?.length : !value)
? label
: (multiple ? values : [value!])
.sort((a, b) => getKey(a).localeCompare(getKey(b)))
.map(getSmallLabel ?? getLabel)
.join(", ")}
</P>
<Icon
icon={ExpandMore}
className="group-focus-within:fill-slate-200 group-hover:fill-slate-200"
/>
</View>
</PressableFeedback>
)}
{isOpen && (
<Portal>
<Pressable
onPress={() => {
setOpen(false);
setSearch("");
}}
tabIndex={-1}
className="absolute inset-0 flex-1 bg-transparent"
/>
<KeyboardAvoidingView
behavior="padding"
className={cn(
"absolute bottom-0 w-full self-center bg-popover pb-safe sm:mx-12 sm:max-w-2xl",
"mt-20 max-h-[80vh] rounded-t-4xl pt-8",
"xl:top-0 xl:right-0 xl:mr-0 xl:rounded-l-4xl xl:rounded-tr-0",
)}
>
<IconButton
icon={Close}
onPress={() => {
setOpen(false);
setSearch("");
}}
className="hidden self-end xl:flex"
/>
<View
className={cn(
"mx-4 mb-2 flex-row items-center rounded-xl border border-accent p-1",
"focus-within:border-2",
)}
>
<Icon icon={SearchIcon} className="mx-2" />
<TextInput
ref={inputRef}
value={search}
onChangeText={setSearch}
placeholder={searchPlaceholder}
autoFocus
textAlignVertical="center"
className="h-full flex-1 font-sans text-base text-slate-600 outline-0 dark:text-slate-400"
/>
</View>
<LegendList
data={data}
estimatedItemSize={48}
keyExtractor={(item: Data | null, index: number) =>
item ? getKey(item) : `placeholder-${index}`
}
renderItem={({ item }: { item: Data | null }) =>
item ? (
<ComboBoxItem
label={getLabel(item)}
selected={selectedKeys.has(getKey(item))}
onSelect={() => {
if (!multiple) {
onValueChange(item);
setOpen(false);
return;
}
if (!selectedKeys.has(getKey(item))) {
onValueChange([...values, item]);
return;
}
onValueChange(
values.filter((v) => getKey(v) !== getKey(item)),
);
}}
/>
) : (
<ComboBoxItemLoader />
)
}
onEndReached={
hasNextPage && !isFetching ? () => fetchNextPage() : undefined
}
onEndReachedThreshold={0.5}
/>
</KeyboardAvoidingView>
</Portal>
)}
</>
);
};
const ComboBoxItem = ({
label,
selected,
onSelect,
}: {
label: string;
selected: boolean;
onSelect: () => void;
}) => {
return (
<PressableFeedback
onPress={onSelect}
className="h-12 w-full flex-row items-center px-4"
>
{selected && <Icon icon={Check} className="mx-6" />}
<P
style={{
paddingLeft: selected ? 0 : 8 * 2 + 24,
}}
className="flex-1"
>
{label}
</P>
</PressableFeedback>
);
};
const ComboBoxItemLoader = () => {
return (
<View className="h-12 w-full flex-row items-center px-4">
<Skeleton className="ml-14 h-4 w-3/5" />
</View>
);
};

View File

@ -0,0 +1,201 @@
import { LegendList } from "@legendapp/list";
import Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg";
import SearchIcon from "@material-symbols/svg-400/rounded/search-fill.svg";
import * as Popover from "@radix-ui/react-popover";
import { useMemo, useRef, useState } from "react";
import { Platform, View } from "react-native";
import { useInfiniteFetch } from "~/query/query";
import { cn } from "~/utils";
import type { ComboBoxProps } from "./combobox";
import { Icon } from "./icons";
import { PressableFeedback } from "./links";
import { InternalTriger } from "./menu.web";
import { Skeleton } from "./skeleton";
import { P } from "./text";
export const ComboBox = <Data,>({
label,
searchPlaceholder,
value,
values,
onValueChange,
query,
getKey,
getLabel,
getSmallLabel,
placeholderCount = 4,
multiple,
Trigger,
}: ComboBoxProps<Data>) => {
const [isOpen, setOpen] = useState(false);
const [search, setSearch] = useState("");
const oldItems = useRef<Data[] | undefined>(undefined);
let { items, fetchNextPage, hasNextPage, isFetching } = useInfiniteFetch(
query(search),
);
if (items) oldItems.current = items;
items ??= oldItems.current;
const data = useMemo(() => {
const placeholders = [...Array(placeholderCount)].fill(null);
if (!items) return placeholders;
return isFetching ? [...items, ...placeholders] : items;
}, [items, isFetching, placeholderCount]);
const selectedKeys = useMemo(() => {
if (multiple) return new Set(values.map(getKey));
return new Set(value !== null ? [getKey(value as Data)] : []);
}, [value, values, multiple, getKey]);
return (
<Popover.Root
open={isOpen}
onOpenChange={(open: boolean) => {
setOpen(open);
if (!open) setSearch("");
}}
>
<Popover.Trigger aria-label={label} asChild>
{Trigger ? (
<InternalTriger Component={Trigger} />
) : (
<InternalTriger
Component={Platform.OS === "web" ? "div" : PressableFeedback}
className={cn(
"group flex-row items-center justify-center overflow-hidden rounded-4xl",
"border-2 border-accent p-1 outline-0 focus-within:bg-accent hover:bg-accent",
"cursor-pointer",
)}
>
<View className="flex-row items-center px-6">
<P className="text-center group-focus-within:text-slate-200 group-hover:text-slate-200">
{(multiple ? !values.length : !value)
? label
: (multiple ? values : [value!])
.sort((a, b) => getKey(a).localeCompare(getKey(b)))
.map(getSmallLabel ?? getLabel)
.join(", ")}
</P>
<Icon
icon={ExpandMore}
className="group-focus-within:fill-slate-200 group-hover:fill-slate-200"
/>
</View>
</InternalTriger>
)}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
sideOffset={4}
onOpenAutoFocus={(e: Event) => e.preventDefault()}
className="z-10 flex min-w-3xs flex-col overflow-hidden rounded bg-popover shadow-xl"
style={{
maxHeight:
"calc(var(--radix-popover-content-available-height) * 0.8)",
}}
>
<div
className={cn(
"flex flex-row items-center border-accent border-b px-2",
)}
>
<Icon icon={SearchIcon} className="mx-1 shrink-0" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
// biome-ignore lint/a11y/noAutofocus: combobox search should auto-focus on open
autoFocus
className={cn(
"w-full bg-transparent py-2 font-sans text-base outline-0",
"text-slate-600 placeholder:text-slate-600/50 dark:text-slate-400 dark:placeholder:text-slate-400/50",
)}
/>
</div>
<LegendList
data={data}
estimatedItemSize={40}
keyExtractor={(item: Data | null, index: number) =>
item ? getKey(item) : `placeholder-${index}`
}
renderItem={({ item }: { item: Data | null }) =>
item ? (
<ComboBoxItem
label={getLabel(item)}
selected={selectedKeys.has(getKey(item))}
onSelect={() => {
if (!multiple) {
onValueChange(item);
setOpen(false);
return;
}
if (!selectedKeys.has(getKey(item))) {
onValueChange([...values, item]);
return;
}
onValueChange(
values.filter((v) => getKey(v) !== getKey(item)),
);
}}
/>
) : (
<ComboBoxItemLoader />
)
}
onEndReached={
hasNextPage && !isFetching ? () => fetchNextPage() : undefined
}
onEndReachedThreshold={0.5}
/>
<Popover.Arrow className="fill-popover" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
const ComboBoxItem = ({
label,
selected,
onSelect,
}: {
label: string;
selected: boolean;
onSelect: () => void;
}) => {
return (
<button
type="button"
onClick={onSelect}
className={cn(
"flex w-full select-none items-center rounded py-2 pr-6 pl-8 outline-0",
"font-sans text-slate-600 dark:text-slate-400",
"hover:bg-accent hover:text-slate-200",
"group",
)}
>
{selected && (
<Icon
icon={Check}
className={cn(
"absolute left-0 w-6 items-center justify-center",
"group-hover:fill-slate-200",
)}
/>
)}
<span className="text-left group-hover:text-slate-200">{label}</span>
</button>
);
};
const ComboBoxItemLoader = () => {
return (
<View className="flex h-10 w-full flex-row items-center py-2 pr-6 pl-8">
<Skeleton className="h-4 w-3/5" />
</View>
);
};

View File

@ -3,6 +3,7 @@ export * from "./alert";
export * from "./avatar";
export * from "./button";
export * from "./chip";
export * from "./combobox";
export * from "./container";
export * from "./divider";
export * from "./icons";

View File

@ -1,11 +1,12 @@
import { useRouter } from "expo-router";
import type { ReactNode } from "react";
import type { ReactNode, RefObject } from "react";
import {
Linking,
Platform,
Pressable,
type PressableProps,
type TextProps,
type View,
} from "react-native";
import { useResolveClassNames } from "uniwind";
import { cn } from "~/utils";
@ -70,11 +71,16 @@ export const A = ({
);
};
export const PressableFeedback = ({ children, ...props }: PressableProps) => {
export const PressableFeedback = ({
children,
ref,
...props
}: PressableProps & { ref?: RefObject<View> }) => {
const { color } = useResolveClassNames("text-slate-400/25");
return (
<Pressable
ref={ref}
android_ripple={{
foreground: true,
color,

View File

@ -1,5 +1,5 @@
import Close from "@material-symbols/svg-400/rounded/close.svg";
import { useRouter } from "expo-router";
import { Stack, useRouter } from "expo-router";
import type { ReactNode } from "react";
import { Pressable, ScrollView, View } from "react-native";
import { cn } from "~/utils";
@ -9,37 +9,54 @@ import { Heading } from "./text";
export const Modal = ({
title,
children,
scroll = true,
}: {
title: string;
children: ReactNode;
scroll?: boolean;
}) => {
const router = useRouter();
return (
<Pressable
className="absolute inset-0 cursor-default! items-center justify-center bg-black/60 max-md:px-4"
onPress={() => {
if (router.canGoBack()) router.back();
}}
>
<>
<Stack.Screen
options={{
presentation: "transparentModal",
headerShown: false,
contentStyle: {
backgroundColor: "transparent",
},
}}
/>
<Pressable
className={cn(
"w-full max-w-3xl rounded-md bg-background p-6",
"max-h-[90vh] cursor-default! overflow-hidden",
)}
onPress={(e) => e.stopPropagation()}
className="absolute inset-0 cursor-default! items-center justify-center bg-black/60 max-md:px-4"
onPress={() => {
if (router.canGoBack()) router.back();
}}
>
<View className="mb-4 flex-row items-center justify-between">
<Heading>{title}</Heading>
<IconButton
icon={Close}
onPress={() => {
if (router.canGoBack()) router.back();
}}
/>
</View>
<ScrollView>{children}</ScrollView>
<Pressable
className={cn(
"w-full max-w-3xl rounded-md bg-background",
"max-h-[90vh] cursor-default! overflow-hidden",
)}
onPress={(e) => e.preventDefault()}
>
<View className="flex-row items-center justify-between p-6">
<Heading>{title}</Heading>
<IconButton
icon={Close}
onPress={() => {
if (router.canGoBack()) router.back();
}}
/>
</View>
{scroll ? (
<ScrollView className="p-6">{children}</ScrollView>
) : (
<View>{children}</View>
)}
</Pressable>
</Pressable>
</Pressable>
</>
);
};

View File

@ -25,6 +25,7 @@ export const InfiniteFetch = <Data, Type extends string = string>({
Empty,
Divider,
Header,
Footer,
fetchMore = true,
contentContainerStyle,
columnWrapperStyle,
@ -45,6 +46,7 @@ export const InfiniteFetch = <Data, Type extends string = string>({
incremental?: boolean;
Divider?: true | ComponentType;
Header?: ComponentType<{ children: JSX.Element }> | ReactElement;
Footer?: ComponentType<{ children: JSX.Element }> | ReactElement;
fetchMore?: boolean;
contentContainerStyle?: ViewStyle;
onScroll?: LegendListProps["onScroll"];
@ -68,8 +70,8 @@ export const InfiniteFetch = <Data, Type extends string = string>({
: placeholderCount;
const placeholders = [...Array(count === 0 ? numColumns : count)].fill(0);
if (!items) return placeholders;
return isFetching ? [...items, ...placeholders] : items;
}, [items, isFetching, placeholderCount, numColumns]);
return isFetching && !isRefetching ? [...items, ...placeholders] : items;
}, [items, isFetching, isRefetching, placeholderCount, numColumns]);
if (!data.length && Empty) return Empty;
@ -100,6 +102,7 @@ export const InfiniteFetch = <Data, Type extends string = string>({
onRefresh={layout.layout !== "horizontal" ? refetch : undefined}
refreshing={isRefetching}
ListHeaderComponent={Header}
ListFooterComponent={Footer}
ItemSeparatorComponent={
Divider === true ? HR : (Divider as any) || undefined
}

View File

@ -322,10 +322,12 @@ export const useMutation = <T = void, QueryRet = void>({
compute,
invalidate,
optimistic,
optimisticKey,
...queryParams
}: MutationParams & {
compute?: (param: T) => MutationParams;
optimistic?: (param: T, previous?: QueryRet) => QueryRet | undefined;
optimisticKey?: QueryIdentifier<unknown>;
invalidate: string[] | null;
}) => {
const { apiUrl, authToken } = useContext(AccountContext);
@ -348,7 +350,11 @@ export const useMutation = <T = void, QueryRet = void>({
...(invalidate && optimistic
? {
onMutate: async (params) => {
const queryKey = toQueryKey({ apiUrl, path: invalidate });
const queryKey = toQueryKey({
apiUrl,
path: optimisticKey?.path ?? invalidate,
params: optimisticKey?.params,
});
await queryClient.cancelQueries({
queryKey,
});
@ -361,7 +367,11 @@ export const useMutation = <T = void, QueryRet = void>({
},
onError: (_, __, context) => {
queryClient.setQueryData(
toQueryKey({ apiUrl, path: invalidate }),
toQueryKey({
apiUrl,
path: optimisticKey?.path ?? invalidate,
params: optimisticKey?.params,
}),
context!.previous,
);
},

View File

@ -0,0 +1 @@
export * from "./videos-modal";

View File

@ -0,0 +1,92 @@
import Path from "@material-symbols/svg-400/rounded/conversion_path-fill.svg";
import LibraryAdd from "@material-symbols/svg-400/rounded/library_add-fill.svg";
import Sort from "@material-symbols/svg-400/rounded/sort.svg";
import Entry from "@material-symbols/svg-400/rounded/tv_next-fill.svg";
import { useTranslation } from "react-i18next";
import { FullVideo } from "~/models";
import {
Button,
ComboBox,
Icon,
Menu,
P,
PressableFeedback,
tooltip,
} from "~/primitives";
const sortModes = [
["path", Path],
["entry", Entry],
] as const;
export const SortMenu = ({
sort,
setSort,
}: {
sort: "path" | "entry";
setSort: (sort: "path" | "entry") => void;
}) => {
const { t } = useTranslation();
return (
<Menu
Trigger={(props) => (
<PressableFeedback
className="flex-row items-center"
{...tooltip(t("browse.sortby-tt"))}
{...props}
>
<Icon icon={Sort} className="mx-1" />
<P>{t(`videos-map.sort-${sort}`)}</P>
</PressableFeedback>
)}
>
{sortModes.map((x) => (
<Menu.Item
key={x[0]}
icon={x[1]}
label={t(`videos-map.sort-${x[0]}`)}
selected={sort === x[0]}
onSelect={() => setSort(x[0])}
/>
))}
</Menu>
);
};
export const AddVideoFooter = ({
addTitle,
}: {
addTitle: (title: string) => void;
}) => {
const { t } = useTranslation();
return (
<ComboBox
Trigger={(props) => (
<Button
icon={LibraryAdd}
text={t("videos-map.add")}
className="m-6 mt-10"
onPress={props.onPress ?? (props as any).onClick}
{...props}
/>
)}
searchPlaceholder={t("navbar.search")}
value={null}
query={(q) => ({
parser: FullVideo,
path: ["api", "videos"],
params: {
query: q,
sort: "path",
},
infinite: true,
})}
getKey={(x) => x.id}
getLabel={(x) => x.path}
onValueChange={(x) => {
if (x) addTitle(x.guess.title);
}}
/>
);
};

View File

@ -0,0 +1,130 @@
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { type Entry, FullVideo, type Page } from "~/models";
import { IconButton, Modal, P, Skeleton, tooltip } from "~/primitives";
import {
InfiniteFetch,
type QueryIdentifier,
useFetch,
useMutation,
} from "~/query";
import { useQueryState } from "~/utils";
import { Header } from "../../details/header";
import { AddVideoFooter, SortMenu } from "./headers";
import { PathItem } from "./path-item";
export const useEditLinks = (
slug: string,
titles: string[],
sort: "path" | "entry",
) => {
const { mutateAsync } = useMutation({
method: "PUT",
path: ["api", "videos", "link"],
compute: ({
video,
entries,
guess = false,
}: {
video: string;
entries: Omit<Entry, "href" | "progress" | "videos">[];
guess?: boolean;
}) => ({
body: [
{
id: video,
for: entries.map((x) =>
guess && x.kind === "episode"
? {
serie: slug,
// @ts-expect-error: idk why it couldn't match x as an episode
season: x.seasonNumber,
// @ts-expect-error: idk why it couldn't match x as an episode
episode: x.episodeNumber,
}
: { slug: x.slug },
),
},
],
}),
invalidate: ["api", "series", slug],
optimisticKey: VideosModal.query(slug, titles, sort),
optimistic: (params, prev?: { pages: Page<FullVideo>[] }) => ({
...prev!,
pages: prev!.pages.map((p) => ({
...p,
items: p!.items.map((x) => {
if (x.id !== params.video) return x;
return { ...x, entries: params.entries };
}) as FullVideo[],
})),
}),
});
return mutateAsync;
};
export const VideosModal = () => {
const [slug] = useQueryState<string>("slug", undefined!);
const { data } = useFetch(Header.query("serie", slug));
const { t } = useTranslation();
const [titles, setTitles] = useState<string[]>([]);
const [sort, setSort] = useState<"entry" | "path">("path");
const editLinks = useEditLinks(slug, titles, sort);
const addTitle = (x: string) => {
if (!titles.includes(x)) setTitles([...titles, x]);
};
return (
<Modal title={data?.name ?? t("misc.loading")} scroll={false}>
{[...titles].map((title) => (
<View
key={title}
className="m-2 flex-row items-center justify-between rounded bg-card px-6"
>
<P>{t("videos-map.related", { title })}</P>
<IconButton
icon={Close}
onPress={() => {
setTitles(titles.filter((x) => x !== title));
}}
{...tooltip(t("misc.cancel"))}
/>
</View>
))}
<View className="mx-6 mb-6 flex-row items-center">
<SortMenu sort={sort} setSort={setSort} />
</View>
<InfiniteFetch
query={VideosModal.query(slug, titles, sort)}
layout={{ layout: "vertical", gap: 8, numColumns: 1, size: 48 }}
Render={({ item }) => (
<PathItem
item={item}
serieSlug={slug}
addTitle={addTitle}
editLinks={editLinks}
/>
)}
Loader={() => <Skeleton />}
Footer={<AddVideoFooter addTitle={addTitle} />}
/>
</Modal>
);
};
VideosModal.query = (
slug: string,
titles: string[],
sort: "path" | "entry",
): QueryIdentifier<FullVideo> => ({
parser: FullVideo,
path: ["api", "series", slug, "videos"],
params: {
sort: sort === "entry" ? ["entry", "path"] : sort,
titles: titles,
},
infinite: true,
});

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,109 @@
import Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import Question from "@material-symbols/svg-400/rounded/question_mark-fill.svg";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { entryDisplayNumber } from "~/components/entries";
import { Entry, type FullVideo } from "~/models";
import { ComboBox, IconButton, P, tooltip } from "~/primitives";
import { uniqBy } from "~/utils";
import type { useEditLinks } from ".";
export const PathItem = ({
item,
serieSlug,
addTitle,
editLinks,
}: {
item: FullVideo;
serieSlug: string;
addTitle: (title: string) => void;
editLinks: ReturnType<typeof useEditLinks>;
}) => {
const { t } = useTranslation();
const saved = item.entries.length;
const guess = !saved
? uniqBy(
item.guess.episodes.map(
(x) =>
({
kind: "episode",
id: `s${x.season}-e${x.episode}`,
seasonNumber: x.season,
episodeNumber: x.episode,
}) as Entry,
),
(x) => x.id,
)
: [];
return (
<View
className="mx-6 h-12 flex-row items-center justify-between hover:bg-card"
style={!saved && { opacity: 0.6 }}
>
<View className="flex-row items-center">
{saved ? (
<IconButton
icon={Close}
onPress={async () => {
addTitle(item.guess.title);
await editLinks({ video: item.id, entries: [] });
}}
{...tooltip(t("videos-map.delete"))}
/>
) : guess.length ? (
<IconButton
icon={Check}
onPress={async () => {
await editLinks({
video: item.id,
entries: guess,
guess: true,
});
}}
{...tooltip(t("videos-map.validate"))}
/>
) : (
<IconButton
disabled
icon={Question}
{...tooltip(t("videos-map.no-guess"))}
/>
)}
<P>{item.path}</P>
</View>
<ComboBox
multiple
label={t("videos-map.none")}
searchPlaceholder={t("navbar.search")}
values={saved ? item.entries : guess}
query={(q) => ({
parser: Entry,
path: ["api", "series", serieSlug, "entries"],
params: {
query: q,
},
infinite: true,
})}
getKey={
saved
? (x) => x.id
: (x) =>
x.kind === "episode"
? `${x.seasonNumber}-${x.episodeNumber}`
: x.id
}
getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`}
getSmallLabel={entryDisplayNumber}
onValueChange={async (entries) => {
if (!entries.length) addTitle(item.guess.title);
await editLinks({
video: item.id,
entries,
});
}}
/>
</View>
);
};

View File

@ -3,6 +3,7 @@ import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg";
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg";
import VideoLibrary from "@material-symbols/svg-400/rounded/video_library-fill.svg";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
@ -116,16 +117,21 @@ const ButtonList = ({
/>
</>
)}
{/* {account?.isAdmin === true && ( */}
{/* <> */}
{/* {kind === "movie" && <HR />} */}
{/* <Menu.Item */}
{/* label={t("home.refreshMetadata")} */}
{/* icon={Refresh} */}
{/* onSelect={() => metadataRefreshMutation.mutate()} */}
{/* /> */}
{/* </> */}
{/* )} */}
{account?.isAdmin === true && (
<>
{kind === "movie" && <HR />}
<Menu.Item
label={t("show.videos-map")}
icon={VideoLibrary}
href={`/series/${slug}/videos`}
/>
{/* <Menu.Item */}
{/* label={t("home.refreshMetadata")} */}
{/* icon={Refresh} */}
{/* onSelect={() => metadataRefreshMutation.mutate()} */}
{/* /> */}
</>
)}
</Menu>
)}
</View>

View File

@ -149,7 +149,6 @@ export const EntryList = ({
{index === 0 ? <SeasonHeader.Loader /> : <EntryLine.Loader />}
</Container>
)}
margin={false}
{...props}
/>
);

View File

@ -75,3 +75,13 @@ export function shuffle<T>(array: T[]): T[] {
return array;
}
export function uniqBy<T>(a: T[], key: (val: T) => string | number): T[] {
const seen: Record<string, boolean> = {};
return a.filter((item) => {
const k = key(item);
if (seen[k]) return false;
seen[k] = true;
return true;
});
}

View File

@ -49,7 +49,7 @@ class KyooClient(metaclass=Singleton):
)
async def get_videos_info(self) -> VideoInfo:
async with self._client.get("videos") as r:
async with self._client.get("videos/guesses") as r:
await self.raise_for_status(r)
return VideoInfo(**await r.json())