Add remap option on series/movies (#1419)

This commit is contained in:
Zoe Roux 2026-04-02 19:00:29 +02:00 committed by GitHub
parent e09dc3ae9b
commit 2f2fdfc13c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 237 additions and 13 deletions

View File

@ -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",

View File

@ -0,0 +1,5 @@
import { MovieRemapModal } from "~/ui/admin";
export { ErrorBoundary } from "~/ui/error-boundary";
export default MovieRemapModal;

View File

@ -0,0 +1,5 @@
import { SerieRemapModal } from "~/ui/admin";
export { ErrorBoundary } from "~/ui/error-boundary";
export default SerieRemapModal;

View File

@ -307,7 +307,7 @@ export const prefetch = async (...queries: QueryIdentifier[]) => {
return client;
};
type MutationParams = {
type MutationParams<T = unknown> = {
method?: "POST" | "PUT" | "PATCH" | "DELETE";
path?: string[];
params?: {
@ -315,15 +315,17 @@ type MutationParams = {
};
body?: object;
formData?: FormData;
parser?: z.ZodType<T> | null;
};
export const useMutation = <T = void, QueryRet = void>({
export const useMutation = <Ret = unknown, T = void, QueryRet = void>({
compute,
invalidate,
optimistic,
optimisticKey,
parser,
...queryParams
}: MutationParams & {
}: MutationParams<Ret> & {
compute?: (param: T) => MutationParams;
optimistic?: (param: T, previous?: QueryRet) => QueryRet | undefined;
optimisticKey?: QueryIdentifier<unknown>;
@ -344,7 +346,7 @@ export const useMutation = <T = void, QueryRet = void>({
body,
formData,
authToken,
parser: null,
parser: parser ?? null,
});
},
...(invalidate && optimistic

View File

@ -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<void>;
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<string | null>(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();
}}

View File

@ -1,2 +1,3 @@
export * from "./remap";
export * from "./users";
export * from "./videos-modal";

View File

@ -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 (
<AddPage
title={t("show.remap")}
allowLibrary={false}
initialKind={kind}
videos={[]}
onSearchSelect={async (item) => {
await remap.mutateAsync(item);
router.navigate("/");
}}
/>
);
};
export const MovieRemapModal = () => <RemapPage kind="movie" />;
export const SerieRemapModal = () => <RemapPage kind="serie" />;

View File

@ -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" && (
<Menu.Item
label={t("show.remap")}
icon={Search}
href={`/${kind}s/${slug}/remap?q=${name}`}
/>
)}
{kind !== "collection" && <HR />}
{kind !== "collection" && (
<Menu.Item

View File

@ -204,7 +204,7 @@ const UnmatchedHeader = ({
scanData: ScanRequest[] | undefined;
}) => {
const { t } = useTranslation();
const rescan = useMutation<void>({
const rescan = useMutation({
method: "PUT",
path: ["scanner", "scan"],
invalidate: null,

View File

@ -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"],

View File

@ -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]

View File

@ -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

View File

@ -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