diff --git a/api/src/controllers/seed/videos.ts b/api/src/controllers/seed/videos.ts index ba834b46..12a93a4c 100644 --- a/api/src/controllers/seed/videos.ts +++ b/api/src/controllers/seed/videos.ts @@ -14,7 +14,7 @@ import { bubbleVideo } from "~/models/examples"; import { isUuid } from "~/models/utils"; import { Guess, SeedVideo, Video } from "~/models/video"; import { comment } from "~/utils"; -import { updateAvailableCount, updateAvailableSince } from "./insert/shows"; +import { updateAvailableCount } from "./insert/shows"; import { linkVideos } from "./video-links"; const CreatedVideo = t.Object({ diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 93d610c6..ddfdd073 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -33,6 +33,7 @@ import { jsonbBuildObject, jsonbObjectAgg, sqlarr, + unnest, } from "~/db/utils"; import type { Entry } from "~/models/entry"; import { KError } from "~/models/error"; @@ -56,7 +57,7 @@ import { import { desc as description } from "~/models/utils/descriptions"; import { Guesses, Video } from "~/models/video"; import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist"; -import { uniqBy } from "~/utils"; +import { comment, uniqBy } from "~/utils"; import { entryProgressQ, entryVideosQ, @@ -601,7 +602,7 @@ export const videosReadH = new Elysia({ tags: ["videos"] }) "series/:id/videos", async ({ params: { id }, - query: { limit, after, query, sort, preferOriginal }, + query: { limit, after, query, sort, preferOriginal, titles }, headers: { "accept-language": langs, ...headers }, request: { url }, jwt: { sub, settings }, @@ -633,7 +634,12 @@ export const videosReadH = new Elysia({ tags: ["videos"] }) .from(videos) .leftJoin(evJoin, eq(videos.pk, evJoin.videoPk)) .leftJoin(entries, eq(entries.pk, evJoin.entryPk)) - .where(eq(entries.showPk, serie.pk)), + .where(eq(entries.showPk, serie.pk)) + .union( + db + .select({ title: sql`title` }) + .from(sql`unnest(${sqlarr(titles ?? [])}::text[]) as title`), + ), ); const languages = processLanguages(langs); @@ -654,6 +660,11 @@ export const videosReadH = new Elysia({ tags: ["videos"] }) 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 }); }, { @@ -679,6 +690,16 @@ export const videosReadH = new Elysia({ tags: ["videos"] }) 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(), diff --git a/front/public/translations/en.json b/front/public/translations/en.json index cc7e645b..c7424a2f 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -22,7 +22,8 @@ "staff": "Staff", "staff-none": "The staff is unknown", "noOverview": "No overview available", - "episode-none": "There is no episodes in this season", "episodeNoMetadata": "No metadata available", + "episode-none": "There is no episodes in this season", + "episodeNoMetadata": "No metadata available", "tags": "Tags", "tags-none": "No tags available", "links": "Links", @@ -46,7 +47,11 @@ "videosCount": "{{number}} videos", "videos-map": "Edit video mappings", "videos-map-none": "NONE", - "videos-map-add": "Add another video" + "videos-map-add": "Add another video", + "videos-map-delete": "Remove video from serie", + "videos-map-validate": "Validate guess and add video to serie", + "videos-map-no-guess": "This video was guessed to be part of this serie but we don't know which episode", + "videos-map-related": "Added videos related to the title {{title}}" }, "browse": { "mediatypekey": { diff --git a/front/src/primitives/modal.tsx b/front/src/primitives/modal.tsx index 6cc13f54..e614a961 100644 --- a/front/src/primitives/modal.tsx +++ b/front/src/primitives/modal.tsx @@ -37,11 +37,11 @@ export const Modal = ({ e.preventDefault()} > - + {title} - {scroll ? {children} : children} + {scroll ? ( + {children} + ) : ( + {children} + )} diff --git a/front/src/ui/admin/videos-modal.tsx b/front/src/ui/admin/videos-modal.tsx index 8915d802..767f60d4 100644 --- a/front/src/ui/admin/videos-modal.tsx +++ b/front/src/ui/admin/videos-modal.tsx @@ -1,10 +1,21 @@ +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 LibraryAdd from "@material-symbols/svg-400/rounded/library_add-fill.svg"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { entryDisplayNumber } from "~/components/entries"; import { Entry, FullVideo, type Page } from "~/models"; -import { Button, ComboBox, IconButton, Modal, P, Skeleton } from "~/primitives"; +import { + Button, + ComboBox, + IconButton, + Modal, + P, + Skeleton, + tooltip, +} from "~/primitives"; import { InfiniteFetch, type QueryIdentifier, @@ -13,11 +24,13 @@ import { } from "~/query"; import { useQueryState } from "~/utils"; import { Header } from "../details/header"; +import { uniqBy } from "~/utils"; export const VideosModal = () => { const [slug] = useQueryState("slug", undefined!); const { data } = useFetch(Header.query("serie", slug)); const { t } = useTranslation(); + const [titles, setTitles] = useState([]); const { mutateAsync: editLinks } = useMutation({ method: "PUT", @@ -25,14 +38,31 @@ export const VideosModal = () => { compute: ({ video, entries, + guess = false, }: { video: string; entries: Omit[]; + guess?: boolean; }) => ({ - body: [{ id: video, for: entries.map((x) => ({ slug: x.slug })) }], + 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), + optimisticKey: VideosModal.query(slug, null), optimistic: (params, prev?: { pages: Page[] }) => ({ ...prev!, pages: prev!.pages.map((p) => ({ @@ -44,68 +74,85 @@ export const VideosModal = () => { })), }), }); - const { mutateAsync: editGuess } = useMutation({ - method: "PUT", - path: ["api", "videos"], - compute: (video: FullVideo) => ({ - body: [ - { - ...video, - guess: { - ...video.guess, - title: data?.name ?? slug, - from: "manual-edit", - history: [...video.guess.history, video.guess], - }, - for: video.guess.episodes.map((x) => ({ - serie: slug, - season: x.season, - episode: x.episode, - })), - entries: undefined, - }, - ], - }), - invalidate: ["api", "series", "slug"], - optimisticKey: VideosModal.query(slug), - optimistic: (params, prev?: { pages: Page[] }) => ({ - ...prev!, - pages: prev!.pages.map((p, i) => { - const idx = p.items.findIndex( - (x) => params.path.localeCompare(x.path) < 0, - ); - if (idx !== -1) { - return { - ...p, - items: [ - ...p.items.slice(0, idx), - params, - ...p.items.slice(idx, -1), - ], - }; - } - if (i === prev!.pages.length) { - return { ...p, items: [...p.items, params] }; - } - return p; - }), - }), - }); return ( + {[...titles].map((title) => ( + +

{t("show.videos-map-related", { title })}

+ { + setTitles(titles.filter((x) => x !== title)); + }} + {...tooltip(t("misc.cancel"))} + /> +
+ ))} ( - -

{item.path}

- + Render={({ item }) => { + 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 ( + + + {saved ? ( + { + if (!titles.includes(item.guess.title)) + setTitles([...titles, item.guess.title]); + await editLinks({ video: item.id, entries: [] }); + }} + {...tooltip(t("show.videos-map-delete"))} + /> + ) : guess.length ? ( + { + await editLinks({ + video: item.id, + entries: guess, + guess: true, + }); + }} + {...tooltip(t("show.videos-map-validate"))} + /> + ) : ( + + )} +

{item.path}

+
({ parser: Entry, path: ["api", "series", slug, "entries"], @@ -114,20 +161,28 @@ export const VideosModal = () => { }, infinite: true, })} - getKey={(x) => x.id} + 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 && !titles.includes(item.guess.title)) + setTitles([...titles, item.guess.title]); await editLinks({ video: item.id, entries, }); }} /> - {}} />
-
- )} + ); + }} Loader={() => } Footer={ {