From d4257c1d02f1188e1751954f423b7c07710d070b Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 25 Jan 2025 16:38:06 +0100 Subject: [PATCH 01/30] Properly register examples for seasons --- api/src/models/movie.ts | 2 +- api/src/models/season.ts | 11 +++++++++-- api/src/models/serie.ts | 6 +++--- api/src/models/utils/resource.ts | 9 +++++---- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index a46aae73..76099c23 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -52,7 +52,7 @@ export const MovieTranslation = t.Object({ export type MovieTranslation = typeof MovieTranslation.static; export const Movie = t.Intersect([ - Resource, + Resource(), MovieTranslation, BaseMovie, t.Object({ isAvailable: t.Boolean() }), diff --git a/api/src/models/season.ts b/api/src/models/season.ts index 90ceae9e..4066b0c1 100644 --- a/api/src/models/season.ts +++ b/api/src/models/season.ts @@ -3,6 +3,7 @@ import { SeasonId } from "./utils/external-id"; import { Image, SeedImage } from "./utils/image"; import { TranslationRecord } from "./utils/language"; import { Resource } from "./utils/resource"; +import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; export const BaseSeason = t.Object({ seasonNumber: t.Number({ minimum: 1 }), @@ -25,13 +26,13 @@ export const SeasonTranslation = t.Object({ }); export type SeasonTranslation = typeof SeasonTranslation.static; -export const Season = t.Intersect([Resource, BaseSeason, SeasonTranslation]); +export const Season = t.Intersect([Resource(), BaseSeason, SeasonTranslation]); export type Season = typeof Season.static; export const SeedSeason = t.Intersect([ t.Omit(BaseSeason, ["createdAt", "nextRefresh"]), t.Object({ - slug: t.String({ format: "slug" }), + slug: t.String({ format: "slug", examples: ["made-in-abyss-s1"] }), translations: TranslationRecord( t.Intersect([ t.Omit(SeasonTranslation, ["poster", "thumbnail", "banner"]), @@ -44,3 +45,9 @@ export const SeedSeason = t.Intersect([ ), }), ]); + +registerExamples(Season, { + ...madeInAbyss.seasons[0], + ...madeInAbyss.seasons[0].translations.en, + ...bubbleImages, +}); diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index db210eb5..a0b600b7 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -6,6 +6,7 @@ import { Genre } from "./utils/genres"; import { Image, SeedImage } from "./utils/image"; import { Language, TranslationRecord } from "./utils/language"; import { Resource } from "./utils/resource"; +import { SeedEntry } from "./entry"; export const SerieStatus = t.UnionEnum([ "unknown", @@ -55,7 +56,7 @@ export const SerieTranslation = t.Object({ }); export type SerieTranslation = typeof SerieTranslation.static; -export const Serie = t.Intersect([Resource, SerieTranslation, BaseSerie]); +export const Serie = t.Intersect([Resource(), SerieTranslation, BaseSerie]); export type Serie = typeof Serie.static; export const SeedSerie = t.Intersect([ @@ -74,7 +75,7 @@ export const SeedSerie = t.Intersect([ ]), ), seasons: t.Array(SeedSeason), - // entries: t.Array(SeedEntry), + entries: t.Array(SeedEntry), // extras: t.Optional(t.Array(SeedExtra)), }), ]); @@ -85,4 +86,3 @@ registerExamples(Serie, { ...madeInAbyss.translations.en, ...bubbleImages, }); -registerExamples(SeedSerie, madeInAbyss); diff --git a/api/src/models/utils/resource.ts b/api/src/models/utils/resource.ts index 6e837397..2fe7465d 100644 --- a/api/src/models/utils/resource.ts +++ b/api/src/models/utils/resource.ts @@ -8,10 +8,11 @@ FormatRegistry.Set("slug", (slug) => { return /^[a-z0-9-]+$/g.test(slug); }); -export const Resource = t.Object({ - id: t.String({ format: "uuid" }), - slug: t.String({ format: "slug" }), -}); +export const Resource = () => + t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug" }), + }); const checker = TypeCompiler.Compile(t.String({ format: "uuid" })); export const isUuid = (id: string) => checker.Check(id); From b9c022f614352cc7d9b8ab64fcd35ca01c9d2cc0 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 25 Jan 2025 16:38:32 +0100 Subject: [PATCH 02/30] Add seed entry types --- api/src/models/entry/episode.ts | 16 +++++++++++--- api/src/models/entry/extra.ts | 2 +- api/src/models/entry/index.ts | 12 +++++++++- api/src/models/entry/movie-entry.ts | 32 ++++++++++++++++++++++----- api/src/models/entry/special.ts | 16 +++++++++++--- api/src/models/entry/unknown-entry.ts | 2 +- 6 files changed, 65 insertions(+), 15 deletions(-) diff --git a/api/src/models/entry/episode.ts b/api/src/models/entry/episode.ts index bd8dea2a..602a2816 100644 --- a/api/src/models/entry/episode.ts +++ b/api/src/models/entry/episode.ts @@ -1,7 +1,6 @@ import { t } from "elysia"; -import { EpisodeId } from "../utils/external-id"; -import { Resource } from "../utils/resource"; import { BaseEntry, EntryTranslation } from "./base-entry"; +import { EpisodeId, SeedImage, TranslationRecord, Resource } from "../utils"; export const BaseEpisode = t.Intersect([ BaseEntry, @@ -14,5 +13,16 @@ export const BaseEpisode = t.Intersect([ }), ]); -export const Episode = t.Intersect([Resource, BaseEpisode, EntryTranslation]); +export const Episode = t.Intersect([Resource(), BaseEpisode, EntryTranslation]); export type Episode = typeof Episode.static; + +export const SeedEpisode = t.Intersect([ + BaseEpisode, + t.Omit(BaseEntry, ["thumbnail", "createdAt", "nextRefresh"]), + t.Object({ + thumbnail: t.Nullable(SeedImage), + translations: TranslationRecord(EntryTranslation), + videos: t.Optional(t.Array(t.String({ format: "uuid" }))), + }), +]); +export type SeedEpisode = typeof SeedEpisode.static; diff --git a/api/src/models/entry/extra.ts b/api/src/models/entry/extra.ts index b6196de4..a0667413 100644 --- a/api/src/models/entry/extra.ts +++ b/api/src/models/entry/extra.ts @@ -31,5 +31,5 @@ export const BaseExtra = t.Intersect( }, ); -export const Extra = t.Intersect([Resource, BaseExtra, EntryTranslation]); +export const Extra = t.Intersect([Resource(), BaseExtra, EntryTranslation]); export type Extra = typeof Extra.static; diff --git a/api/src/models/entry/index.ts b/api/src/models/entry/index.ts index 1df97a0b..72b5bb43 100644 --- a/api/src/models/entry/index.ts +++ b/api/src/models/entry/index.ts @@ -1,9 +1,19 @@ import { t } from "elysia"; -import { Episode, MovieEntry, Special } from "../entry"; +import { + Episode, + SeedEpisode, + MovieEntry, + SeedMovieEntry, + Special, + SeedSpecial, +} from "../entry"; export const Entry = t.Union([Episode, MovieEntry, Special]); export type Entry = typeof Entry.static; +export const SeedEntry = t.Union([SeedEpisode, SeedMovieEntry, SeedSpecial]); +export type SeedEntry = typeof Entry.static; + export * from "./episode"; export * from "./movie-entry"; export * from "./special"; diff --git a/api/src/models/entry/movie-entry.ts b/api/src/models/entry/movie-entry.ts index 205c88ef..643928e1 100644 --- a/api/src/models/entry/movie-entry.ts +++ b/api/src/models/entry/movie-entry.ts @@ -1,13 +1,17 @@ import { t } from "elysia"; import { comment } from "../../utils"; -import { ExternalId } from "../utils/external-id"; -import { Image } from "../utils/image"; -import { Resource } from "../utils/resource"; import { BaseEntry, EntryTranslation } from "./base-entry"; +import { + Resource, + Image, + SeedImage, + ExternalId, + TranslationRecord, +} from "../utils"; export const BaseMovieEntry = t.Intersect( [ - t.Omit(BaseEntry, ["thumbnail"]), + BaseEntry, t.Object({ kind: t.Literal("movie"), order: t.Number({ @@ -29,13 +33,29 @@ export const MovieEntryTranslation = t.Intersect([ EntryTranslation, t.Object({ tagline: t.Nullable(t.String()), - thumbnail: t.Nullable(Image), + poster: t.Nullable(Image), }), ]); export const MovieEntry = t.Intersect([ - Resource, + Resource(), BaseMovieEntry, MovieEntryTranslation, ]); export type MovieEntry = typeof MovieEntry.static; + +export const SeedMovieEntry = t.Intersect([ + BaseMovieEntry, + t.Omit(BaseEntry, ["thumbnail", "createdAt", "nextRefresh"]), + t.Object({ + thumbnail: t.Nullable(SeedImage), + translations: TranslationRecord( + t.Intersect([ + t.Omit(MovieEntryTranslation, ["poster"]), + t.Object({ poster: t.Nullable(SeedImage) }), + ]), + ), + videos: t.Optional(t.Array(t.String({ format: "uuid" }))), + }), +]); +export type SeedMovieEntry = typeof SeedMovieEntry.static; diff --git a/api/src/models/entry/special.ts b/api/src/models/entry/special.ts index bb1898d2..d7527f41 100644 --- a/api/src/models/entry/special.ts +++ b/api/src/models/entry/special.ts @@ -1,8 +1,7 @@ import { t } from "elysia"; import { comment } from "../../utils"; -import { EpisodeId } from "../utils/external-id"; -import { Resource } from "../utils/resource"; import { BaseEntry, EntryTranslation } from "./base-entry"; +import { EpisodeId, Resource, SeedImage, TranslationRecord } from "../utils"; export const BaseSpecial = t.Intersect( [ @@ -25,5 +24,16 @@ export const BaseSpecial = t.Intersect( }, ); -export const Special = t.Intersect([Resource, BaseSpecial, EntryTranslation]); +export const Special = t.Intersect([Resource(), BaseSpecial, EntryTranslation]); export type Special = typeof Special.static; + +export const SeedSpecial = t.Intersect([ + BaseSpecial, + t.Omit(BaseEntry, ["thumbnail", "createdAt", "nextRefresh"]), + t.Object({ + thumbnail: t.Nullable(SeedImage), + translations: TranslationRecord(EntryTranslation), + videos: t.Optional(t.Array(t.String({ format: "uuid" }))), + }), +]); +export type SeedSpecial = typeof SeedSpecial.static; diff --git a/api/src/models/entry/unknown-entry.ts b/api/src/models/entry/unknown-entry.ts index e60d224d..383fd15b 100644 --- a/api/src/models/entry/unknown-entry.ts +++ b/api/src/models/entry/unknown-entry.ts @@ -23,7 +23,7 @@ export const UnknownEntryTranslation = t.Omit(EntryTranslation, [ ]); export const UnknownEntry = t.Intersect([ - Resource, + Resource(), BaseUnknownEntry, UnknownEntryTranslation, ]); From ec3d48ac79e23630cd7a6c0005a633a9bf76d529 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jan 2025 00:11:03 +0100 Subject: [PATCH 03/30] Extract movie insert to a show insert method --- api/src/controllers/seed/insert/shows.ts | 89 ++++++++++++++ api/src/controllers/seed/movies.ts | 144 ++++------------------- api/src/controllers/videos.ts | 2 +- api/src/models/entry/index.ts | 2 +- api/src/utils.ts | 4 + 5 files changed, 119 insertions(+), 122 deletions(-) create mode 100644 api/src/controllers/seed/insert/shows.ts diff --git a/api/src/controllers/seed/insert/shows.ts b/api/src/controllers/seed/insert/shows.ts new file mode 100644 index 00000000..eb895d7e --- /dev/null +++ b/api/src/controllers/seed/insert/shows.ts @@ -0,0 +1,89 @@ +import { eq, sql } from "drizzle-orm"; +import { db } from "~/db"; +import { showTranslations, shows } from "~/db/schema"; +import { conflictUpdateAllExcept } from "~/db/utils"; +import type { SeedMovie } from "~/models/movie"; +import { getYear } from "~/utils"; +import { processOptImage } from "../images"; + +type Show = typeof shows.$inferInsert; +type ShowTrans = typeof showTranslations.$inferInsert; + +export const insertShow = async ( + show: Show, + translations: SeedMovie["translations"], +) => { + return await db.transaction(async (tx) => { + const ret = await insertBaseShow(tx, show); + if ("status" in ret) return ret; + + const trans: ShowTrans[] = await Promise.all( + Object.entries(translations).map(async ([lang, tr]) => ({ + pk: ret.pk, + language: lang, + ...tr, + poster: await processOptImage(tr.poster), + thumbnail: await processOptImage(tr.thumbnail), + logo: await processOptImage(tr.logo), + banner: await processOptImage(tr.banner), + })), + ); + await tx + .insert(showTranslations) + .values(trans) + .onConflictDoUpdate({ + target: [showTranslations.pk, showTranslations.language], + set: conflictUpdateAllExcept(showTranslations, ["pk", "language"]), + }); + return ret; + }); +}; + +async function insertBaseShow( + tx: Parameters[0]>[0], + show: Show, +) { + function insert() { + return tx + .insert(shows) + .values(show) + .onConflictDoUpdate({ + target: shows.slug, + set: conflictUpdateAllExcept(shows, ["pk", "id", "slug", "createdAt"]), + // if year is different, this is not an update but a conflict (ex: dune-1984 vs dune-2021) + setWhere: sql`date_part('year', ${shows.startAir}) = date_part('year', excluded."start_air")`, + }) + .returning({ + pk: shows.pk, + id: shows.id, + slug: shows.slug, + // https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667 + updated: sql`(xmax <> 0)`.as("updated"), + }); + } + + let [ret] = await insert(); + if (ret) return ret; + + // ret is undefined when the conflict's where return false (meaning we have + // a conflicting slug but a different air year. + // try to insert adding the year at the end of the slug. + if (show.startAir && !show.slug.endsWith(`${getYear(show.startAir)}`)) { + show.slug = `${show.slug}-${getYear(show.startAir)}`; + [ret] = await insert(); + if (ret) return ret; + } + + // if at this point ret is still undefined, we could not reconciliate. + // simply bail and let the caller handle this. + const [{ id }] = await db + .select({ id: shows.id }) + .from(shows) + .where(eq(shows.slug, show.slug)) + .limit(1); + return { + status: 409 as const, + id, + slug: show.slug, + }; +} diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 2c32f492..14ef30e2 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -1,23 +1,19 @@ -import { eq, inArray, sql } from "drizzle-orm"; +import { inArray, sql } from "drizzle-orm"; import { t } from "elysia"; import { db } from "~/db"; import { - entries, + type entries, entryTranslations, entryVideoJointure as evj, - showTranslations, - shows, videos, } from "~/db/schema"; import { conflictUpdateAllExcept } from "~/db/utils"; import type { SeedMovie } from "~/models/movie"; -import { processOptImage } from "./images"; +import { getYear } from "~/utils"; +import { insertEntries } from "./insert/entries"; +import { insertShow } from "./insert/shows"; import { guessNextRefresh } from "./refresh"; -type Show = typeof shows.$inferInsert; -type ShowTrans = typeof showTranslations.$inferInsert; -type Entry = typeof entries.$inferInsert; - export const SeedMovieResponse = t.Object({ id: t.String({ format: "uuid" }), slug: t.String({ format: "slug", examples: ["bubble"] }), @@ -30,7 +26,8 @@ export type SeedMovieResponse = typeof SeedMovieResponse.static; export const seedMovie = async ( seed: SeedMovie, ): Promise< - | (SeedMovieResponse & { status: "Created" | "OK" | "Conflict" }) + | (SeedMovieResponse & { updated: boolean }) + | { status: 409; id: string; slug: string } | { status: 422; message: string } > => { if (seed.slug === "random") { @@ -44,116 +41,27 @@ export const seedMovie = async ( } const { translations, videos: vids, ...bMovie } = seed; + const nextRefresh = guessNextRefresh(bMovie.airDate ?? new Date()); - const ret = await db.transaction(async (tx) => { - const movie: Show = { + const ret = await insertShow( + { kind: "movie", startAir: bMovie.airDate, - nextRefresh: guessNextRefresh(bMovie.airDate ?? new Date()), + nextRefresh, ...bMovie, - }; + }, + translations, + ); + if ("status" in ret) return ret; - const insert = () => - tx - .insert(shows) - .values(movie) - .onConflictDoUpdate({ - target: shows.slug, - set: conflictUpdateAllExcept(shows, [ - "pk", - "id", - "slug", - "createdAt", - ]), - // if year is different, this is not an update but a conflict (ex: dune-1984 vs dune-2021) - setWhere: sql`date_part('year', ${shows.startAir}) = date_part('year', excluded."start_air")`, - }) - .returning({ - pk: shows.pk, - id: shows.id, - slug: shows.slug, - // https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667 - updated: sql`(xmax <> 0)`.as("updated"), - }); - let [ret] = await insert(); - if (!ret) { - // ret is undefined when the conflict's where return false (meaning we have - // a conflicting slug but a different air year. - // try to insert adding the year at the end of the slug. - if ( - movie.startAir && - !movie.slug.endsWith(`${getYear(movie.startAir)}`) - ) { - movie.slug = `${movie.slug}-${getYear(movie.startAir)}`; - [ret] = await insert(); - } - - // if at this point ret is still undefined, we could not reconciliate. - // simply bail and let the caller handle this. - if (!ret) { - const [{ id }] = await db - .select({ id: shows.id }) - .from(shows) - .where(eq(shows.slug, movie.slug)) - .limit(1); - return { - status: "Conflict" as const, - id, - slug: movie.slug, - videos: [], - }; - } - } - - // even if never shown to the user, a movie still has an entry. - const movieEntry: Entry = { type: "movie", ...bMovie }; - const [entry] = await tx - .insert(entries) - .values(movieEntry) - .onConflictDoUpdate({ - target: entries.slug, - set: conflictUpdateAllExcept(entries, [ - "pk", - "id", - "slug", - "createdAt", - ]), - }) - .returning({ pk: entries.pk }); - - const trans: ShowTrans[] = await Promise.all( - Object.entries(translations).map(async ([lang, tr]) => ({ - pk: ret.pk, - // TODO: normalize lang or error if invalid - language: lang, - ...tr, - poster: await processOptImage(tr.poster), - thumbnail: await processOptImage(tr.thumbnail), - logo: await processOptImage(tr.logo), - banner: await processOptImage(tr.banner), - })), - ); - await tx - .insert(showTranslations) - .values(trans) - .onConflictDoUpdate({ - target: [showTranslations.pk, showTranslations.language], - set: conflictUpdateAllExcept(showTranslations, ["pk", "language"]), - }); - - const entryTrans = trans.map((x) => ({ ...x, pk: entry.pk })); - await tx - .insert(entryTranslations) - .values(entryTrans) - .onConflictDoUpdate({ - target: [entryTranslations.pk, entryTranslations.language], - set: conflictUpdateAllExcept(entryTranslations, ["pk", "language"]), - }); - - return { ...ret, entry: entry.pk }; - }); - - if (ret.status === "Conflict") return ret; + // even if never shown to the user, a movie still has an entry. + const [entry] = await insertEntries(ret.pk, [ + { + kind: "movie", + nextRefresh, + ...bMovie, + }, + ]); let retVideos: { slug: string }[] = []; if (vids) { @@ -182,13 +90,9 @@ export const seedMovie = async ( } return { - status: ret.updated ? "OK" : "Created", + updated: ret.updated, id: ret.id, slug: ret.slug, videos: retVideos, }; }; - -function getYear(date: string) { - return new Date(date).getUTCFullYear(); -} diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 47f64b71..4761e5d1 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -20,7 +20,7 @@ export const videos = new Elysia({ prefix: "/videos", tags: ["videos"] }) response: { 200: "video" }, }) .post( - "/", + "", async ({ body }) => { return await db .insert(videosT) diff --git a/api/src/models/entry/index.ts b/api/src/models/entry/index.ts index 72b5bb43..73e73e6c 100644 --- a/api/src/models/entry/index.ts +++ b/api/src/models/entry/index.ts @@ -12,7 +12,7 @@ export const Entry = t.Union([Episode, MovieEntry, Special]); export type Entry = typeof Entry.static; export const SeedEntry = t.Union([SeedEpisode, SeedMovieEntry, SeedSpecial]); -export type SeedEntry = typeof Entry.static; +export type SeedEntry = typeof SeedEntry.static; export * from "./episode"; export * from "./movie-entry"; diff --git a/api/src/utils.ts b/api/src/utils.ts index 00c2f5ab..bb3d374e 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -6,3 +6,7 @@ export const comment = (str: TemplateStringsArray, ...values: any[]) => .replace(/^[ \t]+/gm, "") // leading spaces .replace(/([^\n])\n([^\n])/g, "$1 $2") // two lines to space separated line .replace(/\n{2}/g, "\n"); // keep newline if there's an empty line + +export function getYear(date: string) { + return new Date(date).getUTCFullYear(); +} From 470a8deae99e1b24a825fa7b8a3b40783526bc5c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jan 2025 00:11:28 +0100 Subject: [PATCH 04/30] Start entry insert --- api/src/controllers/seed/insert/entries.ts | 82 ++++++++++++++++++++++ api/src/db/schema/entries.ts | 8 ++- 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 api/src/controllers/seed/insert/entries.ts diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts new file mode 100644 index 00000000..528c4793 --- /dev/null +++ b/api/src/controllers/seed/insert/entries.ts @@ -0,0 +1,82 @@ +import { db } from "~/db"; +import { entries, entryTranslations } from "~/db/schema"; +import { conflictUpdateAllExcept } from "~/db/utils"; +import type { Entry, SeedEntry } from "~/models/entry"; +import { processOptImage } from "../images"; +import { guessNextRefresh } from "../refresh"; + +type EntryI = typeof entries.$inferInsert; +type EntryTrans = typeof entryTranslations.$inferInsert; + +const generateSlug = (showSlug: string, entry: SeedEntry): string => { + switch (entry.kind) { + case "episode": + return `${showSlug}-s${entry.seasonNumber}e${entry.episodeNumber}`; + case "special": + return `${showSlug}-sp${entry.number}`; + case "movie": + return entry.order === 1 ? showSlug : `${showSlug}-${entry.order}`; + } +}; + +export const insertEntries = async ( + show: { pk: number; slug: string }, + items: SeedEntry[], +) => { + const vals = await Promise.all( + items.map(async (seed) => { + const { translations, videos, ...entry } = seed; + return { + entry: { + ...entry, + showPk: show.pk, + slug: generateSlug(show.slug, seed), + thumbnails: await processOptImage(seed.thumbnail), + nextRefresh: guessNextRefresh(entry.airDate ?? new Date()), + } satisfies EntryI, + translations: (await Promise.all( + Object.entries(translations).map(async ([lang, tr]) => ({ + ...tr, + language: lang, + poster: + seed.kind === "movie" + ? await processOptImage(tr.poster) + : undefined, + })), + )) satisfies Omit[], + videos, + }; + }), + ); + + return await db.transaction(async (tx) => { + const ret = await tx + .insert(entries) + .values(vals.map((x) => x.entry)) + .onConflictDoUpdate({ + target: entries.slug, + set: conflictUpdateAllExcept(entries, [ + "pk", + "showPk", + "id", + "slug", + "createdAt", + ]), + }) + .returning({ pk: entries.pk }); + + await tx + .insert(entryTranslations) + .values( + vals.map((x, i) => + x.translations.map((tr) => ({ ...tr, pk: ret[i].pk })), + ), + ) + .onConflictDoUpdate({ + target: [entryTranslations.pk, entryTranslations.language], + set: conflictUpdateAllExcept(entryTranslations, ["pk", "language"]), + }); + + return { ...ret, entry: entry.pk }; + }); +}; diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index bb870390..64380470 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -51,11 +51,15 @@ export const entries = schema.table( pk: integer().primaryKey().generatedAlwaysAsIdentity(), id: uuid().notNull().unique().defaultRandom(), slug: varchar({ length: 255 }).notNull().unique(), - showPk: integer().references(() => shows.pk, { onDelete: "cascade" }), + showPk: integer() + .notNull() + .references(() => shows.pk, { onDelete: "cascade" }), order: real(), seasonNumber: integer(), episodeNumber: integer(), - type: entryType().notNull(), + kind: entryType().notNull(), + // only when kind=extra + extraKind: text(), airDate: date(), runtime: integer(), thumbnails: image(), From 962672e4edd33e39f3a4a81f2e3ee3bf70e2f0e2 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jan 2025 00:11:56 +0100 Subject: [PATCH 05/30] Start series insert --- api/src/controllers/seed/index.ts | 59 ++++++++++++++++++++++++------ api/src/controllers/seed/series.ts | 51 ++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 api/src/controllers/seed/series.ts diff --git a/api/src/controllers/seed/index.ts b/api/src/controllers/seed/index.ts index c87f643f..cbcd5be6 100644 --- a/api/src/controllers/seed/index.ts +++ b/api/src/controllers/seed/index.ts @@ -1,29 +1,37 @@ import { Value } from "@sinclair/typebox/value"; import Elysia from "elysia"; import { KError } from "~/models/error"; -import { Movie, SeedMovie } from "~/models/movie"; +import { SeedMovie } from "~/models/movie"; +import { SeedSerie } from "~/models/serie"; import { Resource } from "~/models/utils"; import { comment } from "~/utils"; import { SeedMovieResponse, seedMovie } from "./movies"; +import { SeedSerieResponse, seedSerie } from "./series"; export const seed = new Elysia() .model({ - movie: Movie, "seed-movie": SeedMovie, "seed-movie-response": SeedMovieResponse, + "seed-serie": SeedSerie, + "seed-serie-response": SeedSerieResponse, }) .post( "/movies", async ({ body, error }) => { // needed due to https://github.com/elysiajs/elysia/issues/671 - body = Value.Decode(SeedMovie, body); + const movie = Value.Decode(SeedMovie, body) as SeedMovie; - const ret = await seedMovie(body); - if (ret.status === 422) return error(422, ret); - return error(ret.status, ret); + const ret = await seedMovie(movie); + if ("status" in ret) return error(ret.status, ret as any); + return error(ret.updated ? 200 : 201, ret); }, { - body: "seed-movie", + detail: { + tags: ["movies"], + description: + "Create a movie & all related metadata. Can also link videos.", + }, + body: SeedMovie, response: { 200: { ...SeedMovieResponse, @@ -31,18 +39,47 @@ export const seed = new Elysia() }, 201: { ...SeedMovieResponse, description: "Created a new movie." }, 409: { - ...Resource, + ...Resource(), description: comment` A movie with the same slug but a different air date already exists. Change the slug and re-run the request. `, }, - 422: { ...KError, description: "Invalid schema in body." }, + 422: KError, }, + }, + ) + .post( + "/series", + async ({ body, error }) => { + // needed due to https://github.com/elysiajs/elysia/issues/671 + const serie = Value.Decode(SeedSerie, body) as SeedSerie; + + const ret = await seedSerie(serie); + if ("status" in ret) return error(ret.status, ret as any); + return error(ret.updated ? 200 : 201, ret); + }, + { detail: { - tags: ["movies"], + tags: ["series"], description: - "Create a movie & all related metadata. Can also link videos.", + "Create a series & all related metadata. Can also link videos.", + }, + body: SeedSerie, + response: { + 200: { + ...SeedSerieResponse, + description: "Existing serie edited/updated.", + }, + 201: { ...SeedSerieResponse, description: "Created a new serie." }, + 409: { + ...Resource(), + description: comment` + A serie with the same slug but a different air date already exists. + Change the slug and re-run the request. + `, + }, + 422: KError, }, }, ); diff --git a/api/src/controllers/seed/series.ts b/api/src/controllers/seed/series.ts new file mode 100644 index 00000000..948889e6 --- /dev/null +++ b/api/src/controllers/seed/series.ts @@ -0,0 +1,51 @@ +import { t } from "elysia"; +import type { SeedSerie } from "~/models/serie"; +import { getYear } from "~/utils"; +import { insertShow } from "./insert/shows"; +import { guessNextRefresh } from "./refresh"; +import { insertEntries } from "./insert/entries"; + +export const SeedSerieResponse = t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug", examples: ["bubble"] }), +}); +export type SeedSerieResponse = typeof SeedSerieResponse.static; + +export const seedSerie = async ( + seed: SeedSerie, +): Promise< + | (SeedSerieResponse & { updated: boolean }) + | { status: 409; id: string; slug: string } + | { status: 422; message: string } +> => { + if (seed.slug === "random") { + if (!seed.startAir) { + return { + status: 422, + message: "`random` is a reserved slug. Use something else.", + }; + } + seed.slug = `random-${getYear(seed.startAir)}`; + } + + const { translations, seasons, entries, ...serie } = seed; + const nextRefresh = guessNextRefresh(serie.startAir ?? new Date()); + + const ret = await insertShow( + { + kind: "serie", + nextRefresh, + ...serie, + }, + translations, + ); + if ("status" in ret) return ret; + + const retEntries = await insertEntries(ret.pk, entries); + + return { + updated: ret.updated, + id: ret.id, + slug: ret.slug, + }; +}; From 5ca0ef08d44247ba72a4c26d93ed9eea7830259c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jan 2025 14:38:07 +0100 Subject: [PATCH 06/30] Make processImages synchronous --- api/src/controllers/seed/images.ts | 9 ++++++--- api/src/controllers/seed/insert/shows.ts | 17 +++++++++-------- api/src/controllers/seed/movies.ts | 17 +++++++++-------- api/src/controllers/seed/series.ts | 23 +++++++++++++++-------- biome.json | 1 - 5 files changed, 39 insertions(+), 28 deletions(-) diff --git a/api/src/controllers/seed/images.ts b/api/src/controllers/seed/images.ts index bbb595f8..12f0d016 100644 --- a/api/src/controllers/seed/images.ts +++ b/api/src/controllers/seed/images.ts @@ -1,6 +1,9 @@ import type { Image } from "~/models/utils"; -export const processImage = async (url: string): Promise => { +// this will only push a task to the image downloader service and not download it instantly. +// this is both done to prevent to many requests to be sent at once and to make sure POST +// requests are not blocked by image downloading or blurhash calculation +export const processImage = (url: string): Image => { const hasher = new Bun.CryptoHasher("sha256"); hasher.update(url); @@ -13,7 +16,7 @@ export const processImage = async (url: string): Promise => { }; }; -export const processOptImage = (url: string | null): Promise => { - if (!url) return Promise.resolve(null); +export const processOptImage = (url: string | null): Image | null => { + if (!url) return null; return processImage(url); }; diff --git a/api/src/controllers/seed/insert/shows.ts b/api/src/controllers/seed/insert/shows.ts index eb895d7e..8986e33e 100644 --- a/api/src/controllers/seed/insert/shows.ts +++ b/api/src/controllers/seed/insert/shows.ts @@ -3,6 +3,7 @@ import { db } from "~/db"; import { showTranslations, shows } from "~/db/schema"; import { conflictUpdateAllExcept } from "~/db/utils"; import type { SeedMovie } from "~/models/movie"; +import type { SeedSerie } from "~/models/serie"; import { getYear } from "~/utils"; import { processOptImage } from "../images"; @@ -11,22 +12,22 @@ type ShowTrans = typeof showTranslations.$inferInsert; export const insertShow = async ( show: Show, - translations: SeedMovie["translations"], + translations: SeedMovie["translations"] | SeedSerie["translations"], ) => { return await db.transaction(async (tx) => { const ret = await insertBaseShow(tx, show); if ("status" in ret) return ret; - const trans: ShowTrans[] = await Promise.all( - Object.entries(translations).map(async ([lang, tr]) => ({ + const trans: ShowTrans[] = Object.entries(translations).map( + ([lang, tr]) => ({ pk: ret.pk, language: lang, ...tr, - poster: await processOptImage(tr.poster), - thumbnail: await processOptImage(tr.thumbnail), - logo: await processOptImage(tr.logo), - banner: await processOptImage(tr.banner), - })), + poster: processOptImage(tr.poster), + thumbnail: processOptImage(tr.thumbnail), + logo: processOptImage(tr.logo), + banner: processOptImage(tr.banner), + }), ); await tx .insert(showTranslations) diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 14ef30e2..32e7f700 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -43,7 +43,7 @@ export const seedMovie = async ( const { translations, videos: vids, ...bMovie } = seed; const nextRefresh = guessNextRefresh(bMovie.airDate ?? new Date()); - const ret = await insertShow( + const show = await insertShow( { kind: "movie", startAir: bMovie.airDate, @@ -52,12 +52,13 @@ export const seedMovie = async ( }, translations, ); - if ("status" in ret) return ret; + if ("status" in show) return show; // even if never shown to the user, a movie still has an entry. - const [entry] = await insertEntries(ret.pk, [ + const [entry] = await insertEntries(show, [ { kind: "movie", + order: 1, nextRefresh, ...bMovie, }, @@ -70,12 +71,12 @@ export const seedMovie = async ( .select( db .select({ - entry: sql`${ret.entry}`.as("entry"), + entry: sql`${show.entry}`.as("entry"), video: videos.pk, // TODO: do not add rendering if all videos of the entry have the same rendering slug: sql` concat( - ${ret.slug}::text, + ${show.slug}::text, case when ${videos.part} <> null then concat('-p', ${videos.part}) else '' end, case when ${videos.version} <> 1 then concat('-v', ${videos.version}) else '' end ) @@ -90,9 +91,9 @@ export const seedMovie = async ( } return { - updated: ret.updated, - id: ret.id, - slug: ret.slug, + updated: show.updated, + id: show.id, + slug: show.slug, videos: retVideos, }; }; diff --git a/api/src/controllers/seed/series.ts b/api/src/controllers/seed/series.ts index 948889e6..bd421493 100644 --- a/api/src/controllers/seed/series.ts +++ b/api/src/controllers/seed/series.ts @@ -1,13 +1,19 @@ import { t } from "elysia"; import type { SeedSerie } from "~/models/serie"; import { getYear } from "~/utils"; +import { insertEntries } from "./insert/entries"; import { insertShow } from "./insert/shows"; import { guessNextRefresh } from "./refresh"; -import { insertEntries } from "./insert/entries"; export const SeedSerieResponse = t.Object({ id: t.String({ format: "uuid" }), - slug: t.String({ format: "slug", examples: ["bubble"] }), + slug: t.String({ format: "slug", examples: ["made-in-abyss"] }), + entries: t.Array( + t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug", examples: ["made-in-abyss-s1e1"] }), + }), + ), }); export type SeedSerieResponse = typeof SeedSerieResponse.static; @@ -31,7 +37,7 @@ export const seedSerie = async ( const { translations, seasons, entries, ...serie } = seed; const nextRefresh = guessNextRefresh(serie.startAir ?? new Date()); - const ret = await insertShow( + const show = await insertShow( { kind: "serie", nextRefresh, @@ -39,13 +45,14 @@ export const seedSerie = async ( }, translations, ); - if ("status" in ret) return ret; + if ("status" in show) return show; - const retEntries = await insertEntries(ret.pk, entries); + const retEntries = await insertEntries(show, entries); return { - updated: ret.updated, - id: ret.id, - slug: ret.slug, + updated: show.updated, + id: show.id, + slug: show.slug, + entries: retEntries, }; }; diff --git a/biome.json b/biome.json index d8b12b95..84dde2d8 100644 --- a/biome.json +++ b/biome.json @@ -2,7 +2,6 @@ "$schema": "https://biomejs.dev/schemas/1.8.1/schema.json", "formatter": { "enabled": true, - "formatWithErrors": false, "indentStyle": "tab", "indentWidth": 2, "lineEnding": "lf", From 0939289e2c153833fb4d25cb7384b708df21e620 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jan 2025 14:45:40 +0100 Subject: [PATCH 07/30] Add seed entry capabilities --- api/src/controllers/seed/insert/entries.ts | 53 +++++++++------------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 528c4793..914255ec 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -23,36 +23,20 @@ export const insertEntries = async ( show: { pk: number; slug: string }, items: SeedEntry[], ) => { - const vals = await Promise.all( - items.map(async (seed) => { + const retEntries = await db.transaction(async (tx) => { + const vals = items.map((seed) => { const { translations, videos, ...entry } = seed; return { - entry: { - ...entry, - showPk: show.pk, - slug: generateSlug(show.slug, seed), - thumbnails: await processOptImage(seed.thumbnail), - nextRefresh: guessNextRefresh(entry.airDate ?? new Date()), - } satisfies EntryI, - translations: (await Promise.all( - Object.entries(translations).map(async ([lang, tr]) => ({ - ...tr, - language: lang, - poster: - seed.kind === "movie" - ? await processOptImage(tr.poster) - : undefined, - })), - )) satisfies Omit[], - videos, + ...entry, + showPk: show.pk, + slug: generateSlug(show.slug, seed), + thumbnails: processOptImage(seed.thumbnail), + nextRefresh: guessNextRefresh(entry.airDate ?? new Date()), }; - }), - ); - - return await db.transaction(async (tx) => { + }); const ret = await tx .insert(entries) - .values(vals.map((x) => x.entry)) + .values(vals) .onConflictDoUpdate({ target: entries.slug, set: conflictUpdateAllExcept(entries, [ @@ -63,20 +47,25 @@ export const insertEntries = async ( "createdAt", ]), }) - .returning({ pk: entries.pk }); + .returning({ pk: entries.pk, id: entries.id, slug: entries.slug }); + const trans = items.flatMap((seed, i) => + Object.entries(seed.translations).map(([lang, tr]) => ({ + // assumes ret is ordered like items. + pk: ret[i].pk, + language: lang, + ...tr, + })), + ); await tx .insert(entryTranslations) - .values( - vals.map((x, i) => - x.translations.map((tr) => ({ ...tr, pk: ret[i].pk })), - ), - ) + .values(trans) .onConflictDoUpdate({ target: [entryTranslations.pk, entryTranslations.language], set: conflictUpdateAllExcept(entryTranslations, ["pk", "language"]), }); - return { ...ret, entry: entry.pk }; + return ret; }); + return retEntries; }; From 2510420cea72c104f797cd5c741155311a71fb55 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jan 2025 18:36:33 +0100 Subject: [PATCH 08/30] Fix seed entries type --- api/src/models/entry/episode.ts | 5 ++--- api/src/models/entry/movie-entry.ts | 3 +-- api/src/models/entry/special.ts | 3 +-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/api/src/models/entry/episode.ts b/api/src/models/entry/episode.ts index 602a2816..43198a3c 100644 --- a/api/src/models/entry/episode.ts +++ b/api/src/models/entry/episode.ts @@ -1,6 +1,6 @@ import { t } from "elysia"; +import { EpisodeId, Resource, SeedImage, TranslationRecord } from "../utils"; import { BaseEntry, EntryTranslation } from "./base-entry"; -import { EpisodeId, SeedImage, TranslationRecord, Resource } from "../utils"; export const BaseEpisode = t.Intersect([ BaseEntry, @@ -17,8 +17,7 @@ export const Episode = t.Intersect([Resource(), BaseEpisode, EntryTranslation]); export type Episode = typeof Episode.static; export const SeedEpisode = t.Intersect([ - BaseEpisode, - t.Omit(BaseEntry, ["thumbnail", "createdAt", "nextRefresh"]), + t.Omit(BaseEpisode, ["thumbnail", "createdAt", "nextRefresh"]), t.Object({ thumbnail: t.Nullable(SeedImage), translations: TranslationRecord(EntryTranslation), diff --git a/api/src/models/entry/movie-entry.ts b/api/src/models/entry/movie-entry.ts index 643928e1..0be952d7 100644 --- a/api/src/models/entry/movie-entry.ts +++ b/api/src/models/entry/movie-entry.ts @@ -45,8 +45,7 @@ export const MovieEntry = t.Intersect([ export type MovieEntry = typeof MovieEntry.static; export const SeedMovieEntry = t.Intersect([ - BaseMovieEntry, - t.Omit(BaseEntry, ["thumbnail", "createdAt", "nextRefresh"]), + t.Omit(BaseMovieEntry, ["thumbnail", "createdAt", "nextRefresh"]), t.Object({ thumbnail: t.Nullable(SeedImage), translations: TranslationRecord( diff --git a/api/src/models/entry/special.ts b/api/src/models/entry/special.ts index d7527f41..56302cd5 100644 --- a/api/src/models/entry/special.ts +++ b/api/src/models/entry/special.ts @@ -28,8 +28,7 @@ export const Special = t.Intersect([Resource(), BaseSpecial, EntryTranslation]); export type Special = typeof Special.static; export const SeedSpecial = t.Intersect([ - BaseSpecial, - t.Omit(BaseEntry, ["thumbnail", "createdAt", "nextRefresh"]), + t.Omit(BaseSpecial, ["thumbnail", "createdAt", "nextRefresh"]), t.Object({ thumbnail: t.Nullable(SeedImage), translations: TranslationRecord(EntryTranslation), From 8d6121d8b0eb030e162fd2301966c851b5d800d1 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jan 2025 18:37:49 +0100 Subject: [PATCH 09/30] Handle video joint creation with entries --- api/src/controllers/movies.ts | 7 +-- api/src/controllers/seed/insert/entries.ts | 56 +++++++++++++++++++--- api/src/controllers/seed/movies.ts | 48 ++++--------------- api/src/controllers/seed/series.ts | 8 ++++ api/src/db/schema/videos.ts | 2 +- 5 files changed, 69 insertions(+), 52 deletions(-) diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 7c83f5b4..024f9bb0 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -1,11 +1,6 @@ import { type SQL, and, eq, exists, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; -import { - entries, - entryVideoJointure as entryVideoJoint, - showTranslations, - shows, -} from "~/db/schema"; +import { entries, entryVideoJoint, showTranslations, shows } from "~/db/schema"; import { getColumns, sqlarr } from "~/db/utils"; import { KError } from "~/models/error"; import { bubble } from "~/models/examples"; diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 914255ec..ad346e81 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -1,13 +1,16 @@ +import { eq, sql } from "drizzle-orm"; import { db } from "~/db"; -import { entries, entryTranslations } from "~/db/schema"; +import { + entries, + entryTranslations, + entryVideoJoint as entryVideoJoin, + videos, +} from "~/db/schema"; import { conflictUpdateAllExcept } from "~/db/utils"; -import type { Entry, SeedEntry } from "~/models/entry"; +import type { SeedEntry } from "~/models/entry"; import { processOptImage } from "../images"; import { guessNextRefresh } from "../refresh"; -type EntryI = typeof entries.$inferInsert; -type EntryTrans = typeof entryTranslations.$inferInsert; - const generateSlug = (showSlug: string, entry: SeedEntry): string => { switch (entry.kind) { case "episode": @@ -67,5 +70,46 @@ export const insertEntries = async ( return ret; }); - return retEntries; + + const vids = items.flatMap( + (seed, i) => + seed.videos?.map((x) => ({ videoId: x, entryPk: retEntries[i].pk })) ?? + [], + ); + + if (vids.length === 0) + return retEntries.map((x) => ({ id: x.id, slug: x.slug, videos: [] })); + + const hasRenderingQ = db + .select() + .from(entryVideoJoin) + .where(eq(entryVideoJoin.entry, sql`vids.entryPk`)); + + const retVideos = await db + .insert(entryVideoJoin) + .select( + db + .select({ + entry: sql`vids.entryPk`.as("entry"), + video: videos.pk, + slug: sql` + concat( + ${show.slug}::text, + case when ${videos.part} <> null then ('-p' || ${videos.part}) else '' end, + case when ${videos.version} <> 1 then ('-v' || ${videos.version}) else '' end, + case when exists(${hasRenderingQ}) then concat('-', ${videos.rendering}) else '' end + ) + `.as("slug"), + }) + .from(sql`values(${vids}) as vids(videoId, entryPk)`) + .innerJoin(videos, eq(videos.id, sql`vids.videoId`)), + ) + .onConflictDoNothing() + .returning({ slug: entryVideoJoin.slug, entryPk: sql`vids.entryPk` }); + + return retEntries.map((entry) => ({ + id: entry.id, + slug: entry.slug, + videos: retVideos.filter((x) => x.entryPk === entry.pk), + })); }; diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 32e7f700..eeab21f5 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -1,13 +1,4 @@ -import { inArray, sql } from "drizzle-orm"; import { t } from "elysia"; -import { db } from "~/db"; -import { - type entries, - entryTranslations, - entryVideoJointure as evj, - videos, -} from "~/db/schema"; -import { conflictUpdateAllExcept } from "~/db/utils"; import type { SeedMovie } from "~/models/movie"; import { getYear } from "~/utils"; import { insertEntries } from "./insert/entries"; @@ -40,7 +31,7 @@ export const seedMovie = async ( seed.slug = `random-${getYear(seed.airDate)}`; } - const { translations, videos: vids, ...bMovie } = seed; + const { translations, videos, ...bMovie } = seed; const nextRefresh = guessNextRefresh(bMovie.airDate ?? new Date()); const show = await insertShow( @@ -57,43 +48,22 @@ export const seedMovie = async ( // even if never shown to the user, a movie still has an entry. const [entry] = await insertEntries(show, [ { + ...bMovie, kind: "movie", order: 1, - nextRefresh, - ...bMovie, + thumbnail: (bMovie.originalLanguage + ? translations[bMovie.originalLanguage] + : Object.values(translations)[0] + )?.thumbnail, + translations, + videos, }, ]); - let retVideos: { slug: string }[] = []; - if (vids) { - retVideos = await db - .insert(evj) - .select( - db - .select({ - entry: sql`${show.entry}`.as("entry"), - video: videos.pk, - // TODO: do not add rendering if all videos of the entry have the same rendering - slug: sql` - concat( - ${show.slug}::text, - case when ${videos.part} <> null then concat('-p', ${videos.part}) else '' end, - case when ${videos.version} <> 1 then concat('-v', ${videos.version}) else '' end - ) - `.as("slug"), - // case when (select count(1) from ${evj} where ${evj.entry} = ${ret.entry}) <> 0 then concat('-', ${videos.rendering}) else '' end - }) - .from(videos) - .where(inArray(videos.id, vids)), - ) - .onConflictDoNothing() - .returning({ slug: evj.slug }); - } - return { updated: show.updated, id: show.id, slug: show.slug, - videos: retVideos, + videos: entry.videos, }; }; diff --git a/api/src/controllers/seed/series.ts b/api/src/controllers/seed/series.ts index bd421493..3bf8dd9b 100644 --- a/api/src/controllers/seed/series.ts +++ b/api/src/controllers/seed/series.ts @@ -12,6 +12,14 @@ export const SeedSerieResponse = t.Object({ t.Object({ id: t.String({ format: "uuid" }), slug: t.String({ format: "slug", examples: ["made-in-abyss-s1e1"] }), + videos: t.Array( + t.Object({ + slug: t.String({ + format: "slug", + examples: ["mode-in-abyss-s1e1v2"], + }), + }), + ), }), ), }); diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index ad8adad9..c233b0bc 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -33,7 +33,7 @@ export const videos = schema.table( ], ); -export const entryVideoJointure = schema.table( +export const entryVideoJoint = schema.table( "entry_video_jointure", { entry: integer() From a216fd0d67d5c88a941adf448c56e3d1566f994f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jan 2025 21:14:03 +0100 Subject: [PATCH 10/30] Add migration for entries --- api/drizzle/0007_entries.sql | 3 + api/drizzle/meta/0007_snapshot.json | 952 ++++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + 3 files changed, 962 insertions(+) create mode 100644 api/drizzle/0007_entries.sql create mode 100644 api/drizzle/meta/0007_snapshot.json diff --git a/api/drizzle/0007_entries.sql b/api/drizzle/0007_entries.sql new file mode 100644 index 00000000..a2d53f11 --- /dev/null +++ b/api/drizzle/0007_entries.sql @@ -0,0 +1,3 @@ +ALTER TABLE "kyoo"."entries" RENAME COLUMN "type" TO "kind";--> statement-breakpoint +ALTER TABLE "kyoo"."entries" ALTER COLUMN "show_pk" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "kyoo"."entries" ADD COLUMN "extra_kind" text; \ No newline at end of file diff --git a/api/drizzle/meta/0007_snapshot.json b/api/drizzle/meta/0007_snapshot.json new file mode 100644 index 00000000..6dc71465 --- /dev/null +++ b/api/drizzle/meta/0007_snapshot.json @@ -0,0 +1,952 @@ +{ + "id": "e70b1585-a927-4436-b2a0-d0ef216911f1", + "prevId": "ca86d88f-b380-4b41-9c3d-d8acef369e4c", + "version": "7", + "dialect": "postgresql", + "tables": { + "kyoo.entries": { + "name": "entries", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "entries_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "extra_kind": { + "name": "extra_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "air_date": { + "name": "air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "entries_show_pk_shows_pk_fk": { + "name": "entries_show_pk_shows_pk_fk", + "tableFrom": "entries", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entries_id_unique": { + "name": "entries_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "entries_slug_unique": { + "name": "entries_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "entries_showPk_seasonNumber_episodeNumber_unique": { + "name": "entries_showPk_seasonNumber_episodeNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_pk", "season_number", "episode_number"] + } + }, + "policies": {}, + "checkConstraints": { + "order_positive": { + "name": "order_positive", + "value": "\"kyoo\".\"entries\".\"order\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.entry_translations": { + "name": "entry_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "entry_translations_pk_entries_pk_fk": { + "name": "entry_translations_pk_entries_pk_fk", + "tableFrom": "entry_translations", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_translations_pk_language_pk": { + "name": "entry_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.season_translations": { + "name": "season_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "season_translations_pk_seasons_pk_fk": { + "name": "season_translations_pk_seasons_pk_fk", + "tableFrom": "season_translations", + "tableTo": "seasons", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "season_translations_pk_language_pk": { + "name": "season_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.seasons": { + "name": "seasons", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "seasons_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "show_fk": { + "name": "show_fk", + "columns": [ + { + "expression": "show_pk", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + } + }, + "foreignKeys": { + "seasons_show_pk_shows_pk_fk": { + "name": "seasons_show_pk_shows_pk_fk", + "tableFrom": "seasons", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "seasons_id_unique": { + "name": "seasons_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "seasons_slug_unique": { + "name": "seasons_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "seasons_showPk_seasonNumber_unique": { + "name": "seasons_showPk_seasonNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_pk", "season_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.show_translations": { + "name": "show_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trailer_url": { + "name": "trailer_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "name_trgm": { + "name": "name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "tags": { + "name": "tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "show_translations_pk_shows_pk_fk": { + "name": "show_translations_pk_shows_pk_fk", + "tableFrom": "show_translations", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_translations_pk_language_pk": { + "name": "show_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.shows": { + "name": "shows", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "shows_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "show_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "genres": { + "name": "genres", + "type": "genres[]", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "show_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "original_language": { + "name": "original_language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kind": { + "name": "kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "rating": { + "name": "rating", + "columns": [ + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "startAir": { + "name": "startAir", + "columns": [ + { + "expression": "start_air", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shows_id_unique": { + "name": "shows_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "shows_slug_unique": { + "name": "shows_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "rating_valid": { + "name": "rating_valid", + "value": "\"kyoo\".\"shows\".\"rating\" between 0 and 100" + }, + "runtime_valid": { + "name": "runtime_valid", + "value": "\"kyoo\".\"shows\".\"runtime\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.entry_video_jointure": { + "name": "entry_video_jointure", + "schema": "kyoo", + "columns": { + "entry": { + "name": "entry", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video": { + "name": "video", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "entry_video_jointure_entry_entries_pk_fk": { + "name": "entry_video_jointure_entry_entries_pk_fk", + "tableFrom": "entry_video_jointure", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["entry"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_video_jointure_video_videos_pk_fk": { + "name": "entry_video_jointure_video_videos_pk_fk", + "tableFrom": "entry_video_jointure", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": ["video"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_video_jointure_entry_video_pk": { + "name": "entry_video_jointure_entry_video_pk", + "columns": ["entry", "video"] + } + }, + "uniqueConstraints": { + "entry_video_jointure_slug_unique": { + "name": "entry_video_jointure_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.videos": { + "name": "videos", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "videos_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendering": { + "name": "rendering", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "guess": { + "name": "guess", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "videos_id_unique": { + "name": "videos_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "videos_path_unique": { + "name": "videos_path_unique", + "nullsNotDistinct": false, + "columns": ["path"] + } + }, + "policies": {}, + "checkConstraints": { + "part_pos": { + "name": "part_pos", + "value": "\"kyoo\".\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"kyoo\".\"videos\".\"version\" >= 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "kyoo.entry_type": { + "name": "entry_type", + "schema": "kyoo", + "values": ["unknown", "episode", "movie", "special", "extra"] + }, + "kyoo.genres": { + "name": "genres", + "schema": "kyoo", + "values": [ + "action", + "adventure", + "animation", + "comedy", + "crime", + "documentary", + "drama", + "family", + "fantasy", + "history", + "horror", + "music", + "mystery", + "romance", + "science-fiction", + "thriller", + "war", + "western", + "kids", + "reality", + "politics", + "soap", + "talk" + ] + }, + "kyoo.show_kind": { + "name": "show_kind", + "schema": "kyoo", + "values": ["serie", "movie"] + }, + "kyoo.show_status": { + "name": "show_status", + "schema": "kyoo", + "values": ["unknown", "finished", "airing", "planned"] + } + }, + "schemas": { + "kyoo": "kyoo" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index a5f9b23a..eda2b441 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1737763164759, "tag": "0006_seasons", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1737913931275, + "tag": "0007_entries", + "breakpoints": true } ] } From 2588eef23bf45def6258f91b580a44ef1d0e5559 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jan 2025 21:18:07 +0100 Subject: [PATCH 11/30] Create `values` helper & fix video join insertion --- api/src/controllers/seed/insert/entries.ts | 17 ++++++++++------- api/src/db/utils.ts | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index ad346e81..3cc0c84c 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -6,7 +6,7 @@ import { entryVideoJoint as entryVideoJoin, videos, } from "~/db/schema"; -import { conflictUpdateAllExcept } from "~/db/utils"; +import { conflictUpdateAllExcept, values } from "~/db/utils"; import type { SeedEntry } from "~/models/entry"; import { processOptImage } from "../images"; import { guessNextRefresh } from "../refresh"; @@ -83,15 +83,15 @@ export const insertEntries = async ( const hasRenderingQ = db .select() .from(entryVideoJoin) - .where(eq(entryVideoJoin.entry, sql`vids.entryPk`)); + .where(eq(entryVideoJoin.entry, sql`vids.entryPk::integer`)); const retVideos = await db .insert(entryVideoJoin) .select( db .select({ - entry: sql`vids.entryPk`.as("entry"), - video: videos.pk, + entry: sql`vids.entryPk::integer`.as("entry"), + video: sql`${videos.pk}`.as("video"), slug: sql` concat( ${show.slug}::text, @@ -101,11 +101,14 @@ export const insertEntries = async ( ) `.as("slug"), }) - .from(sql`values(${vids}) as vids(videoId, entryPk)`) - .innerJoin(videos, eq(videos.id, sql`vids.videoId`)), + .from(values(vids).as("vids")) + .innerJoin(videos, eq(videos.id, sql`vids.videoId::uuid`)), ) .onConflictDoNothing() - .returning({ slug: entryVideoJoin.slug, entryPk: sql`vids.entryPk` }); + .returning({ + slug: entryVideoJoin.slug, + entryPk: entryVideoJoin.entry, + }); return retEntries.map((entry) => ({ id: entry.id, diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index 79fbdb48..d71296e1 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -70,3 +70,25 @@ export function conflictUpdateAllExcept< export function sqlarr(array: unknown[]) { return `{${array.map((item) => `"${item}"`).join(",")}}`; } + +// TODO: upstream this +// TODO: type values (everything is a `text` for now) +export function values(items: Record[]) { + const [firstProp, ...props] = Object.keys(items[0]); + const values = items + .map((x) => { + let ret = sql`(${x[firstProp]}`; + for (const val of props) { + ret = sql`${ret}, ${x[val]}`; + } + return sql`${ret})`; + }) + .reduce((acc, x) => sql`${acc}, ${x}`); + const valueNames = [firstProp, ...props].join(", "); + + return { + as: (name: string) => { + return sql`(values ${values}) as ${sql.raw(name)}(${sql.raw(valueNames)})`; + }, + }; +} From fabf6b686359bd400a63d9c472d9ae3e113d3e38 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jan 2025 22:13:07 +0100 Subject: [PATCH 12/30] Define relations on evj --- api/src/controllers/movies.ts | 10 +++++----- api/src/controllers/seed/insert/entries.ts | 2 +- api/src/db/schema/entries.ts | 16 ++++++++++++++- api/src/db/schema/videos.ts | 23 ++++++++++++++++++++-- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 024f9bb0..21a4f3e6 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -1,6 +1,6 @@ import { type SQL, and, eq, exists, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; -import { entries, entryVideoJoint, showTranslations, shows } from "~/db/schema"; +import { entries, entryVideoJoin, showTranslations, shows } from "~/db/schema"; import { getColumns, sqlarr } from "~/db/utils"; import { KError } from "~/models/error"; import { bubble } from "~/models/examples"; @@ -81,8 +81,8 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) exists( db .select() - .from(entryVideoJoint) - .where(eq(entries.pk, entryVideoJoint.entry)), + .from(entryVideoJoin) + .where(eq(entries.pk, entryVideoJoin.entry)), ), ), ), @@ -258,8 +258,8 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) exists( db .select() - .from(entryVideoJoint) - .where(eq(entries.pk, entryVideoJoint.entry)), + .from(entryVideoJoin) + .where(eq(entries.pk, entryVideoJoin.entry)), ), ) .as("video"); diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 3cc0c84c..1a39d8c2 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -3,7 +3,7 @@ import { db } from "~/db"; import { entries, entryTranslations, - entryVideoJoint as entryVideoJoin, + entryVideoJoin, videos, } from "~/db/schema"; import { conflictUpdateAllExcept, values } from "~/db/utils"; diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index 64380470..b95f55d7 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -1,4 +1,4 @@ -import { sql } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { check, date, @@ -14,6 +14,7 @@ import { } from "drizzle-orm/pg-core"; import { shows } from "./shows"; import { image, language, schema } from "./utils"; +import { entryVideoJoin } from "./videos"; export const entryType = schema.enum("entry_type", [ "unknown", @@ -92,3 +93,16 @@ export const entryTranslations = schema.table( }, (t) => [primaryKey({ columns: [t.pk, t.language] })], ); + +export const entryRelations = relations(entries, ({ many }) => ({ + translations: many(entryTranslations, { relationName: "entryTranslations" }), + evj: many(entryVideoJoin, { relationName: "evj_entry" }), +})); + +export const entryTrRelations = relations(entryTranslations, ({ one }) => ({ + entry: one(entries, { + relationName: "entryTranslations", + fields: [entryTranslations.pk], + references: [entries.pk], + }), +})); diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index c233b0bc..2464629f 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -1,4 +1,4 @@ -import { sql } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { check, integer, @@ -33,7 +33,7 @@ export const videos = schema.table( ], ); -export const entryVideoJoint = schema.table( +export const entryVideoJoin = schema.table( "entry_video_jointure", { entry: integer() @@ -46,3 +46,22 @@ export const entryVideoJoint = schema.table( }, (t) => [primaryKey({ columns: [t.entry, t.video] })], ); + +export const videosRelations = relations(videos, ({ many }) => ({ + evj: many(entryVideoJoin, { + relationName: "evj_video", + }), +})); + +export const evjRelations = relations(entryVideoJoin, ({ one }) => ({ + video: one(videos, { + relationName: "evj_video", + fields: [entryVideoJoin.video], + references: [videos.pk], + }), + entry: one(entries, { + relationName: "evj_entry", + fields: [entryVideoJoin.entry], + references: [entries.pk], + }), +})); From e5bb462e3651b7f41863f345ae4b0aaa689e9d5d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jan 2025 22:15:41 +0100 Subject: [PATCH 13/30] Update test helpers --- api/src/controllers/videos.ts | 5 ++-- api/src/models/video.ts | 1 + .../{movies/movies-helper.ts => helper.ts} | 30 ++++++++++++++++--- .../movies/get-all-movies-with-null.test.ts | 6 ++-- api/tests/movies/get-all-movies.test.ts | 12 ++++---- api/tests/movies/get-movie.test.ts | 2 +- 6 files changed, 40 insertions(+), 16 deletions(-) rename api/tests/{movies/movies-helper.ts => helper.ts} (64%) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 4761e5d1..a51766cd 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -21,12 +21,13 @@ export const videos = new Elysia({ prefix: "/videos", tags: ["videos"] }) }) .post( "", - async ({ body }) => { - return await db + async ({ body, error }) => { + const ret = await db .insert(videosT) .values(body) .onConflictDoNothing() .returning({ id: videosT.id, path: videosT.path }); + return error(201, ret); }, { body: t.Array(SeedVideo), diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 0583a14a..2987557f 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -36,3 +36,4 @@ export type Video = typeof Video.static; registerExamples(Video, bubbleVideo); export const SeedVideo = t.Omit(Video, ["id", "slug", "createdAt"]); +export type SeedVideo = typeof SeedVideo.static; diff --git a/api/tests/movies/movies-helper.ts b/api/tests/helper.ts similarity index 64% rename from api/tests/movies/movies-helper.ts rename to api/tests/helper.ts index c5ca4830..089570ac 100644 --- a/api/tests/movies/movies-helper.ts +++ b/api/tests/helper.ts @@ -3,15 +3,23 @@ import { buildUrl } from "tests/utils"; import { base } from "~/base"; import { movies } from "~/controllers/movies"; import { seed } from "~/controllers/seed"; +import { series } from "~/controllers/series"; +import { videos } from "~/controllers/videos"; import type { SeedMovie } from "~/models/movie"; +import type { SeedVideo } from "~/models/video"; -export const movieApp = new Elysia().use(base).use(movies).use(seed); +export const app = new Elysia() + .use(base) + .use(movies) + .use(series) + .use(videos) + .use(seed); export const getMovie = async ( id: string, { langs, ...query }: { langs?: string; preferOriginal?: boolean }, ) => { - const resp = await movieApp.handle( + const resp = await app.handle( new Request(buildUrl(`movies/${id}`, query), { method: "GET", headers: langs @@ -37,7 +45,7 @@ export const getMovies = async ({ langs?: string; preferOriginal?: boolean; }) => { - const resp = await movieApp.handle( + const resp = await app.handle( new Request(buildUrl("movies", query), { method: "GET", headers: langs @@ -52,7 +60,7 @@ export const getMovies = async ({ }; export const createMovie = async (movie: SeedMovie) => { - const resp = await movieApp.handle( + const resp = await app.handle( new Request(buildUrl("movies"), { method: "POST", body: JSON.stringify(movie), @@ -64,3 +72,17 @@ export const createMovie = async (movie: SeedMovie) => { const body = await resp.json(); return [resp, body] as const; }; + +export const createVideo = async (video: SeedVideo | SeedVideo[]) => { + const resp = await app.handle( + new Request(buildUrl("videos"), { + method: "POST", + body: JSON.stringify(Array.isArray(video) ? video : [video]), + headers: { + "Content-Type": "application/json", + }, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; diff --git a/api/tests/movies/get-all-movies-with-null.test.ts b/api/tests/movies/get-all-movies-with-null.test.ts index 8f4ca9f7..1abeed9c 100644 --- a/api/tests/movies/get-all-movies-with-null.test.ts +++ b/api/tests/movies/get-all-movies-with-null.test.ts @@ -7,7 +7,7 @@ import { shows } from "~/db/schema"; import { bubble } from "~/models/examples"; import { dune1984 } from "~/models/examples/dune-1984"; import { dune } from "~/models/examples/dune-2021"; -import { createMovie, getMovies, movieApp } from "./movies-helper"; +import { createMovie, getMovies, app } from "../helper"; beforeAll(async () => { await db.delete(shows); @@ -77,7 +77,7 @@ describe("with a null value", () => { ), }); - resp = await movieApp.handle(new Request(next)); + resp = await app.handle(new Request(next)); body = await resp.json(); expectStatus(resp, body).toBe(200); @@ -124,7 +124,7 @@ describe("with a null value", () => { ), }); - resp = await movieApp.handle(new Request(next)); + resp = await app.handle(new Request(next)); body = await resp.json(); expectStatus(resp, body).toBe(200); diff --git a/api/tests/movies/get-all-movies.test.ts b/api/tests/movies/get-all-movies.test.ts index 0694aeed..1a875dc6 100644 --- a/api/tests/movies/get-all-movies.test.ts +++ b/api/tests/movies/get-all-movies.test.ts @@ -8,7 +8,7 @@ import { dune1984 } from "~/models/examples/dune-1984"; import { dune } from "~/models/examples/dune-2021"; import type { Movie } from "~/models/movie"; import { isUuid } from "~/models/utils"; -import { getMovies, movieApp } from "./movies-helper"; +import { getMovies, app } from "../helper"; beforeAll(async () => { await db.delete(shows); @@ -73,7 +73,7 @@ describe("Get all movies", () => { }); expectStatus(resp, body).toBe(200); - resp = await movieApp.handle(new Request(body.next)); + resp = await app.handle(new Request(body.next)); body = await resp.json(); expectStatus(resp, body).toBe(200); @@ -106,7 +106,7 @@ describe("Get all movies", () => { ), }); - resp = await movieApp.handle(new Request(next)); + resp = await app.handle(new Request(next)); body = await resp.json(); expectStatus(resp, body).toBe(200); @@ -162,7 +162,7 @@ describe("Get all movies", () => { expect(items.length).toBe(1); expect(items[0].id).toBe(expectedIds[0]); // Get Second Page - resp = await movieApp.handle(new Request(body.next)); + resp = await app.handle(new Request(body.next)); body = await resp.json(); expectStatus(resp, body).toBe(200); @@ -177,7 +177,7 @@ describe("Get all movies", () => { }); expectStatus(resp, body).toBe(200); - const resp2 = await movieApp.handle(new Request(body.next)); + const resp2 = await app.handle(new Request(body.next)); const body2 = await resp2.json(); expectStatus(resp2, body).toBe(200); @@ -188,7 +188,7 @@ describe("Get all movies", () => { }); it("Get /random", async () => { - const resp = await movieApp.handle( + const resp = await app.handle( new Request("http://localhost/movies/random"), ); expect(resp.status).toBe(302); diff --git a/api/tests/movies/get-movie.test.ts b/api/tests/movies/get-movie.test.ts index 607f8cec..eddf6664 100644 --- a/api/tests/movies/get-movie.test.ts +++ b/api/tests/movies/get-movie.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { expectStatus } from "tests/utils"; import { seedMovie } from "~/controllers/seed/movies"; import { bubble } from "~/models/examples"; -import { getMovie } from "./movies-helper"; +import { getMovie } from "../helper"; let bubbleId = ""; From f2c1982afac0cf0c91edaf0071dc11adb3a88711 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jan 2025 22:34:47 +0100 Subject: [PATCH 14/30] Test videos creation of slugs --- api/src/controllers/seed/insert/entries.ts | 2 +- api/src/db/schema/entries.ts | 7 +- api/src/db/schema/shows.ts | 2 + api/tests/movies/seed-movies.test.ts | 129 ++++++++++++++++++++- 4 files changed, 132 insertions(+), 8 deletions(-) diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 1a39d8c2..fc6fdbe5 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -95,7 +95,7 @@ export const insertEntries = async ( slug: sql` concat( ${show.slug}::text, - case when ${videos.part} <> null then ('-p' || ${videos.part}) else '' end, + case when ${videos.part} is not null then ('-p' || ${videos.part}) else '' end, case when ${videos.version} <> 1 then ('-v' || ${videos.version}) else '' end, case when exists(${hasRenderingQ}) then concat('-', ${videos.rendering}) else '' end ) diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index b95f55d7..ba0ebfad 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -94,9 +94,14 @@ export const entryTranslations = schema.table( (t) => [primaryKey({ columns: [t.pk, t.language] })], ); -export const entryRelations = relations(entries, ({ many }) => ({ +export const entryRelations = relations(entries, ({ one, many }) => ({ translations: many(entryTranslations, { relationName: "entryTranslations" }), evj: many(entryVideoJoin, { relationName: "evj_entry" }), + show: one(shows, { + relationName: "show_entries", + fields: [entries.showPk], + references: [shows.pk], + }), })); export const entryTrRelations = relations(entryTranslations, ({ one }) => ({ diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index 91b24f35..446b29a9 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -13,6 +13,7 @@ import { varchar, } from "drizzle-orm/pg-core"; import { image, language, schema } from "./utils"; +import { entries } from "./entries"; export const showKind = schema.enum("show_kind", ["serie", "movie"]); export const showStatus = schema.enum("show_status", [ @@ -128,6 +129,7 @@ export const showsRelations = relations(shows, ({ many, one }) => ({ fields: [shows.pk, shows.originalLanguage], references: [showTranslations.pk, showTranslations.language], }), + entries: many(entries, { relationName: "show_entries" }), })); export const showsTrRelations = relations(showTranslations, ({ one }) => ({ show: one(shows, { diff --git a/api/tests/movies/seed-movies.test.ts b/api/tests/movies/seed-movies.test.ts index 0a08afa3..33a709d3 100644 --- a/api/tests/movies/seed-movies.test.ts +++ b/api/tests/movies/seed-movies.test.ts @@ -1,11 +1,17 @@ -import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { eq } from "drizzle-orm"; import { expectStatus } from "tests/utils"; import { db } from "~/db"; -import { showTranslations, shows, videos } from "~/db/schema"; +import { + entries, + entryVideoJoin, + showTranslations, + shows, + videos, +} from "~/db/schema"; import { bubble } from "~/models/examples"; import { dune, duneVideo } from "~/models/examples/dune-2021"; -import { createMovie } from "./movies-helper"; +import { createMovie, createVideo } from "../helper"; describe("Movie seeding", () => { it("Can create a movie", async () => { @@ -293,13 +299,124 @@ describe("Movie seeding", () => { ); }); - test.todo("Create correct video slug (version)", async () => {}); - test.todo("Create correct video slug (part)", async () => {}); - test.todo("Create correct video slug (rendering)", async () => {}); + it("Create correct video slug", async () => { + const [vresp, video] = await createVideo({ + path: "/video/bubble.mkv", + part: null, + version: 1, + rendering: "oeunhtoeuth", + }); + expectStatus(vresp, video).toBe(201); + + const [resp, body] = await createMovie({ + ...bubble, + slug: "video-slug-test1", + videos: [video[0].id], + }); + expectStatus(resp, body).toBe(201); + + const ret = await db.query.videos.findFirst({ + where: eq(videos.id, video[0].id), + with: { evj: { with: { entry: true } } }, + }); + expect(ret).not.toBe(undefined); + expect(ret!.evj).toBeArrayOfSize(1); + expect(ret!.evj[0].slug).toBe("video-slug-test1"); + }); + + it("Create correct video slug (version)", async () => { + const [vresp, video] = await createVideo({ + path: "/video/bubble2.mkv", + part: null, + version: 2, + rendering: "oeunhtoeuth", + }); + expectStatus(vresp, video).toBe(201); + + const [resp, body] = await createMovie({ + ...bubble, + slug: "bubble-vtest", + videos: [video[0].id], + }); + expectStatus(resp, body).toBe(201); + + const ret = await db.query.videos.findFirst({ + where: eq(videos.id, video[0].id), + with: { evj: { with: { entry: true } } }, + }); + expect(ret).not.toBe(undefined); + expect(ret!.evj).toBeArrayOfSize(1); + expect(ret!.evj[0].slug).toBe("bubble-vtest-v2"); + }); + it("Create correct video slug (part)", async () => { + const [vresp, video] = await createVideo({ + path: "/video/bubble5.mkv", + part: 1, + version: 2, + rendering: "oaoeueunhtoeuth", + }); + expectStatus(vresp, video).toBe(201); + + const [resp, body] = await createMovie({ + ...bubble, + slug: "bubble-ptest", + videos: [video[0].id], + }); + expectStatus(resp, body).toBe(201); + + const ret = await db.query.videos.findFirst({ + where: eq(videos.id, video[0].id), + with: { evj: { with: { entry: true } } }, + }); + expect(ret).not.toBe(undefined); + expect(ret!.evj).toBeArrayOfSize(1); + expect(ret!.evj[0].slug).toBe("bubble-ptest-p1-v2"); + }); + it("Create correct video slug (rendering)", async () => { + const [vresp, video] = await createVideo([ + { + path: "/video/bubble3.mkv", + part: null, + version: 1, + rendering: "oeunhtoeuth", + }, + { + path: "/video/bubble4.mkv", + part: null, + version: 1, + rendering: "aoeuaoeu", + }, + ]); + expectStatus(vresp, video).toBe(201); + + const [resp, body] = await createMovie({ + ...bubble, + slug: "bubble-rtest", + videos: [video[0].id, video[1].id], + }); + expectStatus(resp, body).toBe(201); + console.log(body) + + const ret = await db.query.shows.findFirst({ + where: eq(shows.id, body.id), + with: { entries: { with: { evj: { with: { entry: true } } } } }, + }); + expect(ret).not.toBe(undefined); + expect(ret!.entries).toBeArrayOfSize(1); + expect(ret!.entries[0].slug).toBe("bubble-rtest"); + expect(ret!.entries[0].evj).toBeArrayOfSize(2); + expect(ret!.entries[0].evj).toContainValues([ + { slug: "bubble-rtest-oeunhtoeuth" }, + { slug: "bubble-rtest-aoeuaoeu" }, + { slug: "bubble-rtest" }, + ]); + }); }); const cleanup = async () => { await db.delete(shows); + await db.delete(entries); + await db.delete(entryVideoJoin); await db.delete(videos); }; // cleanup db beforehand to unsure tests are consistent From b262aeed5dd723cec6aa63a5456ae6173cf85c00 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 27 Jan 2025 18:48:13 +0100 Subject: [PATCH 15/30] Better need rendering calculation when inserting entries --- api/src/controllers/seed/insert/entries.ts | 37 ++++++++++++---------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index fc6fdbe5..7873c586 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -1,4 +1,4 @@ -import { eq, sql } from "drizzle-orm"; +import { type SQL, eq, sql } from "drizzle-orm"; import { db } from "~/db"; import { entries, @@ -73,18 +73,16 @@ export const insertEntries = async ( const vids = items.flatMap( (seed, i) => - seed.videos?.map((x) => ({ videoId: x, entryPk: retEntries[i].pk })) ?? - [], + seed.videos?.map((x) => ({ + videoId: x, + entryPk: retEntries[i].pk, + needRendering: seed.videos!.length > 1, + })) ?? [], ); if (vids.length === 0) return retEntries.map((x) => ({ id: x.id, slug: x.slug, videos: [] })); - const hasRenderingQ = db - .select() - .from(entryVideoJoin) - .where(eq(entryVideoJoin.entry, sql`vids.entryPk::integer`)); - const retVideos = await db .insert(entryVideoJoin) .select( @@ -92,14 +90,10 @@ export const insertEntries = async ( .select({ entry: sql`vids.entryPk::integer`.as("entry"), video: sql`${videos.pk}`.as("video"), - slug: sql` - concat( - ${show.slug}::text, - case when ${videos.part} is not null then ('-p' || ${videos.part}) else '' end, - case when ${videos.version} <> 1 then ('-v' || ${videos.version}) else '' end, - case when exists(${hasRenderingQ}) then concat('-', ${videos.rendering}) else '' end - ) - `.as("slug"), + slug: computeVideoSlug( + sql`${show.slug}::text`, + sql`vids.needRendering::boolean`, + ), }) .from(values(vids).as("vids")) .innerJoin(videos, eq(videos.id, sql`vids.videoId::uuid`)), @@ -116,3 +110,14 @@ export const insertEntries = async ( videos: retVideos.filter((x) => x.entryPk === entry.pk), })); }; + +export function computeVideoSlug(showSlug: SQL, needsRendering: SQL) { + return sql` + concat( + ${showSlug}::text, + case when ${videos.part} is not null then ('-p' || ${videos.part}) else '' end, + case when ${videos.version} <> 1 then ('-v' || ${videos.version}) else '' end, + case when ${needsRendering} then concat('-', ${videos.rendering}) else '' end + ) + `.as("slug"); +} From b0637aeb6a0482f4647cee205096ccf50d0d45ea Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 27 Jan 2025 18:48:41 +0100 Subject: [PATCH 16/30] Type guess from videos --- api/src/models/video.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 2987557f..018b4ed6 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,7 +1,10 @@ -import { t } from "elysia"; +import { type TSchema, t } from "elysia"; import { comment } from "../utils"; import { bubbleVideo, registerExamples } from "./examples"; +const Guess = (schema: T) => + t.Optional(t.Union([schema, t.Array(schema)])); + export const Video = t.Object({ id: t.String({ format: "uuid" }), slug: t.String({ format: "slug" }), @@ -31,6 +34,26 @@ export const Video = t.Object({ }), createdAt: t.String({ format: "date-time" }), + + guess: t.Optional( + t.Object( + { + title: t.Optional(t.String()), + year: Guess(t.Integer()), + season: Guess(t.Integer()), + episode: Guess(t.Integer()), + }, + { + additionalProperties: true, + description: comment` + Metadata guessed from the filename. Kyoo can use those informations to bypass + the scanner/metadata fetching and just register videos to movies/entries that already + exists. If Kyoo can't find a matching movie/entry, this information will be sent to + the scanner. + `, + }, + ), + ), }); export type Video = typeof Video.static; registerExamples(Video, bubbleVideo); From e9c7cfe83223d888e63ec8ed852408cdee854571 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 28 Jan 2025 12:33:53 +0100 Subject: [PATCH 17/30] Fix entries insertion (special numbers not saved) --- api/src/controllers/seed/insert/entries.ts | 18 +++++++++++++----- api/src/db/schema/entries.ts | 2 +- api/src/db/schema/videos.ts | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 7873c586..3979c260 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -1,4 +1,4 @@ -import { type SQL, eq, sql } from "drizzle-orm"; +import { type Column, type SQL, eq, sql } from "drizzle-orm"; import { db } from "~/db"; import { entries, @@ -11,6 +11,8 @@ import type { SeedEntry } from "~/models/entry"; import { processOptImage } from "../images"; import { guessNextRefresh } from "../refresh"; +type EntryI = typeof entries.$inferInsert; + const generateSlug = (showSlug: string, entry: SeedEntry): string => { switch (entry.kind) { case "episode": @@ -27,14 +29,20 @@ export const insertEntries = async ( items: SeedEntry[], ) => { const retEntries = await db.transaction(async (tx) => { - const vals = items.map((seed) => { + const vals: EntryI[] = items.map((seed) => { const { translations, videos, ...entry } = seed; return { ...entry, showPk: show.pk, slug: generateSlug(show.slug, seed), - thumbnails: processOptImage(seed.thumbnail), + thumbnail: processOptImage(seed.thumbnail), nextRefresh: guessNextRefresh(entry.airDate ?? new Date()), + episodeNumber: + entry.kind === "episode" + ? entry.episodeNumber + : entry.kind === "special" + ? entry.number + : undefined, }; }); const ret = await tx @@ -111,10 +119,10 @@ export const insertEntries = async ( })); }; -export function computeVideoSlug(showSlug: SQL, needsRendering: SQL) { +export function computeVideoSlug(showSlug: SQL | Column, needsRendering: SQL) { return sql` concat( - ${showSlug}::text, + ${showSlug}, case when ${videos.part} is not null then ('-p' || ${videos.part}) else '' end, case when ${videos.version} <> 1 then ('-v' || ${videos.version}) else '' end, case when ${needsRendering} then concat('-', ${videos.rendering}) else '' end diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index ba0ebfad..58e40b4c 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -63,7 +63,7 @@ export const entries = schema.table( extraKind: text(), airDate: date(), runtime: integer(), - thumbnails: image(), + thumbnail: image(), externalId: entry_extid(), diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index 2464629f..abe1049f 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -34,7 +34,7 @@ export const videos = schema.table( ); export const entryVideoJoin = schema.table( - "entry_video_jointure", + "entry_video_join", { entry: integer() .notNull() From 79063745535e37b8bafa943e1e21f37feb3d2928 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 28 Jan 2025 12:39:26 +0100 Subject: [PATCH 18/30] Add history & from in the guess type --- api/src/models/video.ts | 47 +++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 018b4ed6..502a4f10 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -36,22 +36,37 @@ export const Video = t.Object({ createdAt: t.String({ format: "date-time" }), guess: t.Optional( - t.Object( - { - title: t.Optional(t.String()), - year: Guess(t.Integer()), - season: Guess(t.Integer()), - episode: Guess(t.Integer()), - }, - { - additionalProperties: true, - description: comment` - Metadata guessed from the filename. Kyoo can use those informations to bypass - the scanner/metadata fetching and just register videos to movies/entries that already - exists. If Kyoo can't find a matching movie/entry, this information will be sent to - the scanner. - `, - }, + t.Recursive((Self) => + t.Object( + { + title: t.String(), + year: Guess(t.Integer()), + season: Guess(t.Integer()), + episode: Guess(t.Integer()), + // TODO: maybe replace "extra" with the `extraKind` value (aka behind-the-scene, trailer, etc) + type: t.Optional(t.UnionEnum(["episode", "movie", "extra"])), + + from: t.String({ + description: "Name of the tool that made the guess", + }), + history: t.Array(t.Omit(Self, ["history"]), { + default: [], + description: comment` + When another tool refines the guess or a user manually edit it, the history of the guesses + are kept in this \`history\` value. + `, + }), + }, + { + additionalProperties: true, + description: comment` + Metadata guessed from the filename. Kyoo can use those informations to bypass + the scanner/metadata fetching and just register videos to movies/entries that already + exists. If Kyoo can't find a matching movie/entry, this information will be sent to + the scanner. + `, + }, + ), ), ), }); From 740bf7adaaef81003544a78d0bc27593e51e3b14 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 28 Jan 2025 12:41:20 +0100 Subject: [PATCH 19/30] Remove tests afterAll --- .../movies/get-all-movies-with-null.test.ts | 11 ++--------- api/tests/movies/get-all-movies.test.ts | 5 +---- api/tests/movies/get-movie.test.ts | 2 +- api/tests/movies/seed-movies.test.ts | 16 ++++------------ 4 files changed, 8 insertions(+), 26 deletions(-) diff --git a/api/tests/movies/get-all-movies-with-null.test.ts b/api/tests/movies/get-all-movies-with-null.test.ts index 1abeed9c..893c4612 100644 --- a/api/tests/movies/get-all-movies-with-null.test.ts +++ b/api/tests/movies/get-all-movies-with-null.test.ts @@ -1,5 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { eq } from "drizzle-orm"; +import { beforeAll, describe, expect, it } from "bun:test"; import { expectStatus } from "tests/utils"; import { seedMovie } from "~/controllers/seed/movies"; import { db } from "~/db"; @@ -7,15 +6,12 @@ import { shows } from "~/db/schema"; import { bubble } from "~/models/examples"; import { dune1984 } from "~/models/examples/dune-1984"; import { dune } from "~/models/examples/dune-2021"; -import { createMovie, getMovies, app } from "../helper"; +import { app, createMovie, getMovies } from "../helper"; beforeAll(async () => { await db.delete(shows); for (const movie of [bubble, dune1984, dune]) await seedMovie(movie); }); -afterAll(async () => { - await db.delete(shows); -}); describe("with a null value", () => { // Those before/after hooks are NOT scopped to the describe due to a bun bug @@ -47,9 +43,6 @@ describe("with a null value", () => { externalId: {}, }); }); - afterAll(async () => { - await db.delete(shows).where(eq(shows.slug, "no-air-date")); - }); it("sort by dates desc with a null value", async () => { let [resp, body] = await getMovies({ diff --git a/api/tests/movies/get-all-movies.test.ts b/api/tests/movies/get-all-movies.test.ts index 1a875dc6..a10c2f56 100644 --- a/api/tests/movies/get-all-movies.test.ts +++ b/api/tests/movies/get-all-movies.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { beforeAll, describe, expect, it } from "bun:test"; import { expectStatus } from "tests/utils"; import { seedMovie } from "~/controllers/seed/movies"; import { db } from "~/db"; @@ -14,9 +14,6 @@ beforeAll(async () => { await db.delete(shows); for (const movie of [bubble, dune1984, dune]) await seedMovie(movie); }); -afterAll(async () => { - await db.delete(shows); -}); describe("Get all movies", () => { it("Invalid filter params", async () => { diff --git a/api/tests/movies/get-movie.test.ts b/api/tests/movies/get-movie.test.ts index eddf6664..675f1f70 100644 --- a/api/tests/movies/get-movie.test.ts +++ b/api/tests/movies/get-movie.test.ts @@ -8,7 +8,7 @@ let bubbleId = ""; beforeAll(async () => { const ret = await seedMovie(bubble); - if (ret.status !== 422) bubbleId = ret.id; + if (!("status" in ret)) bubbleId = ret.id; }); describe("Get movie", () => { diff --git a/api/tests/movies/seed-movies.test.ts b/api/tests/movies/seed-movies.test.ts index 33a709d3..03b84f41 100644 --- a/api/tests/movies/seed-movies.test.ts +++ b/api/tests/movies/seed-movies.test.ts @@ -1,14 +1,8 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { beforeAll, describe, expect, it } from "bun:test"; import { eq } from "drizzle-orm"; import { expectStatus } from "tests/utils"; import { db } from "~/db"; -import { - entries, - entryVideoJoin, - showTranslations, - shows, - videos, -} from "~/db/schema"; +import { showTranslations, shows, videos } from "~/db/schema"; import { bubble } from "~/models/examples"; import { dune, duneVideo } from "~/models/examples/dune-2021"; import { createMovie, createVideo } from "../helper"; @@ -388,6 +382,7 @@ describe("Movie seeding", () => { }, ]); expectStatus(vresp, video).toBe(201); + console.log(video); const [resp, body] = await createMovie({ ...bubble, @@ -395,7 +390,7 @@ describe("Movie seeding", () => { videos: [video[0].id, video[1].id], }); expectStatus(resp, body).toBe(201); - console.log(body) + console.log(body); const ret = await db.query.shows.findFirst({ where: eq(shows.id, body.id), @@ -415,10 +410,7 @@ describe("Movie seeding", () => { const cleanup = async () => { await db.delete(shows); - await db.delete(entries); - await db.delete(entryVideoJoin); await db.delete(videos); }; // cleanup db beforehand to unsure tests are consistent beforeAll(cleanup); -afterAll(cleanup); From 015f58226a9004fa9997a2a2202f33599f33e850 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 28 Jan 2025 12:42:44 +0100 Subject: [PATCH 20/30] Update drizzle + migration --- api/bun.lock | 4 +- api/drizzle/0008_entries.sql | 12 + api/drizzle/meta/0008_snapshot.json | 952 ++++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/package.json | 4 +- 5 files changed, 975 insertions(+), 4 deletions(-) create mode 100644 api/drizzle/0008_entries.sql create mode 100644 api/drizzle/meta/0008_snapshot.json diff --git a/api/bun.lock b/api/bun.lock index 335e7740..3dd35f56 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -100,9 +100,9 @@ "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], - "drizzle-kit": ["drizzle-kit@0.30.2", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-vhdLrxWA32WNVF77NabpSnX7pQBornx64VDQDmKddRonOB2Xe/yY4glQ7rECoa+ogqcQNo7VblLUbeBK6Zn9Ow=="], + "drizzle-kit": ["drizzle-kit@0.30.3", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-kT8sgyC2hZrtOh5okhEBiwgx8jx+EjLUFoANFVVkBbxIjcb8XjaUorZ0rwCEUEd7THclI3ZARR64pmxloMW3Aw=="], - "drizzle-orm": ["drizzle-orm@0.38.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-s7/5BpLKO+WJRHspvpqTydxFob8i1vo2rEx4pY6TGY7QSMuUfWUuzaY0DIpXCkgHOo37BaFC+SJQb99dDUXT3Q=="], + "drizzle-orm": ["drizzle-orm@0.39.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-kkZwo3Jvht0fdJD/EWGx0vYcEK0xnGrlNVaY07QYluRZA9N21B9VFbY+54bnb/1xvyzcg97tE65xprSAP/fFGQ=="], "elysia": ["elysia@1.2.10", "", { "dependencies": { "@sinclair/typebox": "^0.34.13", "cookie": "^1.0.2", "memoirist": "^0.2.0", "openapi-types": "^12.1.3" }, "peerDependencies": { "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-QcNl2FjhHFRpKaqy1NoMpyCjJ7OcKBnHwLUkqGu09QwIV84PFb82ILvYJG4GS1RbGv76OA50luaqBLrM3SLZ2w=="], diff --git a/api/drizzle/0008_entries.sql b/api/drizzle/0008_entries.sql new file mode 100644 index 00000000..6e1e5570 --- /dev/null +++ b/api/drizzle/0008_entries.sql @@ -0,0 +1,12 @@ +ALTER TABLE "kyoo"."entry_video_jointure" RENAME TO "entry_video_join";--> statement-breakpoint +ALTER TABLE "kyoo"."entries" RENAME COLUMN "thumbnails" TO "thumbnail";--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" DROP CONSTRAINT "entry_video_jointure_slug_unique";--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" DROP CONSTRAINT "entry_video_jointure_entry_entries_pk_fk"; +--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" DROP CONSTRAINT "entry_video_jointure_video_videos_pk_fk"; +--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" DROP CONSTRAINT "entry_video_jointure_entry_video_pk";--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" ADD CONSTRAINT "entry_video_join_entry_video_pk" PRIMARY KEY("entry","video");--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" ADD CONSTRAINT "entry_video_join_entry_entries_pk_fk" FOREIGN KEY ("entry") REFERENCES "kyoo"."entries"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" ADD CONSTRAINT "entry_video_join_video_videos_pk_fk" FOREIGN KEY ("video") REFERENCES "kyoo"."videos"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" ADD CONSTRAINT "entry_video_join_slug_unique" UNIQUE("slug"); \ No newline at end of file diff --git a/api/drizzle/meta/0008_snapshot.json b/api/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000..2c1a661e --- /dev/null +++ b/api/drizzle/meta/0008_snapshot.json @@ -0,0 +1,952 @@ +{ + "id": "5c17dd71-409a-4c80-870d-f12386676738", + "prevId": "e70b1585-a927-4436-b2a0-d0ef216911f1", + "version": "7", + "dialect": "postgresql", + "tables": { + "kyoo.entries": { + "name": "entries", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "entries_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "extra_kind": { + "name": "extra_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "air_date": { + "name": "air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "entries_show_pk_shows_pk_fk": { + "name": "entries_show_pk_shows_pk_fk", + "tableFrom": "entries", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entries_id_unique": { + "name": "entries_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "entries_slug_unique": { + "name": "entries_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "entries_showPk_seasonNumber_episodeNumber_unique": { + "name": "entries_showPk_seasonNumber_episodeNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_pk", "season_number", "episode_number"] + } + }, + "policies": {}, + "checkConstraints": { + "order_positive": { + "name": "order_positive", + "value": "\"kyoo\".\"entries\".\"order\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.entry_translations": { + "name": "entry_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "entry_translations_pk_entries_pk_fk": { + "name": "entry_translations_pk_entries_pk_fk", + "tableFrom": "entry_translations", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_translations_pk_language_pk": { + "name": "entry_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.season_translations": { + "name": "season_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "season_translations_pk_seasons_pk_fk": { + "name": "season_translations_pk_seasons_pk_fk", + "tableFrom": "season_translations", + "tableTo": "seasons", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "season_translations_pk_language_pk": { + "name": "season_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.seasons": { + "name": "seasons", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "seasons_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "show_fk": { + "name": "show_fk", + "columns": [ + { + "expression": "show_pk", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + } + }, + "foreignKeys": { + "seasons_show_pk_shows_pk_fk": { + "name": "seasons_show_pk_shows_pk_fk", + "tableFrom": "seasons", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "seasons_id_unique": { + "name": "seasons_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "seasons_slug_unique": { + "name": "seasons_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "seasons_showPk_seasonNumber_unique": { + "name": "seasons_showPk_seasonNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_pk", "season_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.show_translations": { + "name": "show_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trailer_url": { + "name": "trailer_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "name_trgm": { + "name": "name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "tags": { + "name": "tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "show_translations_pk_shows_pk_fk": { + "name": "show_translations_pk_shows_pk_fk", + "tableFrom": "show_translations", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_translations_pk_language_pk": { + "name": "show_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.shows": { + "name": "shows", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "shows_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "show_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "genres": { + "name": "genres", + "type": "genres[]", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "show_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "original_language": { + "name": "original_language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kind": { + "name": "kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "rating": { + "name": "rating", + "columns": [ + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "startAir": { + "name": "startAir", + "columns": [ + { + "expression": "start_air", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shows_id_unique": { + "name": "shows_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "shows_slug_unique": { + "name": "shows_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "rating_valid": { + "name": "rating_valid", + "value": "\"kyoo\".\"shows\".\"rating\" between 0 and 100" + }, + "runtime_valid": { + "name": "runtime_valid", + "value": "\"kyoo\".\"shows\".\"runtime\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.entry_video_join": { + "name": "entry_video_join", + "schema": "kyoo", + "columns": { + "entry": { + "name": "entry", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video": { + "name": "video", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "entry_video_join_entry_entries_pk_fk": { + "name": "entry_video_join_entry_entries_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["entry"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_video_join_video_videos_pk_fk": { + "name": "entry_video_join_video_videos_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": ["video"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_video_join_entry_video_pk": { + "name": "entry_video_join_entry_video_pk", + "columns": ["entry", "video"] + } + }, + "uniqueConstraints": { + "entry_video_join_slug_unique": { + "name": "entry_video_join_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.videos": { + "name": "videos", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "videos_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendering": { + "name": "rendering", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "guess": { + "name": "guess", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "videos_id_unique": { + "name": "videos_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "videos_path_unique": { + "name": "videos_path_unique", + "nullsNotDistinct": false, + "columns": ["path"] + } + }, + "policies": {}, + "checkConstraints": { + "part_pos": { + "name": "part_pos", + "value": "\"kyoo\".\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"kyoo\".\"videos\".\"version\" >= 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "kyoo.entry_type": { + "name": "entry_type", + "schema": "kyoo", + "values": ["unknown", "episode", "movie", "special", "extra"] + }, + "kyoo.genres": { + "name": "genres", + "schema": "kyoo", + "values": [ + "action", + "adventure", + "animation", + "comedy", + "crime", + "documentary", + "drama", + "family", + "fantasy", + "history", + "horror", + "music", + "mystery", + "romance", + "science-fiction", + "thriller", + "war", + "western", + "kids", + "reality", + "politics", + "soap", + "talk" + ] + }, + "kyoo.show_kind": { + "name": "show_kind", + "schema": "kyoo", + "values": ["serie", "movie"] + }, + "kyoo.show_status": { + "name": "show_status", + "schema": "kyoo", + "values": ["unknown", "finished", "airing", "planned"] + } + }, + "schemas": { + "kyoo": "kyoo" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index eda2b441..ea8303e2 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1737913931275, "tag": "0007_entries", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1738064522937, + "tag": "0008_entries", + "breakpoints": true } ] } diff --git a/api/package.json b/api/package.json index c4c5e8a1..22700a95 100644 --- a/api/package.json +++ b/api/package.json @@ -10,8 +10,8 @@ "dependencies": { "@elysiajs/jwt": "^1.2.0", "@elysiajs/swagger": "zoriya/elysia-swagger#build", - "drizzle-kit": "^0.30.2", - "drizzle-orm": "^0.38.4", + "drizzle-kit": "^0.30.3", + "drizzle-orm": "^0.39.0", "elysia": "^1.2.10", "parjs": "^1.3.9", "pg": "^8.13.1" From 1a2ab48c734747e18ad5b8bd22c16f1425837394 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 28 Jan 2025 18:22:57 +0100 Subject: [PATCH 21/30] Start the smart video controller --- api/src/controllers/videos.ts | 104 +++++++++++++++++++++++++++++++--- api/src/index.ts | 4 +- api/src/models/video.ts | 9 +-- api/tests/helper.ts | 4 +- 4 files changed, 103 insertions(+), 18 deletions(-) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index a51766cd..20c55628 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -1,16 +1,23 @@ +import { and, eq, inArray, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { db } from "~/db"; -import { videos as videosT } from "~/db/schema"; +import { entries, entryVideoJoin, shows, videos } from "~/db/schema"; import { bubbleVideo } from "~/models/examples"; import { SeedVideo, Video } from "~/models/video"; import { comment } from "~/utils"; +import { computeVideoSlug } from "./seed/insert/entries"; const CreatedVideo = t.Object({ id: t.String({ format: "uuid" }), - path: t.String({ example: bubbleVideo.path }), + path: t.String({ examples: [bubbleVideo.path] }), + // entries: t.Array( + // t.Object({ + // slug: t.String({ format: "slug", examples: ["bubble-v2"] }), + // }), + // ), }); -export const videos = new Elysia({ prefix: "/videos", tags: ["videos"] }) +export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .model({ video: Video, "created-videos": t.Array(CreatedVideo), @@ -22,20 +29,101 @@ export const videos = new Elysia({ prefix: "/videos", tags: ["videos"] }) .post( "", async ({ body, error }) => { - const ret = await db - .insert(videosT) + const oldRet = await db + .insert(videos) .values(body) .onConflictDoNothing() - .returning({ id: videosT.id, path: videosT.path }); - return error(201, ret); + .returning({ + pk: videos.pk, + id: videos.id, + path: videos.path, + guess: videos.guess, + }); + return error(201, oldRet); + + // TODO: this is a huge untested wip + const vidsI = db.$with("vidsI").as( + db.insert(videos).values(body).onConflictDoNothing().returning({ + pk: videos.pk, + id: videos.id, + path: videos.path, + guess: videos.guess, + }), + ); + + const findEntriesQ = db + .select({ + guess: videos.guess, + entryPk: entries.pk, + showSlug: shows.slug, + // TODO: handle extras here + // guessit can't know if an episode is a special or not. treat specials like a normal episode. + kind: sql` + case when ${entries.kind} = 'movie' then 'movie' else 'episode' end + `.as("kind"), + season: entries.seasonNumber, + episode: entries.episodeNumber, + }) + .from(entries) + .leftJoin(entryVideoJoin, eq(entryVideoJoin.entry, entries.pk)) + .leftJoin(videos, eq(videos.pk, entryVideoJoin.video)) + .leftJoin(shows, eq(shows.pk, entries.showPk)) + .as("find_entries"); + + const hasRenderingQ = db + .select() + .from(entryVideoJoin) + .where(eq(entryVideoJoin.entry, findEntriesQ.entryPk)); + + const ret = await db + .with(vidsI) + .insert(entryVideoJoin) + .select( + db + .select({ + entry: findEntriesQ.entryPk, + video: vidsI.pk, + slug: computeVideoSlug( + findEntriesQ.showSlug, + sql`exists(${hasRenderingQ})`, + ), + }) + .from(vidsI) + .leftJoin( + findEntriesQ, + and( + eq( + sql`${findEntriesQ.guess}->'title'`, + sql`${vidsI.guess}->'title'`, + ), + // TODO: find if @> with a jsonb created on the fly is + // better than multiples checks + sql`${vidsI.guess} @> {"kind": }::jsonb`, + inArray(findEntriesQ.kind, sql`${vidsI.guess}->'type'`), + inArray(findEntriesQ.episode, sql`${vidsI.guess}->'episode'`), + inArray(findEntriesQ.season, sql`${vidsI.guess}->'season'`), + ), + ), + ) + .onConflictDoNothing() + .returning({ + slug: entryVideoJoin.slug, + entryPk: entryVideoJoin.entry, + id: vidsI.id, + path: vidsI.path, + }); + return error(201, ret as any); }, { body: t.Array(SeedVideo), - response: { 201: "created-videos" }, + response: { 201: t.Array(CreatedVideo) }, detail: { description: comment` Create videos in bulk. Duplicated videos will simply be ignored. + + If a videos has a \`guess\` field, it will be used to automatically register the video under an existing + movie or entry. `, }, }, diff --git a/api/src/index.ts b/api/src/index.ts index 5950fd48..348d459b 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -7,7 +7,7 @@ import { movies } from "./controllers/movies"; import { seasonsH } from "./controllers/seasons"; import { seed } from "./controllers/seed"; import { series } from "./controllers/series"; -import { videos } from "./controllers/videos"; +import { videosH } from "./controllers/videos"; import { migrate } from "./db"; import { Image } from "./models/utils"; import { comment } from "./utils"; @@ -75,7 +75,7 @@ const app = new Elysia() .use(series) .use(entries) .use(seasonsH) - .use(videos) + .use(videosH) .use(seed) .listen(3000); diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 502a4f10..85c5057a 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -2,9 +2,6 @@ import { type TSchema, t } from "elysia"; import { comment } from "../utils"; import { bubbleVideo, registerExamples } from "./examples"; -const Guess = (schema: T) => - t.Optional(t.Union([schema, t.Array(schema)])); - export const Video = t.Object({ id: t.String({ format: "uuid" }), slug: t.String({ format: "slug" }), @@ -40,9 +37,9 @@ export const Video = t.Object({ t.Object( { title: t.String(), - year: Guess(t.Integer()), - season: Guess(t.Integer()), - episode: Guess(t.Integer()), + year: t.Array(t.Integer(), { default: [] }), + season: t.Array(t.Integer(), { default: [] }), + episode: t.Array(t.Integer(), { default: [] }), // TODO: maybe replace "extra" with the `extraKind` value (aka behind-the-scene, trailer, etc) type: t.Optional(t.UnionEnum(["episode", "movie", "extra"])), diff --git a/api/tests/helper.ts b/api/tests/helper.ts index 089570ac..80950c61 100644 --- a/api/tests/helper.ts +++ b/api/tests/helper.ts @@ -4,7 +4,7 @@ import { base } from "~/base"; import { movies } from "~/controllers/movies"; import { seed } from "~/controllers/seed"; import { series } from "~/controllers/series"; -import { videos } from "~/controllers/videos"; +import { videosH } from "~/controllers/videos"; import type { SeedMovie } from "~/models/movie"; import type { SeedVideo } from "~/models/video"; @@ -12,7 +12,7 @@ export const app = new Elysia() .use(base) .use(movies) .use(series) - .use(videos) + .use(videosH) .use(seed); export const getMovie = async ( From cf59695e097a6b7e23378c3aae17ec809bcf841c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 28 Jan 2025 18:26:12 +0100 Subject: [PATCH 22/30] Fix need-rendering test --- api/src/controllers/seed/insert/entries.ts | 5 +++-- api/tests/movies/seed-movies.test.ts | 7 ++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 3979c260..9166734b 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -81,10 +81,11 @@ export const insertEntries = async ( const vids = items.flatMap( (seed, i) => - seed.videos?.map((x) => ({ + seed.videos?.map((x, j) => ({ videoId: x, entryPk: retEntries[i].pk, - needRendering: seed.videos!.length > 1, + // The first video should not have a rendering. + needRendering: j && seed.videos!.length > 1, })) ?? [], ); diff --git a/api/tests/movies/seed-movies.test.ts b/api/tests/movies/seed-movies.test.ts index 03b84f41..82d5086b 100644 --- a/api/tests/movies/seed-movies.test.ts +++ b/api/tests/movies/seed-movies.test.ts @@ -382,7 +382,6 @@ describe("Movie seeding", () => { }, ]); expectStatus(vresp, video).toBe(201); - console.log(video); const [resp, body] = await createMovie({ ...bubble, @@ -390,7 +389,6 @@ describe("Movie seeding", () => { videos: [video[0].id, video[1].id], }); expectStatus(resp, body).toBe(201); - console.log(body); const ret = await db.query.shows.findFirst({ where: eq(shows.id, body.id), @@ -401,9 +399,8 @@ describe("Movie seeding", () => { expect(ret!.entries[0].slug).toBe("bubble-rtest"); expect(ret!.entries[0].evj).toBeArrayOfSize(2); expect(ret!.entries[0].evj).toContainValues([ - { slug: "bubble-rtest-oeunhtoeuth" }, - { slug: "bubble-rtest-aoeuaoeu" }, - { slug: "bubble-rtest" }, + expect.objectContaining({ slug: "bubble-rtest" }), + expect.objectContaining({ slug: "bubble-rtest-aoeuaoeu" }), ]); }); }); From b1df97f767a6d242c8d899c69501eb09c2f3cb60 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 28 Jan 2025 18:57:25 +0100 Subject: [PATCH 23/30] Type extra & allow movie entries to specify a slug on seed --- api/src/controllers/seed/insert/entries.ts | 1 + api/src/models/entry/base-entry.ts | 12 -- api/src/models/entry/extra.ts | 30 ++-- api/src/models/entry/movie-entry.ts | 9 +- api/src/models/examples/made-in-abyss.ts | 177 +++++---------------- api/src/models/serie.ts | 4 +- 6 files changed, 68 insertions(+), 165 deletions(-) diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 9166734b..45c9a352 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -20,6 +20,7 @@ const generateSlug = (showSlug: string, entry: SeedEntry): string => { case "special": return `${showSlug}-sp${entry.number}`; case "movie": + if (entry.slug) return entry.slug; return entry.order === 1 ? showSlug : `${showSlug}-${entry.order}`; } }; diff --git a/api/src/models/entry/base-entry.ts b/api/src/models/entry/base-entry.ts index 1fce67ad..dc7a880c 100644 --- a/api/src/models/entry/base-entry.ts +++ b/api/src/models/entry/base-entry.ts @@ -16,15 +16,3 @@ export const EntryTranslation = t.Object({ name: t.Nullable(t.String()), description: t.Nullable(t.String()), }); - -// export const SeedEntry = t.Intersect([ -// Entry, -// t.Object({ videos: t.Optional(t.Array(Video)) }), -// ]); -// export type SeedEntry = typeof SeedEntry.static; -// -// export const SeedExtra = t.Intersect([ -// Extra, -// t.Object({ video: t.Optional(Video) }), -// ]); -// export type SeedExtra = typeof SeedExtra.static; diff --git a/api/src/models/entry/extra.ts b/api/src/models/entry/extra.ts index a0667413..3d4c5b2f 100644 --- a/api/src/models/entry/extra.ts +++ b/api/src/models/entry/extra.ts @@ -1,26 +1,25 @@ import { t } from "elysia"; -import { comment } from "../../utils"; -import { EpisodeId } from "../utils/external-id"; +import { comment } from "~/utils"; +import { SeedImage } from "../utils"; import { Resource } from "../utils/resource"; -import { BaseEntry, EntryTranslation } from "./base-entry"; +import { BaseEntry } from "./base-entry"; export const ExtraType = t.UnionEnum([ "other", - "trailers", + "trailer", "interview", - "behind-the-scenes", - "deleted-scenes", - "bloopers", + "behind-the-scene", + "deleted-scene", + "blooper", ]); export type ExtraType = typeof ExtraType.static; export const BaseExtra = t.Intersect( [ - BaseEntry, + t.Omit(BaseEntry, ["nextRefresh", "airDate"]), t.Object({ kind: ExtraType, - // not sure about this id type - externalId: EpisodeId, + name: t.String(), }), ], { @@ -31,5 +30,14 @@ export const BaseExtra = t.Intersect( }, ); -export const Extra = t.Intersect([Resource(), BaseExtra, EntryTranslation]); +export const Extra = t.Intersect([Resource(), BaseExtra]); export type Extra = typeof Extra.static; + +export const SeedExtra = t.Intersect([ + t.Omit(BaseExtra, ["thumbnail", "createdAt"]), + t.Object({ + thumbnail: t.Nullable(SeedImage), + videos: t.Optional(t.Array(t.String({ format: "uuid" }))), + }), +]); +export type SeedExtra = typeof SeedExtra.static; diff --git a/api/src/models/entry/movie-entry.ts b/api/src/models/entry/movie-entry.ts index 0be952d7..ca8a8e9d 100644 --- a/api/src/models/entry/movie-entry.ts +++ b/api/src/models/entry/movie-entry.ts @@ -1,13 +1,13 @@ import { t } from "elysia"; import { comment } from "../../utils"; -import { BaseEntry, EntryTranslation } from "./base-entry"; import { - Resource, - Image, - SeedImage, ExternalId, + Image, + Resource, + SeedImage, TranslationRecord, } from "../utils"; +import { BaseEntry, EntryTranslation } from "./base-entry"; export const BaseMovieEntry = t.Intersect( [ @@ -47,6 +47,7 @@ export type MovieEntry = typeof MovieEntry.static; export const SeedMovieEntry = t.Intersect([ t.Omit(BaseMovieEntry, ["thumbnail", "createdAt", "nextRefresh"]), t.Object({ + slug: t.Optional(t.String({ format: "slug" })), thumbnail: t.Nullable(SeedImage), translations: TranslationRecord( t.Intersect([ diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts index 0a94bea0..c5cd652b 100644 --- a/api/src/models/examples/made-in-abyss.ts +++ b/api/src/models/examples/made-in-abyss.ts @@ -117,22 +117,20 @@ export const madeInAbyss = { entries: [ { kind: "episode", - id: "ab912364-61c8-4752-ac93-5802212467d8", - slug: "made-in-abyss-s1e13", order: 13, seasonNumber: 1, episodeNumber: 13, - name: "The Challengers", - description: - "Nanachi and Mitty's past is revealed. How did they become what they are and who is responsible for it? Meanwhile, Riko is on the mend after her injuries.", + translations: { + en: { + name: "The Challengers", + description: + "Nanachi and Mitty's past is revealed. How did they become what they are and who is responsible for it? Meanwhile, Riko is on the mend after her injuries.", + }, + }, runtime: 47, airDate: "2017-09-29", - thumbnail: { - id: "c2bfd626-bfdb-dee8-caa6-b6a7e7cb74ad", - source: - "https://image.tmdb.org/t/p/original/j9t1quh24suXxBetV7Q77YngID6.jpg", - blurhash: "L370#nD*^jEN}r$$$%J8i_-URkNc", - }, + thumbnail: + "https://image.tmdb.org/t/p/original/j9t1quh24suXxBetV7Q77YngID6.jpg", externalId: { themoviedatabase: { serieId: "72636", @@ -141,39 +139,23 @@ export const madeInAbyss = { link: "https://www.themoviedb.org/tv/72636/season/1/episode/13", }, }, - createdAt: "2024-10-06T20:09:09.28103Z", - nextRefresh: "2024-12-06T20:08:42.366583Z", - videos: [ - { - id: "0905bddd-8b93-403c-9b9c-db472e55d6cc", - slug: "made-in-abyss-s1e13", - path: "/video/Made in Abyss/Made in Abyss S01E13.mkv", - rendering: - "e27f226fe5e8d87cd396d0c3d24e1b1135aa563fcfca081bf68c6a71b44de107", - part: null, - version: 1, - createdAt: "2024-10-06T20:09:09.28103Z", - }, - ], }, { kind: "special", - id: "1a83288a-3089-447f-9710-94297d614c51", - slug: "made-in-abyss-ova3", - // beween s1e13 & movie (which has 13.5 for the `order field`) + // between s1e13 & movie (which has 13.5 for the `order field`) order: 13.25, number: 3, - name: "Maruruk's Everday 3 - Cleaning", - description: - "Short played before Made in Abyss Movie 3: Dawn of the Deep Soul in Japan's theatrical screenings before the main movie from 2020-01-17 to 2020-01-23.", + translations: { + en: { + name: "Maruruk's Everday 3 - Cleaning", + description: + "Short played before Made in Abyss Movie 3: Dawn of the Deep Soul in Japan's theatrical screenings before the main movie from 2020-01-17 to 2020-01-23.", + }, + }, runtime: 3, airDate: "2020-01-31", - thumbnail: { - id: "f4ac4b0a-c857-ea95-4042-601314a26e71", - source: - "https://image.tmdb.org/t/p/original/4cMeg2ihvACsGVaSUcQJJZd96Je.jpg", - blurhash: "LAD,Pg%dc}tPDQfk.7kBo|ayR7WC", - }, + thumbnail: + "https://image.tmdb.org/t/p/original/4cMeg2ihvACsGVaSUcQJJZd96Je.jpg", externalId: { themoviedatabase: { serieId: "72636", @@ -182,77 +164,48 @@ export const madeInAbyss = { link: "https://www.themoviedb.org/tv/72636/season/0/episode/3", }, }, - createdAt: "2024-10-06T20:09:17.551272Z", - nextRefresh: "2024-12-06T20:08:29.463394Z", - videos: [ - { - id: "9153f7dc-b635-4a04-a2db-9c08ea205ec3", - slug: "made-in-abyss-ova3", - path: "/video/Made in Abyss/Made in Abyss S00E03.mkv", - rendering: - "0391acf2268983de705f65381d252f1b0cd3c3563209303dc50cf71ab400ebf4", - part: null, - version: 1, - createdAt: "2024-10-06T20:09:17.551272Z", - }, - ], }, { kind: "movie", - id: "59312db0-df8c-446e-be26-2b2107d0cbde", slug: "made-in-abyss-dawn-of-the-deep-soul", order: 13.5, - name: "Made in Abyss: Dawn of the Deep Soul", - tagline: "Defy the darkness", - description: - "A continuation of the epic adventure of plucky Riko and Reg who are joined by their new friend Nanachi. Together they descend into the Abyss' treacherous fifth layer, the Sea of Corpses, and encounter the mysterious Bondrewd, a legendary White Whistle whose shadow looms over Nanachi's troubled past. Bondrewd is ingratiatingly hospitable, but the brave adventurers know things are not always as they seem in the enigmatic Abyss.", + translations: { + en: { + name: "Made in Abyss: Dawn of the Deep Soul", + tagline: "Defy the darkness", + description: + "A continuation of the epic adventure of plucky Riko and Reg who are joined by their new friend Nanachi. Together they descend into the Abyss' treacherous fifth layer, the Sea of Corpses, and encounter the mysterious Bondrewd, a legendary White Whistle whose shadow looms over Nanachi's troubled past. Bondrewd is ingratiatingly hospitable, but the brave adventurers know things are not always as they seem in the enigmatic Abyss.", + poster: + "https://image.tmdb.org/t/p/original/4cMeg2ihvACsGVaSUcQJJZd96Je.jpg", + }, + }, + thumbnail: + "https://image.tmdb.org/t/p/original/4cMeg2ihvACsGVaSUcQJJZd96Je.jpg", runtime: 105, airDate: "2020-01-17", - poster: { - id: "f4ac4b0a-c857-ea95-4042-601314a26e71", - source: - "https://image.tmdb.org/t/p/original/4cMeg2ihvACsGVaSUcQJJZd96Je.jpg", - blurhash: "LAD,Pg%dc}tPDQfk.7kBo|ayR7WC", - }, externalId: { themoviedatabase: { dataId: "72636", link: "https://www.themoviedb.org/tv/72636/season/0/episode/3", }, }, - createdAt: "2024-10-06T20:09:17.551272Z", - nextRefresh: "2024-12-06T20:08:29.463394Z", - videos: [ - { - id: "d3cedfc5-23f4-4aab-b4d3-98bef2954442", - slug: "made-in-abyss-dawn-of-the-deep-soul", - path: "/video/Made in Abyss/Made in Abyss Dawn of the Deep Soul.mkv", - rendering: - "a59ba5d88a4935d900db312422eec6f16827ce2572cc8c0eb6c8fffc5e235d6d", - part: null, - version: 1, - createdAt: "2024-10-06T20:09:17.551272Z", - }, - ], }, { kind: "episode", - id: "bd155be3-39d0-4253-bb29-a60bedb62943", - slug: "made-in-abyss-s2e1", order: 14, seasonNumber: 2, episodeNumber: 1, - name: "The Compass Pointed to the Darkness", - description: - "An old man speaks of a golden city that lies within a devouring abyss somewhere in uncharted waters. One explorer may be the key to finding both.", + translations: { + en: { + name: "The Compass Pointed to the Darkness", + description: + "An old man speaks of a golden city that lies within a devouring abyss somewhere in uncharted waters. One explorer may be the key to finding both.", + }, + }, runtime: 23, airDate: "2022-07-06", - thumbnail: { - id: "072da617-f349-4a68-eb27-d097624b373c", - source: - "https://image.tmdb.org/t/p/original/Tgu6E3aMf7sFHFbEIMEjetnpMi.jpg", - blurhash: "LOI#x]yE01xtE2D*kWt7NGjENGM|", - }, + thumbnail: + "https://image.tmdb.org/t/p/original/Tgu6E3aMf7sFHFbEIMEjetnpMi.jpg", externalId: { themoviedatabase: { serieId: "72636", @@ -261,62 +214,14 @@ export const madeInAbyss = { link: "https://www.themoviedb.org/tv/72636/season/2/episode/1", }, }, - createdAt: "2024-10-06T20:09:05.651996Z", - nextRefresh: "2024-12-06T20:08:22.854073Z", - videos: [ - { - id: "3cbcc337-f1da-486a-93bd-c705a58545eb", - slug: "made-in-abyss-s2e1-p1", - path: "/video/Made in Abyss/Made In Abyss S02E01 Part 1.mkv", - rendering: - "6239d558696fd1cbcd70a67346e748382fe141bbe7ea01a5d702cdcc02aa996f", - part: 1, - version: 1, - createdAt: "2024-10-06T20:09:05.651996Z", - }, - { - id: "67b37a00-7459-4287-9bbf-e058675850b5", - slug: "made-in-abyss-s2e1-p2", - path: "/video/Made in Abyss/Made In Abyss S02E01 Part 2.mkv", - rendering: - "6239d558696fd1cbcd70a67346e748382fe141bbe7ea01a5d702cdcc02aa996f", - part: 2, - version: 1, - createdAt: "2024-10-06T20:09:05.651996Z", - }, - ], }, ], extras: [ { - kind: "behind-the-scenes", - id: "a9b27fcc-9423-44ad-b875-d35a7a25b613", - slug: "made-in-abyss-the-making-of-01", + kind: "behind-the-scene", name: "The Making of MADE IN ABYSS 01", - description: null, runtime: 17, - airDate: "2017-10-25", thumbnail: null, - externalId: { - themoviedatabase: { - serieId: "72636", - season: 0, - episode: 13, - link: "https://thetvdb.com/series/made-in-abyss/episodes/8835068", - }, - }, - createdAt: "2024-10-06T20:09:05.651996Z", - nextRefresh: "2024-12-06T20:08:22.854073Z", - video: { - id: "ee3f58eb-0f72-423e-b247-0695cfabfa88", - slug: "made-in-abyss-s2e1-p2", - path: "/video/Made in Abyss/Made In Abyss S02E01 Part 2.mkv", - rendering: - "6239d558696fd1cbcd70a67346e748382fe141bbe7ea01a5d702cdcc02aa996f", - part: 2, - version: 1, - createdAt: "2024-10-06T20:09:05.651996Z", - }, }, ], } satisfies SeedSerie; diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index a0b600b7..35b5f8cc 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -1,4 +1,5 @@ import { t } from "elysia"; +import { SeedEntry, SeedExtra } from "./entry"; import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; import { SeedSeason } from "./season"; import { ExternalId } from "./utils/external-id"; @@ -6,7 +7,6 @@ import { Genre } from "./utils/genres"; import { Image, SeedImage } from "./utils/image"; import { Language, TranslationRecord } from "./utils/language"; import { Resource } from "./utils/resource"; -import { SeedEntry } from "./entry"; export const SerieStatus = t.UnionEnum([ "unknown", @@ -76,7 +76,7 @@ export const SeedSerie = t.Intersect([ ), seasons: t.Array(SeedSeason), entries: t.Array(SeedEntry), - // extras: t.Optional(t.Array(SeedExtra)), + extras: t.Optional(t.Array(SeedExtra)), }), ]); export type SeedSerie = typeof SeedSerie.static; From f9554bd1288486ec4279e7cc4973018cf7657870 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 28 Jan 2025 20:56:34 +0100 Subject: [PATCH 24/30] Define seasons relations --- api/src/db/schema/entries.ts | 4 ++-- api/src/db/schema/seasons.ts | 20 ++++++++++++++++++++ api/src/db/schema/shows.ts | 14 ++++++++------ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index 58e40b4c..28b3192c 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -95,7 +95,7 @@ export const entryTranslations = schema.table( ); export const entryRelations = relations(entries, ({ one, many }) => ({ - translations: many(entryTranslations, { relationName: "entryTranslations" }), + translations: many(entryTranslations, { relationName: "entry_translations" }), evj: many(entryVideoJoin, { relationName: "evj_entry" }), show: one(shows, { relationName: "show_entries", @@ -106,7 +106,7 @@ export const entryRelations = relations(entries, ({ one, many }) => ({ export const entryTrRelations = relations(entryTranslations, ({ one }) => ({ entry: one(entries, { - relationName: "entryTranslations", + relationName: "entry_translations", fields: [entryTranslations.pk], references: [entries.pk], }), diff --git a/api/src/db/schema/seasons.ts b/api/src/db/schema/seasons.ts index a69f6da2..48e444b1 100644 --- a/api/src/db/schema/seasons.ts +++ b/api/src/db/schema/seasons.ts @@ -1,3 +1,4 @@ +import { relations } from "drizzle-orm"; import { date, index, @@ -67,3 +68,22 @@ export const seasonTranslations = schema.table( }, (t) => [primaryKey({ columns: [t.pk, t.language] })], ); + +export const seasonRelations = relations(seasons, ({ one, many }) => ({ + translations: many(seasonTranslations, { + relationName: "season_translations", + }), + show: one(shows, { + relationName: "show_seasons", + fields: [seasons.showPk], + references: [shows.pk], + }), +})); + +export const seasonTrRelations = relations(seasonTranslations, ({ one }) => ({ + season: one(seasons, { + relationName: "season_translation", + fields: [seasonTranslations.pk], + references: [seasons.pk], + }), +})); diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index 446b29a9..38b51041 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -14,6 +14,7 @@ import { } from "drizzle-orm/pg-core"; import { image, language, schema } from "./utils"; import { entries } from "./entries"; +import { seasons } from "./seasons"; export const showKind = schema.enum("show_kind", ["serie", "movie"]); export const showStatus = schema.enum("show_status", [ @@ -121,29 +122,30 @@ export const showTranslations = schema.table( export const showsRelations = relations(shows, ({ many, one }) => ({ selectedTranslation: many(showTranslations, { - relationName: "selectedTranslation", + relationName: "selected_translation", }), - translations: many(showTranslations, { relationName: "showTranslations" }), + translations: many(showTranslations, { relationName: "show_translations" }), originalTranslation: one(showTranslations, { - relationName: "originalTranslation", + relationName: "original_translation", fields: [shows.pk, shows.originalLanguage], references: [showTranslations.pk, showTranslations.language], }), entries: many(entries, { relationName: "show_entries" }), + seasons: many(seasons, { relationName: "show_seasons" }), })); export const showsTrRelations = relations(showTranslations, ({ one }) => ({ show: one(shows, { - relationName: "showTranslations", + relationName: "show_translations", fields: [showTranslations.pk], references: [shows.pk], }), selectedTranslation: one(shows, { - relationName: "selectedTranslation", + relationName: "selected_translation", fields: [showTranslations.pk], references: [shows.pk], }), originalTranslation: one(shows, { - relationName: "originalTranslation", + relationName: "original_translation", fields: [showTranslations.pk, showTranslations.language], references: [shows.pk, shows.originalLanguage], }), From 5d8d5721af8c8e3418ac27a5c69b1980466479bf Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 28 Jan 2025 20:57:16 +0100 Subject: [PATCH 25/30] Split helpers in multiples files --- api/src/{base.ts => elysia.ts} | 18 +++++++++++- api/src/index.ts | 20 ++----------- api/tests/helpers/index.ts | 5 ++++ .../{helper.ts => helpers/movies-helper.ts} | 29 +------------------ api/tests/helpers/series-helper.ts | 17 +++++++++++ api/tests/helpers/videos-helper.ts | 17 +++++++++++ .../movies/get-all-movies-with-null.test.ts | 4 +-- api/tests/movies/get-all-movies.test.ts | 2 +- api/tests/movies/get-movie.test.ts | 2 +- api/tests/movies/seed-movies.test.ts | 2 +- 10 files changed, 64 insertions(+), 52 deletions(-) rename api/src/{base.ts => elysia.ts} (61%) create mode 100644 api/tests/helpers/index.ts rename api/tests/{helper.ts => helpers/movies-helper.ts} (61%) create mode 100644 api/tests/helpers/series-helper.ts create mode 100644 api/tests/helpers/videos-helper.ts diff --git a/api/src/base.ts b/api/src/elysia.ts similarity index 61% rename from api/src/base.ts rename to api/src/elysia.ts index 0de1e4aa..68eb2cd3 100644 --- a/api/src/base.ts +++ b/api/src/elysia.ts @@ -1,4 +1,11 @@ -import Elysia from "elysia"; +import { Elysia } from "elysia"; +import { entries } from "./controllers/entries"; +import { movies } from "./controllers/movies"; +import { seasonsH } from "./controllers/seasons"; +import { seed } from "./controllers/seed"; +import { series } from "./controllers/series"; +import { videosH } from "./controllers/videos"; + import type { KError } from "./models/error"; export const base = new Elysia({ name: "base" }) @@ -30,3 +37,12 @@ export const base = new Elysia({ name: "base" }) return error; }) .as("plugin"); + +export const app = new Elysia() + .use(base) + .use(movies) + .use(series) + .use(entries) + .use(seasonsH) + .use(videosH) + .use(seed); diff --git a/api/src/index.ts b/api/src/index.ts index 348d459b..7f2d13ab 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,15 +1,7 @@ import jwt from "@elysiajs/jwt"; import { swagger } from "@elysiajs/swagger"; -import { Elysia } from "elysia"; -import { base } from "./base"; -import { entries } from "./controllers/entries"; -import { movies } from "./controllers/movies"; -import { seasonsH } from "./controllers/seasons"; -import { seed } from "./controllers/seed"; -import { series } from "./controllers/series"; -import { videosH } from "./controllers/videos"; import { migrate } from "./db"; -import { Image } from "./models/utils"; +import { app } from "./elysia"; import { comment } from "./utils"; await migrate(); @@ -31,8 +23,7 @@ if (!secret) { process.exit(1); } -const app = new Elysia() - .use(base) +app .use(jwt({ secret })) .use( swagger({ @@ -70,13 +61,6 @@ const app = new Elysia() }, }), ) - .model({ image: Image }) - .use(movies) - .use(series) - .use(entries) - .use(seasonsH) - .use(videosH) - .use(seed) .listen(3000); console.log(`Api running at ${app.server?.hostname}:${app.server?.port}`); diff --git a/api/tests/helpers/index.ts b/api/tests/helpers/index.ts new file mode 100644 index 00000000..62e4bb29 --- /dev/null +++ b/api/tests/helpers/index.ts @@ -0,0 +1,5 @@ +export * from "./movies-helper"; +export * from "./series-helper"; +export * from "./videos-helper"; + +export * from "~/elysia"; diff --git a/api/tests/helper.ts b/api/tests/helpers/movies-helper.ts similarity index 61% rename from api/tests/helper.ts rename to api/tests/helpers/movies-helper.ts index 80950c61..f2317ea6 100644 --- a/api/tests/helper.ts +++ b/api/tests/helpers/movies-helper.ts @@ -1,19 +1,6 @@ -import Elysia from "elysia"; import { buildUrl } from "tests/utils"; -import { base } from "~/base"; -import { movies } from "~/controllers/movies"; -import { seed } from "~/controllers/seed"; -import { series } from "~/controllers/series"; -import { videosH } from "~/controllers/videos"; +import { app } from "~/elysia"; import type { SeedMovie } from "~/models/movie"; -import type { SeedVideo } from "~/models/video"; - -export const app = new Elysia() - .use(base) - .use(movies) - .use(series) - .use(videosH) - .use(seed); export const getMovie = async ( id: string, @@ -72,17 +59,3 @@ export const createMovie = async (movie: SeedMovie) => { const body = await resp.json(); return [resp, body] as const; }; - -export const createVideo = async (video: SeedVideo | SeedVideo[]) => { - const resp = await app.handle( - new Request(buildUrl("videos"), { - method: "POST", - body: JSON.stringify(Array.isArray(video) ? video : [video]), - headers: { - "Content-Type": "application/json", - }, - }), - ); - const body = await resp.json(); - return [resp, body] as const; -}; diff --git a/api/tests/helpers/series-helper.ts b/api/tests/helpers/series-helper.ts new file mode 100644 index 00000000..51ab6567 --- /dev/null +++ b/api/tests/helpers/series-helper.ts @@ -0,0 +1,17 @@ +import { buildUrl } from "tests/utils"; +import { app } from "~/elysia"; +import type { SeedSerie } from "~/models/serie"; + +export const createSerie = async (serie: SeedSerie) => { + const resp = await app.handle( + new Request(buildUrl("series"), { + method: "POST", + body: JSON.stringify(serie), + headers: { + "Content-Type": "application/json", + }, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; diff --git a/api/tests/helpers/videos-helper.ts b/api/tests/helpers/videos-helper.ts new file mode 100644 index 00000000..5dd0bb70 --- /dev/null +++ b/api/tests/helpers/videos-helper.ts @@ -0,0 +1,17 @@ +import { buildUrl } from "tests/utils"; +import { app } from "~/elysia"; +import type { SeedVideo } from "~/models/video"; + +export const createVideo = async (video: SeedVideo | SeedVideo[]) => { + const resp = await app.handle( + new Request(buildUrl("videos"), { + method: "POST", + body: JSON.stringify(Array.isArray(video) ? video : [video]), + headers: { + "Content-Type": "application/json", + }, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; diff --git a/api/tests/movies/get-all-movies-with-null.test.ts b/api/tests/movies/get-all-movies-with-null.test.ts index 893c4612..5e03cb56 100644 --- a/api/tests/movies/get-all-movies-with-null.test.ts +++ b/api/tests/movies/get-all-movies-with-null.test.ts @@ -6,7 +6,7 @@ import { shows } from "~/db/schema"; import { bubble } from "~/models/examples"; import { dune1984 } from "~/models/examples/dune-1984"; import { dune } from "~/models/examples/dune-2021"; -import { app, createMovie, getMovies } from "../helper"; +import { app, createMovie, getMovies } from "../helpers"; beforeAll(async () => { await db.delete(shows); @@ -14,7 +14,7 @@ beforeAll(async () => { }); describe("with a null value", () => { - // Those before/after hooks are NOT scopped to the describe due to a bun bug + // Those before/after hooks are NOT scoped to the describe due to a bun bug // instead we just make a new file for those /shrug // see: https://github.com/oven-sh/bun/issues/5738 beforeAll(async () => { diff --git a/api/tests/movies/get-all-movies.test.ts b/api/tests/movies/get-all-movies.test.ts index a10c2f56..c15a6548 100644 --- a/api/tests/movies/get-all-movies.test.ts +++ b/api/tests/movies/get-all-movies.test.ts @@ -8,7 +8,7 @@ import { dune1984 } from "~/models/examples/dune-1984"; import { dune } from "~/models/examples/dune-2021"; import type { Movie } from "~/models/movie"; import { isUuid } from "~/models/utils"; -import { getMovies, app } from "../helper"; +import { getMovies, app } from "../helpers"; beforeAll(async () => { await db.delete(shows); diff --git a/api/tests/movies/get-movie.test.ts b/api/tests/movies/get-movie.test.ts index 675f1f70..a0a3f14a 100644 --- a/api/tests/movies/get-movie.test.ts +++ b/api/tests/movies/get-movie.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { expectStatus } from "tests/utils"; import { seedMovie } from "~/controllers/seed/movies"; import { bubble } from "~/models/examples"; -import { getMovie } from "../helper"; +import { getMovie } from "../helpers"; let bubbleId = ""; diff --git a/api/tests/movies/seed-movies.test.ts b/api/tests/movies/seed-movies.test.ts index 82d5086b..34e721de 100644 --- a/api/tests/movies/seed-movies.test.ts +++ b/api/tests/movies/seed-movies.test.ts @@ -5,7 +5,7 @@ import { db } from "~/db"; import { showTranslations, shows, videos } from "~/db/schema"; import { bubble } from "~/models/examples"; import { dune, duneVideo } from "~/models/examples/dune-2021"; -import { createMovie, createVideo } from "../helper"; +import { createMovie, createVideo } from "../helpers"; describe("Movie seeding", () => { it("Can create a movie", async () => { From 32a1e89b272e065a99dbf38d872e8f5c711365e5 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 28 Jan 2025 21:28:36 +0100 Subject: [PATCH 26/30] Add insert seasons --- api/src/controllers/seed/insert/seasons.ts | 61 ++++++++++++++++++++++ api/src/controllers/seed/series.ts | 9 ++++ api/src/models/entry/base-entry.ts | 2 +- api/src/models/examples/bubble.ts | 4 +- api/src/models/examples/made-in-abyss.ts | 18 +++++++ api/src/models/season.ts | 1 + api/src/models/video.ts | 22 ++++---- api/tests/series/seed-serie.test.ts | 35 +++++++++++++ biome.json | 1 + 9 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 api/src/controllers/seed/insert/seasons.ts create mode 100644 api/tests/series/seed-serie.test.ts diff --git a/api/src/controllers/seed/insert/seasons.ts b/api/src/controllers/seed/insert/seasons.ts new file mode 100644 index 00000000..56d649ae --- /dev/null +++ b/api/src/controllers/seed/insert/seasons.ts @@ -0,0 +1,61 @@ +import { db } from "~/db"; +import { seasonTranslations, seasons } from "~/db/schema"; +import { conflictUpdateAllExcept } from "~/db/utils"; +import type { SeedSeason } from "~/models/season"; +import { processOptImage } from "../images"; +import { guessNextRefresh } from "../refresh"; + +type SeasonI = typeof seasons.$inferInsert; +type SeasonTransI = typeof seasonTranslations.$inferInsert; + +export const insertSeasons = async ( + show: { pk: number; slug: string }, + items: SeedSeason[], +) => { + return db.transaction(async (tx) => { + const vals: SeasonI[] = items.map((x) => { + const { translations, ...season } = x; + return { + ...season, + showPk: show.pk, + slug: `${show.slug}-s${season.seasonNumber}`, + nextRefresh: guessNextRefresh(season.startAir ?? new Date()), + }; + }); + const ret = await tx + .insert(seasons) + .values(vals) + .onConflictDoUpdate({ + target: seasons.slug, + set: conflictUpdateAllExcept(seasons, [ + "pk", + "showPk", + "id", + "slug", + "createdAt", + ]), + }) + .returning({ pk: seasons.pk, id: seasons.id, slug: seasons.slug }); + + const trans: SeasonTransI[] = items.flatMap((seed, i) => + Object.entries(seed.translations).map(([lang, tr]) => ({ + // assumes ret is ordered like items. + pk: ret[i].pk, + language: lang, + ...tr, + poster: processOptImage(tr.poster), + thumbnail: processOptImage(tr.thumbnail), + banner: processOptImage(tr.banner), + })), + ); + await tx + .insert(seasonTranslations) + .values(trans) + .onConflictDoUpdate({ + target: [seasonTranslations.pk, seasonTranslations.language], + set: conflictUpdateAllExcept(seasonTranslations, ["pk", "language"]), + }); + + return ret; + }); +}; diff --git a/api/src/controllers/seed/series.ts b/api/src/controllers/seed/series.ts index 3bf8dd9b..349a7101 100644 --- a/api/src/controllers/seed/series.ts +++ b/api/src/controllers/seed/series.ts @@ -4,10 +4,17 @@ import { getYear } from "~/utils"; import { insertEntries } from "./insert/entries"; import { insertShow } from "./insert/shows"; import { guessNextRefresh } from "./refresh"; +import { insertSeasons } from "./insert/seasons"; export const SeedSerieResponse = t.Object({ id: t.String({ format: "uuid" }), slug: t.String({ format: "slug", examples: ["made-in-abyss"] }), + seasons: t.Array( + t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug", examples: ["made-in-abyss-s1"] }), + }), + ), entries: t.Array( t.Object({ id: t.String({ format: "uuid" }), @@ -55,12 +62,14 @@ export const seedSerie = async ( ); if ("status" in show) return show; + const retSeasons = await insertSeasons(show, seasons); const retEntries = await insertEntries(show, entries); return { updated: show.updated, id: show.id, slug: show.slug, + seasons: retSeasons, entries: retEntries, }; }; diff --git a/api/src/models/entry/base-entry.ts b/api/src/models/entry/base-entry.ts index dc7a880c..f848f6e3 100644 --- a/api/src/models/entry/base-entry.ts +++ b/api/src/models/entry/base-entry.ts @@ -2,7 +2,7 @@ import { t } from "elysia"; import { Image } from "../utils/image"; export const BaseEntry = t.Object({ - airDate: t.Nullable(t.String({ format: "data" })), + airDate: t.Nullable(t.String({ format: "date" })), runtime: t.Nullable( t.Number({ minimum: 0, description: "Runtime of the episode in minutes" }), ), diff --git a/api/src/models/examples/bubble.ts b/api/src/models/examples/bubble.ts index 3b816d9c..0246333c 100644 --- a/api/src/models/examples/bubble.ts +++ b/api/src/models/examples/bubble.ts @@ -1,5 +1,5 @@ -import type { SeedMovie } from "../movie"; -import type { Video } from "../video"; +import type { SeedMovie } from "~/models/movie"; +import type { Video } from "~/models/video"; export const bubbleVideo: Video = { id: "3cd436ee-01ff-4f45-ba98-62aabeb22f25", diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts index c5cd652b..129008df 100644 --- a/api/src/models/examples/made-in-abyss.ts +++ b/api/src/models/examples/made-in-abyss.ts @@ -1,4 +1,22 @@ import type { SeedSerie } from "~/models/serie"; +import type { Video } from "~/models/video"; + +export const madeInAbyssVideo: Video = { + id: "3cd436ee-01ff-4f45-ba98-654282531234", + slug: "made-in-abyss-s1e1", + path: "/video/Made in abyss S01E01.mkv", + rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd", + part: null, + version: 1, + guess: { + title: "Made in abyss", + season: [1], + episode: [1], + type: "episode", + from: "guessit", + }, + createdAt: "2024-11-23T15:01:24.968Z", +}; export const madeInAbyss = { slug: "made-in-abyss", diff --git a/api/src/models/season.ts b/api/src/models/season.ts index 4066b0c1..4777a30f 100644 --- a/api/src/models/season.ts +++ b/api/src/models/season.ts @@ -45,6 +45,7 @@ export const SeedSeason = t.Intersect([ ), }), ]); +export type SeedSeason = typeof SeedSeason.static; registerExamples(Season, { ...madeInAbyss.seasons[0], diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 85c5057a..a57bbb6e 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -37,22 +37,24 @@ export const Video = t.Object({ t.Object( { title: t.String(), - year: t.Array(t.Integer(), { default: [] }), - season: t.Array(t.Integer(), { default: [] }), - episode: t.Array(t.Integer(), { default: [] }), + year: t.Optional(t.Array(t.Integer(), { default: [] })), + season: t.Optional(t.Array(t.Integer(), { default: [] })), + episode: t.Optional(t.Array(t.Integer(), { default: [] })), // TODO: maybe replace "extra" with the `extraKind` value (aka behind-the-scene, trailer, etc) type: t.Optional(t.UnionEnum(["episode", "movie", "extra"])), from: t.String({ description: "Name of the tool that made the guess", }), - history: t.Array(t.Omit(Self, ["history"]), { - default: [], - description: comment` - When another tool refines the guess or a user manually edit it, the history of the guesses - are kept in this \`history\` value. - `, - }), + history: t.Optional( + t.Array(t.Omit(Self, ["history"]), { + default: [], + description: comment` + When another tool refines the guess or a user manually edit it, the history of the guesses + are kept in this \`history\` value. + `, + }), + ), }, { additionalProperties: true, diff --git a/api/tests/series/seed-serie.test.ts b/api/tests/series/seed-serie.test.ts new file mode 100644 index 00000000..976b57bf --- /dev/null +++ b/api/tests/series/seed-serie.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; +import { expectStatus } from "tests/utils"; +import { db } from "~/db"; +import { seasons, shows, videos } from "~/db/schema"; +import { madeInAbyss, madeInAbyssVideo } from "~/models/examples"; +import { createSerie } from "../helpers"; + +describe("Serie seeding", () => { + it("Can create a serie with seasons and episodes", async () => { + // create video beforehand to test linking + await db.insert(videos).values(madeInAbyssVideo); + const [resp, body] = await createSerie(madeInAbyss); + + expectStatus(resp, body).toBe(201); + expect(body.id).toBeString(); + expect(body.slug).toBe("made-in-abyss"); + + const ret = await db.query.shows.findFirst({ + where: eq(shows.id, body.id), + with: { + seasons: { orderBy: seasons.seasonNumber }, + entries: true, + }, + }); + + expect(ret).not.toBeNull(); + expect(ret!.seasons).toBeArrayOfSize(2); + expect(ret!.seasons[0].slug).toBe("made-in-abyss-s1"); + expect(ret!.seasons[1].slug).toBe("made-in-abyss-s2"); + // expect(ret!.entries).toBeArrayOfSize( + // madeInAbyss.entries.length + madeInAbyss.extras.length, + // ); + }); +}); diff --git a/biome.json b/biome.json index 84dde2d8..897f520d 100644 --- a/biome.json +++ b/biome.json @@ -2,6 +2,7 @@ "$schema": "https://biomejs.dev/schemas/1.8.1/schema.json", "formatter": { "enabled": true, + "formatWithErrors": true, "indentStyle": "tab", "indentWidth": 2, "lineEnding": "lf", From 4424e9b40a6525c09f3b85c06bc8ba946c321a0b Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 30 Jan 2025 18:54:30 +0100 Subject: [PATCH 27/30] Handle extra seeding --- api/src/controllers/seed/insert/entries.ts | 72 +++++++++++++++++----- api/src/controllers/seed/series.ts | 15 ++++- api/src/models/entry/extra.ts | 3 +- api/src/models/examples/made-in-abyss.ts | 2 + api/tests/series/seed-serie.test.ts | 6 +- 5 files changed, 75 insertions(+), 23 deletions(-) diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 45c9a352..eecb5e2d 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -7,13 +7,26 @@ import { videos, } from "~/db/schema"; import { conflictUpdateAllExcept, values } from "~/db/utils"; -import type { SeedEntry } from "~/models/entry"; +import type { SeedEntry as SEntry, SeedExtra as SExtra } from "~/models/entry"; import { processOptImage } from "../images"; import { guessNextRefresh } from "../refresh"; +type SeedEntry = SEntry & { + video?: undefined; +}; +type SeedExtra = Omit & { + videos?: undefined; + translations?: undefined; + kind: "extra"; + extraKind: SExtra["kind"]; +}; + type EntryI = typeof entries.$inferInsert; -const generateSlug = (showSlug: string, entry: SeedEntry): string => { +const generateSlug = ( + showSlug: string, + entry: SeedEntry | SeedExtra, +): string => { switch (entry.kind) { case "episode": return `${showSlug}-s${entry.seasonNumber}e${entry.episodeNumber}`; @@ -22,22 +35,29 @@ const generateSlug = (showSlug: string, entry: SeedEntry): string => { case "movie": if (entry.slug) return entry.slug; return entry.order === 1 ? showSlug : `${showSlug}-${entry.order}`; + case "extra": + return entry.slug; } }; export const insertEntries = async ( show: { pk: number; slug: string }, - items: SeedEntry[], + items: (SeedEntry | SeedExtra)[], ) => { + if (!items) return []; + const retEntries = await db.transaction(async (tx) => { const vals: EntryI[] = items.map((seed) => { - const { translations, videos, ...entry } = seed; + const { translations, videos, video, ...entry } = seed; return { ...entry, showPk: show.pk, slug: generateSlug(show.slug, seed), thumbnail: processOptImage(seed.thumbnail), - nextRefresh: guessNextRefresh(entry.airDate ?? new Date()), + nextRefresh: + entry.kind !== "extra" + ? guessNextRefresh(entry.airDate ?? new Date()) + : guessNextRefresh(new Date()), episodeNumber: entry.kind === "episode" ? entry.episodeNumber @@ -61,14 +81,25 @@ export const insertEntries = async ( }) .returning({ pk: entries.pk, id: entries.id, slug: entries.slug }); - const trans = items.flatMap((seed, i) => - Object.entries(seed.translations).map(([lang, tr]) => ({ + const trans = items.flatMap((seed, i) => { + if (seed.kind === "extra") { + return { + pk: ret[i].pk, + // yeah we hardcode the language to extra because if we want to support + // translations one day it won't be awkward + language: "extra", + name: seed.name, + description: null, + }; + } + + return Object.entries(seed.translations).map(([lang, tr]) => ({ // assumes ret is ordered like items. pk: ret[i].pk, language: lang, ...tr, - })), - ); + })); + }); await tx .insert(entryTranslations) .values(trans) @@ -80,15 +111,22 @@ export const insertEntries = async ( return ret; }); - const vids = items.flatMap( - (seed, i) => - seed.videos?.map((x, j) => ({ - videoId: x, + const vids = items.flatMap((seed, i) => { + if (seed.kind === "extra") { + return { + videoId: seed.video, entryPk: retEntries[i].pk, - // The first video should not have a rendering. - needRendering: j && seed.videos!.length > 1, - })) ?? [], - ); + needRendering: false, + }; + } + if (!seed.videos) return []; + return seed.videos.map((x, j) => ({ + videoId: x, + entryPk: retEntries[i].pk, + // The first video should not have a rendering. + needRendering: j && seed.videos!.length > 1, + })); + }); if (vids.length === 0) return retEntries.map((x) => ({ id: x.id, slug: x.slug, videos: [] })); diff --git a/api/src/controllers/seed/series.ts b/api/src/controllers/seed/series.ts index 349a7101..c8c2667d 100644 --- a/api/src/controllers/seed/series.ts +++ b/api/src/controllers/seed/series.ts @@ -2,9 +2,9 @@ import { t } from "elysia"; import type { SeedSerie } from "~/models/serie"; import { getYear } from "~/utils"; import { insertEntries } from "./insert/entries"; +import { insertSeasons } from "./insert/seasons"; import { insertShow } from "./insert/shows"; import { guessNextRefresh } from "./refresh"; -import { insertSeasons } from "./insert/seasons"; export const SeedSerieResponse = t.Object({ id: t.String({ format: "uuid" }), @@ -29,6 +29,12 @@ export const SeedSerieResponse = t.Object({ ), }), ), + extras: t.Array( + t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug", examples: ["made-in-abyss-s1e1"] }), + }), + ), }); export type SeedSerieResponse = typeof SeedSerieResponse.static; @@ -49,7 +55,7 @@ export const seedSerie = async ( seed.slug = `random-${getYear(seed.startAir)}`; } - const { translations, seasons, entries, ...serie } = seed; + const { translations, seasons, entries, extras, ...serie } = seed; const nextRefresh = guessNextRefresh(serie.startAir ?? new Date()); const show = await insertShow( @@ -64,6 +70,10 @@ export const seedSerie = async ( const retSeasons = await insertSeasons(show, seasons); const retEntries = await insertEntries(show, entries); + const retExtras = await insertEntries( + show, + (extras ?? []).map((x) => ({ ...x, kind: "extra", extraKind: x.kind })), + ); return { updated: show.updated, @@ -71,5 +81,6 @@ export const seedSerie = async ( slug: show.slug, seasons: retSeasons, entries: retEntries, + extras: retExtras, }; }; diff --git a/api/src/models/entry/extra.ts b/api/src/models/entry/extra.ts index 3d4c5b2f..5d002976 100644 --- a/api/src/models/entry/extra.ts +++ b/api/src/models/entry/extra.ts @@ -36,8 +36,9 @@ export type Extra = typeof Extra.static; export const SeedExtra = t.Intersect([ t.Omit(BaseExtra, ["thumbnail", "createdAt"]), t.Object({ + slug: t.String({ format: "slug" }), thumbnail: t.Nullable(SeedImage), - videos: t.Optional(t.Array(t.String({ format: "uuid" }))), + video: t.String({ format: "uuid" }), }), ]); export type SeedExtra = typeof SeedExtra.static; diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts index 129008df..37e679d9 100644 --- a/api/src/models/examples/made-in-abyss.ts +++ b/api/src/models/examples/made-in-abyss.ts @@ -237,9 +237,11 @@ export const madeInAbyss = { extras: [ { kind: "behind-the-scene", + slug: "made-in-abyss-making-of", name: "The Making of MADE IN ABYSS 01", runtime: 17, thumbnail: null, + video: "3cd436ee-01ff-4f45-ba98-654282531234", }, ], } satisfies SeedSerie; diff --git a/api/tests/series/seed-serie.test.ts b/api/tests/series/seed-serie.test.ts index 976b57bf..37614b69 100644 --- a/api/tests/series/seed-serie.test.ts +++ b/api/tests/series/seed-serie.test.ts @@ -28,8 +28,8 @@ describe("Serie seeding", () => { expect(ret!.seasons).toBeArrayOfSize(2); expect(ret!.seasons[0].slug).toBe("made-in-abyss-s1"); expect(ret!.seasons[1].slug).toBe("made-in-abyss-s2"); - // expect(ret!.entries).toBeArrayOfSize( - // madeInAbyss.entries.length + madeInAbyss.extras.length, - // ); + expect(ret!.entries).toBeArrayOfSize( + madeInAbyss.entries.length + madeInAbyss.extras.length, + ); }); }); From 30bf2d9207ae610b6e0b7bc0f6a54883e01b962a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 30 Jan 2025 19:32:09 +0100 Subject: [PATCH 28/30] Test entries seeding --- api/tests/series/seed-serie.test.ts | 55 ++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/api/tests/series/seed-serie.test.ts b/api/tests/series/seed-serie.test.ts index 37614b69..d3a0587e 100644 --- a/api/tests/series/seed-serie.test.ts +++ b/api/tests/series/seed-serie.test.ts @@ -20,7 +20,7 @@ describe("Serie seeding", () => { where: eq(shows.id, body.id), with: { seasons: { orderBy: seasons.seasonNumber }, - entries: true, + entries: { with: { translations: true } }, }, }); @@ -31,5 +31,58 @@ describe("Serie seeding", () => { expect(ret!.entries).toBeArrayOfSize( madeInAbyss.entries.length + madeInAbyss.extras.length, ); + + const ep13 = madeInAbyss.entries.find((x) => x.order === 13)!; + expect(ret!.entries.find((x) => x.order === 13)).toMatchObject({ + ...ep13, + slug: "made-in-abyss-s1e13", + thumbnail: { source: ep13.thumbnail }, + translations: [ + { + language: "en", + ...ep13.translations.en, + }, + ], + }); + + const { number, ...special } = madeInAbyss.entries.find( + (x) => x.kind === "special", + )!; + expect(ret!.entries.find((x) => x.kind === "special")).toMatchObject({ + ...special, + slug: "made-in-abyss-sp3", + episodeNumber: number, + thumbnail: { source: special.thumbnail }, + translations: [ + { + language: "en", + ...special.translations.en, + }, + ], + }); + + const movie = madeInAbyss.entries.find((x) => x.kind === "movie")!; + expect(ret!.entries.find((x) => x.kind === "movie")).toMatchObject({ + ...movie, + thumbnail: { source: movie.thumbnail }, + translations: [ + { + language: "en", + ...movie.translations.en, + }, + ], + }); + + const { name, video, kind, ...extra } = madeInAbyss.extras[0]; + expect(ret!.entries.find((x) => x.kind === "extra")).toMatchObject({ + ...extra, + extraKind: kind, + translations: [ + { + language: "extra", + name, + }, + ], + }); }); }); From b16c2374c402037feb5fdc547d0d97cdd94fe039 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 30 Jan 2025 20:03:35 +0100 Subject: [PATCH 29/30] Format stuff --- api/src/db/schema/shows.ts | 2 +- api/src/db/utils.ts | 2 +- api/src/models/entry/index.ts | 4 ++-- api/src/models/entry/special.ts | 2 +- api/src/models/season.ts | 2 +- api/tests/movies/get-all-movies.test.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index 38b51041..6745eed0 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -12,9 +12,9 @@ import { uuid, varchar, } from "drizzle-orm/pg-core"; -import { image, language, schema } from "./utils"; import { entries } from "./entries"; import { seasons } from "./seasons"; +import { image, language, schema } from "./utils"; export const showKind = schema.enum("show_kind", ["serie", "movie"]); export const showStatus = schema.enum("show_status", [ diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index d71296e1..f2a4947a 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -71,7 +71,7 @@ export function sqlarr(array: unknown[]) { return `{${array.map((item) => `"${item}"`).join(",")}}`; } -// TODO: upstream this +// See https://github.com/drizzle-team/drizzle-orm/issues/4044 // TODO: type values (everything is a `text` for now) export function values(items: Record[]) { const [firstProp, ...props] = Object.keys(items[0]); diff --git a/api/src/models/entry/index.ts b/api/src/models/entry/index.ts index 73e73e6c..56b9b9f9 100644 --- a/api/src/models/entry/index.ts +++ b/api/src/models/entry/index.ts @@ -1,11 +1,11 @@ import { t } from "elysia"; import { Episode, - SeedEpisode, MovieEntry, + SeedEpisode, SeedMovieEntry, - Special, SeedSpecial, + Special, } from "../entry"; export const Entry = t.Union([Episode, MovieEntry, Special]); diff --git a/api/src/models/entry/special.ts b/api/src/models/entry/special.ts index 56302cd5..188c8b3c 100644 --- a/api/src/models/entry/special.ts +++ b/api/src/models/entry/special.ts @@ -1,7 +1,7 @@ import { t } from "elysia"; import { comment } from "../../utils"; -import { BaseEntry, EntryTranslation } from "./base-entry"; import { EpisodeId, Resource, SeedImage, TranslationRecord } from "../utils"; +import { BaseEntry, EntryTranslation } from "./base-entry"; export const BaseSpecial = t.Intersect( [ diff --git a/api/src/models/season.ts b/api/src/models/season.ts index 4777a30f..d5c032eb 100644 --- a/api/src/models/season.ts +++ b/api/src/models/season.ts @@ -1,9 +1,9 @@ import { t } from "elysia"; +import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; import { SeasonId } from "./utils/external-id"; import { Image, SeedImage } from "./utils/image"; import { TranslationRecord } from "./utils/language"; import { Resource } from "./utils/resource"; -import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; export const BaseSeason = t.Object({ seasonNumber: t.Number({ minimum: 1 }), diff --git a/api/tests/movies/get-all-movies.test.ts b/api/tests/movies/get-all-movies.test.ts index c15a6548..b908c216 100644 --- a/api/tests/movies/get-all-movies.test.ts +++ b/api/tests/movies/get-all-movies.test.ts @@ -8,7 +8,7 @@ import { dune1984 } from "~/models/examples/dune-1984"; import { dune } from "~/models/examples/dune-2021"; import type { Movie } from "~/models/movie"; import { isUuid } from "~/models/utils"; -import { getMovies, app } from "../helpers"; +import { app, getMovies } from "../helpers"; beforeAll(async () => { await db.delete(shows); From b6dff90ccd4f2e03850d63690cd728fd3a586dc4 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 30 Jan 2025 20:07:04 +0100 Subject: [PATCH 30/30] Disable biome lint --- api/src/controllers/videos.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 20c55628..5462d3ae 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -42,6 +42,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) return error(201, oldRet); // TODO: this is a huge untested wip + // biome-ignore lint/correctness/noUnreachable: leave me alone const vidsI = db.$with("vidsI").as( db.insert(videos).values(body).onConflictDoNothing().returning({ pk: videos.pk,