diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 802c1066..2f9f414d 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -515,10 +515,13 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) 201: t.Array( t.Object({ id: t.String({ format: "uuid" }), - path: t.String(), + path: t.String({ examples: ["/video/made in abyss s1e13.mkv"] }), entries: t.Array( t.Object({ - slug: t.String({ format: "slug" }), + slug: t.String({ + format: "slug", + examples: ["made-in-abyss-s1e13"], + }), }), ), }), diff --git a/api/src/models/utils/filters/to-sql.ts b/api/src/models/utils/filters/to-sql.ts index 96600331..a35d37c3 100644 --- a/api/src/models/utils/filters/to-sql.ts +++ b/api/src/models/utils/filters/to-sql.ts @@ -58,7 +58,7 @@ export const toDrizzle = (expr: Expression, config: FilterDef): SQL => { { in: where }, ); } - expr.value = { type: "bool", value: expr.value.value === "true" } + expr.value = { type: "bool", value: expr.value.value === "true" }; } if (prop.type !== expr.value.type) { throw new KErrorT( diff --git a/api/tests/helpers/videos-helper.ts b/api/tests/helpers/videos-helper.ts index de1a6a54..d21a94dd 100644 --- a/api/tests/helpers/videos-helper.ts +++ b/api/tests/helpers/videos-helper.ts @@ -43,3 +43,20 @@ export const deleteVideo = async (paths: string[]) => { const body = await resp.json(); return [resp, body] as const; }; + +export const linkVideos = async ( + links: { id: string; for: SeedVideo["for"] }[], +) => { + const resp = await handlers.handle( + new Request(buildUrl("videos/link"), { + method: "POST", + body: JSON.stringify(links), + headers: { + "Content-Type": "application/json", + ...(await getJwtHeaders()), + }, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; diff --git a/api/tests/movies/get-movie.test.ts b/api/tests/movies/get-movie.test.ts index 5e7b9ee9..96786c57 100644 --- a/api/tests/movies/get-movie.test.ts +++ b/api/tests/movies/get-movie.test.ts @@ -9,6 +9,7 @@ let bubbleId = ""; beforeAll(async () => { await db.delete(shows); + await db.delete(videos); await db.insert(videos).values(bubbleVideo); const [ret, body] = await createMovie(bubble); expect(ret.status).toBe(201); @@ -66,21 +67,29 @@ describe("Get movie", () => { const [resp, body] = await getMovie(bubble.slug, { langs: "fr,pr,*" }); expectStatus(resp, body).toBe(200); - expect(body).toMatchObject({ - slug: bubble.slug, - name: bubble.translations.en.name, - }); - expect(resp.headers.get("Content-Language")).toBe("en"); + expect(body.slug).toBe(bubble.slug); + const lang = resp.headers.get("Content-Language"); + if (lang === "en") { + expect(body.name).toBe(bubble.translations.en.name); + } else if (lang === "ja") { + expect(body.name).toBe(bubble.translations.ja.name); + } else { + expect(lang).toBe("en"); + } }); it("Works without accept-language header", async () => { const [resp, body] = await getMovie(bubble.slug, { langs: undefined }); expectStatus(resp, body).toBe(200); - expect(body).toMatchObject({ - slug: bubble.slug, - name: bubble.translations.en.name, - }); - expect(resp.headers.get("Content-Language")).toBe("en"); + expect(body.slug).toBe(bubble.slug); + const lang = resp.headers.get("Content-Language"); + if (lang === "en") { + expect(body.name).toBe(bubble.translations.en.name); + } else if (lang === "ja") { + expect(body.name).toBe(bubble.translations.ja.name); + } else { + expect(lang).toBe("en"); + } }); it("Fallback if translations does not exist", async () => { const [resp, body] = await getMovie(bubble.slug, { langs: "en-au" }); diff --git a/api/tests/videos/getdel.test.ts b/api/tests/videos/getdel.test.ts index a3247d27..aa346f4f 100644 --- a/api/tests/videos/getdel.test.ts +++ b/api/tests/videos/getdel.test.ts @@ -6,10 +6,11 @@ import { createVideo, deleteVideo, getVideos, + linkVideos, } from "tests/helpers"; import { expectStatus } from "tests/utils"; import { db } from "~/db"; -import { entries, shows, videos } from "~/db/schema"; +import { entries, entryVideoJoin, shows, videos } from "~/db/schema"; import { bubble, madeInAbyss } from "~/models/examples"; beforeAll(async () => { @@ -57,12 +58,35 @@ beforeAll(async () => { version: 1, for: [{ movie: bubble.slug }], }, + { + guess: { + title: "mia", + episodes: [{ season: 1, episode: 1 }], // Different episode for unlinked + from: "test", + history: [], + }, + part: null, + path: "/video/mia-unlinked.mkv", + rendering: "sha-unlinked-1", + version: 1, + // No 'for' initially + }, + { + guess: { title: "bubble", from: "test", history: [] }, + part: null, + path: "/video/bubble-unlinked.mkv", + rendering: "sha-unlinked-2", + version: 1, + // No 'for' initially + }, ]); expectStatus(ret, body).toBe(201); - expect(body).toBeArrayOfSize(3); + expect(body).toBeArrayOfSize(5); expect(body[0].entries).toBeArrayOfSize(1); expect(body[1].entries).toBeArrayOfSize(1); expect(body[2].entries).toBeArrayOfSize(1); + expect(body[3].entries).toBeArrayOfSize(0); // Unlinked + expect(body[4].entries).toBeArrayOfSize(0); // Unlinked const items = await db.query.shows.findMany(); expect(items.find((x) => x.slug === "bubble")!.availableCount).toBe(1); @@ -141,8 +165,10 @@ describe("Video get/deletion", () => { }, }, }); - expect(body.unmatched).toBeArrayOfSize(1); - expect(body.unmatched[0]).toBe("/video/mia s1e13 unknown test.mkv"); + expect(body.unmatched).toBeArrayOfSize(3); + expect(body.unmatched).toContain("/video/mia s1e13 unknown test.mkv"); + expect(body.unmatched).toContain("/video/mia-unlinked.mkv"); + expect(body.unmatched).toContain("/video/bubble-unlinked.mkv"); }); it("Mismatch title guess", async () => { @@ -249,3 +275,69 @@ describe("Video get/deletion", () => { expect(body[0]).toBe("/video/mia s1e13 unknown test.mkv"); }); }); + +describe("Video linking", () => { + it("Should link videos to entries", async () => { + const allVideos = await db + .select({ + id: videos.id, + path: videos.path, + rendering: videos.rendering, + }) + .from(videos); + + const miaUnlinkedVideo = allVideos.find( + (v) => v.rendering === "sha-unlinked-1", + ); + const bubbleUnlinkedVideo = allVideos.find( + (v) => v.rendering === "sha-unlinked-2", + ); + + expect(miaUnlinkedVideo).toBeDefined(); + expect(bubbleUnlinkedVideo).toBeDefined(); + + const [resp, body] = await linkVideos([ + { + id: miaUnlinkedVideo!.id, + for: [{ slug: `${madeInAbyss.slug}-s1e13` }], + }, + { + id: bubbleUnlinkedVideo!.id, + for: [{ movie: bubble.slug }], + }, + ]); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(2); + + expect(body[0]).toMatchObject({ + id: miaUnlinkedVideo!.id, + path: "/video/mia-unlinked.mkv", + entries: [ + { + slug: expect.stringContaining(`${madeInAbyss.slug}-s1e13`), + }, + ], + }); + + expect(body[1]).toMatchObject({ + id: bubbleUnlinkedVideo!.id, + path: "/video/bubble-unlinked.mkv", + entries: [ + { + slug: expect.stringContaining(bubble.slug), + }, + ], + }); + + const miaShow = await db.query.shows.findFirst({ + where: eq(shows.slug, madeInAbyss.slug), + }); + expect(miaShow!.availableCount).toBe(1); + + const bubbleShow = await db.query.shows.findFirst({ + where: eq(shows.slug, bubble.slug), + }); + expect(bubbleShow!.availableCount).toBe(1); + }); +}); diff --git a/scanner/scanner/client.py b/scanner/scanner/client.py index e86aa90e..07bfeca4 100644 --- a/scanner/scanner/client.py +++ b/scanner/scanner/client.py @@ -7,9 +7,9 @@ from aiohttp import ClientSession from pydantic import TypeAdapter from .models.movie import Movie +from .models.request import Request from .models.serie import Serie from .models.videos import For, Resource, Video, VideoCreated, VideoInfo, VideoLink -from .requests import Request from .utils import Singleton logger = getLogger(__name__) @@ -96,7 +96,7 @@ class KyooClient(metaclass=Singleton): ) async with self._client.post( - "videos", + "videos/link", data=TypeAdapter(list[VideoLink]).dump_json( [map_request(x) for x in videos], by_alias=True, diff --git a/scanner/scanner/models/request.py b/scanner/scanner/models/request.py new file mode 100644 index 00000000..55b5cb0c --- /dev/null +++ b/scanner/scanner/models/request.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from typing import Literal + +from pydantic import Field + +from .videos import Guess +from ..utils import Model + + +class Request(Model, extra="allow"): + pk: int | None = Field(exclude=True, default=None) + kind: Literal["episode", "movie"] + title: str + year: int | None + external_id: dict[str, str] + videos: list[Request.Video] + + class Video(Model): + id: str + episodes: list[Guess.Episode] diff --git a/scanner/scanner/requests.py b/scanner/scanner/requests.py index 17c9566a..f4a6997c 100644 --- a/scanner/scanner/requests.py +++ b/scanner/scanner/requests.py @@ -1,33 +1,18 @@ -from __future__ import annotations - from asyncio import CancelledError, Event, TaskGroup from logging import getLogger -from typing import Literal, cast +from typing import cast from asyncpg import Connection, Pool -from pydantic import Field, TypeAdapter +from pydantic import TypeAdapter from .client import KyooClient -from .models.videos import Guess, Resource +from .models.request import Request +from .models.videos import Resource from .providers.provider import Provider -from .utils import Model logger = getLogger(__name__) -class Request(Model, extra="allow"): - pk: int | None = Field(exclude=True, default=None) - kind: Literal["episode", "movie"] - title: str - year: int | None - external_id: dict[str, str] - videos: list[Request.Video] - - class Video(Model): - id: str - episodes: list[Guess.Episode] - - class RequestCreator: def __init__(self, database: Connection): self._database = database