mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-04-07 01:31:56 -04:00
Add remap option on series/movies (#1419)
This commit is contained in:
parent
e09dc3ae9b
commit
2f2fdfc13c
@ -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",
|
||||
|
||||
5
front/src/app/(app)/movies/[slug]/remap.tsx
Normal file
5
front/src/app/(app)/movies/[slug]/remap.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { MovieRemapModal } from "~/ui/admin";
|
||||
|
||||
export { ErrorBoundary } from "~/ui/error-boundary";
|
||||
|
||||
export default MovieRemapModal;
|
||||
5
front/src/app/(app)/series/[slug]/remap.tsx
Normal file
5
front/src/app/(app)/series/[slug]/remap.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { SerieRemapModal } from "~/ui/admin";
|
||||
|
||||
export { ErrorBoundary } from "~/ui/error-boundary";
|
||||
|
||||
export default SerieRemapModal;
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./remap";
|
||||
export * from "./users";
|
||||
export * from "./videos-modal";
|
||||
|
||||
47
front/src/ui/admin/remap.tsx
Normal file
47
front/src/ui/admin/remap.tsx
Normal 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" />;
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user