diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 269178d2..478b7538 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -213,7 +213,7 @@ export async function getEntries({ }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) - .leftJoinLateral(entryVideosQ, sql`true`) + .crossJoinLateral(entryVideosQ) .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) .where( and( diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 02b57cf2..f317e033 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -286,7 +286,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) }) .from(hist) .leftJoin(entries, valEqEntries) - .leftJoinLateral(nextEntryQ, sql`true`), + .crossJoinLateral(nextEntryQ), ) .onConflictDoUpdate({ target: [watchlist.profilePk, watchlist.showPk], diff --git a/api/src/controllers/profiles/nextup.ts b/api/src/controllers/profiles/nextup.ts index 6df5d360..4e8b6ac9 100644 --- a/api/src/controllers/profiles/nextup.ts +++ b/api/src/controllers/profiles/nextup.ts @@ -112,7 +112,7 @@ export const nextup = new Elysia({ tags: ["profiles"] }) .from(entries) .innerJoin(watchlist, eq(watchlist.nextEntry, entries.pk)) .innerJoin(transQ, eq(entries.pk, transQ.pk)) - .leftJoinLateral(entryVideosQ, sql`true`) + .crossJoinLateral(entryVideosQ) .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) .where( and( diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 71c6e567..26c2af66 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -41,7 +41,7 @@ import { entryProgressQ, entryVideosQ, mapProgress } from "../entries"; export const watchStatusQ = db .select({ ...getColumns(watchlist), - percent: sql`${watchlist.seenCount}`.as("percent"), + percent: sql`${watchlist.seenCount}`.as("percent"), }) .from(watchlist) .leftJoin(profiles, eq(watchlist.profilePk, profiles.pk)) @@ -161,9 +161,9 @@ const showRelations = { ).as("videos"), }) .from(entryVideoJoin) + .innerJoin(entries, eq(entries.showPk, shows.pk)) + .innerJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .where(eq(entryVideoJoin.entryPk, entries.pk)) - .leftJoin(entries, eq(entries.showPk, shows.pk)) - .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); }, firstEntry: ({ languages }: { languages: string[] }) => { @@ -190,7 +190,7 @@ const showRelations = { .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) - .leftJoinLateral(entryVideosQ, sql`true`) + .crossJoinLateral(entryVideosQ) .where(and(eq(entries.showPk, shows.pk), ne(entries.kind, "extra"))) .orderBy(entries.order) .limit(1) @@ -220,7 +220,7 @@ const showRelations = { .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) - .leftJoinLateral(entryVideosQ, sql`true`) + .crossJoinLateral(entryVideosQ) .where(eq(watchStatusQ.nextEntry, entries.pk)) .as("nextEntry"); }, diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index b9d51e03..855aedcf 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -53,9 +53,8 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) slug: shows.slug, }) .from(videos) - .leftJoin( + .crossJoin( sql`jsonb_array_elements_text(${videos.guess}->'year') as year`, - sql`true`, ) .innerJoin(entryVideoJoin, eq(entryVideoJoin.videoPk, videos.pk)) .innerJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) @@ -179,7 +178,6 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) return x.for.map((e) => ({ video: vids.find((v) => v.path === x.path)!.pk, path: x.path, - needRendering: x.for!.length > 1, entry: { ...e, movie: @@ -216,6 +214,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) order: entries.order, showId: sql`${shows.id}`.as("showId"), showSlug: sql`${shows.slug}`.as("showSlug"), + externalId: entries.externalId, }) .from(entries) .innerJoin(shows, eq(entries.showPk, shows.pk)) @@ -235,13 +234,12 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) videoPk: videos.pk, slug: computeVideoSlug( entriesQ.slug, - sql`j.needRendering or exists(${hasRenderingQ})`, + sql`exists(${hasRenderingQ})`, ), }) .from( values(vidEntries, { video: "integer", - needRendering: "boolean", entry: "jsonb", }).as("j"), ) @@ -293,6 +291,10 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) ), ), ), + and( + sql`j.entry ? 'externalId'`, + sql`j.entry->'externalId' <@ ${entriesQ.externalId}`, + ), ), ), ) diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 874a006c..15799dd8 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,9 +1,21 @@ +import { PatternString } from "@sinclair/typebox"; import { t } from "elysia"; import { type Prettify, comment } from "~/utils"; import { ExtraType } from "./entry/extra"; -import { bubbleVideo, registerExamples } from "./examples"; +import { bubble, bubbleVideo, registerExamples } from "./examples"; import { DbMetadata, EpisodeId, ExternalId, Resource } from "./utils"; +const ExternalIds = t.Record( + t.String(), + t.Omit( + t.Union([ + EpisodeId.patternProperties[PatternString], + ExternalId().patternProperties[PatternString], + ]), + ["link"], + ), +); + export const Guess = t.Recursive((Self) => t.Object( { @@ -13,6 +25,7 @@ export const Guess = t.Recursive((Self) => episode: t.Optional(t.Array(t.Integer(), { default: [] })), kind: t.Optional(t.UnionEnum(["episode", "movie", "extra"])), extraKind: t.Optional(ExtraType), + externalId: t.Optional(ExternalIds), from: t.String({ description: "Name of the tool that made the guess", @@ -78,7 +91,7 @@ export const SeedVideo = t.Object({ }), }), t.Object({ - externalId: t.Union([EpisodeId, ExternalId()]), + externalId: ExternalIds, }), t.Object({ movie: t.Union([ @@ -86,26 +99,28 @@ export const SeedVideo = t.Object({ t.String({ format: "slug", examples: ["bubble"] }), ]), }), - t.Intersect([ - t.Object({ - serie: t.Union([ - t.String({ format: "uuid" }), - t.String({ format: "slug", examples: ["made-in-abyss"] }), - ]), - }), - t.Union([ - t.Object({ - season: t.Integer({ minimum: 1 }), - episode: t.Integer(), - }), - t.Object({ - order: t.Number(), - }), - t.Object({ - special: t.Integer(), - }), + t.Object({ + serie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["made-in-abyss"] }), ]), - ]), + season: t.Integer({ minimum: 1 }), + episode: t.Integer(), + }), + t.Object({ + serie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["made-in-abyss"] }), + ]), + order: t.Number(), + }), + t.Object({ + serie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["made-in-abyss"] }), + ]), + special: t.Integer(), + }), ]), { default: [] }, ), @@ -123,13 +138,29 @@ export const Video = t.Composite([ export type Video = Prettify; // type used in entry responses (the slug comes from the entryVideoJoin) -export const EmbeddedVideo = t.Composite([ - t.Object({ slug: t.String({ format: "slug" }) }), - t.Omit(Video, ["guess", "createdAt", "updatedAt"]), -]); +export const EmbeddedVideo = t.Composite( + [ + t.Object({ slug: t.String({ format: "slug" }) }), + t.Omit(Video, ["guess", "createdAt", "updatedAt"]), + ], + { additionalProperties: true }, +); export type EmbeddedVideo = Prettify; registerExamples(Video, bubbleVideo); +registerExamples(SeedVideo, { + ...bubbleVideo, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedatabase: { + dataId: bubble.externalId.themoviedatabase.dataId, + }, + }, + }, + ], +}); export const Guesses = t.Object({ paths: t.Array(t.String()), diff --git a/api/tests/manual.ts b/api/tests/manual.ts index 9c7661d5..ca821e3a 100644 --- a/api/tests/manual.ts +++ b/api/tests/manual.ts @@ -8,7 +8,6 @@ import { createSerie, createVideo } from "./helpers"; // export JWT_SECRET="this is a secret"; // export JWT_ISSUER="https://kyoo.zoriya.dev"; - await migrate(); await db.delete(shows); await db.delete(profiles); diff --git a/api/tests/videos/scanner.test.ts b/api/tests/videos/scanner.test.ts index c5302079..82624adc 100644 --- a/api/tests/videos/scanner.test.ts +++ b/api/tests/videos/scanner.test.ts @@ -220,4 +220,185 @@ describe("Video seeding", () => { expect(vid!.evj[0].slug).toBe("made-in-abyss-dawn-of-the-deep-soul"); expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-dawn-of-the-deep-soul"); }); + + it("With external id", async () => { + const [resp, body] = await createVideo({ + guess: { + title: "mia", + season: [0], + episode: [3], + from: "test", + externalId: { + themoviedb: { serieId: "72636", season: 1, episode: 13 }, + }, + }, + part: null, + path: "/video/mia s1e13 [tmdb=72636].mkv", + rendering: "notehu", + version: 1, + for: [ + { + externalId: { + themoviedb: { serieId: "72636", season: 1, episode: 13 }, + }, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia s1e13 [tmdb=72636].mkv"); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13-notehu"); + expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-s1e13"); + }); + + it("With movie external id", async () => { + const [resp, body] = await createVideo({ + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedb: { dataId: "912598", season: 1, episode: 13 }, + }, + }, + part: null, + path: "/video/bubble [tmdb=912598].mkv", + rendering: "onetuh", + version: 1, + for: [ + { + externalId: { + themoviedb: { serieId: "912598", season: 1, episode: 13 }, + }, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble [tmdb=912598].mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("bubble-onetuh"); + expect(vid!.evj[0].entry.slug).toBe("bubble"); + }); + + it("Two for the same entry", async () => { + const [resp, body] = await createVideo({ + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedb: { dataId: "912598", season: 1, episode: 13 }, + }, + }, + part: null, + path: "/video/bubble [tmdb=912598].mkv", + rendering: "cwhtn", + version: 1, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedb: { serieId: "912598", season: 1, episode: 13 }, + }, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble [tmdb=912598].mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("bubble-cwhtn"); + expect(vid!.evj[0].entry.slug).toBe("bubble"); + }); + + it("Two for the same entry WITHOUT rendering", async () => { + await db.delete(videos); + const [resp, body] = await createVideo({ + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedb: { dataId: "912598", season: 1, episode: 13 }, + }, + }, + part: null, + path: "/video/bubble [tmdb=912598].mkv", + rendering: "cwhtn", + version: 1, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedb: { serieId: "912598", season: 1, episode: 13 }, + }, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble [tmdb=912598].mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("bubble"); + expect(vid!.evj[0].entry.slug).toBe("bubble"); + }); });