diff --git a/api/src/base.ts b/api/src/base.ts index eb713601..d6db13a7 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -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), ); diff --git a/api/src/controllers/seed/video-links.ts b/api/src/controllers/seed/video-links.ts new file mode 100644 index 00000000..afb20854 --- /dev/null +++ b/api/src/controllers/seed/video-links.ts @@ -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 & { + 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, + ); + + 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] })), + }, + }, + ); diff --git a/api/src/controllers/seed/videos.ts b/api/src/controllers/seed/videos.ts new file mode 100644 index 00000000..12a93a4c --- /dev/null +++ b/api/src/controllers/seed/videos.ts @@ -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()) }, + }, + ); diff --git a/api/src/controllers/video-metadata.ts b/api/src/controllers/video-metadata.ts new file mode 100644 index 00000000..06c2df15 --- /dev/null +++ b/api/src/controllers/video-metadata.ts @@ -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.", + }, + }, + }, + ); diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 04bdeb0b..80c60650 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -3,19 +3,21 @@ import { desc, eq, gt, + inArray, isNotNull, lt, max, min, - ne, notExists, or, + type SQL, sql, + type WithSubquery, } 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 { db } from "~/db"; import { entries, entryVideoJoin, @@ -28,22 +30,18 @@ import { import { watchlist } from "~/db/schema/watchlist"; import { coalesce, - conflictUpdateAllExcept, getColumns, - isUniqueConstraint, jsonbAgg, jsonbBuildObject, jsonbObjectAgg, sqlarr, - unnest, - unnestValues, } from "~/db/utils"; -import { Entry } from "~/models/entry"; +import type { Entry } from "~/models/entry"; import { KError } from "~/models/error"; -import { bubbleVideo } from "~/models/examples"; -import { Progress } from "~/models/history"; -import { Movie, type MovieStatus } from "~/models/movie"; -import { Serie } from "~/models/serie"; +import { FullVideo } from "~/models/full-video"; +import type { Progress } from "~/models/history"; +import type { Movie, MovieStatus } from "~/models/movie"; +import type { Serie } from "~/models/serie"; import { AcceptLanguage, buildRelations, @@ -58,178 +56,37 @@ import { sortToSql, } from "~/models/utils"; import { desc as description } from "~/models/utils/descriptions"; -import { Guess, Guesses, SeedVideo, Video } from "~/models/video"; +import { Guesses, Video } from "~/models/video"; import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist"; -import { comment } from "~/utils"; +import { comment, uniqBy } from "~/utils"; import { entryProgressQ, entryVideosQ, getEntryTransQ, mapProgress, } from "./entries"; -import { computeVideoSlug } from "./seed/insert/entries"; -import { - updateAvailableCount, - updateAvailableSince, -} from "./seed/insert/shows"; -async function linkVideos( - tx: Transaction, - links: { - video: number; - entry: Omit & { - 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, - ); - - 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; -} - -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"] }), - }), - ), -}); +const videoSort = Sort( + { + path: videos.path, + entry: [ + { + sql: entries.showPk, + isNullable: true, + accessor: (x: any) => x.entries?.[0]?.showPk, + }, + { + sql: entries.order, + isNullable: true, + accessor: (x: any) => x.entries?.[0]?.order, + }, + ], + }, + { + default: ["path"], + tablePk: videos.pk, + }, +); const videoRelations = { slugs: () => { @@ -273,32 +130,6 @@ const videoRelations = { as "progress" )` as any; }, - entries: ({ languages }: { languages: string[] }) => { - const transQ = getEntryTransQ(languages); - - return db - .select({ - json: coalesce( - jsonbAgg( - jsonbBuildObject({ - ...getColumns(entries), - ...getColumns(transQ), - number: entries.episodeNumber, - videos: entryVideosQ.videos, - progress: mapProgress({ aliased: false }), - }), - ), - 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"); - }, show: ({ languages, preferOriginal, @@ -447,14 +278,108 @@ function getNextVideoEntry({ .as("next"); } -export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] }) - .model({ - video: Video, - error: t.Object({}), - }) +// make an alias so entry video join is not usable on subqueries +const evJoin = alias(entryVideoJoin, "evj"); + +export async function getVideos({ + after, + limit, + query, + sort, + filter, + languages, + preferOriginal = false, + relations = [], + userId, + cte = [], +}: { + after?: string; + limit: number; + query?: string; + sort?: Sort; + filter?: SQL; + languages: string[]; + preferOriginal?: boolean; + relations?: (keyof typeof videoRelations)[]; + userId: string; + cte?: WithSubquery[]; +}) { + let ret = await db + .with(...cte) + .select({ + ...getColumns(videos), + ...buildRelations(["slugs", ...relations], videoRelations, { + languages, + preferOriginal, + }), + }) + .from(videos) + .leftJoin(evJoin, eq(videos.pk, evJoin.videoPk)) + // join entries only for sorting, we can't select entries here for perf reasons. + .leftJoin(entries, eq(entries.pk, evJoin.entryPk)) + .where( + and( + filter, + query ? sql`${videos.path} %> ${query}::text` : undefined, + keysetPaginate({ after, sort }), + ), + ) + .orderBy( + ...(query + ? [sql`word_similarity(${query}::text, ${videos.path}) desc`] + : sortToSql(sort)), + videos.pk, + ) + .limit(limit) + .execute({ userId }); + + ret = uniqBy(ret, (x) => x.pk); + if (!ret.length) return []; + + const entriesByVideo = await fetchEntriesForVideos({ + videoPks: ret.map((x) => x.pk), + languages, + userId, + }); + + return ret.map((x) => ({ + ...x, + entries: entriesByVideo[x.pk] ?? [], + })) as unknown as FullVideo[]; +} + +async function fetchEntriesForVideos({ + videoPks, + languages, + userId, +}: { + videoPks: number[]; + languages: string[]; + userId: string; +}) { + if (!videoPks.length) return {}; + + const transQ = getEntryTransQ(languages); + const ret = await db + .select({ + videoPk: entryVideoJoin.videoPk, + ...getColumns(entries), + ...getColumns(transQ), + number: entries.episodeNumber, + }) + .from(entryVideoJoin) + .innerJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) + .innerJoin(transQ, eq(entries.pk, transQ.pk)) + .where(eq(entryVideoJoin.videoPk, sql`any(${sqlarr(videoPks)})`)) + .execute({ userId }); + + return Object.groupBy(ret, (x) => x.videoPk); +} + +export const videosReadH = new Elysia({ tags: ["videos"] }) .use(auth) .get( - ":id", + "videos/:id", async ({ params: { id }, query: { with: relations, preferOriginal }, @@ -463,34 +388,21 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] }) 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", "progress", "entries", ...relations], - videoRelations, - { - languages, - preferOriginal: preferOriginal ?? settings.preferOriginal, - }, - ), - }) - .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) { + const [ret] = await getVideos({ + limit: 1, + filter: and(isUuid(id) ? eq(videos.id, id) : eq(evJoin.slug, id)), + languages, + preferOriginal: preferOriginal ?? settings.preferOriginal, + relations, + userId: sub, + }); + if (!ret) { return status(404, { status: 404, message: `No video found with id or slug '${id}'`, }); } - return video as any; + return ret; }, { detail: { @@ -517,44 +429,7 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] }) "accept-language": AcceptLanguage(), }), response: { - 200: t.Composite([ - Video, - t.Object({ - slugs: t.Array( - t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }), - ), - progress: Progress, - 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, - }), - ), - ), - show: t.Optional( - t.Union([ - t.Composite([t.Object({ kind: t.Literal("movie") }), Movie]), - t.Composite([t.Object({ kind: t.Literal("serie") }), Serie]), - ]), - ), - }), - ]), + 200: FullVideo, 404: { ...KError, description: "No video found with the given id or slug.", @@ -564,177 +439,60 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] }) }, ) .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`); + "videos", + async ({ + query: { limit, after, query, sort, preferOriginal }, + headers: { "accept-language": langs, ...headers }, + request: { url }, + jwt: { sub, settings }, + }) => { + const languages = processLanguages(langs); + const items = await getVideos({ + limit, + after, + query, + sort, + languages, + preferOriginal: preferOriginal ?? settings.preferOriginal, + userId: sub, + }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { - description: "Get redirected to the direct stream of the video", + description: "Get a video & it's related entries", }, - params: t.Object({ - id: t.String({ - description: "The id or slug of the video to watch.", - example: "made-in-abyss-s1e13", + query: t.Object({ + sort: videoSort, + query: t.Optional(t.String({ description: description.query })), + limit: t.Integer({ + minimum: 1, + maximum: 250, + default: 50, + description: "Max page size.", }), + after: t.Optional(t.String({ description: description.after })), + preferOriginal: t.Optional( + t.Boolean({ + description: description.preferOriginal, + }), + ), + }), + headers: t.Object({ + "accept-language": AcceptLanguage(), }), response: { - 302: t.Void({ - description: - "Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)", - }), + 200: Page(FullVideo), 404: { ...KError, description: "No video found with the given id or slug.", }, + 422: KError, }, }, ) .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.", - }, - }, - }, - ) - .get( - "", + "videos/guesses", async () => { const years = db.$with("years").as( db @@ -805,7 +563,7 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] }) }, ) .get( - "unmatched", + "videos/unmatched", async ({ query: { sort, query, limit, after }, request: { url } }) => { const ret = await db .select() @@ -852,245 +610,120 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] }) 422: KError, }, }, - ); + ) + .get( + "series/:id/videos", + async ({ + params: { id }, + query: { limit, after, query, sort, preferOriginal, titles }, + headers: { "accept-language": langs, ...headers }, + request: { url }, + jwt: { sub, settings }, + status, + }) => { + const [serie] = await db + .select({ pk: shows.pk }) + .from(shows) + .where( + and( + eq(shows.kind, "serie"), + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + ), + ) + .limit(1); -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 }) => { - if (body.length === 0) { - return status(422, { status: 422, message: "No videos" }); - } - 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, { - 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) - `, - }); - } - - 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 (!serie) { + return status(404, { + status: 404, + message: `No serie with the id or slug: '${id}'.`, }); + } - if (!vidEntries.length) { - return status( - 201, - vids.map((x) => ({ - id: x.id, - path: x.path, - guess: x.guess, - entries: [], - })), - ); - } + const titleGuess = db.$with("title_guess").as( + db + .selectDistinctOn([sql`${videos.guess}->>'title'`], { + title: sql`${videos.guess}->>'title'`.as("title"), + }) + .from(videos) + .leftJoin(evJoin, eq(videos.pk, evJoin.videoPk)) + .leftJoin(entries, eq(entries.pk, evJoin.entryPk)) + .where(eq(entries.showPk, serie.pk)) + .union( + db + .select({ title: sql`title` }) + .from(sql`unnest(${sqlarr(titles ?? [])}::text[]) as title`), + ), + ); - const links = await linkVideos(tx, vidEntries); - - return status( - 201, - vids.map((x) => ({ - id: x.id, - path: x.path, - guess: x.guess, - entries: links[x.pk] ?? [], - })), - ); + const languages = processLanguages(langs); + const items = await getVideos({ + cte: [titleGuess], + filter: or( + eq(entries.showPk, serie.pk), + inArray( + sql`${videos.guess}->>'title'`, + db.select().from(titleGuess), + ), + ), + limit, + after, + query, + sort, + languages, + preferOriginal: preferOriginal ?? settings.preferOriginal, + userId: sub, }); + for (const i of items) + i.entries = i.entries.filter( + (x) => + (x as unknown as typeof entries.$inferSelect).showPk === serie.pk, + ); + return createPage(items, { url, sort, limit, headers }); }, { - detail: { - description: comment` - Create videos in bulk. - Duplicated videos will simply be ignored. - - If a videos has a \`guess\` field, it will be used to automatically register the video under an existing - movie or entry. - `, - }, - body: t.Array(SeedVideo), + detail: { description: "List videos of a serie" }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the serie.", + example: "made-in-abyss", + }), + }), + query: t.Object({ + sort: videoSort, + query: t.Optional(t.String({ description: description.query })), + limit: t.Integer({ + minimum: 1, + maximum: 250, + default: 50, + description: "Max page size.", + }), + after: t.Optional(t.String({ description: description.after })), + preferOriginal: t.Optional( + t.Boolean({ + description: description.preferOriginal, + }), + ), + titles: t.Optional( + t.Array( + t.String({ + description: comment` + Return videos in the serie + videos with a title + guess equal to one of the element of this list + `, + }), + ), + ), + }), + headers: t.Object({ + "accept-language": AcceptLanguage(), + }), response: { - 201: t.Array(CreatedVideo), - 409: { + 200: Page(FullVideo), + 404: { ...KError, - description: - "Invalid rendering specified. (conflicts with an existing video)", + description: "No video found with the given id or slug.", }, 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()) }, - }, - ) - .post( - "/link", - async ({ body, status }) => { - return await db.transaction(async (tx) => { - 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 lVids = body.flatMap((x) => { - return 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, - }, - })); - }); - const links = await linkVideos(tx, lVids); - 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: t.Array( - t.Object({ - id: t.String({ - description: "Id of the video", - format: "uuid", - }), - for: t.Array(SeedVideo.properties.for.items), - }), - ), - response: { - 201: 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"], - }), - }), - ), - }), - ), - 422: KError, - }, - }, ); diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index 7e3cc836..eab72392 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -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 }) => ({ diff --git a/api/src/models/full-video.ts b/api/src/models/full-video.ts new file mode 100644 index 00000000..06ed7ce9 --- /dev/null +++ b/api/src/models/full-video.ts @@ -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; diff --git a/api/src/models/utils/sort.ts b/api/src/models/utils/sort.ts index f0f2b5da..2decc59a 100644 --- a/api/src/models/utils/sort.ts +++ b/api/src/models/utils/sort.ts @@ -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, { 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]); }), }; }) diff --git a/api/src/utils.ts b/api/src/utils.ts index 207acc28..8abceea8 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -33,7 +33,7 @@ export function uniq(a: T[]): T[] { return uniqBy(a, (x) => x as string); } -export function uniqBy(a: T[], key: (val: T) => string): T[] { +export function uniqBy(a: T[], key: (val: T) => string | number): T[] { const seen: Record = {}; return a.filter((item) => { const k = key(item); diff --git a/api/tests/helpers/videos-helper.ts b/api/tests/helpers/videos-helper.ts index c301d3a4..87803fdd 100644 --- a/api/tests/helpers/videos-helper.ts +++ b/api/tests/helpers/videos-helper.ts @@ -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(), }), diff --git a/api/tests/manual.ts b/api/tests/manual.ts index 0bdce7ee..f4c6d849 100644 --- a/api/tests/manual.ts +++ b/api/tests/manual.ts @@ -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); diff --git a/api/tests/videos/getdel.test.ts b/api/tests/videos/getdel.test.ts index 0cd78443..81ec8119 100644 --- a/api/tests/videos/getdel.test.ts +++ b/api/tests/videos/getdel.test.ts @@ -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: { diff --git a/api/tsconfig.json b/api/tsconfig.json index 67912b95..05ef7fa6 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ES2022", - "module": "ES2022", + "target": "esnext", + "module": "esnext", "moduleResolution": "node", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, diff --git a/biome.json b/biome.json index 79b45ac7..5123d159 100644 --- a/biome.json +++ b/biome.json @@ -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.*"] } } } diff --git a/front/bun.lock b/front/bun.lock index 03e4f9fa..8a4d8112 100644 --- a/front/bun.lock +++ b/front/bun.lock @@ -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=="], diff --git a/front/package.json b/front/package.json index 376d06d9..534794d5 100644 --- a/front/package.json +++ b/front/package.json @@ -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", diff --git a/front/public/translations/en.json b/front/public/translations/en.json index 02a90036..e8dac9c8 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -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": { diff --git a/front/src/app/(app)/_layout.tsx b/front/src/app/(app)/_layout.tsx index eeafe777..e64491bd 100644 --- a/front/src/app/(app)/_layout.tsx +++ b/front/src/app/(app)/_layout.tsx @@ -28,17 +28,6 @@ export default function Layout() { }, headerTintColor: color as string, }} - > - - + /> ); } diff --git a/front/src/app/(app)/series/[slug].tsx b/front/src/app/(app)/series/[slug]/index.tsx similarity index 100% rename from front/src/app/(app)/series/[slug].tsx rename to front/src/app/(app)/series/[slug]/index.tsx diff --git a/front/src/app/(app)/series/[slug]/videos.tsx b/front/src/app/(app)/series/[slug]/videos.tsx new file mode 100644 index 00000000..757712ea --- /dev/null +++ b/front/src/app/(app)/series/[slug]/videos.tsx @@ -0,0 +1,3 @@ +import { VideosModal } from "~/ui/admin"; + +export default VideosModal; diff --git a/front/src/components/entries/index.ts b/front/src/components/entries/index.ts index aecb4df4..f472eb6b 100644 --- a/front/src/components/entries/index.ts +++ b/front/src/components/entries/index.ts @@ -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) => { switch (entry.kind) { case "episode": return `S${entry.seasonNumber}:E${entry.episodeNumber}`; diff --git a/front/src/models/video.ts b/front/src/models/video.ts index 50b81263..1632cceb 100644 --- a/front/src/models/video.ts +++ b/front/src/models/video.ts @@ -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(), diff --git a/front/src/primitives/button.tsx b/front/src/primitives/button.tsx index 15aff478..c90c6bf2 100644 --- a/front/src/primitives/button.tsx +++ b/front/src/primitives/button.tsx @@ -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 = ({ className, ...props }: { - disabled?: boolean; + disabled?: boolean | null; text?: string; icon?: ComponentProps["icon"] | Falsy; ricon?: ComponentProps["icon"] | Falsy; - ref?: Ref; + ref?: Ref; className?: string; as?: ComponentType; } & AsProps) => { const Container = as ?? PressableFeedback; return ( = { + multiple?: false; + value: Data | null; + values?: never; + onValueChange: (item: Data | null) => void; +}; + +type ComboBoxMultiProps = { + multiple: true; + value?: never; + values: Data[]; + onValueChange: (items: Data[]) => void; +}; + +type ComboBoxBaseProps = { + searchPlaceholder?: string; + query: (search: string) => QueryIdentifier; + getKey: (item: Data) => string; + getLabel: (item: Data) => string; + getSmallLabel?: (item: Data) => string; + placeholderCount?: number; + label?: string; + Trigger?: ComponentType; +}; + +export type ComboBoxProps = ComboBoxBaseProps & + (ComboBoxSingleProps | ComboBoxMultiProps); + +export const ComboBox = ({ + label, + value, + values, + onValueChange, + query, + getLabel, + getSmallLabel, + getKey, + searchPlaceholder, + placeholderCount = 4, + multiple, + Trigger, +}: ComboBoxProps) => { + const [isOpen, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const inputRef = useRef(null); + + const oldItems = useRef(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 ? ( + setOpen(true)} /> + ) : ( + 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", + )} + > + +

+ {(multiple ? !values?.length : !value) + ? label + : (multiple ? values : [value!]) + .sort((a, b) => getKey(a).localeCompare(getKey(b))) + .map(getSmallLabel ?? getLabel) + .join(", ")} +

+ +
+
+ )} + {isOpen && ( + + { + setOpen(false); + setSearch(""); + }} + tabIndex={-1} + className="absolute inset-0 flex-1 bg-transparent" + /> + + { + setOpen(false); + setSearch(""); + }} + className="hidden self-end xl:flex" + /> + + + + + + item ? getKey(item) : `placeholder-${index}` + } + renderItem={({ item }: { item: Data | null }) => + item ? ( + { + if (!multiple) { + onValueChange(item); + setOpen(false); + return; + } + + if (!selectedKeys.has(getKey(item))) { + onValueChange([...values, item]); + return; + } + onValueChange( + values.filter((v) => getKey(v) !== getKey(item)), + ); + }} + /> + ) : ( + + ) + } + onEndReached={ + hasNextPage && !isFetching ? () => fetchNextPage() : undefined + } + onEndReachedThreshold={0.5} + /> + + + )} + + ); +}; + +const ComboBoxItem = ({ + label, + selected, + onSelect, +}: { + label: string; + selected: boolean; + onSelect: () => void; +}) => { + return ( + + {selected && } +

+ {label} +

+
+ ); +}; + +const ComboBoxItemLoader = () => { + return ( + + + + ); +}; diff --git a/front/src/primitives/combobox.web.tsx b/front/src/primitives/combobox.web.tsx new file mode 100644 index 00000000..f2462ea9 --- /dev/null +++ b/front/src/primitives/combobox.web.tsx @@ -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 = ({ + label, + searchPlaceholder, + value, + values, + onValueChange, + query, + getKey, + getLabel, + getSmallLabel, + placeholderCount = 4, + multiple, + Trigger, +}: ComboBoxProps) => { + const [isOpen, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const oldItems = useRef(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 ( + { + setOpen(open); + if (!open) setSearch(""); + }} + > + + {Trigger ? ( + + ) : ( + + +

+ {(multiple ? !values.length : !value) + ? label + : (multiple ? values : [value!]) + .sort((a, b) => getKey(a).localeCompare(getKey(b))) + .map(getSmallLabel ?? getLabel) + .join(", ")} +

+ +
+
+ )} +
+ + 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)", + }} + > +
+ + 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", + )} + /> +
+ + item ? getKey(item) : `placeholder-${index}` + } + renderItem={({ item }: { item: Data | null }) => + item ? ( + { + if (!multiple) { + onValueChange(item); + setOpen(false); + return; + } + + if (!selectedKeys.has(getKey(item))) { + onValueChange([...values, item]); + return; + } + onValueChange( + values.filter((v) => getKey(v) !== getKey(item)), + ); + }} + /> + ) : ( + + ) + } + onEndReached={ + hasNextPage && !isFetching ? () => fetchNextPage() : undefined + } + onEndReachedThreshold={0.5} + /> + +
+
+
+ ); +}; + +const ComboBoxItem = ({ + label, + selected, + onSelect, +}: { + label: string; + selected: boolean; + onSelect: () => void; +}) => { + return ( + + ); +}; + +const ComboBoxItemLoader = () => { + return ( + + + + ); +}; diff --git a/front/src/primitives/index.ts b/front/src/primitives/index.ts index da067309..134eb4e4 100644 --- a/front/src/primitives/index.ts +++ b/front/src/primitives/index.ts @@ -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"; diff --git a/front/src/primitives/links.tsx b/front/src/primitives/links.tsx index f6003a08..a1f27ce0 100644 --- a/front/src/primitives/links.tsx +++ b/front/src/primitives/links.tsx @@ -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 }) => { const { color } = useResolveClassNames("text-slate-400/25"); return ( { const router = useRouter(); return ( - { - if (router.canGoBack()) router.back(); - }} - > + <> + 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(); + }} > - - {title} - { - if (router.canGoBack()) router.back(); - }} - /> - - {children} + e.preventDefault()} + > + + {title} + { + if (router.canGoBack()) router.back(); + }} + /> + + {scroll ? ( + {children} + ) : ( + {children} + )} + - + ); }; diff --git a/front/src/query/fetch-infinite.tsx b/front/src/query/fetch-infinite.tsx index 1ebd45e1..9a38594b 100644 --- a/front/src/query/fetch-infinite.tsx +++ b/front/src/query/fetch-infinite.tsx @@ -25,6 +25,7 @@ export const InfiniteFetch = ({ Empty, Divider, Header, + Footer, fetchMore = true, contentContainerStyle, columnWrapperStyle, @@ -45,6 +46,7 @@ export const InfiniteFetch = ({ 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 = ({ : 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 = ({ onRefresh={layout.layout !== "horizontal" ? refetch : undefined} refreshing={isRefetching} ListHeaderComponent={Header} + ListFooterComponent={Footer} ItemSeparatorComponent={ Divider === true ? HR : (Divider as any) || undefined } diff --git a/front/src/query/query.tsx b/front/src/query/query.tsx index 705970fd..ca402a50 100644 --- a/front/src/query/query.tsx +++ b/front/src/query/query.tsx @@ -322,10 +322,12 @@ export const useMutation = ({ compute, invalidate, optimistic, + optimisticKey, ...queryParams }: MutationParams & { compute?: (param: T) => MutationParams; optimistic?: (param: T, previous?: QueryRet) => QueryRet | undefined; + optimisticKey?: QueryIdentifier; invalidate: string[] | null; }) => { const { apiUrl, authToken } = useContext(AccountContext); @@ -348,7 +350,11 @@ export const useMutation = ({ ...(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 = ({ }, onError: (_, __, context) => { queryClient.setQueryData( - toQueryKey({ apiUrl, path: invalidate }), + toQueryKey({ + apiUrl, + path: optimisticKey?.path ?? invalidate, + params: optimisticKey?.params, + }), context!.previous, ); }, diff --git a/front/src/ui/admin/index.tsx b/front/src/ui/admin/index.tsx new file mode 100644 index 00000000..d75d2289 --- /dev/null +++ b/front/src/ui/admin/index.tsx @@ -0,0 +1 @@ +export * from "./videos-modal"; diff --git a/front/src/ui/admin/videos-modal/headers.tsx b/front/src/ui/admin/videos-modal/headers.tsx new file mode 100644 index 00000000..18a57a22 --- /dev/null +++ b/front/src/ui/admin/videos-modal/headers.tsx @@ -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 ( + ( + + +

{t(`videos-map.sort-${sort}`)}

+
+ )} + > + {sortModes.map((x) => ( + setSort(x[0])} + /> + ))} +
+ ); +}; + +export const AddVideoFooter = ({ + addTitle, +}: { + addTitle: (title: string) => void; +}) => { + const { t } = useTranslation(); + + return ( + ( +