diff --git a/front/public/translations/en.json b/front/public/translations/en.json index 92224374..78e33aa9 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -48,6 +48,7 @@ "version": "Version {{number}}", "part": "Part {{number}}", "videos-map": "Edit video mappings", + "remap": "Remap", "staff-as":"as {{character}}", "staff-kind": { "actor": "Actor", diff --git a/front/src/app/(app)/movies/[slug]/remap.tsx b/front/src/app/(app)/movies/[slug]/remap.tsx new file mode 100644 index 00000000..6bb25d98 --- /dev/null +++ b/front/src/app/(app)/movies/[slug]/remap.tsx @@ -0,0 +1,5 @@ +import { MovieRemapModal } from "~/ui/admin"; + +export { ErrorBoundary } from "~/ui/error-boundary"; + +export default MovieRemapModal; diff --git a/front/src/app/(app)/series/[slug]/remap.tsx b/front/src/app/(app)/series/[slug]/remap.tsx new file mode 100644 index 00000000..988e5a7b --- /dev/null +++ b/front/src/app/(app)/series/[slug]/remap.tsx @@ -0,0 +1,5 @@ +import { SerieRemapModal } from "~/ui/admin"; + +export { ErrorBoundary } from "~/ui/error-boundary"; + +export default SerieRemapModal; diff --git a/front/src/query/query.tsx b/front/src/query/query.tsx index c41ae2af..71d03c5b 100644 --- a/front/src/query/query.tsx +++ b/front/src/query/query.tsx @@ -307,7 +307,7 @@ export const prefetch = async (...queries: QueryIdentifier[]) => { return client; }; -type MutationParams = { +type MutationParams = { method?: "POST" | "PUT" | "PATCH" | "DELETE"; path?: string[]; params?: { @@ -315,15 +315,17 @@ type MutationParams = { }; body?: object; formData?: FormData; + parser?: z.ZodType | null; }; -export const useMutation = ({ +export const useMutation = ({ compute, invalidate, optimistic, optimisticKey, + parser, ...queryParams -}: MutationParams & { +}: MutationParams & { compute?: (param: T) => MutationParams; optimistic?: (param: T, previous?: QueryRet) => QueryRet | undefined; optimisticKey?: QueryIdentifier; @@ -344,7 +346,7 @@ export const useMutation = ({ body, formData, authToken, - parser: null, + parser: parser ?? null, }); }, ...(invalidate && optimistic diff --git a/front/src/ui/admin/add.tsx b/front/src/ui/admin/add.tsx index ae237785..cecb26a4 100644 --- a/front/src/ui/admin/add.tsx +++ b/front/src/ui/admin/add.tsx @@ -152,11 +152,15 @@ export const AddPage = ({ title, icon, allowLibrary, + initialKind, + onSearchSelect, videos = [], }: { title?: string; icon?: Icon; allowLibrary: boolean; + initialKind?: "movie" | "serie" | "library"; + onSearchSelect?: (item: SearchMovie | SearchSerie) => Promise; videos: { id: string; episodes: { season: number | null; episode: number }[]; @@ -167,7 +171,7 @@ export const AddPage = ({ const [query, setQuery] = useQueryState("q", ""); const [kind, setKind] = useQueryState<"movie" | "serie" | "library">( "kind", - allowLibrary ? "library" : "movie", + initialKind ?? (allowLibrary ? "library" : "movie"), ); const [selected, setSelected] = useState(null); @@ -284,9 +288,14 @@ export const AddPage = ({ } onSelect={async () => { setSelected(item.id); - if (item.kind.startsWith("search")) - await addShow.mutateAsync(item as SearchMovie | SearchSerie); - else await matchExisting.mutateAsync(item as Show); + if (item.kind.startsWith("search")) { + if (onSearchSelect) + await onSearchSelect(item as SearchMovie | SearchSerie); + else + await addShow.mutateAsync(item as SearchMovie | SearchSerie); + } else { + await matchExisting.mutateAsync(item as Show); + } setSelected(null); if (router.canGoBack()) router.back(); }} diff --git a/front/src/ui/admin/index.tsx b/front/src/ui/admin/index.tsx index 2b887389..0abc8f6f 100644 --- a/front/src/ui/admin/index.tsx +++ b/front/src/ui/admin/index.tsx @@ -1,2 +1,3 @@ +export * from "./remap"; export * from "./users"; export * from "./videos-modal"; diff --git a/front/src/ui/admin/remap.tsx b/front/src/ui/admin/remap.tsx new file mode 100644 index 00000000..192090a4 --- /dev/null +++ b/front/src/ui/admin/remap.tsx @@ -0,0 +1,47 @@ +import { useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; +import type { SearchMovie, SearchSerie } from "~/models"; +import { useMutation } from "~/query"; +import { useQueryState } from "~/utils"; +import { AddPage } from "./add"; + +const RemapPage = ({ kind }: { kind: "movie" | "serie" }) => { + const [slug] = useQueryState("slug", undefined!); + const { t } = useTranslation(); + const router = useRouter(); + const remap = useMutation({ + method: "POST", + path: ["scanner", kind === "movie" ? "movies" : "series", slug, "remap"], + compute: (item: SearchMovie | SearchSerie) => ({ + body: { + title: item.name, + year: + "airDate" in item + ? item.airDate?.getFullYear() + : item.startAir?.getFullYear(), + externalId: Object.fromEntries( + Object.entries(item.externalId).map(([k, v]) => [k, v[0].dataId]), + ), + videos: [], + }, + }), + invalidate: null, + }); + + return ( + { + await remap.mutateAsync(item); + router.navigate("/"); + }} + /> + ); +}; + +export const MovieRemapModal = () => ; + +export const SerieRemapModal = () => ; diff --git a/front/src/ui/details/header.tsx b/front/src/ui/details/header.tsx index c5925c8d..c398f11a 100644 --- a/front/src/ui/details/header.tsx +++ b/front/src/ui/details/header.tsx @@ -4,6 +4,7 @@ import Delete from "@material-symbols/svg-400/rounded/delete.svg"; 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 Search from "@material-symbols/svg-400/rounded/search-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 { useRouter } from "expo-router"; @@ -164,6 +165,13 @@ const ButtonList = ({ icon={VideoLibrary} href={`/${kind === "movie" ? "movies" : "series"}/${slug}/videos`} /> + {kind !== "collection" && ( + + )} {kind !== "collection" &&
} {kind !== "collection" && ( { const { t } = useTranslation(); - const rescan = useMutation({ + const rescan = useMutation({ method: "PUT", path: ["scanner", "scan"], invalidate: null, diff --git a/scanner/scanner/client.py b/scanner/scanner/client.py index b72c6e30..905d6201 100644 --- a/scanner/scanner/client.py +++ b/scanner/scanner/client.py @@ -7,12 +7,21 @@ from typing import Literal from aiohttp import ClientResponse, ClientResponseError, ClientSession from pydantic import TypeAdapter -from .models.movie import Movie +from .models.movie import Movie, MovieGet from .models.page import Page from .models.request import Request from .models.serie import Serie from .models.show import Show -from .models.videos import For, Resource, Video, VideoCreated, VideoInfo, VideoLink +from .models.videos import ( + For, + Guess, + Resource, + Video, + VideoCreated, + VideoGet, + VideoInfo, + VideoLink, +) from .utils import Singleton logger = getLogger(__name__) @@ -107,6 +116,56 @@ class KyooClient(metaclass=Singleton): await self.raise_for_status(r) return Show.model_validate(await r.json()) + async def get_movie_videos(self, slug: str) -> list[Request.Video]: + async with self._client.get(f"movies/{slug}?with=videos") as r: + await self.raise_for_status(r) + movie = MovieGet.model_validate(await r.json()) + return [Request.Video(id=video.id, episodes=[]) for video in movie.videos] + + async def get_serie_videos(self, slug: str) -> list[Request.Video]: + videos: dict[str, list[tuple[int, int] | tuple[None, int]]] = {} + next_url: str | None = f"series/{slug}/videos?limit=250" + + while next_url is not None: + async with self._client.get(next_url) as r: + await self.raise_for_status(r) + page = Page[VideoGet].model_validate(await r.json()) + + for video in page.items: + episodes = [ + (entry.seasonNumber, entry.episodeNumber) + for entry in video.entries + if entry.kind == "episode" + ] + episodes += [ + (None, entry.number) + for entry in video.entries + if entry.kind == "special" + ] + + if video.id not in videos: + videos[video.id] = episodes + else: + videos[video.id] += episodes + + next_url = page.next + + return [ + Request.Video( + id=video_id, + episodes=[Guess.Episode(season=s, episode=e) for s, e in set(episodes)], + ) + for video_id, episodes in videos.items() + ] + + async def delete_movie(self, slug: str): + async with self._client.delete(f"movies/{slug}") as r: + await self.raise_for_status(r) + + async def delete_serie(self, slug: str): + async with self._client.delete(f"series/{slug}") as r: + await self.raise_for_status(r) + async def link_videos( self, kind: Literal["movie", "serie"], diff --git a/scanner/scanner/models/movie.py b/scanner/scanner/models/movie.py index ae75dde6..c37578c4 100644 --- a/scanner/scanner/models/movie.py +++ b/scanner/scanner/models/movie.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import date from enum import StrEnum +from scanner.models.videos import VideoGet + from ..utils import Language, Model from .collection import Collection from .genre import Genre @@ -58,3 +60,9 @@ class SearchMovie(Model): poster: str | None original_language: Language | None external_id: dict[str, list[MetadataId]] + + +class MovieGet(Model): + id: str + slug: str + videos: list[VideoGet] diff --git a/scanner/scanner/models/videos.py b/scanner/scanner/models/videos.py index de724b8d..e0fc987e 100644 --- a/scanner/scanner/models/videos.py +++ b/scanner/scanner/models/videos.py @@ -92,3 +92,27 @@ class VideoLink(Model): for_: list[ For.Slug | For.ExternalId | For.Movie | For.Episode | For.Order | For.Special ] + + +class VideoGet(Model): + id: str + path: str + entries: list[Episode | Movie | Special] | None = [] + + class Episode(Model): + kind: Literal["episode"] + id: str + slug: str + seasonNumber: int + episodeNumber: int + + class Special(Model): + kind: Literal["special"] + id: str + slug: str + number: int + + class Movie(Model): + kind: Literal["movie"] + id: str + slug: str diff --git a/scanner/scanner/routers/routes.py b/scanner/scanner/routers/routes.py index 9bfb1d83..f582ba0b 100644 --- a/scanner/scanner/routers/routes.py +++ b/scanner/scanner/routers/routes.py @@ -19,8 +19,7 @@ from ..models.movie import SearchMovie from ..models.page import Page from ..models.request import CreateRequest, Request, RequestRet from ..models.serie import SearchSerie -from ..models.show import Show -from ..models.videos import Video +from ..models.videos import Guess, Video from ..providers.composite import CompositeProvider from ..requests import RequestCreator from ..status import StatusService @@ -242,3 +241,59 @@ async def refresh_serie_by_slug( ] ) return ret + + +@router.post( + "/{kind}/{slug}/remap", + status_code=201, + response_description="Show remap request created.", +) +async def remap_show_by_slug( + kind: Literal["series", "movies"], + slug: str, + body: CreateRequest, + client: Annotated[KyooClient, Depends(get_client)], + requests: Annotated[RequestCreator, Depends(get_request_creator)], + _: Annotated[None, Security(validate_bearer, scopes=["scanner.add"])], +) -> RequestRet: + """ + Delete an existing show and recreate a request with remapped metadata and all videos. + """ + + videos = ( + await client.get_movie_videos(slug) + if kind == "movies" + else await client.get_serie_videos(slug) + ) + + if kind == "movies": + await client.delete_movie(slug) + else: + await client.delete_serie(slug) + + merged_videos: dict[str, set[tuple[int | None, int]]] = {} + for video in body.videos + videos: + if video.id not in merged_videos: + merged_videos[video.id] = set() + merged_videos[video.id].update((ep.season, ep.episode) for ep in video.episodes) + + [ret] = await requests.enqueue( + [ + Request( + kind="movie" if kind == "movies" else "episode", + title=body.title, + year=body.year, + external_id=body.external_id, + videos=[ + Request.Video( + id=video_id, + episodes=[ + Guess.Episode(season=s, episode=e) for s, e in episodes + ], + ) + for video_id, episodes in merged_videos.items() + ], + ) + ] + ) + return ret