From 9e4076e1ea7c60d012433efb80dcb21cde1b90de Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 19 Oct 2024 19:41:04 +0200 Subject: [PATCH 001/105] Elysia init --- api/.gitignore | 42 ++++++++++++++++++++++++++++++++++++++++++ api/bun.lockb | Bin 0 -> 4196 bytes api/package.json | 17 +++++++++++++++++ api/src/index.ts | 5 +++++ api/tsconfig.json | 14 ++++++++++++++ shell.nix | 1 + 6 files changed, 79 insertions(+) create mode 100644 api/.gitignore create mode 100755 api/bun.lockb create mode 100644 api/package.json create mode 100644 api/src/index.ts create mode 100644 api/tsconfig.json diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 000000000..87e56100f --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +**/*.trace +**/*.zip +**/*.tar.gz +**/*.tgz +**/*.log +package-lock.json +**/*.bun \ No newline at end of file diff --git a/api/bun.lockb b/api/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..327eb02f57c5df901af922542bce2c702f3f5fdf GIT binary patch literal 4196 zcmd^CYfw{16uya}YE)2*)cOomWfXI75<);80YO?T9TibjP-;jna4{q?_XY%8p;nPm z5%JMdEJY9>qqa&_Y<*H4d}FmnEv@3KR`E4D)$vh&8eYo-zz14SFsC5aY;7>ZPH)J{!e^t3d|Xin2`(sY?vND#zhfA293?2%k6 z^JrUqUT*DYm+w)vp{FJeUe$E>$7}b7SlwU{h}f=(c7M@1d^-^jAT%b%_8+a!WEl-X zOoG;%1d$U3x(jGE=uV)uM&ne5whblPL;JLVAUc5V31)Q!T?=jWzr=q*)~Flv&P1%Z z{IEWxZcgWjybVY9mAm&X5nfPs-E!6K{PN54g;O$?oUHgo9&^iO#?9o~yskIA3aPmK z0b4_cv^NItzaCw@W95;e%+k~!7@&PAL0&@wwh?|{AL2vB1Bx?(@!2pqj>jXMvvy&8 z9bm!%kFXLbUZ~=PV0=%QFaq%4YunsyoWP6 z{1*Wf2zYnEp*6VvL%cT-4F$Xl;Ceto+8M#~r$7a*ADswUL(2z!LB>yg7qgx{;w$s1-FYpNnJFg?PsASR1HRB|b2%+SBc+Bu~*LBJH3u~(8 z*OvxtJToFJB)a?j8}isRDynAb7g>|20Sh+mK1}#7?;uamRg70W^!e6-7x@5-Xu?`? zoNGc%&l5Vm$DR1|X=T3)xU#yvjrHpHWqPY@aIC20-1zhz@u7;krrQzkY-_t}b=mO; zW#sgHWk~O_2fZA49nXbmYI?@u)BPLzHr^gT?Z8O4p!hz;?|CfCezGdCa?_K5b(ea1 znrcFdMzZ57?`}9<9I0wJAgAYh|69XB z)`8bCjzuxMCLL0(-rhTW*zwYeq`<2DouBrst-kwBkDE6`qR_Y>GT+j{;J$UrP$DJK`aoi(!h$;{5 zyJ}^ogj)7b_ZceOzi8CNuONUeBd?M$2jhh5J}_K@p>D zqyEGtV_KX~u>&uDscgIhMlT4uTE>No*F{fTQrP{ZrzFg8J?f&l52@}4vL=4J{=x3D| z0`IFTukh0U=Ucv(e}Z=MS{%%pwIpL;IgMUV;zW}iYo=+nL?M%~OcrfS8Z1#tGLbN7LBuC=#eW(Iq6G8fqf{?#`&>OAR~Phq_46$ z6UP}e!jLjjP7oHrfa`Ng`{xYynL{VK3)CSJKj@R;EF5RgLcz=c(vOtO2%N9uJR5ox zK&BuAa0ZVvZ|LzO6)IBsH<&>%vrXWWz)?RNQ!Jg9PMgWd=^Sk~X!J3hnK2}*CGtO= z9nkD-8P9YSf4KyseUZO#ThPZ5muaHe6pk~offQw?lNpvXXOad}S}IE#&B>H~hbSpu zk;=i-3I)j}XBkbj0ls+b3}Gx|(CRgenL>n##_35KmXqpetx-oyEoMflHKv)22HL>c zCe)f46W7A5FGPbOFYE)`U>A^SEigZ4C@}ISOZjMNh2fcX1Nxb^)E9l-dH{ua#9bTMg(8R%$z7w&>!|#D7lx0p6r4rvLx| literal 0 HcmV?d00001 diff --git a/api/package.json b/api/package.json new file mode 100644 index 000000000..8fc08dd11 --- /dev/null +++ b/api/package.json @@ -0,0 +1,17 @@ +{ + "name": "api", + "version": "1.0.50", + "scripts": { + "dev": "bun --watch src/index.ts", + "build": "bun build src/index.ts --target bun --outdir ./dist", + "start": "NODE_ENV=production bun dist/index.js", + "test": "bun test" + }, + "dependencies": { + "elysia": "latest" + }, + "devDependencies": { + "bun-types": "latest" + }, + "module": "src/index.js" +} diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 000000000..8f28d3686 --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,5 @@ +import { Elysia } from "elysia"; + +const app = new Elysia().get("/", () => "Hello Elysia").listen(3000); + +console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`); diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 000000000..e03f1d367 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ES2022", + "moduleResolution": "node", + "types": [ + "bun-types" + ], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/shell.nix b/shell.nix index 22e5f9c8b..7e882007e 100644 --- a/shell.nix +++ b/shell.nix @@ -42,6 +42,7 @@ in sqlc go-swag robotframework-tidy + bun ]; DOTNET_ROOT = "${dotnet}"; From ad8673a46ff879e666f43c91fee276fb530ca788 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 24 Oct 2024 21:33:56 +0200 Subject: [PATCH 002/105] Setup elysia swagger --- api/.gitignore | 44 ++-------------------------------- api/src/controllers/entries.ts | 2 ++ api/src/index.ts | 8 +++++-- 3 files changed, 10 insertions(+), 44 deletions(-) create mode 100644 api/src/controllers/entries.ts diff --git a/api/.gitignore b/api/.gitignore index 87e56100f..ef720b3f4 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -1,42 +1,2 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env.local -.env.development.local -.env.test.local -.env.production.local - -# vercel -.vercel - -**/*.trace -**/*.zip -**/*.tar.gz -**/*.tgz -**/*.log -package-lock.json -**/*.bun \ No newline at end of file +node_modules +**/*.bun diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts new file mode 100644 index 000000000..18d659cf3 --- /dev/null +++ b/api/src/controllers/entries.ts @@ -0,0 +1,2 @@ +export const EntriesController = new Elysia() + .get('/entries', () => "hello"); diff --git a/api/src/index.ts b/api/src/index.ts index 8f28d3686..ed69c0b74 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,5 +1,9 @@ import { Elysia } from "elysia"; +import { swagger } from "@elysiajs/swagger"; -const app = new Elysia().get("/", () => "Hello Elysia").listen(3000); +const app = new Elysia() + .use(swagger()) + .get("/", () => "Hello Elysia") + .listen(3000); -console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`); +console.log(`Api running at ${app.server?.hostname}:${app.server?.port}`); From 7eaf1e172927f865472a5ab6b4a2edb3d2f70be6 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 24 Oct 2024 21:34:10 +0200 Subject: [PATCH 003/105] Setup drizzle --- api/bun.lockb | Bin 4196 -> 18123 bytes api/package.json | 6 +++++- api/src/db/index.ts | 9 +++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 api/src/db/index.ts diff --git a/api/bun.lockb b/api/bun.lockb index 327eb02f57c5df901af922542bce2c702f3f5fdf..41765e9fe95e68821ce4b567950ddae3f86de8c7 100755 GIT binary patch literal 18123 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 cJ=_jr>xm3c$k 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 VZP#J5)%^(dtFX$4wEus9{|DPk9=ZSk delta 847 zcmb7CUr3Wt6uUmiMDGH&7-mHY`C4va%Ai`Gq5XFM59zrm%g6!OFUr!Nq;PAV@@BZ$&=iKj?pP(OX zlj4paU9E>dCG+XU`1kpvqt2?Q=#T)R8t{@M26$# zWo&3P8IKTh4A~Y!E(HlI;<=Sz3SFyL6ecp~y%qIRZAfg-K-EXY$F8X`W&4 zyoP%p8Fh*tx9IWy@zkck_}zu4w;C87aEVUQMM#Q3#%;35=DD5Ej@~%^GS28lzt}0d zoTNzMh0i3(E>kCZo_#))dxU~c(d`qxD5&^w$`PJux!>E(Xgf|&4zz6rVV}QGorOeT>)d?L6$(H1eg)6|>~GUkuW Date: Thu, 24 Oct 2024 21:34:20 +0200 Subject: [PATCH 004/105] Add entries sql setup --- api/src/db/schema.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 api/src/db/schema.ts diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts new file mode 100644 index 000000000..84ef301bc --- /dev/null +++ b/api/src/db/schema.ts @@ -0,0 +1,53 @@ +import { sql } from "drizzle-orm"; +import { + check, + date, + integer, + jsonb, + pgEnum, + pgTable, + primaryKey, + text, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +export const entryType = pgEnum("entry_type", ["unknown", "episode", "movie", "special", "extra"]); + +export const entries = pgTable( + "entries", + { + pk: integer().primaryKey().generatedAlwaysAsIdentity(), + id: uuid().notNull().unique().defaultRandom(), + slug: varchar({ length: 255 }).notNull().unique(), + // showId: integer().references(() => show.id), + order: integer().notNull(), + seasonNumber: integer(), + episodeNumber: integer(), + type: entryType().notNull(), + airDate: date(), + runtime: integer(), + thumbnails: jsonb(), + nextRefresh: date(), + externalId: jsonb().notNull().default({}), + }, + (t) => ({ + // episodeKey: unique().on(t.showId, t.seasonNumber, t.episodeNumber), + orderPositive: check("orderPositive", sql`${t.order} >= 0`), + }), +); + +export const entriesTranslation = pgTable( + "entries_translation", + { + pk: integer() + .notNull() + .references(() => entries.id), + language: varchar({ length: 255 }).notNull(), + name: text(), + description: text(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.pk, t.language] }), + }), +); From e0704458ee2150ce3cc109b916b54a9c913349cd Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 24 Oct 2024 21:56:16 +0200 Subject: [PATCH 005/105] Adding migrations boilerplate --- api/bun.lockb | Bin 18123 -> 39931 bytes api/drizzle.config.ts | 10 ++++++++++ api/package.json | 1 + api/src/index.ts | 11 +++++++++++ 4 files changed, 22 insertions(+) create mode 100644 api/drizzle.config.ts diff --git a/api/bun.lockb b/api/bun.lockb index 41765e9fe95e68821ce4b567950ddae3f86de8c7..355cc64aa2eb108134dbc1a5405c8eec3ddad1db 100755 GIT binary patch literal 39931 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 UR7bbMO+(EqTg*fAx4vTf9e3;I Date: Sun, 27 Oct 2024 21:20:05 +0100 Subject: [PATCH 006/105] Fix schema and add pgSchema --- api/drizzle.config.ts | 3 +++ api/src/db/schema.ts | 22 +++++++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/api/drizzle.config.ts b/api/drizzle.config.ts index 147548b8b..a79475017 100644 --- a/api/drizzle.config.ts +++ b/api/drizzle.config.ts @@ -7,4 +7,7 @@ export default defineConfig({ dbCredentials: { url: process.env.DATABASE_URL!, }, + migrations: { + schema: "kyoo", + }, }); diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 84ef301bc..8515f39a6 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -4,17 +4,25 @@ import { date, integer, jsonb, - pgEnum, - pgTable, + pgSchema, primaryKey, text, + timestamp, uuid, varchar, } from "drizzle-orm/pg-core"; -export const entryType = pgEnum("entry_type", ["unknown", "episode", "movie", "special", "extra"]); +const schema = pgSchema("kyoo"); -export const entries = pgTable( +export const entryType = schema.enum("entry_type", [ + "unknown", + "episode", + "movie", + "special", + "extra", +]); + +export const entries = schema.table( "entries", { pk: integer().primaryKey().generatedAlwaysAsIdentity(), @@ -28,7 +36,7 @@ export const entries = pgTable( airDate: date(), runtime: integer(), thumbnails: jsonb(), - nextRefresh: date(), + nextRefresh: timestamp({ withTimezone: true }), externalId: jsonb().notNull().default({}), }, (t) => ({ @@ -37,12 +45,12 @@ export const entries = pgTable( }), ); -export const entriesTranslation = pgTable( +export const entriesTranslation = schema.table( "entries_translation", { pk: integer() .notNull() - .references(() => entries.id), + .references(() => entries.pk, { onDelete: "cascade" }), language: varchar({ length: 255 }).notNull(), name: text(), description: text(), From 8acb1750b62e812db6a941699ce685e4e4f9be99 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 27 Oct 2024 21:20:12 +0100 Subject: [PATCH 007/105] Add inital migration --- api/drizzle/0000_init.sql | 32 +++++ api/drizzle/meta/0000_snapshot.json | 204 ++++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 13 ++ 3 files changed, 249 insertions(+) create mode 100644 api/drizzle/0000_init.sql create mode 100644 api/drizzle/meta/0000_snapshot.json create mode 100644 api/drizzle/meta/_journal.json diff --git a/api/drizzle/0000_init.sql b/api/drizzle/0000_init.sql new file mode 100644 index 000000000..dc4826ad2 --- /dev/null +++ b/api/drizzle/0000_init.sql @@ -0,0 +1,32 @@ +CREATE TYPE "kyoo"."entry_type" AS ENUM('unknown', 'episode', 'movie', 'special', 'extra');--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "kyoo"."entries" ( + "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."entries_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "slug" varchar(255) NOT NULL, + "order" integer NOT NULL, + "seasonNumber" integer, + "episodeNumber" integer, + "type" "kyoo"."entry_type" NOT NULL, + "airDate" date, + "runtime" integer, + "thumbnails" jsonb, + "nextRefresh" timestamp with time zone, + "externalId" jsonb DEFAULT '{}'::jsonb NOT NULL, + CONSTRAINT "entries_id_unique" UNIQUE("id"), + CONSTRAINT "entries_slug_unique" UNIQUE("slug"), + CONSTRAINT "orderPositive" CHECK ("entries"."order" >= 0) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "kyoo"."entries_translation" ( + "pk" integer NOT NULL, + "language" varchar(255) NOT NULL, + "name" text, + "description" text, + CONSTRAINT "entries_translation_pk_language_pk" PRIMARY KEY("pk","language") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "kyoo"."entries_translation" ADD CONSTRAINT "entries_translation_pk_entries_pk_fk" FOREIGN KEY ("pk") REFERENCES "kyoo"."entries"("pk") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/api/drizzle/meta/0000_snapshot.json b/api/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..7e59d5894 --- /dev/null +++ b/api/drizzle/meta/0000_snapshot.json @@ -0,0 +1,204 @@ +{ + "id": "362abc74-1487-46ff-bfe2-203ea699f19e", + "prevId": "00000000-0000-0000-0000-000000000000", + "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 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "seasonNumber": { + "name": "seasonNumber", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episodeNumber": { + "name": "episodeNumber", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "airDate": { + "name": "airDate", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "nextRefresh": { + "name": "nextRefresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "externalId": { + "name": "externalId", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entries_id_unique": { + "name": "entries_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + }, + "entries_slug_unique": { + "name": "entries_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "checkConstraints": { + "orderPositive": { + "name": "orderPositive", + "value": "\"entries\".\"order\" >= 0" + } + } + }, + "kyoo.entries_translation": { + "name": "entries_translation", + "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 + } + }, + "indexes": {}, + "foreignKeys": { + "entries_translation_pk_entries_pk_fk": { + "name": "entries_translation_pk_entries_pk_fk", + "tableFrom": "entries_translation", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": [ + "pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entries_translation_pk_language_pk": { + "name": "entries_translation_pk_language_pk", + "columns": [ + "pk", + "language" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "enums": { + "kyoo.entry_type": { + "name": "entry_type", + "schema": "kyoo", + "values": [ + "unknown", + "episode", + "movie", + "special", + "extra" + ] + } + }, + "schemas": {}, + "sequences": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json new file mode 100644 index 000000000..6c51b6b48 --- /dev/null +++ b/api/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1730060281406, + "tag": "0000_init", + "breakpoints": true + } + ] +} \ No newline at end of file From da3a5181df0eb0c91caac83590c9e5245f17dcb1 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 29 Oct 2024 14:32:16 +0100 Subject: [PATCH 008/105] Fix connection things --- api/.env.example | 9 +++++++++ api/src/controllers/entries.ts | 2 ++ api/src/db/index.ts | 8 ++++++-- api/src/index.ts | 9 +-------- 4 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 api/.env.example diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 000000000..dfd8f53b2 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,9 @@ +# vi: ft=sh +# shellcheck disable=SC2034 + + +POSTGRES_USER=kyoo +POSTGRES_PASSWORD=password +POSTGRES_DB=kyooDB +POSTGRES_SERVER=postgres +POSTGRES_PORT=5432 diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 18d659cf3..459415760 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -1,2 +1,4 @@ +import { Elysia } from "elysia"; + export const EntriesController = new Elysia() .get('/entries', () => "hello"); diff --git a/api/src/db/index.ts b/api/src/db/index.ts index 980d98b0f..e336c6ad8 100644 --- a/api/src/db/index.ts +++ b/api/src/db/index.ts @@ -1,8 +1,12 @@ import { drizzle } from "drizzle-orm/node-postgres"; -const db = drizzle({ +export const db = drizzle({ connection: { - connectionString: process.env.DATABASE_URL!, + user: process.env.POSTGRES_USER ?? "kyoo", + password: process.env.POSTGRES_PASSWORD ?? "password", + database: process.env.POSTGRES_DB ?? "kyooDB", + host: process.env.POSTGRES_SERVER ?? "postgres", + port: Number(process.env.POSTGRES_PORT) || 5432, ssl: true, }, casing: "snake_case", diff --git a/api/src/index.ts b/api/src/index.ts index f43b0f3bb..240bb828c 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,15 +1,8 @@ import { Elysia } from "elysia"; import { swagger } from "@elysiajs/swagger"; -import { drizzle } from "drizzle-orm/node-postgres"; +import { db } from "./db"; import { migrate } from "drizzle-orm/node-postgres/migrator"; -if (!process.env.DATABASE_URL) { - console.error("Missing `DATABASE_URL` environment variable. Exiting"); - process.exit(1); -} - -const db = drizzle(process.env.DATABASE_URL); - await migrate(db, { migrationsFolder: "" }); const app = new Elysia() From c40f086244361f8bb059c328ee3dd315d04ab0be Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 1 Nov 2024 16:58:46 +0100 Subject: [PATCH 009/105] Define show schema and split schema --- api/drizzle.config.ts | 2 +- api/src/db/{schema.ts => schema/entries.ts} | 6 +- api/src/db/schema/shows.ts | 89 +++++++++++++++++++++ api/src/db/schema/utils.ts | 5 ++ 4 files changed, 97 insertions(+), 5 deletions(-) rename api/src/db/{schema.ts => schema/entries.ts} (93%) create mode 100644 api/src/db/schema/shows.ts create mode 100644 api/src/db/schema/utils.ts diff --git a/api/drizzle.config.ts b/api/drizzle.config.ts index a79475017..50f19f188 100644 --- a/api/drizzle.config.ts +++ b/api/drizzle.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ out: "./drizzle", - schema: "./src/db/schema.ts", + schema: "./src/db/schema", dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL!, diff --git a/api/src/db/schema.ts b/api/src/db/schema/entries.ts similarity index 93% rename from api/src/db/schema.ts rename to api/src/db/schema/entries.ts index 8515f39a6..b57d59a97 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema/entries.ts @@ -4,15 +4,13 @@ import { date, integer, jsonb, - pgSchema, primaryKey, text, timestamp, uuid, varchar, } from "drizzle-orm/pg-core"; - -const schema = pgSchema("kyoo"); +import { language, schema } from "./utils"; export const entryType = schema.enum("entry_type", [ "unknown", @@ -51,7 +49,7 @@ export const entriesTranslation = schema.table( pk: integer() .notNull() .references(() => entries.pk, { onDelete: "cascade" }), - language: varchar({ length: 255 }).notNull(), + language: language().notNull(), name: text(), description: text(), }, diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts new file mode 100644 index 000000000..895d23697 --- /dev/null +++ b/api/src/db/schema/shows.ts @@ -0,0 +1,89 @@ +import { sql } from "drizzle-orm"; +import { + check, + date, + integer, + jsonb, + primaryKey, + smallint, + text, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; +import { language, schema } from "./utils"; + +export const showKind = schema.enum("show_kind", ["serie", "movie"]); +export const showStatus = schema.enum("show_status", ["unknown", "finished", "airing", "planned"]); +export const genres = schema.enum("genres", [ + "action", + "adventure", + "animation", + "comedy", + "crime", + "documentary", + "drama", + "family", + "fantasy", + "history", + "horror", + "music", + "mystery", + "romance", + "science-fiction", + "thriller", + "war", + "western", + "kids", + "reality", + "politics", + "soap", + "talk", +]); + +export const shows = schema.table( + "shows", + { + pk: integer().primaryKey().generatedAlwaysAsIdentity(), + id: uuid().notNull().unique().defaultRandom(), + slug: varchar({ length: 255 }).notNull().unique(), + kind: showKind().notNull(), + genres: genres().array().notNull(), + rating: smallint(), + status: showStatus().notNull(), + startAir: date(), + endAir: date(), + originalLanguage: language(), + + externalId: jsonb().notNull().default({}), + + createdAt: timestamp({ withTimezone: true }), + nextRefresh: timestamp({ withTimezone: true }), + }, + (t) => ({ + ratingValid: check("ratingValid", sql`0 <= ${t.rating} && ${t.rating} <= 100`), + }), +); + +export const showTranslations = schema.table( + "show_translations", + { + pk: integer() + .notNull() + .references(() => shows.pk, { onDelete: "cascade" }), + language: language().notNull(), + name: text().notNull(), + description: text(), + tagline: text(), + aliases: text().array().notNull(), + tags: text().array().notNull(), + trailerUrl: text(), + poster: jsonb(), + thumbnail: jsonb(), + banner: jsonb(), + logo: jsonb(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.pk, t.language] }), + }), +); diff --git a/api/src/db/schema/utils.ts b/api/src/db/schema/utils.ts new file mode 100644 index 000000000..c6151cf47 --- /dev/null +++ b/api/src/db/schema/utils.ts @@ -0,0 +1,5 @@ +import { pgSchema, varchar } from "drizzle-orm/pg-core"; + +export const schema = pgSchema("kyoo"); + +export const language = () => varchar({ length: 255 }); From 4c91462b056ba3e13568f9948af506422f3579e3 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 1 Nov 2024 17:10:31 +0100 Subject: [PATCH 010/105] Generate migration --- api/drizzle/0001_shows.sql | 45 +++ api/drizzle/meta/0001_snapshot.json | 491 ++++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/src/db/schema/entries.ts | 5 +- api/src/db/schema/shows.ts | 2 +- 5 files changed, 548 insertions(+), 2 deletions(-) create mode 100644 api/drizzle/0001_shows.sql create mode 100644 api/drizzle/meta/0001_snapshot.json diff --git a/api/drizzle/0001_shows.sql b/api/drizzle/0001_shows.sql new file mode 100644 index 000000000..405f56331 --- /dev/null +++ b/api/drizzle/0001_shows.sql @@ -0,0 +1,45 @@ +--> statement-breakpoint +CREATE TYPE "kyoo"."genres" AS ENUM('action', 'adventure', 'animation', 'comedy', 'crime', 'documentary', 'drama', 'family', 'fantasy', 'history', 'horror', 'music', 'mystery', 'romance', 'science-fiction', 'thriller', 'war', 'western', 'kids', 'reality', 'politics', 'soap', 'talk');--> statement-breakpoint +CREATE TYPE "kyoo"."show_kind" AS ENUM('serie', 'movie');--> statement-breakpoint +CREATE TYPE "kyoo"."show_status" AS ENUM('unknown', 'finished', 'airing', 'planned');--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "kyoo"."show_translations" ( + "pk" integer NOT NULL, + "language" varchar(255) NOT NULL, + "name" text NOT NULL, + "description" text, + "tagline" text, + "aliases" text[] NOT NULL, + "tags" text[] NOT NULL, + "trailerUrl" text, + "poster" jsonb, + "thumbnail" jsonb, + "banner" jsonb, + "logo" jsonb, + CONSTRAINT "show_translations_pk_language_pk" PRIMARY KEY("pk","language") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "kyoo"."shows" ( + "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."shows_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "slug" varchar(255) NOT NULL, + "kind" "kyoo"."show_kind" NOT NULL, + "genres" genres[] NOT NULL, + "rating" smallint, + "status" "kyoo"."show_status" NOT NULL, + "startAir" date, + "endAir" date, + "originalLanguage" varchar(255), + "externalId" jsonb DEFAULT '{}'::jsonb NOT NULL, + "createdAt" timestamp with time zone DEFAULT now(), + "nextRefresh" timestamp with time zone, + CONSTRAINT "shows_id_unique" UNIQUE("id"), + CONSTRAINT "shows_slug_unique" UNIQUE("slug"), + CONSTRAINT "ratingValid" CHECK (0 <= "shows"."rating" && "shows"."rating" <= 100) +); +--> statement-breakpoint +ALTER TABLE "kyoo"."entries" ADD COLUMN "createdAt" timestamp with time zone DEFAULT now();--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "kyoo"."show_translations" ADD CONSTRAINT "show_translations_pk_shows_pk_fk" FOREIGN KEY ("pk") REFERENCES "kyoo"."shows"("pk") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/api/drizzle/meta/0001_snapshot.json b/api/drizzle/meta/0001_snapshot.json new file mode 100644 index 000000000..3b58b80da --- /dev/null +++ b/api/drizzle/meta/0001_snapshot.json @@ -0,0 +1,491 @@ +{ + "id": "0f48a319-94fe-4bcc-b63c-28ce280abc9a", + "prevId": "362abc74-1487-46ff-bfe2-203ea699f19e", + "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 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "seasonNumber": { + "name": "seasonNumber", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episodeNumber": { + "name": "episodeNumber", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "airDate": { + "name": "airDate", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "externalId": { + "name": "externalId", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "nextRefresh": { + "name": "nextRefresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entries_id_unique": { + "name": "entries_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + }, + "entries_slug_unique": { + "name": "entries_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "checkConstraints": { + "orderPositive": { + "name": "orderPositive", + "value": "\"entries\".\"order\" >= 0" + } + } + }, + "kyoo.entries_translation": { + "name": "entries_translation", + "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 + } + }, + "indexes": {}, + "foreignKeys": { + "entries_translation_pk_entries_pk_fk": { + "name": "entries_translation_pk_entries_pk_fk", + "tableFrom": "entries_translation", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": [ + "pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entries_translation_pk_language_pk": { + "name": "entries_translation_pk_language_pk", + "columns": [ + "pk", + "language" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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 + }, + "trailerUrl": { + "name": "trailerUrl", + "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 + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": {}, + "checkConstraints": {} + }, + "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 + }, + "status": { + "name": "status", + "type": "show_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "startAir": { + "name": "startAir", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "endAir": { + "name": "endAir", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "originalLanguage": { + "name": "originalLanguage", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "externalId": { + "name": "externalId", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "nextRefresh": { + "name": "nextRefresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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" + ] + } + }, + "checkConstraints": { + "ratingValid": { + "name": "ratingValid", + "value": "0 <= \"shows\".\"rating\" && \"shows\".\"rating\" <= 100" + } + } + } + }, + "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": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index 6c51b6b48..ef18c1904 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1730060281406, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1730477283024, + "tag": "0001_shows", + "breakpoints": true } ] } \ No newline at end of file diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index b57d59a97..d8395223b 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -34,8 +34,11 @@ export const entries = schema.table( airDate: date(), runtime: integer(), thumbnails: jsonb(), - nextRefresh: timestamp({ withTimezone: true }), + externalId: jsonb().notNull().default({}), + + createdAt: timestamp({ withTimezone: true }).defaultNow(), + nextRefresh: timestamp({ withTimezone: true }), }, (t) => ({ // episodeKey: unique().on(t.showId, t.seasonNumber, t.episodeNumber), diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index 895d23697..d862dddaa 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -57,7 +57,7 @@ export const shows = schema.table( externalId: jsonb().notNull().default({}), - createdAt: timestamp({ withTimezone: true }), + createdAt: timestamp({ withTimezone: true }).defaultNow(), nextRefresh: timestamp({ withTimezone: true }), }, (t) => ({ From 908e06c88fcb079bfd911cf27a01ec6af51dee34 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 1 Nov 2024 21:46:34 +0100 Subject: [PATCH 011/105] Create elysia schemas for movies --- api/src/models/external-id.ts | 11 +++++++++ api/src/models/image.ts | 10 +++++++++ api/src/models/movie.ts | 42 +++++++++++++++++++++++++++++++++++ api/src/models/show.ts | 36 ++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 api/src/models/external-id.ts create mode 100644 api/src/models/image.ts create mode 100644 api/src/models/movie.ts create mode 100644 api/src/models/show.ts diff --git a/api/src/models/external-id.ts b/api/src/models/external-id.ts new file mode 100644 index 000000000..368c00b66 --- /dev/null +++ b/api/src/models/external-id.ts @@ -0,0 +1,11 @@ +import { t } from "elysia"; + +export const ExternalId = t.Record( + t.String(), + t.Object({ + dataId: t.String(), + link: t.Nullable(t.String({ format: "uri" })), + }), +); + +export type ExternalId = typeof ExternalId.static; diff --git a/api/src/models/image.ts b/api/src/models/image.ts new file mode 100644 index 000000000..0ff56c9ec --- /dev/null +++ b/api/src/models/image.ts @@ -0,0 +1,10 @@ +import { t } from "elysia"; + +export const Image = t.Object({ + source: t.String({ format: "uri" }), + blurhash: t.String(), + low: t.String({ format: "uri" }), + medium: t.String({ format: "uri" }), + high: t.String({ format: "uri" }), +}); +export type Image = typeof Image.static; diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts new file mode 100644 index 000000000..5dae34696 --- /dev/null +++ b/api/src/models/movie.ts @@ -0,0 +1,42 @@ +import { t } from "elysia"; +import { Genre, ShowStatus } from "./show"; +import { Image } from "./image"; +import { ExternalId } from "./external-id"; + +export const Movie = t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String(), + name: t.String(), + description: t.Nullable(t.String()), + tagline: t.Nullable(t.String()), + aliases: t.Array(t.String()), + tags: t.Array(t.String()), + + genres: t.Array(Genre), + rating: t.Nullable(t.Number({ minimum: 0, maximum: 100 })), + status: ShowStatus, + + airDate: t.Nullable(t.Date()), + originalLanguage: t.Nullable(t.String()), + + trailerUrl: t.Nullable(t.String()), + poster: t.Nullable(Image), + thumbnail: t.Nullable(Image), + banner: t.Nullable(Image), + logo: t.Nullable(Image), + + // this is a datetime, not just a date. + createdAt: t.Date(), + nextRefresh: t.Date(), + + externalId: ExternalId, +}); + +export type Movie = typeof Movie.static; + +// Movie.examples = [{ +// slug: "bubble", +// title: "Bubble", +// tagline: "Is she a calamity or a blessing?", +// description: " In an abandoned Tokyo overrun by bubbles and gravitational abnormalities, one gifted young man has a fateful meeting with a mysterious girl. ", +// }] diff --git a/api/src/models/show.ts b/api/src/models/show.ts new file mode 100644 index 000000000..9f1c7e638 --- /dev/null +++ b/api/src/models/show.ts @@ -0,0 +1,36 @@ +import { t } from "elysia"; + +export const ShowStatus = t.UnionEnum([ + "unknown", + "finished", + "airing", + "planned", +]); +export type ShowStatus = typeof ShowStatus.static; + +export const Genre = t.UnionEnum([ + "action", + "adventure", + "animation", + "comedy", + "crime", + "documentary", + "drama", + "family", + "fantasy", + "history", + "horror", + "music", + "mystery", + "romance", + "science-fiction", + "thriller", + "war", + "western", + "kids", + "reality", + "politics", + "soap", + "talk", +]); +export type Genre = typeof Genre.static; From ed5d677ae188f157a0f0c99d487cc2284bd177f9 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 1 Nov 2024 21:47:18 +0100 Subject: [PATCH 012/105] Type json in drizzle schemas --- api/drizzle/0002_shows.sql | 2 + api/drizzle/meta/0000_snapshot.json | 389 ++++++------ api/drizzle/meta/0001_snapshot.json | 944 +++++++++++++--------------- api/drizzle/meta/0002_snapshot.json | 455 ++++++++++++++ api/drizzle/meta/_journal.json | 45 +- api/src/db/schema/entries.ts | 4 +- api/src/db/schema/shows.ts | 32 +- api/src/db/schema/utils.ts | 19 +- 8 files changed, 1163 insertions(+), 727 deletions(-) create mode 100644 api/drizzle/0002_shows.sql create mode 100644 api/drizzle/meta/0002_snapshot.json diff --git a/api/drizzle/0002_shows.sql b/api/drizzle/0002_shows.sql new file mode 100644 index 000000000..bd83742f1 --- /dev/null +++ b/api/drizzle/0002_shows.sql @@ -0,0 +1,2 @@ +ALTER TABLE "kyoo"."shows" ALTER COLUMN "createdAt" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "kyoo"."shows" ALTER COLUMN "nextRefresh" SET NOT NULL; diff --git a/api/drizzle/meta/0000_snapshot.json b/api/drizzle/meta/0000_snapshot.json index 7e59d5894..9d29e87f3 100644 --- a/api/drizzle/meta/0000_snapshot.json +++ b/api/drizzle/meta/0000_snapshot.json @@ -1,204 +1,187 @@ { - "id": "362abc74-1487-46ff-bfe2-203ea699f19e", - "prevId": "00000000-0000-0000-0000-000000000000", - "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 - }, - "order": { - "name": "order", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "seasonNumber": { - "name": "seasonNumber", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "episodeNumber": { - "name": "episodeNumber", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "entry_type", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "airDate": { - "name": "airDate", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "runtime": { - "name": "runtime", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "thumbnails": { - "name": "thumbnails", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "nextRefresh": { - "name": "nextRefresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "externalId": { - "name": "externalId", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "entries_id_unique": { - "name": "entries_id_unique", - "nullsNotDistinct": false, - "columns": [ - "id" - ] - }, - "entries_slug_unique": { - "name": "entries_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - } - }, - "checkConstraints": { - "orderPositive": { - "name": "orderPositive", - "value": "\"entries\".\"order\" >= 0" - } - } - }, - "kyoo.entries_translation": { - "name": "entries_translation", - "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 - } - }, - "indexes": {}, - "foreignKeys": { - "entries_translation_pk_entries_pk_fk": { - "name": "entries_translation_pk_entries_pk_fk", - "tableFrom": "entries_translation", - "tableTo": "entries", - "schemaTo": "kyoo", - "columnsFrom": [ - "pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "entries_translation_pk_language_pk": { - "name": "entries_translation_pk_language_pk", - "columns": [ - "pk", - "language" - ] - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "enums": { - "kyoo.entry_type": { - "name": "entry_type", - "schema": "kyoo", - "values": [ - "unknown", - "episode", - "movie", - "special", - "extra" - ] - } - }, - "schemas": {}, - "sequences": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file + "id": "362abc74-1487-46ff-bfe2-203ea699f19e", + "prevId": "00000000-0000-0000-0000-000000000000", + "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 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "seasonNumber": { + "name": "seasonNumber", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episodeNumber": { + "name": "episodeNumber", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "airDate": { + "name": "airDate", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "nextRefresh": { + "name": "nextRefresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "externalId": { + "name": "externalId", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entries_id_unique": { + "name": "entries_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "entries_slug_unique": { + "name": "entries_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "checkConstraints": { + "orderPositive": { + "name": "orderPositive", + "value": "\"entries\".\"order\" >= 0" + } + } + }, + "kyoo.entries_translation": { + "name": "entries_translation", + "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 + } + }, + "indexes": {}, + "foreignKeys": { + "entries_translation_pk_entries_pk_fk": { + "name": "entries_translation_pk_entries_pk_fk", + "tableFrom": "entries_translation", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entries_translation_pk_language_pk": { + "name": "entries_translation_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "enums": { + "kyoo.entry_type": { + "name": "entry_type", + "schema": "kyoo", + "values": ["unknown", "episode", "movie", "special", "extra"] + } + }, + "schemas": {}, + "sequences": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/0001_snapshot.json b/api/drizzle/meta/0001_snapshot.json index 3b58b80da..387887c48 100644 --- a/api/drizzle/meta/0001_snapshot.json +++ b/api/drizzle/meta/0001_snapshot.json @@ -1,491 +1,455 @@ { - "id": "0f48a319-94fe-4bcc-b63c-28ce280abc9a", - "prevId": "362abc74-1487-46ff-bfe2-203ea699f19e", - "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 - }, - "order": { - "name": "order", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "seasonNumber": { - "name": "seasonNumber", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "episodeNumber": { - "name": "episodeNumber", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "entry_type", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "airDate": { - "name": "airDate", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "runtime": { - "name": "runtime", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "thumbnails": { - "name": "thumbnails", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "externalId": { - "name": "externalId", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "nextRefresh": { - "name": "nextRefresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "entries_id_unique": { - "name": "entries_id_unique", - "nullsNotDistinct": false, - "columns": [ - "id" - ] - }, - "entries_slug_unique": { - "name": "entries_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - } - }, - "checkConstraints": { - "orderPositive": { - "name": "orderPositive", - "value": "\"entries\".\"order\" >= 0" - } - } - }, - "kyoo.entries_translation": { - "name": "entries_translation", - "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 - } - }, - "indexes": {}, - "foreignKeys": { - "entries_translation_pk_entries_pk_fk": { - "name": "entries_translation_pk_entries_pk_fk", - "tableFrom": "entries_translation", - "tableTo": "entries", - "schemaTo": "kyoo", - "columnsFrom": [ - "pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "entries_translation_pk_language_pk": { - "name": "entries_translation_pk_language_pk", - "columns": [ - "pk", - "language" - ] - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "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 - }, - "trailerUrl": { - "name": "trailerUrl", - "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 - }, - "logo": { - "name": "logo", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "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": {}, - "checkConstraints": {} - }, - "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 - }, - "status": { - "name": "status", - "type": "show_status", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "startAir": { - "name": "startAir", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "endAir": { - "name": "endAir", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "originalLanguage": { - "name": "originalLanguage", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "externalId": { - "name": "externalId", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "nextRefresh": { - "name": "nextRefresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "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" - ] - } - }, - "checkConstraints": { - "ratingValid": { - "name": "ratingValid", - "value": "0 <= \"shows\".\"rating\" && \"shows\".\"rating\" <= 100" - } - } - } - }, - "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": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file + "id": "0f48a319-94fe-4bcc-b63c-28ce280abc9a", + "prevId": "362abc74-1487-46ff-bfe2-203ea699f19e", + "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 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "seasonNumber": { + "name": "seasonNumber", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episodeNumber": { + "name": "episodeNumber", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "airDate": { + "name": "airDate", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "externalId": { + "name": "externalId", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "nextRefresh": { + "name": "nextRefresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entries_id_unique": { + "name": "entries_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "entries_slug_unique": { + "name": "entries_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "checkConstraints": { + "orderPositive": { + "name": "orderPositive", + "value": "\"entries\".\"order\" >= 0" + } + } + }, + "kyoo.entries_translation": { + "name": "entries_translation", + "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 + } + }, + "indexes": {}, + "foreignKeys": { + "entries_translation_pk_entries_pk_fk": { + "name": "entries_translation_pk_entries_pk_fk", + "tableFrom": "entries_translation", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entries_translation_pk_language_pk": { + "name": "entries_translation_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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 + }, + "trailerUrl": { + "name": "trailerUrl", + "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 + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": {}, + "checkConstraints": {} + }, + "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 + }, + "status": { + "name": "status", + "type": "show_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "startAir": { + "name": "startAir", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "endAir": { + "name": "endAir", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "originalLanguage": { + "name": "originalLanguage", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "externalId": { + "name": "externalId", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "nextRefresh": { + "name": "nextRefresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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"] + } + }, + "checkConstraints": { + "ratingValid": { + "name": "ratingValid", + "value": "0 <= \"shows\".\"rating\" && \"shows\".\"rating\" <= 100" + } + } + } + }, + "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": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/0002_snapshot.json b/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 000000000..2cdff00da --- /dev/null +++ b/api/drizzle/meta/0002_snapshot.json @@ -0,0 +1,455 @@ +{ + "id": "1948acaf-7a29-4521-988d-439653779e39", + "prevId": "0f48a319-94fe-4bcc-b63c-28ce280abc9a", + "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 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "seasonNumber": { + "name": "seasonNumber", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episodeNumber": { + "name": "episodeNumber", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "airDate": { + "name": "airDate", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "externalId": { + "name": "externalId", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "nextRefresh": { + "name": "nextRefresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entries_id_unique": { + "name": "entries_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "entries_slug_unique": { + "name": "entries_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "checkConstraints": { + "orderPositive": { + "name": "orderPositive", + "value": "\"entries\".\"order\" >= 0" + } + } + }, + "kyoo.entries_translation": { + "name": "entries_translation", + "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 + } + }, + "indexes": {}, + "foreignKeys": { + "entries_translation_pk_entries_pk_fk": { + "name": "entries_translation_pk_entries_pk_fk", + "tableFrom": "entries_translation", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entries_translation_pk_language_pk": { + "name": "entries_translation_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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 + }, + "trailerUrl": { + "name": "trailerUrl", + "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 + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": {}, + "checkConstraints": {} + }, + "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 + }, + "status": { + "name": "status", + "type": "show_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "startAir": { + "name": "startAir", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "endAir": { + "name": "endAir", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "originalLanguage": { + "name": "originalLanguage", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "externalId": { + "name": "externalId", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "nextRefresh": { + "name": "nextRefresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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"] + } + }, + "checkConstraints": { + "ratingValid": { + "name": "ratingValid", + "value": "0 <= \"shows\".\"rating\" && \"shows\".\"rating\" <= 100" + } + } + } + }, + "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": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index ef18c1904..5fa49ee67 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -1,20 +1,27 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1730060281406, - "tag": "0000_init", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1730477283024, - "tag": "0001_shows", - "breakpoints": true - } - ] -} \ No newline at end of file + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1730060281406, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1730477283024, + "tag": "0001_shows", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1730487641214, + "tag": "0002_shows", + "breakpoints": true + } + ] +} diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index d8395223b..e7b52a6df 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -10,7 +10,7 @@ import { uuid, varchar, } from "drizzle-orm/pg-core"; -import { language, schema } from "./utils"; +import { image, language, schema } from "./utils"; export const entryType = schema.enum("entry_type", [ "unknown", @@ -33,7 +33,7 @@ export const entries = schema.table( type: entryType().notNull(), airDate: date(), runtime: integer(), - thumbnails: jsonb(), + thumbnails: image(), externalId: jsonb().notNull().default({}), diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index d862dddaa..6e015dccf 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -11,10 +11,15 @@ import { uuid, varchar, } from "drizzle-orm/pg-core"; -import { language, schema } from "./utils"; +import { externalid, image, language, schema } from "./utils"; export const showKind = schema.enum("show_kind", ["serie", "movie"]); -export const showStatus = schema.enum("show_status", ["unknown", "finished", "airing", "planned"]); +export const showStatus = schema.enum("show_status", [ + "unknown", + "finished", + "airing", + "planned", +]); export const genres = schema.enum("genres", [ "action", "adventure", @@ -51,17 +56,20 @@ export const shows = schema.table( genres: genres().array().notNull(), rating: smallint(), status: showStatus().notNull(), - startAir: date(), - endAir: date(), + startAir: date({ mode: "date" }), + endAir: date({ mode: "date" }), originalLanguage: language(), - externalId: jsonb().notNull().default({}), + externalId: externalid(), - createdAt: timestamp({ withTimezone: true }).defaultNow(), - nextRefresh: timestamp({ withTimezone: true }), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + nextRefresh: timestamp({ withTimezone: true }).notNull(), }, (t) => ({ - ratingValid: check("ratingValid", sql`0 <= ${t.rating} && ${t.rating} <= 100`), + ratingValid: check( + "ratingValid", + sql`0 <= ${t.rating} && ${t.rating} <= 100`, + ), }), ); @@ -78,10 +86,10 @@ export const showTranslations = schema.table( aliases: text().array().notNull(), tags: text().array().notNull(), trailerUrl: text(), - poster: jsonb(), - thumbnail: jsonb(), - banner: jsonb(), - logo: jsonb(), + poster: image(), + thumbnail: image(), + banner: image(), + logo: image(), }, (t) => ({ pk: primaryKey({ columns: [t.pk, t.language] }), diff --git a/api/src/db/schema/utils.ts b/api/src/db/schema/utils.ts index c6151cf47..1183628a4 100644 --- a/api/src/db/schema/utils.ts +++ b/api/src/db/schema/utils.ts @@ -1,5 +1,22 @@ -import { pgSchema, varchar } from "drizzle-orm/pg-core"; +import { jsonb, pgSchema, varchar } from "drizzle-orm/pg-core"; export const schema = pgSchema("kyoo"); export const language = () => varchar({ length: 255 }); + +export const image = () => + jsonb().$type<{ source: string; blurhash: string }>(); + +export const externalid = () => + jsonb() + .$type< + Record< + string, + { + dataId: string; + link: string | null; + } + > + >() + .notNull() + .default({}); From 4c7a02b44306030c86f3d901f103ede3290f5ea2 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 1 Nov 2024 21:47:30 +0100 Subject: [PATCH 013/105] Add api biome.json --- api/biome.json | 6 ++++++ api/bun.lockb | Bin 39931 -> 39931 bytes 2 files changed, 6 insertions(+) create mode 100644 api/biome.json diff --git a/api/biome.json b/api/biome.json new file mode 100644 index 000000000..66dcb3df1 --- /dev/null +++ b/api/biome.json @@ -0,0 +1,6 @@ +{ + "extends": ["../biome.json"], + "formatter": { + "lineWidth": 80 + } +} diff --git a/api/bun.lockb b/api/bun.lockb index 355cc64aa2eb108134dbc1a5405c8eec3ddad1db..d58c717cce10db8e16acf760c8be303bfd13470d 100755 GIT binary patch delta 23 fcmeypo$2>>rVZ>rVZ Date: Fri, 1 Nov 2024 21:49:00 +0100 Subject: [PATCH 014/105] Add first drizzle query with multi-language --- api/src/controllers/entries.ts | 4 --- api/src/controllers/movies.ts | 58 ++++++++++++++++++++++++++++++++++ api/src/db/index.ts | 6 ++++ api/src/index.ts | 2 ++ 4 files changed, 66 insertions(+), 4 deletions(-) delete mode 100644 api/src/controllers/entries.ts create mode 100644 api/src/controllers/movies.ts diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts deleted file mode 100644 index 459415760..000000000 --- a/api/src/controllers/entries.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Elysia } from "elysia"; - -export const EntriesController = new Elysia() - .get('/entries', () => "hello"); diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts new file mode 100644 index 000000000..5c3649c56 --- /dev/null +++ b/api/src/controllers/movies.ts @@ -0,0 +1,58 @@ +import { Elysia, t } from "elysia"; +import { Movie } from "../models/movie"; +import { db } from "../db"; +import { shows, showTranslations } from "../db/schema/shows"; +import { eq, and, sql, or, inArray } from "drizzle-orm"; + +const findMovie = db + .select() + .from(shows) + .innerJoin( + db + .selectDistinctOn([showTranslations.language]) + .from(showTranslations) + .where( + or( + inArray(showTranslations.language, sql.placeholder("langs")), + eq(showTranslations.language, shows.originalLanguage), + ), + ) + .orderBy( + sql`array_position(${showTranslations.language}, ${sql.placeholder("langs")})`, + ) + .as("t"), + eq(shows.pk, showTranslations.pk), + ) + .where( + and( + eq(shows.kind, "movie"), + or( + eq(shows.id, sql.placeholder("id")), + eq(shows.slug, sql.placeholder("id")), + ), + ), + ) + .orderBy() + .limit(1) + .prepare("findMovie"); + +export const movies = new Elysia({ prefix: "/movies" }) + .model({ + movie: Movie, + error: t.Object({}), + }) + .guard({ + params: t.Object({ + id: t.String(), + }), + response: { 200: "movie", 404: "error" }, + }) + .get("/:id", async ({ params: { id }, error }) => { + const ret = await findMovie.execute({ id }); + if (ret.length !== 1) return error(404, {}); + return { + ...ret[0].shows, + ...ret[0].t, + airDate: ret[0].shows.startAir, + }; + }); diff --git a/api/src/db/index.ts b/api/src/db/index.ts index e336c6ad8..7a0ab7f1b 100644 --- a/api/src/db/index.ts +++ b/api/src/db/index.ts @@ -1,6 +1,12 @@ +import * as entries from "./schema/entries"; +import * as shows from "./schema/shows"; import { drizzle } from "drizzle-orm/node-postgres"; export const db = drizzle({ + schema: { + ...entries, + ...shows, + }, connection: { user: process.env.POSTGRES_USER ?? "kyoo", password: process.env.POSTGRES_PASSWORD ?? "password", diff --git a/api/src/index.ts b/api/src/index.ts index 240bb828c..bac98e54f 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -2,12 +2,14 @@ import { Elysia } from "elysia"; import { swagger } from "@elysiajs/swagger"; import { db } from "./db"; import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { movies } from "./controllers/movies"; await migrate(db, { migrationsFolder: "" }); const app = new Elysia() .use(swagger()) .get("/", () => "Hello Elysia") + .use(movies) .listen(3000); console.log(`Api running at ${app.server?.hostname}:${app.server?.port}`); From 6d13f0610b8e0fd925c917073a9a739db9b3099c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 4 Nov 2024 20:31:53 +0100 Subject: [PATCH 015/105] Use select with named fields for movies --- api/src/controllers/movies.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 5c3649c56..78155c60d 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -2,10 +2,17 @@ import { Elysia, t } from "elysia"; import { Movie } from "../models/movie"; import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; -import { eq, and, sql, or, inArray } from "drizzle-orm"; +import { eq, and, sql, or, inArray, getTableColumns } from "drizzle-orm"; + +const { pk: _, kind, startAir, endAir, ...moviesCol } = getTableColumns(shows); +const { pk, language, ...translationsCol } = getTableColumns(showTranslations); const findMovie = db - .select() + .select({ + ...moviesCol, + airDate: startAir, + ...translationsCol, + }) .from(shows) .innerJoin( db @@ -50,9 +57,5 @@ export const movies = new Elysia({ prefix: "/movies" }) .get("/:id", async ({ params: { id }, error }) => { const ret = await findMovie.execute({ id }); if (ret.length !== 1) return error(404, {}); - return { - ...ret[0].shows, - ...ret[0].t, - airDate: ret[0].shows.startAir, - }; + return ret[0]; }); From e7ed36caff31271a7257db8eb198dc8017aff8c5 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 7 Nov 2024 11:34:22 +0100 Subject: [PATCH 016/105] Add jwt verification on the api --- api/.env.example | 5 +++++ api/bun.lockb | Bin 39931 -> 40649 bytes api/package.json | 1 + api/src/index.ts | 15 +++++++++++++++ 4 files changed, 21 insertions(+) diff --git a/api/.env.example b/api/.env.example index dfd8f53b2..7a77e2a11 100644 --- a/api/.env.example +++ b/api/.env.example @@ -1,6 +1,11 @@ # vi: ft=sh # shellcheck disable=SC2034 +# either an hard-coded secret to decode jwts or empty to use keibi's public secret. +# this should only be used in tests +JWT_SECRET= +# keibi's server to retrive the public jwt secret +AUHT_SERVER=http://auth:4568 POSTGRES_USER=kyoo POSTGRES_PASSWORD=password diff --git a/api/bun.lockb b/api/bun.lockb index d58c717cce10db8e16acf760c8be303bfd13470d..1af685df995ba353940fba7c93ee6c1c2851a607 100755 GIT binary patch delta 5957 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 ATmS$7 diff --git a/api/package.json b/api/package.json index 1e3a98306..c75a936eb 100644 --- a/api/package.json +++ b/api/package.json @@ -8,6 +8,7 @@ "test": "bun test" }, "dependencies": { + "@elysiajs/jwt": "^1.1.1", "@elysiajs/swagger": "^1.1.5", "drizzle-kit": "^0.26.2", "drizzle-orm": "^0.35.3", diff --git a/api/src/index.ts b/api/src/index.ts index bac98e54f..51978e1e2 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -3,10 +3,25 @@ import { swagger } from "@elysiajs/swagger"; import { db } from "./db"; import { migrate } from "drizzle-orm/node-postgres/migrator"; import { movies } from "./controllers/movies"; +import jwt from "@elysiajs/jwt"; await migrate(db, { migrationsFolder: "" }); +let secret = process.env.JWT_SECRET; +if (!secret) { + const auth = process.env.AUTH_SERVER ?? "http://auth:4568"; + const ret = await fetch(`${auth}/info`); + const info = await ret.json(); + secret = info.publicKey; +} + +if (!secret) { + console.error("missing jwt secret or auth server. exiting"); + process.exit(1); +} + const app = new Elysia() + .use(jwt({ secret })) .use(swagger()) .get("/", () => "Hello Elysia") .use(movies) From 6f71b98209b3cbc553370741fa62ed0ab3b84bfe Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 7 Nov 2024 11:51:13 +0100 Subject: [PATCH 017/105] Add video schema --- api/src/db/index.ts | 4 +++- api/src/db/schema/videos.ts | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 api/src/db/schema/videos.ts diff --git a/api/src/db/index.ts b/api/src/db/index.ts index 7a0ab7f1b..529103e7c 100644 --- a/api/src/db/index.ts +++ b/api/src/db/index.ts @@ -1,11 +1,13 @@ +import { drizzle } from "drizzle-orm/node-postgres"; import * as entries from "./schema/entries"; import * as shows from "./schema/shows"; -import { drizzle } from "drizzle-orm/node-postgres"; +import * as videos from "./schema/videos"; export const db = drizzle({ schema: { ...entries, ...shows, + ...videos, }, connection: { user: process.env.POSTGRES_USER ?? "kyoo", diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts new file mode 100644 index 000000000..a4ad244f9 --- /dev/null +++ b/api/src/db/schema/videos.ts @@ -0,0 +1,22 @@ +import { sql } from "drizzle-orm"; +import { check, integer, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { schema } from "./utils"; + +export const videos = schema.table( + "videos", + { + pk: integer().primaryKey().generatedAlwaysAsIdentity(), + id: uuid().notNull().unique().defaultRandom(), + path: text().notNull().unique(), + rendering: integer(), + part: integer(), + version: integer(), + + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + ratingValid: check("renderingPos", sql`0 <= ${t.rendering}`), + partValid: check("partPos", sql`0 <= ${t.part}`), + versionValid: check("versionPos", sql`0 <= ${t.version}`), + }), +); From 00774230af6cdf04c6bc609a4efea61017ca45da Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 8 Nov 2024 22:27:01 +0100 Subject: [PATCH 018/105] Add video model --- api/src/models/video.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 api/src/models/video.ts diff --git a/api/src/models/video.ts b/api/src/models/video.ts new file mode 100644 index 000000000..b2011c929 --- /dev/null +++ b/api/src/models/video.ts @@ -0,0 +1,13 @@ +import { t } from "elysia"; + +export const Video = t.Object({ + id: t.String({ format: "uuid" }), + path: t.String(), + rendering: t.Number({ minimum: 0 }), + part: t.Number({ minimum: 0 }), + version: t.Number({ minimum: 0 }), + + createdAt: t.String({ format: "date-time" }), +}); + +export type Video = typeof Video.static; From f53e8e3611a2addbb58bc56dd3fa3fd52b7d933b Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 8 Nov 2024 22:27:58 +0100 Subject: [PATCH 019/105] Store image id instead of low/middle/high uri --- api/src/db/schema/utils.ts | 2 +- api/src/models/image.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/api/src/db/schema/utils.ts b/api/src/db/schema/utils.ts index 1183628a4..02f6e8afb 100644 --- a/api/src/db/schema/utils.ts +++ b/api/src/db/schema/utils.ts @@ -5,7 +5,7 @@ export const schema = pgSchema("kyoo"); export const language = () => varchar({ length: 255 }); export const image = () => - jsonb().$type<{ source: string; blurhash: string }>(); + jsonb().$type<{ id: string; source: string; blurhash: string }>(); export const externalid = () => jsonb() diff --git a/api/src/models/image.ts b/api/src/models/image.ts index 0ff56c9ec..407de4251 100644 --- a/api/src/models/image.ts +++ b/api/src/models/image.ts @@ -1,10 +1,8 @@ import { t } from "elysia"; export const Image = t.Object({ + id: t.String({ format: "uuid" }), source: t.String({ format: "uri" }), blurhash: t.String(), - low: t.String({ format: "uri" }), - medium: t.String({ format: "uri" }), - high: t.String({ format: "uri" }), }); export type Image = typeof Image.static; From 4f74ffc5ce9fdd9b2a9e64cf253c5bf383ccda0c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 8 Nov 2024 22:30:49 +0100 Subject: [PATCH 020/105] Add video migration & runtime field on shows --- api/drizzle/0003_runtime.sql | 17 + api/drizzle/meta/0003_snapshot.json | 555 ++++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/src/db/schema/shows.ts | 3 +- api/src/models/movie.ts | 1 + 5 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 api/drizzle/0003_runtime.sql create mode 100644 api/drizzle/meta/0003_snapshot.json diff --git a/api/drizzle/0003_runtime.sql b/api/drizzle/0003_runtime.sql new file mode 100644 index 000000000..2221b5685 --- /dev/null +++ b/api/drizzle/0003_runtime.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS "kyoo"."videos" ( + "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."videos_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "path" text NOT NULL, + "rendering" integer, + "part" integer, + "version" integer, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "videos_id_unique" UNIQUE("id"), + CONSTRAINT "videos_path_unique" UNIQUE("path"), + CONSTRAINT "renderingPos" CHECK (0 <= "videos"."rendering"), + CONSTRAINT "partPos" CHECK (0 <= "videos"."part"), + CONSTRAINT "versionPos" CHECK (0 <= "videos"."version") +); +--> statement-breakpoint +ALTER TABLE "kyoo"."shows" ADD COLUMN "runtime" integer;--> statement-breakpoint +ALTER TABLE "kyoo"."shows" ADD CONSTRAINT "runtimeValid" CHECK (0 <= "shows"."runtime"); diff --git a/api/drizzle/meta/0003_snapshot.json b/api/drizzle/meta/0003_snapshot.json new file mode 100644 index 000000000..95799ee18 --- /dev/null +++ b/api/drizzle/meta/0003_snapshot.json @@ -0,0 +1,555 @@ +{ + "id": "2ded3184-f416-40f0-8259-fcab6a4a1edc", + "prevId": "1948acaf-7a29-4521-988d-439653779e39", + "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 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "seasonNumber": { + "name": "seasonNumber", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episodeNumber": { + "name": "episodeNumber", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "airDate": { + "name": "airDate", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "externalId": { + "name": "externalId", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "nextRefresh": { + "name": "nextRefresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entries_id_unique": { + "name": "entries_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "entries_slug_unique": { + "name": "entries_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "checkConstraints": { + "orderPositive": { + "name": "orderPositive", + "value": "\"entries\".\"order\" >= 0" + } + } + }, + "kyoo.entries_translation": { + "name": "entries_translation", + "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 + } + }, + "indexes": {}, + "foreignKeys": { + "entries_translation_pk_entries_pk_fk": { + "name": "entries_translation_pk_entries_pk_fk", + "tableFrom": "entries_translation", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entries_translation_pk_language_pk": { + "name": "entries_translation_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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 + }, + "trailerUrl": { + "name": "trailerUrl", + "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 + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": {}, + "checkConstraints": {} + }, + "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 + }, + "startAir": { + "name": "startAir", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "endAir": { + "name": "endAir", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "originalLanguage": { + "name": "originalLanguage", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "externalId": { + "name": "externalId", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "nextRefresh": { + "name": "nextRefresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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"] + } + }, + "checkConstraints": { + "ratingValid": { + "name": "ratingValid", + "value": "0 <= \"shows\".\"rating\" && \"shows\".\"rating\" <= 100" + }, + "runtimeValid": { + "name": "runtimeValid", + "value": "0 <= \"shows\".\"runtime\"" + } + } + }, + "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": "integer", + "primaryKey": false, + "notNull": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "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"] + } + }, + "checkConstraints": { + "renderingPos": { + "name": "renderingPos", + "value": "0 <= \"videos\".\"rendering\"" + }, + "partPos": { + "name": "partPos", + "value": "0 <= \"videos\".\"part\"" + }, + "versionPos": { + "name": "versionPos", + "value": "0 <= \"videos\".\"version\"" + } + } + } + }, + "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": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index 5fa49ee67..e48a9a2e7 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1730487641214, "tag": "0002_shows", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1731101306525, + "tag": "0003_runtime", + "breakpoints": true } ] } diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index 6e015dccf..2e4bcde24 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -3,7 +3,6 @@ import { check, date, integer, - jsonb, primaryKey, smallint, text, @@ -55,6 +54,7 @@ export const shows = schema.table( kind: showKind().notNull(), genres: genres().array().notNull(), rating: smallint(), + runtime: integer(), status: showStatus().notNull(), startAir: date({ mode: "date" }), endAir: date({ mode: "date" }), @@ -70,6 +70,7 @@ export const shows = schema.table( "ratingValid", sql`0 <= ${t.rating} && ${t.rating} <= 100`, ), + runtimeValid: check("runtimeValid", sql`0 <= ${t.runtime}`), }), ); diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 5dae34696..f0c20011c 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -15,6 +15,7 @@ export const Movie = t.Object({ genres: t.Array(Genre), rating: t.Nullable(t.Number({ minimum: 0, maximum: 100 })), status: ShowStatus, + runtime: t.Nullable(t.Number({ minimum: 0 })), airDate: t.Nullable(t.Date()), originalLanguage: t.Nullable(t.String()), From 78e84cf960c266fcc2fceca5bf6184889396a491 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 8 Nov 2024 22:32:19 +0100 Subject: [PATCH 021/105] Use string for date & datetime --- api/src/db/schema/entries.ts | 4 ++-- api/src/db/schema/shows.ts | 10 ++++++---- api/src/models/movie.ts | 9 ++++----- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index e7b52a6df..dfb13fd48 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -37,8 +37,8 @@ export const entries = schema.table( externalId: jsonb().notNull().default({}), - createdAt: timestamp({ withTimezone: true }).defaultNow(), - nextRefresh: timestamp({ withTimezone: true }), + createdAt: timestamp({ withTimezone: true, mode: "string" }).defaultNow(), + nextRefresh: timestamp({ withTimezone: true, mode: "string" }), }, (t) => ({ // episodeKey: unique().on(t.showId, t.seasonNumber, t.episodeNumber), diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index 2e4bcde24..ba2aa3e68 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -56,14 +56,16 @@ export const shows = schema.table( rating: smallint(), runtime: integer(), status: showStatus().notNull(), - startAir: date({ mode: "date" }), - endAir: date({ mode: "date" }), + startAir: date(), + endAir: date(), originalLanguage: language(), externalId: externalid(), - createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), - nextRefresh: timestamp({ withTimezone: true }).notNull(), + createdAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .defaultNow(), + nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(), }, (t) => ({ ratingValid: check( diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index f0c20011c..765ce890c 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -17,18 +17,17 @@ export const Movie = t.Object({ status: ShowStatus, runtime: t.Nullable(t.Number({ minimum: 0 })), - airDate: t.Nullable(t.Date()), + airDate: t.Nullable(t.String({ format: "date" })), originalLanguage: t.Nullable(t.String()), - trailerUrl: t.Nullable(t.String()), poster: t.Nullable(Image), thumbnail: t.Nullable(Image), banner: t.Nullable(Image), logo: t.Nullable(Image), + trailerUrl: t.Nullable(t.String()), - // this is a datetime, not just a date. - createdAt: t.Date(), - nextRefresh: t.Date(), + createdAt: t.String({ format: "date-time" }), + nextRefresh: t.String({ format: "date-time" }), externalId: ExternalId, }); From a37c4fe7236fe14963e3c250ca44372240555308 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 8 Nov 2024 22:33:04 +0100 Subject: [PATCH 022/105] Fix subquery handling of translations --- api/src/controllers/movies.ts | 41 +++++++++++++++---------------- api/src/db/schema/utils.ts | 45 ++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 78155c60d..13242ec60 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -2,34 +2,35 @@ import { Elysia, t } from "elysia"; import { Movie } from "../models/movie"; import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; -import { eq, and, sql, or, inArray, getTableColumns } from "drizzle-orm"; +import { eq, and, sql, or, inArray } from "drizzle-orm"; +import { getColumns } from "../db/schema/utils"; + +const translations = db + .selectDistinctOn([showTranslations.language]) + .from(showTranslations) + .where( + or( + inArray(showTranslations.language, sql.placeholder("langs")), + eq(showTranslations.language, shows.originalLanguage), + ), + ) + .orderBy( + sql`array_position(${showTranslations.language}, ${sql.placeholder("langs")})`, + ) + .as("t"); + +const { pk: _, kind, startAir, endAir, ...moviesCol } = getColumns(shows); +const { pk, language, ...translationsCol } = getColumns(translations); -const { pk: _, kind, startAir, endAir, ...moviesCol } = getTableColumns(shows); -const { pk, language, ...translationsCol } = getTableColumns(showTranslations); const findMovie = db .select({ ...moviesCol, airDate: startAir, - ...translationsCol, + translations: translationsCol, }) .from(shows) - .innerJoin( - db - .selectDistinctOn([showTranslations.language]) - .from(showTranslations) - .where( - or( - inArray(showTranslations.language, sql.placeholder("langs")), - eq(showTranslations.language, shows.originalLanguage), - ), - ) - .orderBy( - sql`array_position(${showTranslations.language}, ${sql.placeholder("langs")})`, - ) - .as("t"), - eq(shows.pk, showTranslations.pk), - ) + .innerJoin(translations, eq(shows.pk, translations.pk)) .where( and( eq(shows.kind, "movie"), diff --git a/api/src/db/schema/utils.ts b/api/src/db/schema/utils.ts index 02f6e8afb..f314ccd39 100644 --- a/api/src/db/schema/utils.ts +++ b/api/src/db/schema/utils.ts @@ -1,4 +1,20 @@ -import { jsonb, pgSchema, varchar } from "drizzle-orm/pg-core"; +import { + is, + type ColumnsSelection, + type Subquery, + Table, + View, + ViewBaseConfig, +} from "drizzle-orm"; +import type { AnyMySqlSelect } from "drizzle-orm/mysql-core"; +import { + type AnyPgSelect, + jsonb, + pgSchema, + varchar, +} from "drizzle-orm/pg-core"; +import type { AnySQLiteSelect } from "drizzle-orm/sqlite-core"; +import type { WithSubquery } from "drizzle-orm/subquery"; export const schema = pgSchema("kyoo"); @@ -20,3 +36,30 @@ export const externalid = () => >() .notNull() .default({}); + +// https://github.com/sindresorhus/type-fest/blob/main/source/simplify.d.ts#L58 +type Simplify = {[KeyType in keyof T]: T[KeyType]} & {}; + +// See https://github.com/drizzle-team/drizzle-orm/pull/1789 +type Select = AnyPgSelect | AnyMySqlSelect | AnySQLiteSelect; +type AnySelect = Simplify & Partial>>; +export function getColumns< + T extends + | Table + | View + | Subquery + | WithSubquery + | AnySelect, +>( + table: T, +): T extends Table + ? T["_"]["columns"] + : T extends View | Subquery | WithSubquery | AnySelect + ? T["_"]["selectedFields"] + : never { + return is(table, Table) + ? (table as any)[(Table as any).Symbol.Columns] + : is(table, View) + ? (table as any)[ViewBaseConfig].selectedFields + : table._.selectedFields; +} From 8d01a87b6c4388b11d59ffa3c9fc6e49758bba0c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 8 Nov 2024 22:33:33 +0100 Subject: [PATCH 023/105] wip: Add movie/video example --- api/src/models/examples.ts | 58 ++++++++++++++++++++++++++++++++++++++ api/src/models/movie.ts | 8 ++---- api/src/models/video.ts | 19 +++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 api/src/models/examples.ts diff --git a/api/src/models/examples.ts b/api/src/models/examples.ts new file mode 100644 index 000000000..25f2f765c --- /dev/null +++ b/api/src/models/examples.ts @@ -0,0 +1,58 @@ +import type { CompleteVideo } from "./video"; + +export const bubble: CompleteVideo = { + id: "0934da28-4a49-404e-920b-a150404a3b6d", + path: "/video/Bubble/Bubble (2022).mkv", + rendering: 0, + part: 0, + version: 1, + createdAt: "2023-11-29T11:42:06.030838Z", + movie: { + id: "008f0b42-61b8-4155-857a-cbe5f40dd35d", + slug: "bubble", + name: "Bubble", + tagline: "Is she a calamity or a blessing?", + description: + "In an abandoned Tokyo overrun by bubbles and gravitational abnormalities, one gifted young man has a fateful meeting with a mysterious girl.", + aliases: ["Baburu", "バブル:2022", "Bubble"], + tags: ["adolescence", "disaster", "battle", "gravity", "anime"], + genres: ["animation", "adventure", "science-fiction", "fantasy"], + rating: 74, + status: "finished", + runtime: 101, + airDate: "2022-02-14", + originalLanguage: null, + poster: { + id: "befdc7dd-2a67-0704-92af-90d49eee0315", + source: + "https://image.tmdb.org/t/p/original/65dad96VE8FJPEdrAkhdsuWMWH9.jpg", + blurhash: "LFC@2F;K$%xZ5?W.MwNF0iD~MxR:", + }, + thumbnail: { + id: "b29908f3-a64d-ae98-923b-18bf7995ab04", + source: + "https://image.tmdb.org/t/p/original/a8Q2g0g7XzAF6gcB8qgn37ccb9Y.jpg", + blurhash: "LpH3afE1XAveyGS7t6V[R4xZn+S6", + }, + banner: null, + logo: { + id: "3357fad0-de40-4ca5-15e6-eb065d35be86", + source: + "https://image.tmdb.org/t/p/original/ihIs7fayAmZieMlMQbs6TWM77uf.png", + blurhash: "LMDc5#MwE0,sTKE0R*S~4mxunhb_", + }, + trailerUrl: "https://www.youtube.com/watch?v=vs7zsyIZkMM", + createdAt: "2023-11-29T11:42:06.030838Z", + nextRefresh: "2025-01-07T22:40:59.960952Z", + externalId: { + themoviedatabase: { + dataId: "912598", + link: "https://www.themoviedb.org/movie/912598", + }, + imdb: { + dataId: "tt16360006", + link: "https://www.imdb.com/title/tt16360006", + }, + }, + }, +}; diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 765ce890c..beed9b031 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -2,6 +2,7 @@ import { t } from "elysia"; import { Genre, ShowStatus } from "./show"; import { Image } from "./image"; import { ExternalId } from "./external-id"; +import { bubble } from "./examples"; export const Movie = t.Object({ id: t.String({ format: "uuid" }), @@ -34,9 +35,4 @@ export const Movie = t.Object({ export type Movie = typeof Movie.static; -// Movie.examples = [{ -// slug: "bubble", -// title: "Bubble", -// tagline: "Is she a calamity or a blessing?", -// description: " In an abandoned Tokyo overrun by bubbles and gravitational abnormalities, one gifted young man has a fateful meeting with a mysterious girl. ", -// }] +Movie.examples = [bubble.movie]; diff --git a/api/src/models/video.ts b/api/src/models/video.ts index b2011c929..248c72668 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,4 +1,6 @@ import { t } from "elysia"; +import { Movie } from "./movie"; +import { bubble } from "./examples"; export const Video = t.Object({ id: t.String({ format: "uuid" }), @@ -11,3 +13,20 @@ export const Video = t.Object({ }); export type Video = typeof Video.static; + +Video.examples = [bubble]; + +export const CompleteVideo = t.Intersect([ + Video, + t.Union([ + t.Object({ + movie: Movie, + }), + t.Object({ + // TODO: implement that + episodes: t.Array(t.Object({})), + }), + ]), +]); + +export type CompleteVideo = typeof CompleteVideo.static; From d3fc3894bca771d08e0eaf429ebbfffc51b0b87c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 8 Nov 2024 23:18:38 +0100 Subject: [PATCH 024/105] Manually fix migrations and startup --- api/drizzle/0001_shows.sql | 4 ++-- api/drizzle/meta/0001_snapshot.json | 2 +- api/src/db/index.ts | 2 +- api/src/db/schema/shows.ts | 2 +- api/src/index.ts | 17 ++++++++++++----- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/api/drizzle/0001_shows.sql b/api/drizzle/0001_shows.sql index 405f56331..3779aea73 100644 --- a/api/drizzle/0001_shows.sql +++ b/api/drizzle/0001_shows.sql @@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS "kyoo"."shows" ( "id" uuid DEFAULT gen_random_uuid() NOT NULL, "slug" varchar(255) NOT NULL, "kind" "kyoo"."show_kind" NOT NULL, - "genres" genres[] NOT NULL, + "genres" "kyoo"."genres"[] NOT NULL, "rating" smallint, "status" "kyoo"."show_status" NOT NULL, "startAir" date, @@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS "kyoo"."shows" ( "nextRefresh" timestamp with time zone, CONSTRAINT "shows_id_unique" UNIQUE("id"), CONSTRAINT "shows_slug_unique" UNIQUE("slug"), - CONSTRAINT "ratingValid" CHECK (0 <= "shows"."rating" && "shows"."rating" <= 100) + CONSTRAINT "ratingValid" CHECK ("shows"."rating" between 0 and 100) ); --> statement-breakpoint ALTER TABLE "kyoo"."entries" ADD COLUMN "createdAt" timestamp with time zone DEFAULT now();--> statement-breakpoint diff --git a/api/drizzle/meta/0001_snapshot.json b/api/drizzle/meta/0001_snapshot.json index 387887c48..1e5684cec 100644 --- a/api/drizzle/meta/0001_snapshot.json +++ b/api/drizzle/meta/0001_snapshot.json @@ -391,7 +391,7 @@ "checkConstraints": { "ratingValid": { "name": "ratingValid", - "value": "0 <= \"shows\".\"rating\" && \"shows\".\"rating\" <= 100" + "value": "\"shows\".\"rating\" between 0 and 100" } } } diff --git a/api/src/db/index.ts b/api/src/db/index.ts index 529103e7c..04e146651 100644 --- a/api/src/db/index.ts +++ b/api/src/db/index.ts @@ -15,7 +15,7 @@ export const db = drizzle({ database: process.env.POSTGRES_DB ?? "kyooDB", host: process.env.POSTGRES_SERVER ?? "postgres", port: Number(process.env.POSTGRES_PORT) || 5432, - ssl: true, + ssl: false, }, casing: "snake_case", }); diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index ba2aa3e68..e090903d0 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -70,7 +70,7 @@ export const shows = schema.table( (t) => ({ ratingValid: check( "ratingValid", - sql`0 <= ${t.rating} && ${t.rating} <= 100`, + sql`${t.rating} between 0 and 100`, ), runtimeValid: check("runtimeValid", sql`0 <= ${t.runtime}`), }), diff --git a/api/src/index.ts b/api/src/index.ts index 51978e1e2..2bb795f62 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -5,18 +5,25 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; import { movies } from "./controllers/movies"; import jwt from "@elysiajs/jwt"; -await migrate(db, { migrationsFolder: "" }); +await migrate(db, { migrationsSchema: "kyoo", migrationsFolder: "./drizzle" }); + +if (process.env.SEED) { +} let secret = process.env.JWT_SECRET; if (!secret) { const auth = process.env.AUTH_SERVER ?? "http://auth:4568"; - const ret = await fetch(`${auth}/info`); - const info = await ret.json(); - secret = info.publicKey; + try { + const ret = await fetch(`${auth}/info`); + const info = await ret.json(); + secret = info.publicKey; + } catch (error) { + console.error(`Can't access auth server at ${auth}:\n${error}`); + } } if (!secret) { - console.error("missing jwt secret or auth server. exiting"); + console.error("Missing jwt secret or auth server. exiting"); process.exit(1); } From e2e04b88f4c0ef3fde4a2e313cb5d100282bff64 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 8 Nov 2024 23:18:51 +0100 Subject: [PATCH 025/105] Upgrade packages --- api/bun.lockb | Bin 40649 -> 39908 bytes api/package.json | 8 ++++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/bun.lockb b/api/bun.lockb index 1af685df995ba353940fba7c93ee6c1c2851a607..518a9809290739c25f2c15b17f7972915dca1c2f 100755 GIT binary patch delta 6604 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 dQEYFy?g0BX^I+-5 Date: Fri, 8 Nov 2024 23:39:29 +0100 Subject: [PATCH 026/105] Update schema for new drizzle version and fix some config issue --- api/drizzle.config.ts | 1 + api/src/db/schema/entries.ts | 16 ++++++++-------- api/src/db/schema/shows.ts | 15 +++++---------- api/src/db/schema/videos.ts | 10 +++++----- 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/api/drizzle.config.ts b/api/drizzle.config.ts index 50f19f188..cf35e7c29 100644 --- a/api/drizzle.config.ts +++ b/api/drizzle.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ out: "./drizzle", schema: "./src/db/schema", dialect: "postgresql", + casing: "snake_case", dbCredentials: { url: process.env.DATABASE_URL!, }, diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index dfb13fd48..d9dcca510 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -7,10 +7,12 @@ import { primaryKey, text, timestamp, + unique, uuid, varchar, } from "drizzle-orm/pg-core"; import { image, language, schema } from "./utils"; +import { shows } from "./shows"; export const entryType = schema.enum("entry_type", [ "unknown", @@ -26,7 +28,7 @@ export const entries = schema.table( pk: integer().primaryKey().generatedAlwaysAsIdentity(), id: uuid().notNull().unique().defaultRandom(), slug: varchar({ length: 255 }).notNull().unique(), - // showId: integer().references(() => show.id), + showId: integer().references(() => shows.id, { onDelete: "cascade" }), order: integer().notNull(), seasonNumber: integer(), episodeNumber: integer(), @@ -40,10 +42,10 @@ export const entries = schema.table( createdAt: timestamp({ withTimezone: true, mode: "string" }).defaultNow(), nextRefresh: timestamp({ withTimezone: true, mode: "string" }), }, - (t) => ({ - // episodeKey: unique().on(t.showId, t.seasonNumber, t.episodeNumber), - orderPositive: check("orderPositive", sql`${t.order} >= 0`), - }), + (t) => [ + unique().on(t.showId, t.seasonNumber, t.episodeNumber), + check("order_positive", sql`${t.order} >= 0`), + ], ); export const entriesTranslation = schema.table( @@ -56,7 +58,5 @@ export const entriesTranslation = schema.table( name: text(), description: text(), }, - (t) => ({ - pk: primaryKey({ columns: [t.pk, t.language] }), - }), + (t) => [primaryKey({ columns: [t.pk, t.language] })], ); diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index e090903d0..c64fd3b62 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -67,13 +67,10 @@ export const shows = schema.table( .defaultNow(), nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(), }, - (t) => ({ - ratingValid: check( - "ratingValid", - sql`${t.rating} between 0 and 100`, - ), - runtimeValid: check("runtimeValid", sql`0 <= ${t.runtime}`), - }), + (t) => [ + check("rating_valid", sql`${t.rating} between 0 and 100`), + check("runtime_valid", sql`${t.runtime} >= 0`), + ], ); export const showTranslations = schema.table( @@ -94,7 +91,5 @@ export const showTranslations = schema.table( banner: image(), logo: image(), }, - (t) => ({ - pk: primaryKey({ columns: [t.pk, t.language] }), - }), + (t) => [primaryKey({ columns: [t.pk, t.language] })], ); diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index a4ad244f9..86574f815 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -14,9 +14,9 @@ export const videos = schema.table( createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }, - (t) => ({ - ratingValid: check("renderingPos", sql`0 <= ${t.rendering}`), - partValid: check("partPos", sql`0 <= ${t.part}`), - versionValid: check("versionPos", sql`0 <= ${t.version}`), - }), + (t) => [ + check("rendering_pos", sql`${t.rendering} >= 0`), + check("part_pos", sql`${t.part} >= 0`), + check("version_pos", sql`${t.version} >= 0`), + ], ); From 143ac6c721d4784fff3c59e024da6bb778f2d12f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 8 Nov 2024 23:39:39 +0100 Subject: [PATCH 027/105] Regenerate migrations --- api/drizzle/0000_init.sql | 84 ++++- api/drizzle/0001_shows.sql | 45 --- api/drizzle/0002_shows.sql | 2 - api/drizzle/0003_runtime.sql | 17 - api/drizzle/meta/0000_snapshot.json | 444 ++++++++++++++++++++-- api/drizzle/meta/0001_snapshot.json | 455 ----------------------- api/drizzle/meta/0002_snapshot.json | 455 ----------------------- api/drizzle/meta/0003_snapshot.json | 555 ---------------------------- api/drizzle/meta/_journal.json | 23 +- 9 files changed, 502 insertions(+), 1578 deletions(-) delete mode 100644 api/drizzle/0001_shows.sql delete mode 100644 api/drizzle/0002_shows.sql delete mode 100644 api/drizzle/0003_runtime.sql delete mode 100644 api/drizzle/meta/0001_snapshot.json delete mode 100644 api/drizzle/meta/0002_snapshot.json delete mode 100644 api/drizzle/meta/0003_snapshot.json diff --git a/api/drizzle/0000_init.sql b/api/drizzle/0000_init.sql index dc4826ad2..d10a11605 100644 --- a/api/drizzle/0000_init.sql +++ b/api/drizzle/0000_init.sql @@ -1,20 +1,28 @@ +CREATE SCHEMA "kyoo"; +--> statement-breakpoint CREATE TYPE "kyoo"."entry_type" AS ENUM('unknown', 'episode', 'movie', 'special', 'extra');--> statement-breakpoint +CREATE TYPE "kyoo"."genres" AS ENUM('action', 'adventure', 'animation', 'comedy', 'crime', 'documentary', 'drama', 'family', 'fantasy', 'history', 'horror', 'music', 'mystery', 'romance', 'science-fiction', 'thriller', 'war', 'western', 'kids', 'reality', 'politics', 'soap', 'talk');--> statement-breakpoint +CREATE TYPE "kyoo"."show_kind" AS ENUM('serie', 'movie');--> statement-breakpoint +CREATE TYPE "kyoo"."show_status" AS ENUM('unknown', 'finished', 'airing', 'planned');--> statement-breakpoint CREATE TABLE IF NOT EXISTS "kyoo"."entries" ( "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."entries_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), "id" uuid DEFAULT gen_random_uuid() NOT NULL, "slug" varchar(255) NOT NULL, + "show_id" integer, "order" integer NOT NULL, - "seasonNumber" integer, - "episodeNumber" integer, + "season_number" integer, + "episode_number" integer, "type" "kyoo"."entry_type" NOT NULL, - "airDate" date, + "air_date" date, "runtime" integer, "thumbnails" jsonb, - "nextRefresh" timestamp with time zone, - "externalId" jsonb DEFAULT '{}'::jsonb NOT NULL, + "external_id" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + "next_refresh" timestamp with time zone, CONSTRAINT "entries_id_unique" UNIQUE("id"), CONSTRAINT "entries_slug_unique" UNIQUE("slug"), - CONSTRAINT "orderPositive" CHECK ("entries"."order" >= 0) + CONSTRAINT "entries_showId_seasonNumber_episodeNumber_unique" UNIQUE("show_id","season_number","episode_number"), + CONSTRAINT "order_positive" CHECK ("entries"."order" >= 0) ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "kyoo"."entries_translation" ( @@ -25,8 +33,72 @@ CREATE TABLE IF NOT EXISTS "kyoo"."entries_translation" ( CONSTRAINT "entries_translation_pk_language_pk" PRIMARY KEY("pk","language") ); --> statement-breakpoint +CREATE TABLE IF NOT EXISTS "kyoo"."show_translations" ( + "pk" integer NOT NULL, + "language" varchar(255) NOT NULL, + "name" text NOT NULL, + "description" text, + "tagline" text, + "aliases" text[] NOT NULL, + "tags" text[] NOT NULL, + "trailer_url" text, + "poster" jsonb, + "thumbnail" jsonb, + "banner" jsonb, + "logo" jsonb, + CONSTRAINT "show_translations_pk_language_pk" PRIMARY KEY("pk","language") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "kyoo"."shows" ( + "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."shows_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "slug" varchar(255) NOT NULL, + "kind" "kyoo"."show_kind" NOT NULL, + "genres" "genres"[] NOT NULL, + "rating" smallint, + "runtime" integer, + "status" "kyoo"."show_status" NOT NULL, + "start_air" date, + "end_air" date, + "original_language" varchar(255), + "external_id" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "next_refresh" timestamp with time zone NOT NULL, + CONSTRAINT "shows_id_unique" UNIQUE("id"), + CONSTRAINT "shows_slug_unique" UNIQUE("slug"), + CONSTRAINT "rating_valid" CHECK ("shows"."rating" between 0 and 100), + CONSTRAINT "runtime_valid" CHECK ("shows"."runtime" >= 0) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "kyoo"."videos" ( + "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."videos_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "path" text NOT NULL, + "rendering" integer, + "part" integer, + "version" integer, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "videos_id_unique" UNIQUE("id"), + CONSTRAINT "videos_path_unique" UNIQUE("path"), + CONSTRAINT "rendering_pos" CHECK ("videos"."rendering" >= 0), + CONSTRAINT "part_pos" CHECK ("videos"."part" >= 0), + CONSTRAINT "version_pos" CHECK ("videos"."version" >= 0) +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "kyoo"."entries" ADD CONSTRAINT "entries_show_id_shows_id_fk" FOREIGN KEY ("show_id") REFERENCES "kyoo"."shows"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint DO $$ BEGIN ALTER TABLE "kyoo"."entries_translation" ADD CONSTRAINT "entries_translation_pk_entries_pk_fk" FOREIGN KEY ("pk") REFERENCES "kyoo"."entries"("pk") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "kyoo"."show_translations" ADD CONSTRAINT "show_translations_pk_shows_pk_fk" FOREIGN KEY ("pk") REFERENCES "kyoo"."shows"("pk") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/api/drizzle/0001_shows.sql b/api/drizzle/0001_shows.sql deleted file mode 100644 index 3779aea73..000000000 --- a/api/drizzle/0001_shows.sql +++ /dev/null @@ -1,45 +0,0 @@ ---> statement-breakpoint -CREATE TYPE "kyoo"."genres" AS ENUM('action', 'adventure', 'animation', 'comedy', 'crime', 'documentary', 'drama', 'family', 'fantasy', 'history', 'horror', 'music', 'mystery', 'romance', 'science-fiction', 'thriller', 'war', 'western', 'kids', 'reality', 'politics', 'soap', 'talk');--> statement-breakpoint -CREATE TYPE "kyoo"."show_kind" AS ENUM('serie', 'movie');--> statement-breakpoint -CREATE TYPE "kyoo"."show_status" AS ENUM('unknown', 'finished', 'airing', 'planned');--> statement-breakpoint -CREATE TABLE IF NOT EXISTS "kyoo"."show_translations" ( - "pk" integer NOT NULL, - "language" varchar(255) NOT NULL, - "name" text NOT NULL, - "description" text, - "tagline" text, - "aliases" text[] NOT NULL, - "tags" text[] NOT NULL, - "trailerUrl" text, - "poster" jsonb, - "thumbnail" jsonb, - "banner" jsonb, - "logo" jsonb, - CONSTRAINT "show_translations_pk_language_pk" PRIMARY KEY("pk","language") -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "kyoo"."shows" ( - "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."shows_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), - "id" uuid DEFAULT gen_random_uuid() NOT NULL, - "slug" varchar(255) NOT NULL, - "kind" "kyoo"."show_kind" NOT NULL, - "genres" "kyoo"."genres"[] NOT NULL, - "rating" smallint, - "status" "kyoo"."show_status" NOT NULL, - "startAir" date, - "endAir" date, - "originalLanguage" varchar(255), - "externalId" jsonb DEFAULT '{}'::jsonb NOT NULL, - "createdAt" timestamp with time zone DEFAULT now(), - "nextRefresh" timestamp with time zone, - CONSTRAINT "shows_id_unique" UNIQUE("id"), - CONSTRAINT "shows_slug_unique" UNIQUE("slug"), - CONSTRAINT "ratingValid" CHECK ("shows"."rating" between 0 and 100) -); ---> statement-breakpoint -ALTER TABLE "kyoo"."entries" ADD COLUMN "createdAt" timestamp with time zone DEFAULT now();--> statement-breakpoint -DO $$ BEGIN - ALTER TABLE "kyoo"."show_translations" ADD CONSTRAINT "show_translations_pk_shows_pk_fk" FOREIGN KEY ("pk") REFERENCES "kyoo"."shows"("pk") ON DELETE cascade ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; diff --git a/api/drizzle/0002_shows.sql b/api/drizzle/0002_shows.sql deleted file mode 100644 index bd83742f1..000000000 --- a/api/drizzle/0002_shows.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "kyoo"."shows" ALTER COLUMN "createdAt" SET NOT NULL;--> statement-breakpoint -ALTER TABLE "kyoo"."shows" ALTER COLUMN "nextRefresh" SET NOT NULL; diff --git a/api/drizzle/0003_runtime.sql b/api/drizzle/0003_runtime.sql deleted file mode 100644 index 2221b5685..000000000 --- a/api/drizzle/0003_runtime.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TABLE IF NOT EXISTS "kyoo"."videos" ( - "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."videos_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), - "id" uuid DEFAULT gen_random_uuid() NOT NULL, - "path" text NOT NULL, - "rendering" integer, - "part" integer, - "version" integer, - "createdAt" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "videos_id_unique" UNIQUE("id"), - CONSTRAINT "videos_path_unique" UNIQUE("path"), - CONSTRAINT "renderingPos" CHECK (0 <= "videos"."rendering"), - CONSTRAINT "partPos" CHECK (0 <= "videos"."part"), - CONSTRAINT "versionPos" CHECK (0 <= "videos"."version") -); ---> statement-breakpoint -ALTER TABLE "kyoo"."shows" ADD COLUMN "runtime" integer;--> statement-breakpoint -ALTER TABLE "kyoo"."shows" ADD CONSTRAINT "runtimeValid" CHECK (0 <= "shows"."runtime"); diff --git a/api/drizzle/meta/0000_snapshot.json b/api/drizzle/meta/0000_snapshot.json index 9d29e87f3..1bc852313 100644 --- a/api/drizzle/meta/0000_snapshot.json +++ b/api/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "362abc74-1487-46ff-bfe2-203ea699f19e", + "id": "88c55813-3ceb-468d-b010-c2b8f7fa875e", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -38,20 +38,26 @@ "primaryKey": false, "notNull": true }, + "show_id": { + "name": "show_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, "order": { "name": "order", "type": "integer", "primaryKey": false, "notNull": true }, - "seasonNumber": { - "name": "seasonNumber", + "season_number": { + "name": "season_number", "type": "integer", "primaryKey": false, "notNull": false }, - "episodeNumber": { - "name": "episodeNumber", + "episode_number": { + "name": "episode_number", "type": "integer", "primaryKey": false, "notNull": false @@ -63,8 +69,8 @@ "primaryKey": false, "notNull": true }, - "airDate": { - "name": "airDate", + "air_date": { + "name": "air_date", "type": "date", "primaryKey": false, "notNull": false @@ -81,22 +87,40 @@ "primaryKey": false, "notNull": false }, - "nextRefresh": { - "name": "nextRefresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "externalId": { - "name": "externalId", + "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": false, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, "indexes": {}, - "foreignKeys": {}, + "foreignKeys": { + "entries_show_id_shows_id_fk": { + "name": "entries_show_id_shows_id_fk", + "tableFrom": "entries", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": { "entries_id_unique": { @@ -108,14 +132,21 @@ "name": "entries_slug_unique", "nullsNotDistinct": false, "columns": ["slug"] + }, + "entries_showId_seasonNumber_episodeNumber_unique": { + "name": "entries_showId_seasonNumber_episodeNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_id", "season_number", "episode_number"] } }, + "policies": {}, "checkConstraints": { - "orderPositive": { - "name": "orderPositive", + "order_positive": { + "name": "order_positive", "value": "\"entries\".\"order\" >= 0" } - } + }, + "isRLSEnabled": false }, "kyoo.entries_translation": { "name": "entries_translation", @@ -166,7 +197,335 @@ } }, "uniqueConstraints": {}, - "checkConstraints": {} + "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 + }, + "trailer_url": { + "name": "trailer_url", + "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 + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": {}, + "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": "\"shows\".\"rating\" between 0 and 100" + }, + "runtime_valid": { + "name": "runtime_valid", + "value": "\"shows\".\"runtime\" >= 0" + } + }, + "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": "integer", + "primaryKey": false, + "notNull": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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": { + "rendering_pos": { + "name": "rendering_pos", + "value": "\"videos\".\"rendering\" >= 0" + }, + "part_pos": { + "name": "part_pos", + "value": "\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"videos\".\"version\" >= 0" + } + }, + "isRLSEnabled": false } }, "enums": { @@ -174,10 +533,53 @@ "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": {}, + "schemas": { + "kyoo": "kyoo" + }, "sequences": {}, + "roles": {}, + "policies": {}, "views": {}, "_meta": { "columns": {}, diff --git a/api/drizzle/meta/0001_snapshot.json b/api/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 1e5684cec..000000000 --- a/api/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,455 +0,0 @@ -{ - "id": "0f48a319-94fe-4bcc-b63c-28ce280abc9a", - "prevId": "362abc74-1487-46ff-bfe2-203ea699f19e", - "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 - }, - "order": { - "name": "order", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "seasonNumber": { - "name": "seasonNumber", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "episodeNumber": { - "name": "episodeNumber", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "entry_type", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "airDate": { - "name": "airDate", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "runtime": { - "name": "runtime", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "thumbnails": { - "name": "thumbnails", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "externalId": { - "name": "externalId", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "nextRefresh": { - "name": "nextRefresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "entries_id_unique": { - "name": "entries_id_unique", - "nullsNotDistinct": false, - "columns": ["id"] - }, - "entries_slug_unique": { - "name": "entries_slug_unique", - "nullsNotDistinct": false, - "columns": ["slug"] - } - }, - "checkConstraints": { - "orderPositive": { - "name": "orderPositive", - "value": "\"entries\".\"order\" >= 0" - } - } - }, - "kyoo.entries_translation": { - "name": "entries_translation", - "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 - } - }, - "indexes": {}, - "foreignKeys": { - "entries_translation_pk_entries_pk_fk": { - "name": "entries_translation_pk_entries_pk_fk", - "tableFrom": "entries_translation", - "tableTo": "entries", - "schemaTo": "kyoo", - "columnsFrom": ["pk"], - "columnsTo": ["pk"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "entries_translation_pk_language_pk": { - "name": "entries_translation_pk_language_pk", - "columns": ["pk", "language"] - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "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 - }, - "trailerUrl": { - "name": "trailerUrl", - "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 - }, - "logo": { - "name": "logo", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "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": {}, - "checkConstraints": {} - }, - "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 - }, - "status": { - "name": "status", - "type": "show_status", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "startAir": { - "name": "startAir", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "endAir": { - "name": "endAir", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "originalLanguage": { - "name": "originalLanguage", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "externalId": { - "name": "externalId", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "nextRefresh": { - "name": "nextRefresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "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"] - } - }, - "checkConstraints": { - "ratingValid": { - "name": "ratingValid", - "value": "\"shows\".\"rating\" between 0 and 100" - } - } - } - }, - "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": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/api/drizzle/meta/0002_snapshot.json b/api/drizzle/meta/0002_snapshot.json deleted file mode 100644 index 2cdff00da..000000000 --- a/api/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,455 +0,0 @@ -{ - "id": "1948acaf-7a29-4521-988d-439653779e39", - "prevId": "0f48a319-94fe-4bcc-b63c-28ce280abc9a", - "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 - }, - "order": { - "name": "order", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "seasonNumber": { - "name": "seasonNumber", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "episodeNumber": { - "name": "episodeNumber", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "entry_type", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "airDate": { - "name": "airDate", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "runtime": { - "name": "runtime", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "thumbnails": { - "name": "thumbnails", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "externalId": { - "name": "externalId", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "nextRefresh": { - "name": "nextRefresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "entries_id_unique": { - "name": "entries_id_unique", - "nullsNotDistinct": false, - "columns": ["id"] - }, - "entries_slug_unique": { - "name": "entries_slug_unique", - "nullsNotDistinct": false, - "columns": ["slug"] - } - }, - "checkConstraints": { - "orderPositive": { - "name": "orderPositive", - "value": "\"entries\".\"order\" >= 0" - } - } - }, - "kyoo.entries_translation": { - "name": "entries_translation", - "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 - } - }, - "indexes": {}, - "foreignKeys": { - "entries_translation_pk_entries_pk_fk": { - "name": "entries_translation_pk_entries_pk_fk", - "tableFrom": "entries_translation", - "tableTo": "entries", - "schemaTo": "kyoo", - "columnsFrom": ["pk"], - "columnsTo": ["pk"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "entries_translation_pk_language_pk": { - "name": "entries_translation_pk_language_pk", - "columns": ["pk", "language"] - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "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 - }, - "trailerUrl": { - "name": "trailerUrl", - "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 - }, - "logo": { - "name": "logo", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "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": {}, - "checkConstraints": {} - }, - "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 - }, - "status": { - "name": "status", - "type": "show_status", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "startAir": { - "name": "startAir", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "endAir": { - "name": "endAir", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "originalLanguage": { - "name": "originalLanguage", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "externalId": { - "name": "externalId", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "nextRefresh": { - "name": "nextRefresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "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"] - } - }, - "checkConstraints": { - "ratingValid": { - "name": "ratingValid", - "value": "0 <= \"shows\".\"rating\" && \"shows\".\"rating\" <= 100" - } - } - } - }, - "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": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/api/drizzle/meta/0003_snapshot.json b/api/drizzle/meta/0003_snapshot.json deleted file mode 100644 index 95799ee18..000000000 --- a/api/drizzle/meta/0003_snapshot.json +++ /dev/null @@ -1,555 +0,0 @@ -{ - "id": "2ded3184-f416-40f0-8259-fcab6a4a1edc", - "prevId": "1948acaf-7a29-4521-988d-439653779e39", - "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 - }, - "order": { - "name": "order", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "seasonNumber": { - "name": "seasonNumber", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "episodeNumber": { - "name": "episodeNumber", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "entry_type", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "airDate": { - "name": "airDate", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "runtime": { - "name": "runtime", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "thumbnails": { - "name": "thumbnails", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "externalId": { - "name": "externalId", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "nextRefresh": { - "name": "nextRefresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "entries_id_unique": { - "name": "entries_id_unique", - "nullsNotDistinct": false, - "columns": ["id"] - }, - "entries_slug_unique": { - "name": "entries_slug_unique", - "nullsNotDistinct": false, - "columns": ["slug"] - } - }, - "checkConstraints": { - "orderPositive": { - "name": "orderPositive", - "value": "\"entries\".\"order\" >= 0" - } - } - }, - "kyoo.entries_translation": { - "name": "entries_translation", - "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 - } - }, - "indexes": {}, - "foreignKeys": { - "entries_translation_pk_entries_pk_fk": { - "name": "entries_translation_pk_entries_pk_fk", - "tableFrom": "entries_translation", - "tableTo": "entries", - "schemaTo": "kyoo", - "columnsFrom": ["pk"], - "columnsTo": ["pk"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "entries_translation_pk_language_pk": { - "name": "entries_translation_pk_language_pk", - "columns": ["pk", "language"] - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "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 - }, - "trailerUrl": { - "name": "trailerUrl", - "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 - }, - "logo": { - "name": "logo", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "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": {}, - "checkConstraints": {} - }, - "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 - }, - "startAir": { - "name": "startAir", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "endAir": { - "name": "endAir", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "originalLanguage": { - "name": "originalLanguage", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "externalId": { - "name": "externalId", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "nextRefresh": { - "name": "nextRefresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "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"] - } - }, - "checkConstraints": { - "ratingValid": { - "name": "ratingValid", - "value": "0 <= \"shows\".\"rating\" && \"shows\".\"rating\" <= 100" - }, - "runtimeValid": { - "name": "runtimeValid", - "value": "0 <= \"shows\".\"runtime\"" - } - } - }, - "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": "integer", - "primaryKey": false, - "notNull": false - }, - "part": { - "name": "part", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "createdAt": { - "name": "createdAt", - "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"] - } - }, - "checkConstraints": { - "renderingPos": { - "name": "renderingPos", - "value": "0 <= \"videos\".\"rendering\"" - }, - "partPos": { - "name": "partPos", - "value": "0 <= \"videos\".\"part\"" - }, - "versionPos": { - "name": "versionPos", - "value": "0 <= \"videos\".\"version\"" - } - } - } - }, - "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": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index e48a9a2e7..4c445bf0b 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -5,30 +5,9 @@ { "idx": 0, "version": "7", - "when": 1730060281406, + "when": 1731105447005, "tag": "0000_init", "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1730477283024, - "tag": "0001_shows", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1730487641214, - "tag": "0002_shows", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1731101306525, - "tag": "0003_runtime", - "breakpoints": true } ] } From 3f97ba729daa10c787715e673a5f9ea795fe49fd Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 8 Nov 2024 23:44:24 +0100 Subject: [PATCH 028/105] Fix entries fk and manually fix migrations --- api/drizzle/0000_init.sql | 10 ++++------ api/drizzle/meta/0000_snapshot.json | 20 ++++++++++---------- api/drizzle/meta/_journal.json | 2 +- api/src/db/schema/entries.ts | 4 ++-- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/api/drizzle/0000_init.sql b/api/drizzle/0000_init.sql index d10a11605..a0ad12e8a 100644 --- a/api/drizzle/0000_init.sql +++ b/api/drizzle/0000_init.sql @@ -1,5 +1,3 @@ -CREATE SCHEMA "kyoo"; ---> statement-breakpoint CREATE TYPE "kyoo"."entry_type" AS ENUM('unknown', 'episode', 'movie', 'special', 'extra');--> statement-breakpoint CREATE TYPE "kyoo"."genres" AS ENUM('action', 'adventure', 'animation', 'comedy', 'crime', 'documentary', 'drama', 'family', 'fantasy', 'history', 'horror', 'music', 'mystery', 'romance', 'science-fiction', 'thriller', 'war', 'western', 'kids', 'reality', 'politics', 'soap', 'talk');--> statement-breakpoint CREATE TYPE "kyoo"."show_kind" AS ENUM('serie', 'movie');--> statement-breakpoint @@ -8,7 +6,7 @@ CREATE TABLE IF NOT EXISTS "kyoo"."entries" ( "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."entries_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), "id" uuid DEFAULT gen_random_uuid() NOT NULL, "slug" varchar(255) NOT NULL, - "show_id" integer, + "show_pk" integer, "order" integer NOT NULL, "season_number" integer, "episode_number" integer, @@ -21,7 +19,7 @@ CREATE TABLE IF NOT EXISTS "kyoo"."entries" ( "next_refresh" timestamp with time zone, CONSTRAINT "entries_id_unique" UNIQUE("id"), CONSTRAINT "entries_slug_unique" UNIQUE("slug"), - CONSTRAINT "entries_showId_seasonNumber_episodeNumber_unique" UNIQUE("show_id","season_number","episode_number"), + CONSTRAINT "entries_showPk_seasonNumber_episodeNumber_unique" UNIQUE("show_pk","season_number","episode_number"), CONSTRAINT "order_positive" CHECK ("entries"."order" >= 0) ); --> statement-breakpoint @@ -54,7 +52,7 @@ CREATE TABLE IF NOT EXISTS "kyoo"."shows" ( "id" uuid DEFAULT gen_random_uuid() NOT NULL, "slug" varchar(255) NOT NULL, "kind" "kyoo"."show_kind" NOT NULL, - "genres" "genres"[] NOT NULL, + "genres" "kyoo"."genres"[] NOT NULL, "rating" smallint, "runtime" integer, "status" "kyoo"."show_status" NOT NULL, @@ -86,7 +84,7 @@ CREATE TABLE IF NOT EXISTS "kyoo"."videos" ( ); --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "kyoo"."entries" ADD CONSTRAINT "entries_show_id_shows_id_fk" FOREIGN KEY ("show_id") REFERENCES "kyoo"."shows"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "kyoo"."entries" ADD CONSTRAINT "entries_show_pk_shows_pk_fk" FOREIGN KEY ("show_pk") REFERENCES "kyoo"."shows"("pk") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; diff --git a/api/drizzle/meta/0000_snapshot.json b/api/drizzle/meta/0000_snapshot.json index 1bc852313..0282c254a 100644 --- a/api/drizzle/meta/0000_snapshot.json +++ b/api/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "88c55813-3ceb-468d-b010-c2b8f7fa875e", + "id": "82560792-5f4a-4723-9543-808719ade682", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -38,8 +38,8 @@ "primaryKey": false, "notNull": true }, - "show_id": { - "name": "show_id", + "show_pk": { + "name": "show_pk", "type": "integer", "primaryKey": false, "notNull": false @@ -110,13 +110,13 @@ }, "indexes": {}, "foreignKeys": { - "entries_show_id_shows_id_fk": { - "name": "entries_show_id_shows_id_fk", + "entries_show_pk_shows_pk_fk": { + "name": "entries_show_pk_shows_pk_fk", "tableFrom": "entries", "tableTo": "shows", "schemaTo": "kyoo", - "columnsFrom": ["show_id"], - "columnsTo": ["id"], + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], "onDelete": "cascade", "onUpdate": "no action" } @@ -133,10 +133,10 @@ "nullsNotDistinct": false, "columns": ["slug"] }, - "entries_showId_seasonNumber_episodeNumber_unique": { - "name": "entries_showId_seasonNumber_episodeNumber_unique", + "entries_showPk_seasonNumber_episodeNumber_unique": { + "name": "entries_showPk_seasonNumber_episodeNumber_unique", "nullsNotDistinct": false, - "columns": ["show_id", "season_number", "episode_number"] + "columns": ["show_pk", "season_number", "episode_number"] } }, "policies": {}, diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index 4c445bf0b..3e5d4a5e0 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -5,7 +5,7 @@ { "idx": 0, "version": "7", - "when": 1731105447005, + "when": 1731105746157, "tag": "0000_init", "breakpoints": true } diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index d9dcca510..0f1829125 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -28,7 +28,7 @@ export const entries = schema.table( pk: integer().primaryKey().generatedAlwaysAsIdentity(), id: uuid().notNull().unique().defaultRandom(), slug: varchar({ length: 255 }).notNull().unique(), - showId: integer().references(() => shows.id, { onDelete: "cascade" }), + showPk: integer().references(() => shows.pk, { onDelete: "cascade" }), order: integer().notNull(), seasonNumber: integer(), episodeNumber: integer(), @@ -43,7 +43,7 @@ export const entries = schema.table( nextRefresh: timestamp({ withTimezone: true, mode: "string" }), }, (t) => [ - unique().on(t.showId, t.seasonNumber, t.episodeNumber), + unique().on(t.showPk, t.seasonNumber, t.episodeNumber), check("order_positive", sql`${t.order} >= 0`), ], ); From 372c1f6875d9edb4308be802af634b91b4a3d67d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 01:58:53 +0100 Subject: [PATCH 029/105] Cleanup example registration & add descriptions --- api/src/models/examples.ts | 20 +++++++++++++++++++- api/src/models/movie.ts | 15 ++++++++++++--- api/src/models/video.ts | 16 ++++++++++++---- api/src/utils.ts | 3 +++ 4 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 api/src/utils.ts diff --git a/api/src/models/examples.ts b/api/src/models/examples.ts index 25f2f765c..8fa3e73d5 100644 --- a/api/src/models/examples.ts +++ b/api/src/models/examples.ts @@ -1,5 +1,23 @@ +import type { TSchema } from "elysia"; import type { CompleteVideo } from "./video"; +export const registerExamples = ( + schema: T, + ...examples: (T["static"] | undefined)[] +) => { + for (const example of examples) { + if (!example) continue; + for (const [key, val] of Object.entries(example)) { + const prop = schema.properties[ + key as keyof typeof schema.properties + ] as TSchema; + if (!prop) continue; + prop.examples ??= []; + prop.examples.push(val); + } + } +}; + export const bubble: CompleteVideo = { id: "0934da28-4a49-404e-920b-a150404a3b6d", path: "/video/Bubble/Bubble (2022).mkv", @@ -21,7 +39,7 @@ export const bubble: CompleteVideo = { status: "finished", runtime: 101, airDate: "2022-02-14", - originalLanguage: null, + originalLanguage: "ja", poster: { id: "befdc7dd-2a67-0704-92af-90d49eee0315", source: diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index beed9b031..3555a5e4c 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -2,7 +2,8 @@ import { t } from "elysia"; import { Genre, ShowStatus } from "./show"; import { Image } from "./image"; import { ExternalId } from "./external-id"; -import { bubble } from "./examples"; +import { bubble, registerExamples } from "./examples"; +import { comment } from "../utils"; export const Movie = t.Object({ id: t.String({ format: "uuid" }), @@ -19,7 +20,15 @@ export const Movie = t.Object({ runtime: t.Nullable(t.Number({ minimum: 0 })), airDate: t.Nullable(t.String({ format: "date" })), - originalLanguage: t.Nullable(t.String()), + originalLanguage: t.Nullable( + t.String({ + description: comment` + The language code this movie was made in. + This is a BCP 47 language code (the IETF Best Current Practices on Tags for Identifying Languages). + BCP 47 is also known as RFC 5646. It subsumes ISO 639 and is backward compatible with it. + `, + }), + ), poster: t.Nullable(Image), thumbnail: t.Nullable(Image), @@ -35,4 +44,4 @@ export const Movie = t.Object({ export type Movie = typeof Movie.static; -Movie.examples = [bubble.movie]; +registerExamples(Movie, bubble.movie); diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 248c72668..e8cdb9fae 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,30 +1,38 @@ import { t } from "elysia"; import { Movie } from "./movie"; -import { bubble } from "./examples"; +import { bubble, registerExamples } from "./examples"; export const Video = t.Object({ id: t.String({ format: "uuid" }), path: t.String(), rendering: t.Number({ minimum: 0 }), - part: t.Number({ minimum: 0 }), - version: t.Number({ minimum: 0 }), + part: t.Nullable(t.Number({ minimum: 0 })), + version: t.Nullable( + t.Number({ + minimum: 0, + description: + "Kyoo will prefer playing back the highest `version` number if there's rendering.", + }), + ), createdAt: t.String({ format: "date-time" }), }); export type Video = typeof Video.static; -Video.examples = [bubble]; +registerExamples(Video, bubble); export const CompleteVideo = t.Intersect([ Video, t.Union([ t.Object({ movie: Movie, + episodes: t.Optional(t.Never()), }), t.Object({ // TODO: implement that episodes: t.Array(t.Object({})), + movie: t.Optional(t.Never()), }), ]), ]); diff --git a/api/src/utils.ts b/api/src/utils.ts new file mode 100644 index 000000000..b4b1ca965 --- /dev/null +++ b/api/src/utils.ts @@ -0,0 +1,3 @@ +// remove indent in multi-line comments +export const comment = (str: TemplateStringsArray) => + str.toString().replace(/^\s+/gm, ""); From 039c19f61c4e0977e75e378fad9bd60dd3db206d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 01:59:06 +0100 Subject: [PATCH 030/105] Create dummy video controller --- api/src/controllers/videos.ts | 11 +++++++++++ api/src/index.ts | 8 +++++--- 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 api/src/controllers/videos.ts diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts new file mode 100644 index 000000000..080ec1a9f --- /dev/null +++ b/api/src/controllers/videos.ts @@ -0,0 +1,11 @@ +import { Elysia, t } from "elysia"; +import { Video } from "../models/video"; + +export const videos = new Elysia({ prefix: "/videos" }) + .model({ + video: Video, + error: t.Object({}), + }) + .get("/:id", () => "hello" as unknown as Video, { + response: { 200: "video" }, + }); diff --git a/api/src/index.ts b/api/src/index.ts index 2bb795f62..25976b321 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,9 +1,10 @@ -import { Elysia } from "elysia"; +import jwt from "@elysiajs/jwt"; import { swagger } from "@elysiajs/swagger"; -import { db } from "./db"; import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { Elysia } from "elysia"; import { movies } from "./controllers/movies"; -import jwt from "@elysiajs/jwt"; +import { videos } from "./controllers/videos"; +import { db } from "./db"; await migrate(db, { migrationsSchema: "kyoo", migrationsFolder: "./drizzle" }); @@ -32,6 +33,7 @@ const app = new Elysia() .use(swagger()) .get("/", () => "Hello Elysia") .use(movies) + .use(videos) .listen(3000); console.log(`Api running at ${app.server?.hostname}:${app.server?.port}`); From 89952185a9d712f8234dd4922305a321467f3efd Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 11:45:44 +0100 Subject: [PATCH 031/105] Cleanup video schema & add descrpitions of api fields --- api/drizzle/0001_video.sql | 7 + api/drizzle/meta/0001_snapshot.json | 597 ++++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/src/db/schema/videos.ts | 15 +- api/src/models/examples.ts | 5 +- api/src/models/video.ts | 28 +- 6 files changed, 647 insertions(+), 12 deletions(-) create mode 100644 api/drizzle/0001_video.sql create mode 100644 api/drizzle/meta/0001_snapshot.json diff --git a/api/drizzle/0001_video.sql b/api/drizzle/0001_video.sql new file mode 100644 index 000000000..ab8ec07c2 --- /dev/null +++ b/api/drizzle/0001_video.sql @@ -0,0 +1,7 @@ +ALTER TABLE "kyoo"."videos" DROP CONSTRAINT "rendering_pos";--> statement-breakpoint +ALTER TABLE "kyoo"."videos" ALTER COLUMN "rendering" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "kyoo"."videos" ALTER COLUMN "rendering" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "kyoo"."videos" ALTER COLUMN "version" SET DEFAULT 1;--> statement-breakpoint +ALTER TABLE "kyoo"."videos" ALTER COLUMN "version" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "kyoo"."videos" ADD COLUMN "slug" varchar(255) NOT NULL;--> statement-breakpoint +ALTER TABLE "kyoo"."videos" ADD CONSTRAINT "videos_slug_unique" UNIQUE("slug"); \ No newline at end of file diff --git a/api/drizzle/meta/0001_snapshot.json b/api/drizzle/meta/0001_snapshot.json new file mode 100644 index 000000000..400a806a0 --- /dev/null +++ b/api/drizzle/meta/0001_snapshot.json @@ -0,0 +1,597 @@ +{ + "id": "32090852-33a7-430a-9df1-97608c063124", + "prevId": "82560792-5f4a-4723-9543-808719ade682", + "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": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "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": false, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "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": "\"entries\".\"order\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.entries_translation": { + "name": "entries_translation", + "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 + } + }, + "indexes": {}, + "foreignKeys": { + "entries_translation_pk_entries_pk_fk": { + "name": "entries_translation_pk_entries_pk_fk", + "tableFrom": "entries_translation", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entries_translation_pk_language_pk": { + "name": "entries_translation_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "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 + }, + "trailer_url": { + "name": "trailer_url", + "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 + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": {}, + "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": "\"shows\".\"rating\" between 0 and 100" + }, + "runtime_valid": { + "name": "runtime_valid", + "value": "\"shows\".\"runtime\" >= 0" + } + }, + "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()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "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_slug_unique": { + "name": "videos_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "videos_path_unique": { + "name": "videos_path_unique", + "nullsNotDistinct": false, + "columns": ["path"] + } + }, + "policies": {}, + "checkConstraints": { + "part_pos": { + "name": "part_pos", + "value": "\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"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 3e5d4a5e0..d35229563 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1731105746157, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1731149082556, + "tag": "0001_video", + "breakpoints": true } ] } diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index 86574f815..efe32d24c 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -1,5 +1,12 @@ import { sql } from "drizzle-orm"; -import { check, integer, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { + check, + integer, + text, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; import { schema } from "./utils"; export const videos = schema.table( @@ -7,15 +14,15 @@ export const videos = schema.table( { pk: integer().primaryKey().generatedAlwaysAsIdentity(), id: uuid().notNull().unique().defaultRandom(), + slug: varchar({ length: 255 }).notNull().unique(), path: text().notNull().unique(), - rendering: integer(), + rendering: text().notNull(), part: integer(), - version: integer(), + version: integer().notNull().default(1), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }, (t) => [ - check("rendering_pos", sql`${t.rendering} >= 0`), check("part_pos", sql`${t.part} >= 0`), check("version_pos", sql`${t.version} >= 0`), ], diff --git a/api/src/models/examples.ts b/api/src/models/examples.ts index 8fa3e73d5..757c7169f 100644 --- a/api/src/models/examples.ts +++ b/api/src/models/examples.ts @@ -20,9 +20,10 @@ export const registerExamples = ( export const bubble: CompleteVideo = { id: "0934da28-4a49-404e-920b-a150404a3b6d", + slug: "bubble", path: "/video/Bubble/Bubble (2022).mkv", - rendering: 0, - part: 0, + rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd", + part: null, version: 1, createdAt: "2023-11-29T11:42:06.030838Z", movie: { diff --git a/api/src/models/video.ts b/api/src/models/video.ts index e8cdb9fae..794c2a949 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,19 +1,35 @@ import { t } from "elysia"; -import { Movie } from "./movie"; +import { comment } from "../utils"; import { bubble, registerExamples } from "./examples"; +import { Movie } from "./movie"; export const Video = t.Object({ id: t.String({ format: "uuid" }), + slug: t.String(), path: t.String(), - rendering: t.Number({ minimum: 0 }), - part: t.Nullable(t.Number({ minimum: 0 })), - version: t.Nullable( + rendering: t.String({ + description: comment` + Sha of the path except \`part\` & \`version\`. + If there are multiples files for the same entry, it can be used to know if each + file is the same content or if it's unrelated (like long-version vs short-version, monochrome vs colored etc) + `, + }), + part: t.Nullable( t.Number({ minimum: 0, - description: - "Kyoo will prefer playing back the highest `version` number if there's rendering.", + description: comment` + If the episode/movie is split into multiples files, the \`part\` field can be used to order them. + The \`rendering\` field is used to know if two parts are in the same group or + if it's another unrelated video file of the same entry. + `, }), ), + version: t.Number({ + minimum: 0, + default: 1, + description: + "Kyoo will prefer playing back the highest `version` number if there are multiples rendering.", + }), createdAt: t.String({ format: "date-time" }), }); From c5a502ec327c635cba1f116649300c58869a8faf Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 12:29:09 +0100 Subject: [PATCH 032/105] Add serie model, dummy controller & example --- api/src/controllers/series.ts | 11 +++ api/src/index.ts | 2 + .../{examples.ts => examples/bubble.ts} | 20 +---- api/src/models/examples/index.ts | 27 +++++++ api/src/models/examples/made-in-abyss.ts | 79 +++++++++++++++++++ api/src/models/movie.ts | 11 ++- api/src/models/serie.ts | 61 ++++++++++++++ api/src/models/show.ts | 8 -- api/src/seed.ts | 7 ++ 9 files changed, 196 insertions(+), 30 deletions(-) create mode 100644 api/src/controllers/series.ts rename api/src/models/{examples.ts => examples/bubble.ts} (78%) create mode 100644 api/src/models/examples/index.ts create mode 100644 api/src/models/examples/made-in-abyss.ts create mode 100644 api/src/models/serie.ts create mode 100644 api/src/seed.ts diff --git a/api/src/controllers/series.ts b/api/src/controllers/series.ts new file mode 100644 index 000000000..2879abadc --- /dev/null +++ b/api/src/controllers/series.ts @@ -0,0 +1,11 @@ +import { Elysia, t } from "elysia"; +import { Serie } from "../models/serie"; + +export const series = new Elysia({ prefix: "/series" }) + .model({ + serie: Serie, + error: t.Object({}), + }) + .get("/:id", () => "hello" as unknown as Serie, { + response: { 200: "serie" }, + }); diff --git a/api/src/index.ts b/api/src/index.ts index 25976b321..7295c631b 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -3,6 +3,7 @@ import { swagger } from "@elysiajs/swagger"; import { migrate } from "drizzle-orm/node-postgres/migrator"; import { Elysia } from "elysia"; import { movies } from "./controllers/movies"; +import { series } from "./controllers/series"; import { videos } from "./controllers/videos"; import { db } from "./db"; @@ -33,6 +34,7 @@ const app = new Elysia() .use(swagger()) .get("/", () => "Hello Elysia") .use(movies) + .use(series) .use(videos) .listen(3000); diff --git a/api/src/models/examples.ts b/api/src/models/examples/bubble.ts similarity index 78% rename from api/src/models/examples.ts rename to api/src/models/examples/bubble.ts index 757c7169f..58acab3f0 100644 --- a/api/src/models/examples.ts +++ b/api/src/models/examples/bubble.ts @@ -1,22 +1,4 @@ -import type { TSchema } from "elysia"; -import type { CompleteVideo } from "./video"; - -export const registerExamples = ( - schema: T, - ...examples: (T["static"] | undefined)[] -) => { - for (const example of examples) { - if (!example) continue; - for (const [key, val] of Object.entries(example)) { - const prop = schema.properties[ - key as keyof typeof schema.properties - ] as TSchema; - if (!prop) continue; - prop.examples ??= []; - prop.examples.push(val); - } - } -}; +import type { CompleteVideo } from "../video"; export const bubble: CompleteVideo = { id: "0934da28-4a49-404e-920b-a150404a3b6d", diff --git a/api/src/models/examples/index.ts b/api/src/models/examples/index.ts new file mode 100644 index 000000000..8a2e2f3d8 --- /dev/null +++ b/api/src/models/examples/index.ts @@ -0,0 +1,27 @@ +import type { TSchema } from "elysia"; + +export const registerExamples = ( + schema: T, + ...examples: (T["static"] | undefined)[] +) => { + if ("anyOf" in schema) { + for (const union of schema.anyOf) { + registerExamples(union, examples); + } + return; + } + for (const example of examples) { + if (!example) continue; + for (const [key, val] of Object.entries(example)) { + const prop = schema.properties[ + key as keyof typeof schema.properties + ] as TSchema; + if (!prop) continue; + prop.examples ??= []; + prop.examples.push(val); + } + } +}; + +export { bubble } from "./bubble"; +export { madeInAbyss } from "./made-in-abyss"; diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts new file mode 100644 index 000000000..b43903580 --- /dev/null +++ b/api/src/models/examples/made-in-abyss.ts @@ -0,0 +1,79 @@ +import type { Serie } from "../serie"; + +export const madeInAbyss: Serie = { + id: "04bcf2ac-3c09-42f6-8357-b003798f9562", + slug: "made-in-abyss", + name: "Made in Abyss", + tagline: "How far would you go… for the ones you love?", + aliases: [ + "Made in Abyss: The Golden City of the Scorching Sun", + "Meidoinabisu", + "Meidoinabisu: Retsujitsu no ôgonkyô", + ], + description: + "Located in the center of a remote island, the Abyss is the last unexplored region, a huge and treacherous fathomless hole inhabited by strange creatures where only the bravest adventurers descend in search of ancient relics. In the upper levels of the Abyss, Riko, a girl who dreams of becoming an explorer, stumbles upon a mysterious little boy.", + tags: [ + "android", + "amnesia", + "post-apocalyptic future", + "exploration", + "friendship", + "mecha", + "survival", + "curse", + "tragedy", + "orphan", + "based on manga", + "robot", + "dark fantasy", + "seinen", + "anime", + "drastic change of life", + "fantasy", + "adventure", + ], + genres: [ + "animation", + "drama", + "action", + "adventure", + "science-fiction", + "fantasy", + ], + status: "finished", + rating: 84, + runtime: 24, + originalLanguage: "ja", + startAir: "2017-07-07", + endAir: "2022-09-28", + poster: { + id: "8205a20e-d91f-804c-3a84-4e4dc6202d66", + source: + "https://image.tmdb.org/t/p/original/4Bh9qzB1Kau4RDaVQXVFdoJ0HcE.jpg", + blurhash: "LZGlS3XTD%jE~Wf,SeV@%2o|WERj", + }, + thumbnail: { + id: "819d816c-88f6-9f3a-b5e7-ce3daaffbac4", + source: + "https://image.tmdb.org/t/p/original/Df9XrvZFIeQfLKfu8evRmzvRsd.jpg", + blurhash: "LmJtk{kq~q%2bbWCxaV@.8RixuNG", + }, + logo: { + id: "23cb7b06-8406-2288-8e40-08bfc16180b5", + source: + "https://image.tmdb.org/t/p/original/7hY3Q4GhkiYPBfn4UoVg0AO4Zgk.png", + blurhash: "LKGaa%M{0zbI#7$%bbofGGw^wcw{", + }, + banner: null, + trailerUrl: "https://www.youtube.com/watch?v=ePOyy6Wlk4s", + externalId: { + themoviedatabase: { + dataId: "72636", + link: "https://www.themoviedb.org/tv/72636", + }, + imdb: { dataId: "tt7222086", link: "https://www.imdb.com/title/tt7222086" }, + tvdb: { dataId: "326109", link: null }, + }, + createdAt: "2023-11-29T11:12:11.949503Z", + nextRefresh: "2025-01-07T11:42:50.948248Z", +}; diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 3555a5e4c..7a0d22715 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -1,10 +1,13 @@ import { t } from "elysia"; -import { Genre, ShowStatus } from "./show"; +import { Genre } from "./show"; import { Image } from "./image"; import { ExternalId } from "./external-id"; import { bubble, registerExamples } from "./examples"; import { comment } from "../utils"; +export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]); +export type MovieStatus = typeof MovieStatus.static; + export const Movie = t.Object({ id: t.String({ format: "uuid" }), slug: t.String(), @@ -16,8 +19,10 @@ export const Movie = t.Object({ genres: t.Array(Genre), rating: t.Nullable(t.Number({ minimum: 0, maximum: 100 })), - status: ShowStatus, - runtime: t.Nullable(t.Number({ minimum: 0 })), + status: MovieStatus, + runtime: t.Nullable( + t.Number({ minimum: 0, description: "Runtime of the movie in minutes." }), + ), airDate: t.Nullable(t.String({ format: "date" })), originalLanguage: t.Nullable( diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts new file mode 100644 index 000000000..cc0850cff --- /dev/null +++ b/api/src/models/serie.ts @@ -0,0 +1,61 @@ +import { t } from "elysia"; +import { Genre } from "./show"; +import { Image } from "./image"; +import { ExternalId } from "./external-id"; +import { madeInAbyss , registerExamples } from "./examples"; +import { comment } from "../utils"; + +export const SerieStatus = t.UnionEnum([ + "unknown", + "finished", + "airing", + "planned", +]); +export type SerieStatus = typeof SerieStatus.static; + +export const Serie = t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String(), + name: t.String(), + description: t.Nullable(t.String()), + tagline: t.Nullable(t.String()), + aliases: t.Array(t.String()), + tags: t.Array(t.String()), + + genres: t.Array(Genre), + rating: t.Nullable(t.Number({ minimum: 0, maximum: 100 })), + status: SerieStatus, + runtime: t.Nullable( + t.Number({ + minimum: 0, + description: "Average runtime of all episodes (in minutes.)", + }), + ), + + startAir: t.Nullable(t.String({ format: "date" })), + endAir: t.Nullable(t.String({ format: "date" })), + originalLanguage: t.Nullable( + t.String({ + description: comment` + The language code this movie was made in. + This is a BCP 47 language code (the IETF Best Current Practices on Tags for Identifying Languages). + BCP 47 is also known as RFC 5646. It subsumes ISO 639 and is backward compatible with it. + `, + }), + ), + + poster: t.Nullable(Image), + thumbnail: t.Nullable(Image), + banner: t.Nullable(Image), + logo: t.Nullable(Image), + trailerUrl: t.Nullable(t.String()), + + createdAt: t.String({ format: "date-time" }), + nextRefresh: t.String({ format: "date-time" }), + + externalId: ExternalId, +}); + +export type Serie = typeof Serie.static; + +registerExamples(Serie, madeInAbyss); diff --git a/api/src/models/show.ts b/api/src/models/show.ts index 9f1c7e638..dbac93b6d 100644 --- a/api/src/models/show.ts +++ b/api/src/models/show.ts @@ -1,13 +1,5 @@ import { t } from "elysia"; -export const ShowStatus = t.UnionEnum([ - "unknown", - "finished", - "airing", - "planned", -]); -export type ShowStatus = typeof ShowStatus.static; - export const Genre = t.UnionEnum([ "action", "adventure", diff --git a/api/src/seed.ts b/api/src/seed.ts new file mode 100644 index 000000000..24aa2b17b --- /dev/null +++ b/api/src/seed.ts @@ -0,0 +1,7 @@ +import { db } from "./db"; +import { videos } from "./db/schema/videos"; +import { Video } from "./models/video"; + +const seed = async () =>{ + db.insert(videos).values(Video.examples) +}; From eb2d2009f7a8e020983e54dae51a84a49ef4d588 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 12:31:16 +0100 Subject: [PATCH 033/105] Movie utils models to directory --- api/src/models/movie.ts | 6 +++--- api/src/models/serie.ts | 6 +++--- api/src/models/{ => utils}/external-id.ts | 0 api/src/models/{show.ts => utils/genres.ts} | 0 api/src/models/{ => utils}/image.ts | 0 5 files changed, 6 insertions(+), 6 deletions(-) rename api/src/models/{ => utils}/external-id.ts (100%) rename api/src/models/{show.ts => utils/genres.ts} (100%) rename api/src/models/{ => utils}/image.ts (100%) diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 7a0d22715..f69208401 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -1,7 +1,7 @@ import { t } from "elysia"; -import { Genre } from "./show"; -import { Image } from "./image"; -import { ExternalId } from "./external-id"; +import { Genre } from "./utils/genres"; +import { Image } from "./utils/image"; +import { ExternalId } from "./utils/external-id"; import { bubble, registerExamples } from "./examples"; import { comment } from "../utils"; diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index cc0850cff..a417dbb19 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -1,7 +1,7 @@ import { t } from "elysia"; -import { Genre } from "./show"; -import { Image } from "./image"; -import { ExternalId } from "./external-id"; +import { Genre } from "./utils/genres"; +import { Image } from "./utils/image"; +import { ExternalId } from "./utils/external-id"; import { madeInAbyss , registerExamples } from "./examples"; import { comment } from "../utils"; diff --git a/api/src/models/external-id.ts b/api/src/models/utils/external-id.ts similarity index 100% rename from api/src/models/external-id.ts rename to api/src/models/utils/external-id.ts diff --git a/api/src/models/show.ts b/api/src/models/utils/genres.ts similarity index 100% rename from api/src/models/show.ts rename to api/src/models/utils/genres.ts diff --git a/api/src/models/image.ts b/api/src/models/utils/image.ts similarity index 100% rename from api/src/models/image.ts rename to api/src/models/utils/image.ts From 7071e07ef4f946b2f0af5f38d4100f1976a0c45f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 13:11:07 +0100 Subject: [PATCH 034/105] Define schema for all entries type --- api/src/db/schema/entries.ts | 2 +- api/src/models/entry.ts | 111 ++++++++++++++++++++++++++++ api/src/models/utils/external-id.ts | 20 +++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 api/src/models/entry.ts diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index 0f1829125..4393e99cd 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -29,7 +29,7 @@ export const entries = schema.table( id: uuid().notNull().unique().defaultRandom(), slug: varchar({ length: 255 }).notNull().unique(), showPk: integer().references(() => shows.pk, { onDelete: "cascade" }), - order: integer().notNull(), + order: integer(), seasonNumber: integer(), episodeNumber: integer(), type: entryType().notNull(), diff --git a/api/src/models/entry.ts b/api/src/models/entry.ts new file mode 100644 index 000000000..9a5f6e381 --- /dev/null +++ b/api/src/models/entry.ts @@ -0,0 +1,111 @@ +import { t } from "elysia"; +import { Image } from "./utils/image"; +import { ExternalId, EpisodeId } from "./utils/external-id"; +import { comment } from "../utils"; + +export const Entry = t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String(), + serieId: t.String({ format: "uuid" }), + name: t.Nullable(t.String()), + description: t.Nullable(t.String()), + airDate: t.Nullable(t.String({ format: "data" })), + runtime: t.Nullable( + t.Number({ minimum: 0, description: "Runtime of the episode in minutes" }), + ), + thumbnail: t.Nullable(Image), + + createtAt: t.String({ format: "date-time" }), + nextRefresh: t.String({ format: "date-time" }), +}); + +export const Episode = t.Union([ + Entry, + t.Object({ + kind: t.Literal("episode"), + seasonId: t.String({ format: "uuid" }), + order: t.Number({ minimum: 1, description: "Absolute playback order." }), + seasonNumber: t.Number(), + episodeNumber: t.Number(), + externalId: EpisodeId, + }), +]); +export type Episode = typeof Episode.static; + +export const MovieEntry = t.Union( + [ + Entry, + t.Object({ + kind: t.Literal("movie"), + order: t.Number({ + minimum: 1, + description: "Absolute playback order. Can be mixed with episodes.", + }), + externalId: ExternalId, + }), + ], + { + description: comment` + If a movie is part of a serie (watching the movie require context from the serie & + the next episode of the serie require you to have seen the movie to understand it.) + `, + }, +); +export type MovieEntry = typeof MovieEntry.static; + +export const Special = t.Union( + [ + Entry, + t.Object({ + kind: t.Literal("special"), + order: t.Number({ + minimum: 1, + description: "Absolute playback order. Can be mixed with episodes.", + }), + number: t.Number({ minimum: 1 }), + externalId: EpisodeId, + }), + ], + { + description: comment` + A special is either an OAV episode (side story & co) or an important episode that was released standalone + (outside of a season.) + `, + }, +); +export type Special = typeof Special.static; + +export const Extra = t.Union( + [ + Entry, + t.Object({ + kind: t.Literal("extra"), + number: t.Number({ minimum: 1 }), + // not sure about this id type + externalId: EpisodeId, + }), + ], + { + description: comment` + An extra can be a beyond-the-scene, short-episodes or anything that is in a different format & not required + in the main story plot. + `, + }, +); +export type Extra = typeof Extra.static; + +export const Video = t.Union( + [ + t.Omit(Entry, ["serieId", "airDate"]), + t.Object({ + kind: t.Literal("unknown"), + }), + ], + { + description: comment` + A video not releated to any series or movie. This can be due to a matching error but it can be a youtube + video or any other video content. + `, + }, +); +export type Video = typeof Video.static; diff --git a/api/src/models/utils/external-id.ts b/api/src/models/utils/external-id.ts index 368c00b66..cbcac6fae 100644 --- a/api/src/models/utils/external-id.ts +++ b/api/src/models/utils/external-id.ts @@ -1,4 +1,5 @@ import { t } from "elysia"; +import { comment } from "../../utils"; export const ExternalId = t.Record( t.String(), @@ -9,3 +10,22 @@ export const ExternalId = t.Record( ); export type ExternalId = typeof ExternalId.static; + +export const EpisodeId = t.Record( + t.String(), + t.Object({ + serieId: t.String({ + descrpition: comment` + Id on the external website. + We store the serie's id because episode id are rarely stable. + `, + }), + season: t.Nullable( + t.Number({ + description: "Null if the external website uses absolute numbering.", + }), + ), + episode: t.Number(), + link: t.Nullable(t.String({ format: "uri" })), + }), +); From 29d11720a5322092e0d63345c23be0bd10be5c7e Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 16:03:01 +0100 Subject: [PATCH 035/105] Add entries dummy controller & fix entries types --- api/src/controllers/entries.ts | 32 ++++++++++++++++++++++++++++++++ api/src/index.ts | 2 ++ api/src/models/entry.ts | 27 +++++++++++++++------------ 3 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 api/src/controllers/entries.ts diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts new file mode 100644 index 000000000..90ca27cfd --- /dev/null +++ b/api/src/controllers/entries.ts @@ -0,0 +1,32 @@ +import { Elysia, t } from "elysia"; +import { + type Entry, + Episode, + Extra, + MovieEntry, + Special, + UnknownEntry, +} from "../models/entry"; + +export const entries = new Elysia() + .model({ + episode: Episode, + movie_entry: MovieEntry, + special: Special, + extra: Extra, + unknown_entry: UnknownEntry, + error: t.Object({}), + }) + .model((models) => ({ + ...models, + entry: t.Union([models.episode, models.movie_entry, models.special]), + })) + .get("/entries/:id", () => "hello" as unknown as Entry, { + response: { 200: "entry" }, + }) + .get("/extras/:id", () => "hello" as unknown as Extra, { + response: { 200: "extra" }, + }) + .get("/unknowns/:id", () => "hello" as unknown as UnknownEntry, { + response: { 200: "unknown_entry" }, + }); diff --git a/api/src/index.ts b/api/src/index.ts index 7295c631b..4460373d8 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -2,6 +2,7 @@ import jwt from "@elysiajs/jwt"; import { swagger } from "@elysiajs/swagger"; import { migrate } from "drizzle-orm/node-postgres/migrator"; import { Elysia } from "elysia"; +import { entries } from "./controllers/entries"; import { movies } from "./controllers/movies"; import { series } from "./controllers/series"; import { videos } from "./controllers/videos"; @@ -35,6 +36,7 @@ const app = new Elysia() .get("/", () => "Hello Elysia") .use(movies) .use(series) + .use(entries) .use(videos) .listen(3000); diff --git a/api/src/models/entry.ts b/api/src/models/entry.ts index 9a5f6e381..c2aff7e39 100644 --- a/api/src/models/entry.ts +++ b/api/src/models/entry.ts @@ -3,7 +3,7 @@ import { Image } from "./utils/image"; import { ExternalId, EpisodeId } from "./utils/external-id"; import { comment } from "../utils"; -export const Entry = t.Object({ +const BaseEntry = t.Object({ id: t.String({ format: "uuid" }), slug: t.String(), serieId: t.String({ format: "uuid" }), @@ -19,8 +19,8 @@ export const Entry = t.Object({ nextRefresh: t.String({ format: "date-time" }), }); -export const Episode = t.Union([ - Entry, +export const Episode = t.Intersect([ + BaseEntry, t.Object({ kind: t.Literal("episode"), seasonId: t.String({ format: "uuid" }), @@ -32,9 +32,9 @@ export const Episode = t.Union([ ]); export type Episode = typeof Episode.static; -export const MovieEntry = t.Union( +export const MovieEntry = t.Intersect( [ - Entry, + BaseEntry, t.Object({ kind: t.Literal("movie"), order: t.Number({ @@ -53,9 +53,9 @@ export const MovieEntry = t.Union( ); export type MovieEntry = typeof MovieEntry.static; -export const Special = t.Union( +export const Special = t.Intersect( [ - Entry, + BaseEntry, t.Object({ kind: t.Literal("special"), order: t.Number({ @@ -75,9 +75,9 @@ export const Special = t.Union( ); export type Special = typeof Special.static; -export const Extra = t.Union( +export const Extra = t.Intersect( [ - Entry, + BaseEntry, t.Object({ kind: t.Literal("extra"), number: t.Number({ minimum: 1 }), @@ -94,9 +94,9 @@ export const Extra = t.Union( ); export type Extra = typeof Extra.static; -export const Video = t.Union( +export const UnknownEntry = t.Intersect( [ - t.Omit(Entry, ["serieId", "airDate"]), + t.Omit(BaseEntry, ["serieId", "airDate", "description"]), t.Object({ kind: t.Literal("unknown"), }), @@ -108,4 +108,7 @@ export const Video = t.Union( `, }, ); -export type Video = typeof Video.static; +export type UnknownEntry = typeof UnknownEntry.static; + +export const Entry = t.Union([Episode, MovieEntry, Special]); +export type Entry = typeof Entry.static; From 84ce544f4d5144744b54a0d35af24049993e0f71 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 16:03:54 +0100 Subject: [PATCH 036/105] Add extra types (from #463). No backing store for now --- api/src/models/entry.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/api/src/models/entry.ts b/api/src/models/entry.ts index c2aff7e39..4f89451cf 100644 --- a/api/src/models/entry.ts +++ b/api/src/models/entry.ts @@ -75,12 +75,24 @@ export const Special = t.Intersect( ); export type Special = typeof Special.static; +export const ExtraType = t.UnionEnum([ + "other", + "trailers", + "interview", + "behind-the-scenes", + "deleted-scenes", + "bloopers", + "mini-story", +]); +export type ExtraType = typeof ExtraType.static; + export const Extra = t.Intersect( [ BaseEntry, t.Object({ kind: t.Literal("extra"), number: t.Number({ minimum: 1 }), + extraType: ExtraType, // not sure about this id type externalId: EpisodeId, }), From ffa42de4f3e36522859bec586002bd1520b1bb36 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 16:19:38 +0100 Subject: [PATCH 037/105] Cleanup external id types in db --- api/src/db/schema/entries.ts | 24 +++++++++++++++++++++++- api/src/db/schema/shows.ts | 17 ++++++++++++++++- api/src/db/schema/utils.ts | 14 -------------- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index 4393e99cd..72d1f3f5d 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -22,6 +22,28 @@ export const entryType = schema.enum("entry_type", [ "extra", ]); +export const entryid = () => + jsonb() + .$type< + Record< + string, + | { + // used for movies + dataId: string; + link: string | null; + } + | { + // used for episodes, specials & extra + serieId: string; + season: number | null; + episode: number; + link: string | null; + } + > + >() + .notNull() + .default({}); + export const entries = schema.table( "entries", { @@ -37,7 +59,7 @@ export const entries = schema.table( runtime: integer(), thumbnails: image(), - externalId: jsonb().notNull().default({}), + externalId: entryid(), createdAt: timestamp({ withTimezone: true, mode: "string" }).defaultNow(), nextRefresh: timestamp({ withTimezone: true, mode: "string" }), diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index c64fd3b62..d59022177 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -3,6 +3,7 @@ import { check, date, integer, + jsonb, primaryKey, smallint, text, @@ -10,7 +11,7 @@ import { uuid, varchar, } from "drizzle-orm/pg-core"; -import { externalid, image, language, schema } from "./utils"; +import { image, language, schema } from "./utils"; export const showKind = schema.enum("show_kind", ["serie", "movie"]); export const showStatus = schema.enum("show_status", [ @@ -45,6 +46,20 @@ export const genres = schema.enum("genres", [ "talk", ]); +export const externalid = () => + jsonb() + .$type< + Record< + string, + { + dataId: string; + link: string | null; + } + > + >() + .notNull() + .default({}); + export const shows = schema.table( "shows", { diff --git a/api/src/db/schema/utils.ts b/api/src/db/schema/utils.ts index f314ccd39..f2de152cf 100644 --- a/api/src/db/schema/utils.ts +++ b/api/src/db/schema/utils.ts @@ -23,20 +23,6 @@ export const language = () => varchar({ length: 255 }); export const image = () => jsonb().$type<{ id: string; source: string; blurhash: string }>(); -export const externalid = () => - jsonb() - .$type< - Record< - string, - { - dataId: string; - link: string | null; - } - > - >() - .notNull() - .default({}); - // https://github.com/sindresorhus/type-fest/blob/main/source/simplify.d.ts#L58 type Simplify = {[KeyType in keyof T]: T[KeyType]} & {}; From 34e145ab233d003c04c4fa7a3abb0addba61f4e1 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 16:21:35 +0100 Subject: [PATCH 038/105] Add seasons in db --- api/drizzle/0002_seasons.sql | 40 ++ api/drizzle/meta/0002_snapshot.json | 788 ++++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/src/db/index.ts | 2 + api/src/db/schema/seasons.ts | 64 +++ 5 files changed, 901 insertions(+) create mode 100644 api/drizzle/0002_seasons.sql create mode 100644 api/drizzle/meta/0002_snapshot.json create mode 100644 api/src/db/schema/seasons.ts diff --git a/api/drizzle/0002_seasons.sql b/api/drizzle/0002_seasons.sql new file mode 100644 index 000000000..ed4a496d0 --- /dev/null +++ b/api/drizzle/0002_seasons.sql @@ -0,0 +1,40 @@ +CREATE TABLE IF NOT EXISTS "kyoo"."season_translation" ( + "pk" integer NOT NULL, + "language" varchar(255) NOT NULL, + "name" text, + "description" text, + "poster" jsonb, + "thumbnail" jsonb, + "logo" jsonb, + "banner" jsonb, + CONSTRAINT "season_translation_pk_language_pk" PRIMARY KEY("pk","language") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "kyoo"."seasons" ( + "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."seasons_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "slug" varchar(255) NOT NULL, + "show_pk" integer, + "season_number" integer NOT NULL, + "start_air" date, + "end_air" date, + "external_id" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + "next_refresh" timestamp with time zone, + CONSTRAINT "seasons_id_unique" UNIQUE("id"), + CONSTRAINT "seasons_slug_unique" UNIQUE("slug"), + CONSTRAINT "seasons_showPk_seasonNumber_unique" UNIQUE("show_pk","season_number") +); +--> statement-breakpoint +ALTER TABLE "kyoo"."entries" ALTER COLUMN "order" DROP NOT NULL;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "kyoo"."season_translation" ADD CONSTRAINT "season_translation_pk_seasons_pk_fk" FOREIGN KEY ("pk") REFERENCES "kyoo"."seasons"("pk") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "kyoo"."seasons" ADD CONSTRAINT "seasons_show_pk_shows_pk_fk" FOREIGN KEY ("show_pk") REFERENCES "kyoo"."shows"("pk") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/api/drizzle/meta/0002_snapshot.json b/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 000000000..39b0376ed --- /dev/null +++ b/api/drizzle/meta/0002_snapshot.json @@ -0,0 +1,788 @@ +{ + "id": "d0f6c500-aa2b-4592-aa31-db646817f708", + "prevId": "32090852-33a7-430a-9df1-97608c063124", + "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": false + }, + "order": { + "name": "order", + "type": "integer", + "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 + }, + "type": { + "name": "type", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "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": false, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "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": "\"entries\".\"order\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.entries_translation": { + "name": "entries_translation", + "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 + } + }, + "indexes": {}, + "foreignKeys": { + "entries_translation_pk_entries_pk_fk": { + "name": "entries_translation_pk_entries_pk_fk", + "tableFrom": "entries_translation", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entries_translation_pk_language_pk": { + "name": "entries_translation_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.season_translation": { + "name": "season_translation", + "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 + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "season_translation_pk_seasons_pk_fk": { + "name": "season_translation_pk_seasons_pk_fk", + "tableFrom": "season_translation", + "tableTo": "seasons", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "season_translation_pk_language_pk": { + "name": "season_translation_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": false, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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 + }, + "trailer_url": { + "name": "trailer_url", + "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 + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": {}, + "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": "\"shows\".\"rating\" between 0 and 100" + }, + "runtime_valid": { + "name": "runtime_valid", + "value": "\"shows\".\"runtime\" >= 0" + } + }, + "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()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "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_slug_unique": { + "name": "videos_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "videos_path_unique": { + "name": "videos_path_unique", + "nullsNotDistinct": false, + "columns": ["path"] + } + }, + "policies": {}, + "checkConstraints": { + "part_pos": { + "name": "part_pos", + "value": "\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"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 d35229563..a1c94b800 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1731149082556, "tag": "0001_video", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1731165599920, + "tag": "0002_seasons", + "breakpoints": true } ] } diff --git a/api/src/db/index.ts b/api/src/db/index.ts index 04e146651..5be8aa52f 100644 --- a/api/src/db/index.ts +++ b/api/src/db/index.ts @@ -1,5 +1,6 @@ import { drizzle } from "drizzle-orm/node-postgres"; import * as entries from "./schema/entries"; +import * as seasons from "./schema/seasons"; import * as shows from "./schema/shows"; import * as videos from "./schema/videos"; @@ -7,6 +8,7 @@ export const db = drizzle({ schema: { ...entries, ...shows, + ...seasons, ...videos, }, connection: { diff --git a/api/src/db/schema/seasons.ts b/api/src/db/schema/seasons.ts new file mode 100644 index 000000000..419d34002 --- /dev/null +++ b/api/src/db/schema/seasons.ts @@ -0,0 +1,64 @@ +import { + date, + integer, + jsonb, + primaryKey, + text, + timestamp, + unique, + uuid, + varchar, +} from "drizzle-orm/pg-core"; +import { image, language, schema } from "./utils"; +import { shows } from "./shows"; + +export const entryid = () => + jsonb() + .$type< + Record< + string, + { + serieId: string; + season: number; + link: string | null; + } + > + >() + .notNull() + .default({}); + +export const seasons = schema.table( + "seasons", + { + pk: integer().primaryKey().generatedAlwaysAsIdentity(), + id: uuid().notNull().unique().defaultRandom(), + slug: varchar({ length: 255 }).notNull().unique(), + showPk: integer().references(() => shows.pk, { onDelete: "cascade" }), + seasonNumber: integer().notNull(), + startAir: date(), + endAir: date(), + + externalId: entryid(), + + createdAt: timestamp({ withTimezone: true, mode: "string" }).defaultNow(), + nextRefresh: timestamp({ withTimezone: true, mode: "string" }), + }, + (t) => [unique().on(t.showPk, t.seasonNumber)], +); + +export const seasonTranslation = schema.table( + "season_translation", + { + pk: integer() + .notNull() + .references(() => seasons.pk, { onDelete: "cascade" }), + language: language().notNull(), + name: text(), + description: text(), + poster: image(), + thumbnail: image(), + logo: image(), + banner: image(), + }, + (t) => [primaryKey({ columns: [t.pk, t.language] })], +); From ed19413576d8bbf83dd7ff9bdd672a7c415e595a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 16:28:53 +0100 Subject: [PATCH 039/105] Remove serieId & seasonId from entry response --- api/src/models/entry.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/src/models/entry.ts b/api/src/models/entry.ts index 4f89451cf..aa2e209a8 100644 --- a/api/src/models/entry.ts +++ b/api/src/models/entry.ts @@ -6,7 +6,6 @@ import { comment } from "../utils"; const BaseEntry = t.Object({ id: t.String({ format: "uuid" }), slug: t.String(), - serieId: t.String({ format: "uuid" }), name: t.Nullable(t.String()), description: t.Nullable(t.String()), airDate: t.Nullable(t.String({ format: "data" })), @@ -23,7 +22,6 @@ export const Episode = t.Intersect([ BaseEntry, t.Object({ kind: t.Literal("episode"), - seasonId: t.String({ format: "uuid" }), order: t.Number({ minimum: 1, description: "Absolute playback order." }), seasonNumber: t.Number(), episodeNumber: t.Number(), @@ -108,7 +106,7 @@ export type Extra = typeof Extra.static; export const UnknownEntry = t.Intersect( [ - t.Omit(BaseEntry, ["serieId", "airDate", "description"]), + t.Omit(BaseEntry, ["airDate", "description"]), t.Object({ kind: t.Literal("unknown"), }), From 0b0ae9abd3ca87661b3f8526baf5934f5a035865 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 16:29:09 +0100 Subject: [PATCH 040/105] Add season model --- api/src/models/season.ts | 23 +++++++++++++++++++++++ api/src/models/utils/external-id.ts | 17 ++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 api/src/models/season.ts diff --git a/api/src/models/season.ts b/api/src/models/season.ts new file mode 100644 index 000000000..b40f2e7f4 --- /dev/null +++ b/api/src/models/season.ts @@ -0,0 +1,23 @@ +import { t } from "elysia"; +import { Image } from "./utils/image"; +import { SeasonId } from "./utils/external-id"; + +export const Season = t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String(), + seasonNumber: t.Number({ minimum: 1 }), + name: t.Nullable(t.String()), + description: t.Nullable(t.String()), + + poster: t.Nullable(Image), + thumbnail: t.Nullable(Image), + banner: t.Nullable(Image), + logo: t.Nullable(Image), + trailerUrl: t.Nullable(t.String()), + + createdAt: t.String({ format: "date-time" }), + nextRefresh: t.String({ format: "date-time" }), + + externalId: SeasonId, +}); +export type Season = typeof Season.static; diff --git a/api/src/models/utils/external-id.ts b/api/src/models/utils/external-id.ts index cbcac6fae..5f8f5b1da 100644 --- a/api/src/models/utils/external-id.ts +++ b/api/src/models/utils/external-id.ts @@ -8,7 +8,6 @@ export const ExternalId = t.Record( link: t.Nullable(t.String({ format: "uri" })), }), ); - export type ExternalId = typeof ExternalId.static; export const EpisodeId = t.Record( @@ -29,3 +28,19 @@ export const EpisodeId = t.Record( link: t.Nullable(t.String({ format: "uri" })), }), ); +export type EpisodeId = typeof EpisodeId.static; + +export const SeasonId = t.Record( + t.String(), + t.Object({ + serieId: t.String({ + descrpition: comment` + Id on the external website. + We store the serie's id because episode id are rarely stable. + `, + }), + season: t.Number(), + link: t.Nullable(t.String({ format: "uri" })), + }), +); +export type SeasonId = typeof SeasonId.static; From 4a9a768488c62f4d352470db0927cfa26925979d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 16:29:16 +0100 Subject: [PATCH 041/105] Add demmy season controller --- api/src/controllers/seasons.ts | 11 +++++++++++ api/src/index.ts | 2 ++ 2 files changed, 13 insertions(+) create mode 100644 api/src/controllers/seasons.ts diff --git a/api/src/controllers/seasons.ts b/api/src/controllers/seasons.ts new file mode 100644 index 000000000..f8ef929b3 --- /dev/null +++ b/api/src/controllers/seasons.ts @@ -0,0 +1,11 @@ +import { Elysia, t } from "elysia"; +import { Season } from "../models/season"; + +export const seasons = new Elysia({ prefix: "/seasons" }) + .model({ + season: Season, + error: t.Object({}), + }) + .get("/:id", () => "hello" as unknown as Season, { + response: { 200: "season" }, + }); diff --git a/api/src/index.ts b/api/src/index.ts index 4460373d8..465c0167f 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -4,6 +4,7 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; import { Elysia } from "elysia"; import { entries } from "./controllers/entries"; import { movies } from "./controllers/movies"; +import { seasons } from "./controllers/seasons"; import { series } from "./controllers/series"; import { videos } from "./controllers/videos"; import { db } from "./db"; @@ -37,6 +38,7 @@ const app = new Elysia() .use(movies) .use(series) .use(entries) + .use(seasons) .use(videos) .listen(3000); From 2a9ea5ecbf432109ecfe0ab928a458a4da511157 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 9 Nov 2024 17:47:57 +0100 Subject: [PATCH 042/105] Add examples for series --- api/src/db/schema/entries.ts | 1 + api/src/db/schema/seasons.ts | 1 - api/src/models/entry.ts | 26 ++- api/src/models/examples/bubble.ts | 110 ++++----- api/src/models/examples/index.ts | 8 +- api/src/models/examples/made-in-abyss.ts | 272 ++++++++++++++++++++++- api/src/models/movie.ts | 2 +- api/src/models/season.ts | 8 +- api/src/models/video.ts | 19 +- 9 files changed, 365 insertions(+), 82 deletions(-) diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index 72d1f3f5d..6d8dc4b34 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -79,6 +79,7 @@ export const entriesTranslation = schema.table( language: language().notNull(), name: text(), description: text(), + tagline: text(), }, (t) => [primaryKey({ columns: [t.pk, t.language] })], ); diff --git a/api/src/db/schema/seasons.ts b/api/src/db/schema/seasons.ts index 419d34002..da65f3d02 100644 --- a/api/src/db/schema/seasons.ts +++ b/api/src/db/schema/seasons.ts @@ -57,7 +57,6 @@ export const seasonTranslation = schema.table( description: text(), poster: image(), thumbnail: image(), - logo: image(), banner: image(), }, (t) => [primaryKey({ columns: [t.pk, t.language] })], diff --git a/api/src/models/entry.ts b/api/src/models/entry.ts index aa2e209a8..fb8182351 100644 --- a/api/src/models/entry.ts +++ b/api/src/models/entry.ts @@ -2,6 +2,7 @@ import { t } from "elysia"; import { Image } from "./utils/image"; import { ExternalId, EpisodeId } from "./utils/external-id"; import { comment } from "../utils"; +import { madeInAbyss, registerExamples } from "./examples"; const BaseEntry = t.Object({ id: t.String({ format: "uuid" }), @@ -14,7 +15,7 @@ const BaseEntry = t.Object({ ), thumbnail: t.Nullable(Image), - createtAt: t.String({ format: "date-time" }), + createdAt: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }), }); @@ -32,13 +33,15 @@ export type Episode = typeof Episode.static; export const MovieEntry = t.Intersect( [ - BaseEntry, + t.Omit(BaseEntry, ["thumbnail"]), t.Object({ kind: t.Literal("movie"), order: t.Number({ minimum: 1, description: "Absolute playback order. Can be mixed with episodes.", }), + tagline: t.String(), + poster: BaseEntry.properties.thumbnail, externalId: ExternalId, }), ], @@ -80,7 +83,6 @@ export const ExtraType = t.UnionEnum([ "behind-the-scenes", "deleted-scenes", "bloopers", - "mini-story", ]); export type ExtraType = typeof ExtraType.static; @@ -88,9 +90,7 @@ export const Extra = t.Intersect( [ BaseEntry, t.Object({ - kind: t.Literal("extra"), - number: t.Number({ minimum: 1 }), - extraType: ExtraType, + kind: ExtraType, // not sure about this id type externalId: EpisodeId, }), @@ -122,3 +122,17 @@ export type UnknownEntry = typeof UnknownEntry.static; export const Entry = t.Union([Episode, MovieEntry, Special]); export type Entry = typeof Entry.static; + +registerExamples( + Episode, + ...madeInAbyss.entries.filter((x) => x.kind === "episode"), +); +registerExamples( + MovieEntry, + ...madeInAbyss.entries.filter((x) => x.kind === "movie"), +); +registerExamples( + Special, + ...madeInAbyss.entries.filter((x) => x.kind === "special"), +); +registerExamples(Extra, ...madeInAbyss.extras); diff --git a/api/src/models/examples/bubble.ts b/api/src/models/examples/bubble.ts index 58acab3f0..48014a71b 100644 --- a/api/src/models/examples/bubble.ts +++ b/api/src/models/examples/bubble.ts @@ -1,59 +1,65 @@ -import type { CompleteVideo } from "../video"; +import type { Movie } from "../movie"; +import type { Video } from "../video"; -export const bubble: CompleteVideo = { - id: "0934da28-4a49-404e-920b-a150404a3b6d", +type CompleteMovie = Movie & { videos: Video[] }; + +export const bubble: CompleteMovie = { + id: "008f0b42-61b8-4155-857a-cbe5f40dd35d", slug: "bubble", - path: "/video/Bubble/Bubble (2022).mkv", - rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd", - part: null, - version: 1, + name: "Bubble", + tagline: "Is she a calamity or a blessing?", + description: + "In an abandoned Tokyo overrun by bubbles and gravitational abnormalities, one gifted young man has a fateful meeting with a mysterious girl.", + aliases: ["Baburu", "バブル:2022", "Bubble"], + tags: ["adolescence", "disaster", "battle", "gravity", "anime"], + genres: ["animation", "adventure", "science-fiction", "fantasy"], + rating: 74, + status: "finished", + runtime: 101, + airDate: "2022-02-14", + originalLanguage: "ja", + poster: { + id: "befdc7dd-2a67-0704-92af-90d49eee0315", + source: + "https://image.tmdb.org/t/p/original/65dad96VE8FJPEdrAkhdsuWMWH9.jpg", + blurhash: "LFC@2F;K$%xZ5?W.MwNF0iD~MxR:", + }, + thumbnail: { + id: "b29908f3-a64d-ae98-923b-18bf7995ab04", + source: + "https://image.tmdb.org/t/p/original/a8Q2g0g7XzAF6gcB8qgn37ccb9Y.jpg", + blurhash: "LpH3afE1XAveyGS7t6V[R4xZn+S6", + }, + banner: null, + logo: { + id: "3357fad0-de40-4ca5-15e6-eb065d35be86", + source: + "https://image.tmdb.org/t/p/original/ihIs7fayAmZieMlMQbs6TWM77uf.png", + blurhash: "LMDc5#MwE0,sTKE0R*S~4mxunhb_", + }, + trailerUrl: "https://www.youtube.com/watch?v=vs7zsyIZkMM", createdAt: "2023-11-29T11:42:06.030838Z", - movie: { - id: "008f0b42-61b8-4155-857a-cbe5f40dd35d", - slug: "bubble", - name: "Bubble", - tagline: "Is she a calamity or a blessing?", - description: - "In an abandoned Tokyo overrun by bubbles and gravitational abnormalities, one gifted young man has a fateful meeting with a mysterious girl.", - aliases: ["Baburu", "バブル:2022", "Bubble"], - tags: ["adolescence", "disaster", "battle", "gravity", "anime"], - genres: ["animation", "adventure", "science-fiction", "fantasy"], - rating: 74, - status: "finished", - runtime: 101, - airDate: "2022-02-14", - originalLanguage: "ja", - poster: { - id: "befdc7dd-2a67-0704-92af-90d49eee0315", - source: - "https://image.tmdb.org/t/p/original/65dad96VE8FJPEdrAkhdsuWMWH9.jpg", - blurhash: "LFC@2F;K$%xZ5?W.MwNF0iD~MxR:", + nextRefresh: "2025-01-07T22:40:59.960952Z", + externalId: { + themoviedatabase: { + dataId: "912598", + link: "https://www.themoviedb.org/movie/912598", }, - thumbnail: { - id: "b29908f3-a64d-ae98-923b-18bf7995ab04", - source: - "https://image.tmdb.org/t/p/original/a8Q2g0g7XzAF6gcB8qgn37ccb9Y.jpg", - blurhash: "LpH3afE1XAveyGS7t6V[R4xZn+S6", - }, - banner: null, - logo: { - id: "3357fad0-de40-4ca5-15e6-eb065d35be86", - source: - "https://image.tmdb.org/t/p/original/ihIs7fayAmZieMlMQbs6TWM77uf.png", - blurhash: "LMDc5#MwE0,sTKE0R*S~4mxunhb_", - }, - trailerUrl: "https://www.youtube.com/watch?v=vs7zsyIZkMM", - createdAt: "2023-11-29T11:42:06.030838Z", - nextRefresh: "2025-01-07T22:40:59.960952Z", - externalId: { - themoviedatabase: { - dataId: "912598", - link: "https://www.themoviedb.org/movie/912598", - }, - imdb: { - dataId: "tt16360006", - link: "https://www.imdb.com/title/tt16360006", - }, + imdb: { + dataId: "tt16360006", + link: "https://www.imdb.com/title/tt16360006", }, }, + videos: [ + { + id: "0934da28-4a49-404e-920b-a150404a3b6d", + slug: "bubble", + path: "/video/Bubble/Bubble (2022).mkv", + rendering: + "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd", + part: null, + version: 1, + createdAt: "2023-11-29T11:42:06.030838Z", + }, + ], }; diff --git a/api/src/models/examples/index.ts b/api/src/models/examples/index.ts index 8a2e2f3d8..5e59939fa 100644 --- a/api/src/models/examples/index.ts +++ b/api/src/models/examples/index.ts @@ -6,7 +6,13 @@ export const registerExamples = ( ) => { if ("anyOf" in schema) { for (const union of schema.anyOf) { - registerExamples(union, examples); + registerExamples(union, ...examples); + } + return; + } + if ("allOf" in schema) { + for (const intersec of schema.allOf) { + registerExamples(intersec, ...examples); } return; } diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts index b43903580..b9c92490e 100644 --- a/api/src/models/examples/made-in-abyss.ts +++ b/api/src/models/examples/made-in-abyss.ts @@ -1,6 +1,15 @@ +import type { Entry, Extra } from "../entry"; +import type { Season } from "../season"; import type { Serie } from "../serie"; +import type { Video } from "../video"; -export const madeInAbyss: Serie = { +type CompleteSerie = Serie & { + seasons: Season[]; + entries: (Entry & { videos: Video[] })[]; + extras: (Extra & { video: Video })[]; +}; + +export const madeInAbyss: CompleteSerie = { id: "04bcf2ac-3c09-42f6-8357-b003798f9562", slug: "made-in-abyss", name: "Made in Abyss", @@ -76,4 +85,265 @@ export const madeInAbyss: Serie = { }, createdAt: "2023-11-29T11:12:11.949503Z", nextRefresh: "2025-01-07T11:42:50.948248Z", + seasons: [ + { + id: "490aa312-53b9-43c2-845d-7cbf32642c98", + slug: "made-in-abyss-s1", + seasonNumber: 1, + name: "Season 1", + description: + "Within the depths of the Abyss, a girl named Riko stumbles upon a robot who looks like a young boy. Riko and her new friend descend into uncharted territory to unlock its mysteries, but what lies in wait for them in the darkness?", + startAir: "2017-07-07", + endAir: "2017-09-29", + poster: { + id: "1c121a2b-d3a2-4ce8-e22a-79b13dde3f7d", + source: + "https://image.tmdb.org/t/p/original/uVK3H8CgtrVgySFpdImvNXkN7RK.jpg", + blurhash: "LYG9BNkrD%V?~WS5S1WA%LbubHV[", + }, + thumbnail: null, + banner: null, + externalId: { + themoviedatabase: { + serieId: "72636", + season: 1, + link: "https://www.themoviedb.org/tv/72636/season/1", + }, + }, + createdAt: "2023-11-29T11:12:13.008151Z", + nextRefresh: "2025-01-07T11:37:50.151836Z", + }, + { + id: "135af9ae-a8eb-4110-a4e4-05eee49e2d76", + slug: "made-in-abyss-s2", + seasonNumber: 2, + name: "The Golden City of the Scorching Sun", + description: + "Set directly after the events of Made in Abyss: Dawn of the Deep Soul, the fifth installment of Made in Abyss covers the adventure of Reg, Riko and Nanachi in the Sixth Layer, The Capital of the Unreturned.", + startAir: "2022-07-06", + endAir: "2022-09-28", + poster: { + id: "a03c57d7-4032-7d97-083a-9a6e51d5f1e7", + source: + "https://image.tmdb.org/t/p/original/clC2erfUqIezhET67Gz9fcKD1L2.jpg", + blurhash: "LpNTRGx]s9oz~WbJRPoft7RjV@a|", + }, + thumbnail: null, + banner: null, + externalId: { + themoviedatabase: { + serieId: "72636", + season: 2, + link: "https://www.themoviedb.org/tv/72636/season/2", + }, + }, + createdAt: "2023-11-29T11:12:13.630306Z", + nextRefresh: "2025-01-07T11:09:19.552971Z", + }, + ], + 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.", + 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", + }, + externalId: { + themoviedatabase: { + serieId: "72636", + season: 1, + episode: 13, + 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`) + 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.", + 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", + }, + externalId: { + themoviedatabase: { + serieId: "72636", + season: 0, + episode: 3, + 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.", + 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.", + 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|", + }, + externalId: { + themoviedatabase: { + serieId: "72636", + season: 2, + episode: 1, + 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", + 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", + }, + }, + ], }; diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index f69208401..263fa27f3 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -49,4 +49,4 @@ export const Movie = t.Object({ export type Movie = typeof Movie.static; -registerExamples(Movie, bubble.movie); +registerExamples(Movie, bubble); diff --git a/api/src/models/season.ts b/api/src/models/season.ts index b40f2e7f4..2b09c5651 100644 --- a/api/src/models/season.ts +++ b/api/src/models/season.ts @@ -1,6 +1,7 @@ import { t } from "elysia"; import { Image } from "./utils/image"; import { SeasonId } from "./utils/external-id"; +import { madeInAbyss, registerExamples } from "./examples"; export const Season = t.Object({ id: t.String({ format: "uuid" }), @@ -9,11 +10,12 @@ export const Season = t.Object({ name: t.Nullable(t.String()), description: t.Nullable(t.String()), + startAir: t.Nullable(t.String({ format: "date" })), + endAir: t.Nullable(t.String({ format: "date" })), + poster: t.Nullable(Image), thumbnail: t.Nullable(Image), banner: t.Nullable(Image), - logo: t.Nullable(Image), - trailerUrl: t.Nullable(t.String()), createdAt: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }), @@ -21,3 +23,5 @@ export const Season = t.Object({ externalId: SeasonId, }); export type Season = typeof Season.static; + +registerExamples(Season, ...madeInAbyss.seasons); diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 794c2a949..bb6f9cb65 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -36,21 +36,4 @@ export const Video = t.Object({ export type Video = typeof Video.static; -registerExamples(Video, bubble); - -export const CompleteVideo = t.Intersect([ - Video, - t.Union([ - t.Object({ - movie: Movie, - episodes: t.Optional(t.Never()), - }), - t.Object({ - // TODO: implement that - episodes: t.Array(t.Object({})), - movie: t.Optional(t.Never()), - }), - ]), -]); - -export type CompleteVideo = typeof CompleteVideo.static; +registerExamples(Video, ...bubble.videos); From 47729d8373c1927a8748ff1cef1fb56920200084 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 10 Nov 2024 18:12:27 +0100 Subject: [PATCH 043/105] Set order type to float --- api/drizzle/0003_order.sql | 3 + api/drizzle/meta/0003_snapshot.json | 788 ++++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/src/db/schema/entries.ts | 3 +- 4 files changed, 800 insertions(+), 1 deletion(-) create mode 100644 api/drizzle/0003_order.sql create mode 100644 api/drizzle/meta/0003_snapshot.json diff --git a/api/drizzle/0003_order.sql b/api/drizzle/0003_order.sql new file mode 100644 index 000000000..4a3225bb1 --- /dev/null +++ b/api/drizzle/0003_order.sql @@ -0,0 +1,3 @@ +ALTER TABLE "kyoo"."entries" ALTER COLUMN "order" SET DATA TYPE real;--> statement-breakpoint +ALTER TABLE "kyoo"."entries_translation" ADD COLUMN "tagline" text;--> statement-breakpoint +ALTER TABLE "kyoo"."season_translation" DROP COLUMN IF EXISTS "logo"; diff --git a/api/drizzle/meta/0003_snapshot.json b/api/drizzle/meta/0003_snapshot.json new file mode 100644 index 000000000..666382261 --- /dev/null +++ b/api/drizzle/meta/0003_snapshot.json @@ -0,0 +1,788 @@ +{ + "id": "2210fd60-8e6a-4503-a2b3-56cc7f3cf15a", + "prevId": "d0f6c500-aa2b-4592-aa31-db646817f708", + "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": false + }, + "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 + }, + "type": { + "name": "type", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "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": false, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "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": "\"entries\".\"order\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.entries_translation": { + "name": "entries_translation", + "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 + } + }, + "indexes": {}, + "foreignKeys": { + "entries_translation_pk_entries_pk_fk": { + "name": "entries_translation_pk_entries_pk_fk", + "tableFrom": "entries_translation", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entries_translation_pk_language_pk": { + "name": "entries_translation_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.season_translation": { + "name": "season_translation", + "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_translation_pk_seasons_pk_fk": { + "name": "season_translation_pk_seasons_pk_fk", + "tableFrom": "season_translation", + "tableTo": "seasons", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "season_translation_pk_language_pk": { + "name": "season_translation_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": false, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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 + }, + "trailer_url": { + "name": "trailer_url", + "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 + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": {}, + "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": "\"shows\".\"rating\" between 0 and 100" + }, + "runtime_valid": { + "name": "runtime_valid", + "value": "\"shows\".\"runtime\" >= 0" + } + }, + "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()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "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_slug_unique": { + "name": "videos_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "videos_path_unique": { + "name": "videos_path_unique", + "nullsNotDistinct": false, + "columns": ["path"] + } + }, + "policies": {}, + "checkConstraints": { + "part_pos": { + "name": "part_pos", + "value": "\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"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 a1c94b800..00781abde 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1731165599920, "tag": "0002_seasons", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1731258712255, + "tag": "0003_order", + "breakpoints": true } ] } diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index 6d8dc4b34..99856210e 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -5,6 +5,7 @@ import { integer, jsonb, primaryKey, + real, text, timestamp, unique, @@ -51,7 +52,7 @@ export const entries = schema.table( id: uuid().notNull().unique().defaultRandom(), slug: varchar({ length: 255 }).notNull().unique(), showPk: integer().references(() => shows.pk, { onDelete: "cascade" }), - order: integer(), + order: real(), seasonNumber: integer(), episodeNumber: integer(), type: entryType().notNull(), From d6cae5ace1398797a3a6e5b27af9b999da45c9a0 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 15 Nov 2024 18:03:16 +0100 Subject: [PATCH 044/105] Small cleanups --- api/README.md | 12 ++++++------ api/bun.lockb | Bin 39908 -> 39908 bytes api/src/seed.ts | 7 ------- api/src/utils.ts | 4 ++-- 4 files changed, 8 insertions(+), 15 deletions(-) delete mode 100644 api/src/seed.ts diff --git a/api/README.md b/api/README.md index 942cba073..eb50f9156 100644 --- a/api/README.md +++ b/api/README.md @@ -98,7 +98,7 @@ erDiagram datetime next_refresh jsonb external_id } - + season_translations { guid id PK,FK string language PK @@ -107,17 +107,17 @@ erDiagram jsonb poster jsonb banner jsonb logo - jsonb thumbnail + jsonb thumbnail } seasons ||--|{ season_translations : has seasons ||--o{ entries : has shows ||--|{ seasons : has - + watched_shows { guid show_id PK, FK guid user_id PK, FK status status "completed|watching|droped|planned" - uint seen_entry_count "NN" + uint seen_entry_count "NN" } shows ||--|{ watched_shows : has @@ -129,7 +129,7 @@ erDiagram datetime played_date } entries ||--|{ watched_entries : has - + roles { guid show_id PK, FK guid staff_id PK, FK @@ -152,7 +152,7 @@ erDiagram datetime next_refresh jsonb external_id } - + staff_translations { guid id PK,FK string language PK diff --git a/api/bun.lockb b/api/bun.lockb index 518a9809290739c25f2c15b17f7972915dca1c2f..4a5df0ec1fe08dd60425144f6e74a0916ed44688 100755 GIT binary patch delta 206 zcmaE|o$1MTrVW`n^^9>AdWMF2h6bDr3=BZrQ2i$TY=_WmB@V_ops=2?34;(&h8HOF zI==b(Iks<%Ob{7E1_pVcj37|P>V8^$MBw>*P#IGO1_PiBH&90T;)dL=u=FILjDen! zg`NR}HB_cwZDCF4Jo~_1U@2oWJwt}noXXb|B z7@6QQ@<16upp4c1wD^d?^Y;)k20$5Zpp5dx4Y^%m=}8C~Yp6`W+QOR7dG>+35K^f* qmBpEf3=GZ-9{;#!d$ScGQ;-hS1q4r>AMFcm7ZQTYY*x?Rs|Nt;C_tV7 diff --git a/api/src/seed.ts b/api/src/seed.ts deleted file mode 100644 index 24aa2b17b..000000000 --- a/api/src/seed.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { db } from "./db"; -import { videos } from "./db/schema/videos"; -import { Video } from "./models/video"; - -const seed = async () =>{ - db.insert(videos).values(Video.examples) -}; diff --git a/api/src/utils.ts b/api/src/utils.ts index b4b1ca965..eefe2e397 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -1,3 +1,3 @@ // remove indent in multi-line comments -export const comment = (str: TemplateStringsArray) => - str.toString().replace(/^\s+/gm, ""); +export const comment = (str: TemplateStringsArray, ...values: any[]) => + str.reduce((acc, str, i) => `${acc}${str}${values[i]}`).replace(/^\s+/gm, ""); From 02ebb6b3f6c614533ff63b869309b7c038ea47e3 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 15 Nov 2024 18:03:42 +0100 Subject: [PATCH 045/105] Add guesses in video db schema --- api/src/db/schema/videos.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index efe32d24c..101287ea8 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -2,6 +2,7 @@ import { sql } from "drizzle-orm"; import { check, integer, + jsonb, text, timestamp, uuid, @@ -19,6 +20,7 @@ export const videos = schema.table( rendering: text().notNull(), part: integer(), version: integer().notNull().default(1), + guess: jsonb().notNull().default({}), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }, From 97d9abca6226b2072a3b1457a07096b563004cc2 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 15 Nov 2024 23:34:15 +0100 Subject: [PATCH 046/105] Add utils for validating slugs & languages --- api/src/models/utils/index.ts | 7 +++++++ api/src/models/utils/language.ts | 28 ++++++++++++++++++++++++++++ api/src/models/utils/resource.ts | 11 +++++++++++ 3 files changed, 46 insertions(+) create mode 100644 api/src/models/utils/index.ts create mode 100644 api/src/models/utils/language.ts create mode 100644 api/src/models/utils/resource.ts diff --git a/api/src/models/utils/index.ts b/api/src/models/utils/index.ts new file mode 100644 index 000000000..0b7bef196 --- /dev/null +++ b/api/src/models/utils/index.ts @@ -0,0 +1,7 @@ +export const ref = (id: string) => `#/components/schemas/${id}`; + +export * from "./external-id"; +export * from "./genres"; +export * from "./image"; +export * from "./language"; +export * from "./resource"; diff --git a/api/src/models/utils/language.ts b/api/src/models/utils/language.ts new file mode 100644 index 000000000..302e67f99 --- /dev/null +++ b/api/src/models/utils/language.ts @@ -0,0 +1,28 @@ +import { FormatRegistry } from "@sinclair/typebox"; +import { t } from "elysia"; +import { comment } from "../../utils"; + +FormatRegistry.Set("language", (lang) => { + try { + const normalized = new Intl.Locale(lang).baseName; + // TODO: we should actually replace the locale with normalized if we managed to parse it but transforms aren't working + return lang === normalized; + } catch { + return false; + } +}); + +type StringProps = NonNullable[0]>; + +// TODO: format validation doesn't work in record's key. We should have a proper way to check that. +export const Language = (props?: StringProps) => + t.String({ + format: "language", + description: comment` + ${props?.description ?? ""} + This is a BCP 47 language code (the IETF Best Current Practices on Tags for Identifying Languages). + BCP 47 is also known as RFC 5646. It subsumes ISO 639 and is backward compatible with it. + `, + error: "Expected a valid (and NORMALIZED) bcp-47 language code.", + ...props, + }); diff --git a/api/src/models/utils/resource.ts b/api/src/models/utils/resource.ts new file mode 100644 index 000000000..0de064362 --- /dev/null +++ b/api/src/models/utils/resource.ts @@ -0,0 +1,11 @@ +import { FormatRegistry } from "@sinclair/typebox"; +import { t } from "elysia"; + +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" }), +}); From cfe6ce9c7e5813578acb993d43cffd12d08bb9a9 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 15 Nov 2024 23:36:41 +0100 Subject: [PATCH 047/105] Split entries & add translations types --- api/src/db/schema/entries.ts | 2 + api/src/models/entry.ts | 138 -------------------------- api/src/models/entry/base-entry.ts | 18 ++++ api/src/models/entry/episode.ts | 18 ++++ api/src/models/entry/extra.ts | 37 +++++++ api/src/models/entry/index.ts | 11 ++ api/src/models/entry/movie-entry.ts | 41 ++++++++ api/src/models/entry/special.ts | 29 ++++++ api/src/models/entry/unknown-entry.ts | 30 ++++++ 9 files changed, 186 insertions(+), 138 deletions(-) delete mode 100644 api/src/models/entry.ts create mode 100644 api/src/models/entry/base-entry.ts create mode 100644 api/src/models/entry/episode.ts create mode 100644 api/src/models/entry/extra.ts create mode 100644 api/src/models/entry/index.ts create mode 100644 api/src/models/entry/movie-entry.ts create mode 100644 api/src/models/entry/special.ts create mode 100644 api/src/models/entry/unknown-entry.ts diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index 99856210e..b1bf583b4 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -80,7 +80,9 @@ export const entriesTranslation = schema.table( language: language().notNull(), name: text(), description: text(), + // those two are only used if kind === "movie" tagline: text(), + poster: image(), }, (t) => [primaryKey({ columns: [t.pk, t.language] })], ); diff --git a/api/src/models/entry.ts b/api/src/models/entry.ts deleted file mode 100644 index fb8182351..000000000 --- a/api/src/models/entry.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { t } from "elysia"; -import { Image } from "./utils/image"; -import { ExternalId, EpisodeId } from "./utils/external-id"; -import { comment } from "../utils"; -import { madeInAbyss, registerExamples } from "./examples"; - -const BaseEntry = t.Object({ - id: t.String({ format: "uuid" }), - slug: t.String(), - name: t.Nullable(t.String()), - description: t.Nullable(t.String()), - airDate: t.Nullable(t.String({ format: "data" })), - runtime: t.Nullable( - t.Number({ minimum: 0, description: "Runtime of the episode in minutes" }), - ), - thumbnail: t.Nullable(Image), - - createdAt: t.String({ format: "date-time" }), - nextRefresh: t.String({ format: "date-time" }), -}); - -export const Episode = t.Intersect([ - BaseEntry, - t.Object({ - kind: t.Literal("episode"), - order: t.Number({ minimum: 1, description: "Absolute playback order." }), - seasonNumber: t.Number(), - episodeNumber: t.Number(), - externalId: EpisodeId, - }), -]); -export type Episode = typeof Episode.static; - -export const MovieEntry = t.Intersect( - [ - t.Omit(BaseEntry, ["thumbnail"]), - t.Object({ - kind: t.Literal("movie"), - order: t.Number({ - minimum: 1, - description: "Absolute playback order. Can be mixed with episodes.", - }), - tagline: t.String(), - poster: BaseEntry.properties.thumbnail, - externalId: ExternalId, - }), - ], - { - description: comment` - If a movie is part of a serie (watching the movie require context from the serie & - the next episode of the serie require you to have seen the movie to understand it.) - `, - }, -); -export type MovieEntry = typeof MovieEntry.static; - -export const Special = t.Intersect( - [ - BaseEntry, - t.Object({ - kind: t.Literal("special"), - order: t.Number({ - minimum: 1, - description: "Absolute playback order. Can be mixed with episodes.", - }), - number: t.Number({ minimum: 1 }), - externalId: EpisodeId, - }), - ], - { - description: comment` - A special is either an OAV episode (side story & co) or an important episode that was released standalone - (outside of a season.) - `, - }, -); -export type Special = typeof Special.static; - -export const ExtraType = t.UnionEnum([ - "other", - "trailers", - "interview", - "behind-the-scenes", - "deleted-scenes", - "bloopers", -]); -export type ExtraType = typeof ExtraType.static; - -export const Extra = t.Intersect( - [ - BaseEntry, - t.Object({ - kind: ExtraType, - // not sure about this id type - externalId: EpisodeId, - }), - ], - { - description: comment` - An extra can be a beyond-the-scene, short-episodes or anything that is in a different format & not required - in the main story plot. - `, - }, -); -export type Extra = typeof Extra.static; - -export const UnknownEntry = t.Intersect( - [ - t.Omit(BaseEntry, ["airDate", "description"]), - t.Object({ - kind: t.Literal("unknown"), - }), - ], - { - description: comment` - A video not releated to any series or movie. This can be due to a matching error but it can be a youtube - video or any other video content. - `, - }, -); -export type UnknownEntry = typeof UnknownEntry.static; - -export const Entry = t.Union([Episode, MovieEntry, Special]); -export type Entry = typeof Entry.static; - -registerExamples( - Episode, - ...madeInAbyss.entries.filter((x) => x.kind === "episode"), -); -registerExamples( - MovieEntry, - ...madeInAbyss.entries.filter((x) => x.kind === "movie"), -); -registerExamples( - Special, - ...madeInAbyss.entries.filter((x) => x.kind === "special"), -); -registerExamples(Extra, ...madeInAbyss.extras); diff --git a/api/src/models/entry/base-entry.ts b/api/src/models/entry/base-entry.ts new file mode 100644 index 000000000..dc7a880cb --- /dev/null +++ b/api/src/models/entry/base-entry.ts @@ -0,0 +1,18 @@ +import { t } from "elysia"; +import { Image } from "../utils/image"; + +export const BaseEntry = t.Object({ + airDate: t.Nullable(t.String({ format: "data" })), + runtime: t.Nullable( + t.Number({ minimum: 0, description: "Runtime of the episode in minutes" }), + ), + thumbnail: t.Nullable(Image), + + createdAt: t.String({ format: "date-time" }), + nextRefresh: t.String({ format: "date-time" }), +}); + +export const EntryTranslation = t.Object({ + name: t.Nullable(t.String()), + description: t.Nullable(t.String()), +}); diff --git a/api/src/models/entry/episode.ts b/api/src/models/entry/episode.ts new file mode 100644 index 000000000..d4db26f4c --- /dev/null +++ b/api/src/models/entry/episode.ts @@ -0,0 +1,18 @@ +import { t } from "elysia"; +import { BaseEntry, EntryTranslation } from "./base-entry"; +import { EpisodeId } from "../utils/external-id"; +import { Resource } from "../utils/resource"; + +export const BaseEpisode = t.Intersect([ + BaseEntry, + t.Object({ + kind: t.Literal("episode"), + order: t.Number({ minimum: 1, description: "Absolute playback order." }), + seasonNumber: t.Number(), + episodeNumber: t.Number(), + externalId: EpisodeId, + }), +]); + +export const Episode = t.Intersect([Resource, BaseEpisode, EntryTranslation]); +export type Episode = typeof Episode.static; diff --git a/api/src/models/entry/extra.ts b/api/src/models/entry/extra.ts new file mode 100644 index 000000000..b26f8a6f6 --- /dev/null +++ b/api/src/models/entry/extra.ts @@ -0,0 +1,37 @@ +import { t } from "elysia"; +import { BaseEntry, EntryTranslation } from "./base-entry"; +import { EpisodeId } from "../utils/external-id"; +import { comment } from "../../utils"; +import { Resource } from "../utils/resource"; + +export const ExtraType = t.UnionEnum([ + "other", + "trailers", + "interview", + "behind-the-scenes", + "deleted-scenes", + "bloopers", +]); +export type ExtraType = typeof ExtraType.static; + +export const BaseExtra = t.Intersect( + [ + BaseEntry, + t.Object({ + kind: ExtraType, + // not sure about this id type + externalId: EpisodeId, + }), + ], + { + description: comment` + An extra can be a beyond-the-scene, short-episodes or anything that is in a different format & not required + in the main story plot. + `, + }, +); + +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 new file mode 100644 index 000000000..1df97a0ba --- /dev/null +++ b/api/src/models/entry/index.ts @@ -0,0 +1,11 @@ +import { t } from "elysia"; +import { Episode, MovieEntry, Special } from "../entry"; + +export const Entry = t.Union([Episode, MovieEntry, Special]); +export type Entry = typeof Entry.static; + +export * from "./episode"; +export * from "./movie-entry"; +export * from "./special"; +export * from "./extra"; +export * from "./unknown-entry"; diff --git a/api/src/models/entry/movie-entry.ts b/api/src/models/entry/movie-entry.ts new file mode 100644 index 000000000..205c88ef6 --- /dev/null +++ b/api/src/models/entry/movie-entry.ts @@ -0,0 +1,41 @@ +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"; + +export const BaseMovieEntry = t.Intersect( + [ + t.Omit(BaseEntry, ["thumbnail"]), + t.Object({ + kind: t.Literal("movie"), + order: t.Number({ + minimum: 1, + description: "Absolute playback order. Can be mixed with episodes.", + }), + externalId: ExternalId, + }), + ], + { + description: comment` + If a movie is part of a serie (watching the movie require context from the serie & + the next episode of the serie require you to have seen the movie to understand it.) + `, + }, +); + +export const MovieEntryTranslation = t.Intersect([ + EntryTranslation, + t.Object({ + tagline: t.Nullable(t.String()), + thumbnail: t.Nullable(Image), + }), +]); + +export const MovieEntry = t.Intersect([ + Resource, + BaseMovieEntry, + MovieEntryTranslation, +]); +export type MovieEntry = typeof MovieEntry.static; diff --git a/api/src/models/entry/special.ts b/api/src/models/entry/special.ts new file mode 100644 index 000000000..bb1898d2f --- /dev/null +++ b/api/src/models/entry/special.ts @@ -0,0 +1,29 @@ +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"; + +export const BaseSpecial = t.Intersect( + [ + BaseEntry, + t.Object({ + kind: t.Literal("special"), + order: t.Number({ + minimum: 1, + description: "Absolute playback order. Can be mixed with episodes.", + }), + number: t.Number({ minimum: 1 }), + externalId: EpisodeId, + }), + ], + { + description: comment` + A special is either an OAV episode (side story & co) or an important episode that was released standalone + (outside of a season.) + `, + }, +); + +export const Special = t.Intersect([Resource, BaseSpecial, EntryTranslation]); +export type Special = typeof Special.static; diff --git a/api/src/models/entry/unknown-entry.ts b/api/src/models/entry/unknown-entry.ts new file mode 100644 index 000000000..e60d224db --- /dev/null +++ b/api/src/models/entry/unknown-entry.ts @@ -0,0 +1,30 @@ +import { t } from "elysia"; +import { comment } from "../../utils"; +import { Resource } from "../utils/resource"; +import { BaseEntry, EntryTranslation } from "./base-entry"; + +export const BaseUnknownEntry = t.Intersect( + [ + t.Omit(BaseEntry, ["airDate"]), + t.Object({ + kind: t.Literal("unknown"), + }), + ], + { + description: comment` + A video not releated to any series or movie. This can be due to a matching error but it can be a youtube + video or any other video content. + `, + }, +); + +export const UnknownEntryTranslation = t.Omit(EntryTranslation, [ + "description", +]); + +export const UnknownEntry = t.Intersect([ + Resource, + BaseUnknownEntry, + UnknownEntryTranslation, +]); +export type UnknownEntry = typeof UnknownEntry.static; From b0dac24eea04eac84f2795c3f33e36f79e865e15 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 15 Nov 2024 23:39:09 +0100 Subject: [PATCH 048/105] Use kind guards in registerExamples --- api/src/models/examples/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/src/models/examples/index.ts b/api/src/models/examples/index.ts index 5e59939fa..2dd5b9f60 100644 --- a/api/src/models/examples/index.ts +++ b/api/src/models/examples/index.ts @@ -1,16 +1,17 @@ import type { TSchema } from "elysia"; +import { KindGuard } from "@sinclair/typebox" export const registerExamples = ( schema: T, ...examples: (T["static"] | undefined)[] ) => { - if ("anyOf" in schema) { + if (KindGuard.IsUnion(schema)) { for (const union of schema.anyOf) { registerExamples(union, ...examples); } return; } - if ("allOf" in schema) { + if (KindGuard.IsIntersect(schema)) { for (const intersec of schema.allOf) { registerExamples(intersec, ...examples); } From c5972bd15f833997f461c8c888cc1798e136d8fc Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 15 Nov 2024 23:44:16 +0100 Subject: [PATCH 049/105] Split translations for movies --- api/src/models/movie.ts | 70 ++++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 263fa27f3..b7eef38a7 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -1,22 +1,17 @@ import { t } from "elysia"; -import { Genre } from "./utils/genres"; -import { Image } from "./utils/image"; -import { ExternalId } from "./utils/external-id"; -import { bubble, registerExamples } from "./examples"; -import { comment } from "../utils"; +import { + ExternalId, + Genre, + Image, + Language, + Resource, +} from "./utils"; +import { Video } from "./video"; export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]); export type MovieStatus = typeof MovieStatus.static; -export const Movie = t.Object({ - id: t.String({ format: "uuid" }), - slug: t.String(), - name: t.String(), - description: t.Nullable(t.String()), - tagline: t.Nullable(t.String()), - aliases: t.Array(t.String()), - tags: t.Array(t.String()), - +export const BaseMovie = t.Object({ genres: t.Array(Genre), rating: t.Nullable(t.Number({ minimum: 0, maximum: 100 })), status: MovieStatus, @@ -26,27 +21,50 @@ export const Movie = t.Object({ airDate: t.Nullable(t.String({ format: "date" })), originalLanguage: t.Nullable( - t.String({ - description: comment` - The language code this movie was made in. - This is a BCP 47 language code (the IETF Best Current Practices on Tags for Identifying Languages). - BCP 47 is also known as RFC 5646. It subsumes ISO 639 and is backward compatible with it. - `, + Language({ + description: "The language code this movie was made in.", }), ), + createdAt: t.String({ format: "date-time" }), + nextRefresh: t.String({ format: "date-time" }), + + externalId: ExternalId, +}); +export type BaseMovie = typeof BaseMovie.static; + +export const MovieTranslation = t.Object({ + name: t.String(), + description: t.Nullable(t.String()), + tagline: t.Nullable(t.String()), + aliases: t.Array(t.String()), + tags: t.Array(t.String()), + poster: t.Nullable(Image), thumbnail: t.Nullable(Image), banner: t.Nullable(Image), logo: t.Nullable(Image), trailerUrl: t.Nullable(t.String()), - - createdAt: t.String({ format: "date-time" }), - nextRefresh: t.String({ format: "date-time" }), - - externalId: ExternalId, }); +export type MovieTranslation = typeof MovieTranslation.static; +export const Movie = t.Intersect([ + Resource, + BaseMovie, + t.Ref(MovieTranslation), +]); export type Movie = typeof Movie.static; -registerExamples(Movie, bubble); +export const SeedMovie = t.Intersect([ + BaseMovie, + t.Object({ + slug: t.String({ format: "slug" }), + image: t.Ref("image"), + toto: t.Ref("mt"), + translations: t.Record(t.String(), t.Ref("mt"), { + minProperties: 1, + }), + videos: t.Optional(t.Array(t.Ref(Video))), + }), +]); +export type SeedMovie = typeof SeedMovie.static; From 7ca441aaaeff58004e6716400087fcc730fbb765 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 15 Nov 2024 23:45:15 +0100 Subject: [PATCH 050/105] Disable ts truncations --- api/tsconfig.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/api/tsconfig.json b/api/tsconfig.json index e03f1d367..ec10ebde4 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -3,12 +3,11 @@ "target": "ES2021", "module": "ES2022", "moduleResolution": "node", - "types": [ - "bun-types" - ], + "types": ["bun-types"], "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "noErrorTruncation": true } } From 887431a335d5681229d993816686f52b3e66f3fa Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 15 Nov 2024 23:46:08 +0100 Subject: [PATCH 051/105] Update movie example (still need seed image update) --- api/src/models/examples/bubble.ts | 64 +++++++++++++++---------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/api/src/models/examples/bubble.ts b/api/src/models/examples/bubble.ts index 48014a71b..ff9f8ad02 100644 --- a/api/src/models/examples/bubble.ts +++ b/api/src/models/examples/bubble.ts @@ -1,43 +1,43 @@ -import type { Movie } from "../movie"; -import type { Video } from "../video"; +import type { SeedMovie } from "../movie"; -type CompleteMovie = Movie & { videos: Video[] }; - -export const bubble: CompleteMovie = { - id: "008f0b42-61b8-4155-857a-cbe5f40dd35d", +export const bubble: SeedMovie = { slug: "bubble", - name: "Bubble", - tagline: "Is she a calamity or a blessing?", - description: - "In an abandoned Tokyo overrun by bubbles and gravitational abnormalities, one gifted young man has a fateful meeting with a mysterious girl.", - aliases: ["Baburu", "バブル:2022", "Bubble"], - tags: ["adolescence", "disaster", "battle", "gravity", "anime"], + translations: { + en: { + name: "Bubble", + tagline: "Is she a calamity or a blessing?", + description: + "In an abandoned Tokyo overrun by bubbles and gravitational abnormalities, one gifted young man has a fateful meeting with a mysterious girl.", + aliases: ["Baburu", "バブル:2022", "Bubble"], + tags: ["adolescence", "disaster", "battle", "gravity", "anime"], + poster: { + id: "befdc7dd-2a67-0704-92af-90d49eee0315", + source: + "https://image.tmdb.org/t/p/original/65dad96VE8FJPEdrAkhdsuWMWH9.jpg", + blurhash: "LFC@2F;K$%xZ5?W.MwNF0iD~MxR:", + }, + thumbnail: { + id: "b29908f3-a64d-ae98-923b-18bf7995ab04", + source: + "https://image.tmdb.org/t/p/original/a8Q2g0g7XzAF6gcB8qgn37ccb9Y.jpg", + blurhash: "LpH3afE1XAveyGS7t6V[R4xZn+S6", + }, + banner: null, + logo: { + id: "3357fad0-de40-4ca5-15e6-eb065d35be86", + source: + "https://image.tmdb.org/t/p/original/ihIs7fayAmZieMlMQbs6TWM77uf.png", + blurhash: "LMDc5#MwE0,sTKE0R*S~4mxunhb_", + }, + trailerUrl: "https://www.youtube.com/watch?v=vs7zsyIZkMM", + }, + }, genres: ["animation", "adventure", "science-fiction", "fantasy"], rating: 74, status: "finished", runtime: 101, airDate: "2022-02-14", originalLanguage: "ja", - poster: { - id: "befdc7dd-2a67-0704-92af-90d49eee0315", - source: - "https://image.tmdb.org/t/p/original/65dad96VE8FJPEdrAkhdsuWMWH9.jpg", - blurhash: "LFC@2F;K$%xZ5?W.MwNF0iD~MxR:", - }, - thumbnail: { - id: "b29908f3-a64d-ae98-923b-18bf7995ab04", - source: - "https://image.tmdb.org/t/p/original/a8Q2g0g7XzAF6gcB8qgn37ccb9Y.jpg", - blurhash: "LpH3afE1XAveyGS7t6V[R4xZn+S6", - }, - banner: null, - logo: { - id: "3357fad0-de40-4ca5-15e6-eb065d35be86", - source: - "https://image.tmdb.org/t/p/original/ihIs7fayAmZieMlMQbs6TWM77uf.png", - blurhash: "LMDc5#MwE0,sTKE0R*S~4mxunhb_", - }, - trailerUrl: "https://www.youtube.com/watch?v=vs7zsyIZkMM", createdAt: "2023-11-29T11:42:06.030838Z", nextRefresh: "2025-01-07T22:40:59.960952Z", externalId: { From 0edf21661883bdd54ccfe5282b00a0450ed30c70 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 15 Nov 2024 23:47:13 +0100 Subject: [PATCH 052/105] Splt translations for seasons & series + seed setup --- api/src/models/examples/made-in-abyss.ts | 13 +----- api/src/models/season.ts | 33 ++++++++------ api/src/models/serie.ts | 55 ++++++++++++++---------- 3 files changed, 55 insertions(+), 46 deletions(-) diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts index b9c92490e..81e97ec78 100644 --- a/api/src/models/examples/made-in-abyss.ts +++ b/api/src/models/examples/made-in-abyss.ts @@ -1,15 +1,6 @@ -import type { Entry, Extra } from "../entry"; -import type { Season } from "../season"; -import type { Serie } from "../serie"; -import type { Video } from "../video"; +import type { SeedSerie } from "../seed"; -type CompleteSerie = Serie & { - seasons: Season[]; - entries: (Entry & { videos: Video[] })[]; - extras: (Extra & { video: Video })[]; -}; - -export const madeInAbyss: CompleteSerie = { +export const madeInAbyss: SeedSerie = { id: "04bcf2ac-3c09-42f6-8357-b003798f9562", slug: "made-in-abyss", name: "Made in Abyss", diff --git a/api/src/models/season.ts b/api/src/models/season.ts index 2b09c5651..212ccb097 100644 --- a/api/src/models/season.ts +++ b/api/src/models/season.ts @@ -1,27 +1,36 @@ import { t } from "elysia"; import { Image } from "./utils/image"; import { SeasonId } from "./utils/external-id"; -import { madeInAbyss, registerExamples } from "./examples"; +import { Resource } from "./utils/resource"; +import { Language } from "./utils/language"; -export const Season = t.Object({ - id: t.String({ format: "uuid" }), - slug: t.String(), +export const BaseSeason = t.Object({ seasonNumber: t.Number({ minimum: 1 }), - name: t.Nullable(t.String()), - description: t.Nullable(t.String()), - startAir: t.Nullable(t.String({ format: "date" })), endAir: t.Nullable(t.String({ format: "date" })), - poster: t.Nullable(Image), - thumbnail: t.Nullable(Image), - banner: t.Nullable(Image), - createdAt: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }), externalId: SeasonId, }); + +export const SeasonTranslation = t.Object({ + name: t.Nullable(t.String()), + description: t.Nullable(t.String()), + + poster: t.Nullable(Image), + thumbnail: t.Nullable(Image), + banner: t.Nullable(Image), +}); +export type SeasonTranslation = typeof SeasonTranslation.static; + +export const Season = t.Intersect([Resource, BaseSeason, SeasonTranslation]); export type Season = typeof Season.static; -registerExamples(Season, ...madeInAbyss.seasons); +export const SeedSeason = t.Intersect([ + BaseSeason, + t.Object({ + translations: t.Record(Language(), SeasonTranslation, { minPropreties: 1 }), + }), +]); diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index a417dbb19..a5dbe3b7e 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -2,8 +2,10 @@ import { t } from "elysia"; import { Genre } from "./utils/genres"; import { Image } from "./utils/image"; import { ExternalId } from "./utils/external-id"; -import { madeInAbyss , registerExamples } from "./examples"; -import { comment } from "../utils"; +import { madeInAbyss, registerExamples } from "./examples"; +import { Resource } from "./utils/resource"; +import { Language } from "./utils/language"; +import { SeedSeason } from "./season"; export const SerieStatus = t.UnionEnum([ "unknown", @@ -13,15 +15,7 @@ export const SerieStatus = t.UnionEnum([ ]); export type SerieStatus = typeof SerieStatus.static; -export const Serie = t.Object({ - id: t.String({ format: "uuid" }), - slug: t.String(), - name: t.String(), - description: t.Nullable(t.String()), - tagline: t.Nullable(t.String()), - aliases: t.Array(t.String()), - tags: t.Array(t.String()), - +export const BaseSerie = t.Object({ genres: t.Array(Genre), rating: t.Nullable(t.Number({ minimum: 0, maximum: 100 })), status: SerieStatus, @@ -35,27 +29,42 @@ export const Serie = t.Object({ startAir: t.Nullable(t.String({ format: "date" })), endAir: t.Nullable(t.String({ format: "date" })), originalLanguage: t.Nullable( - t.String({ - description: comment` - The language code this movie was made in. - This is a BCP 47 language code (the IETF Best Current Practices on Tags for Identifying Languages). - BCP 47 is also known as RFC 5646. It subsumes ISO 639 and is backward compatible with it. - `, + Language({ + description: "The language code this serie was made in.", }), ), + createdAt: t.String({ format: "date-time" }), + nextRefresh: t.String({ format: "date-time" }), + + externalId: ExternalId, +}); + +export const SerieTranslation = t.Object({ + name: t.String(), + description: t.Nullable(t.String()), + tagline: t.Nullable(t.String()), + aliases: t.Array(t.String()), + tags: t.Array(t.String()), + poster: t.Nullable(Image), thumbnail: t.Nullable(Image), banner: t.Nullable(Image), logo: t.Nullable(Image), trailerUrl: t.Nullable(t.String()), - - createdAt: t.String({ format: "date-time" }), - nextRefresh: t.String({ format: "date-time" }), - - externalId: ExternalId, }); +export type SerieTranslation = typeof SerieTranslation.static; +export const Serie = t.Intersect([Resource, BaseSerie, SerieTranslation]); export type Serie = typeof Serie.static; -registerExamples(Serie, madeInAbyss); +export const SeedSerie = t.Intersect([ + BaseSerie, + t.Object({ + translations: t.Record(Language(), SerieTranslation, { minProperties: 1 }), + seasons: t.Array(SeedSeason), + // entries: t.Array(SeedEntry), + // extras: t.Optional(t.Array(SeedExtra)), + }), +]); +export type SeedSerie = typeof SeedSerie.static; From b8b536632deed96ff2a7f0f3e381b4a5d0716318 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 16 Nov 2024 17:12:10 +0100 Subject: [PATCH 053/105] Swagger setup --- api/src/controllers/movies.ts | 29 +++++++++++++++++++++-------- api/src/index.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 13242ec60..b0aa6c88c 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -1,9 +1,10 @@ import { Elysia, t } from "elysia"; -import { Movie } from "../models/movie"; +import { Movie, MovieTranslation } from "../models/movie"; import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; import { eq, and, sql, or, inArray } from "drizzle-orm"; import { getColumns } from "../db/schema/utils"; +import { bubble } from "../models/examples"; const translations = db .selectDistinctOn([showTranslations.language]) @@ -22,7 +23,6 @@ const translations = db const { pk: _, kind, startAir, endAir, ...moviesCol } = getColumns(shows); const { pk, language, ...translationsCol } = getColumns(translations); - const findMovie = db .select({ ...moviesCol, @@ -47,16 +47,29 @@ const findMovie = db export const movies = new Elysia({ prefix: "/movies" }) .model({ movie: Movie, + "movie-translation": MovieTranslation, error: t.Object({}), }) .guard({ params: t.Object({ - id: t.String(), + id: t.String({ + description: "The id or slug of the movie to retrieve", + examples: [bubble.slug], + }), }), response: { 200: "movie", 404: "error" }, + tags: ["Movies"], }) - .get("/:id", async ({ params: { id }, error }) => { - const ret = await findMovie.execute({ id }); - if (ret.length !== 1) return error(404, {}); - return ret[0]; - }); + .get( + "/:id", + async ({ params: { id }, error }) => { + const ret = await findMovie.execute({ id }); + if (ret.length !== 1) return error(404, {}); + return ret[0]; + }, + { + detail: { + description: "Get a movie by id or slug", + }, + }, + ); diff --git a/api/src/index.ts b/api/src/index.ts index 465c0167f..07bd1d77e 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -5,9 +5,12 @@ import { Elysia } from "elysia"; import { entries } from "./controllers/entries"; import { movies } from "./controllers/movies"; import { seasons } from "./controllers/seasons"; +import { seed } from "./controllers/seed"; import { series } from "./controllers/series"; import { videos } from "./controllers/videos"; import { db } from "./db"; +import { Image } from "./models/utils"; +import { comment } from "./utils"; await migrate(db, { migrationsSchema: "kyoo", migrationsFolder: "./drizzle" }); @@ -33,13 +36,40 @@ if (!secret) { const app = new Elysia() .use(jwt({ secret })) - .use(swagger()) + .use( + swagger({ + documentation: { + info: { + title: "Kyoo", + description: comment` + Complete API documentation of Kyoo. + If you need a route not present here, please make an issue over https://github.com/zoriya/kyoo + `, + version: "5.0.0", + contact: { name: "github", url: "https://github.com/zoriya/kyoo" }, + license: { + name: "GPL-3.0 license", + url: "https://github.com/zoriya/Kyoo/blob/master/LICENSE", + }, + }, + servers: [ + { + url: "https://kyoo.zoriya.dev/api", + description: "Kyoo's demo server", + }, + ], + tags: [{ name: "Movies", description: "Routes about movies" }], + }, + }), + ) .get("/", () => "Hello Elysia") + .model({ image: Image }) .use(movies) .use(series) .use(entries) .use(seasons) .use(videos) + .use(seed) .listen(3000); console.log(`Api running at ${app.server?.hostname}:${app.server?.port}`); From 361c07ce53219eebe2a0093512bb2da50b17a97a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 23 Nov 2024 15:42:37 +0100 Subject: [PATCH 054/105] Seed movie schema --- api/src/models/movie.ts | 40 +++++++++++++++++++------------- api/src/models/utils/image.ts | 2 ++ api/src/models/utils/resource.ts | 2 ++ api/src/models/video.ts | 9 +++---- 4 files changed, 33 insertions(+), 20 deletions(-) diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index b7eef38a7..c19ee5be1 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -5,13 +5,15 @@ import { Image, Language, Resource, + SeedImage, } from "./utils"; -import { Video } from "./video"; +import { SeedVideo } from "./video"; +import { bubble, registerExamples } from "./examples"; export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]); export type MovieStatus = typeof MovieStatus.static; -export const BaseMovie = t.Object({ +const BaseMovie = t.Object({ genres: t.Array(Genre), rating: t.Nullable(t.Number({ minimum: 0, maximum: 100 })), status: MovieStatus, @@ -31,8 +33,6 @@ export const BaseMovie = t.Object({ externalId: ExternalId, }); -export type BaseMovie = typeof BaseMovie.static; - export const MovieTranslation = t.Object({ name: t.String(), description: t.Nullable(t.String()), @@ -48,23 +48,31 @@ export const MovieTranslation = t.Object({ }); export type MovieTranslation = typeof MovieTranslation.static; -export const Movie = t.Intersect([ - Resource, - BaseMovie, - t.Ref(MovieTranslation), -]); +export const Movie = t.Intersect([Resource, MovieTranslation, BaseMovie]); export type Movie = typeof Movie.static; export const SeedMovie = t.Intersect([ - BaseMovie, + t.Omit(BaseMovie, ["createdAt", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug" }), - image: t.Ref("image"), - toto: t.Ref("mt"), - translations: t.Record(t.String(), t.Ref("mt"), { - minProperties: 1, - }), - videos: t.Optional(t.Array(t.Ref(Video))), + translations: t.Record( + Language(), + t.Intersect([ + t.Omit(MovieTranslation, ["poster", "thumbnail", "banner", "logo"]), + t.Object({ + poster: t.Nullable(SeedImage), + thumbnail: t.Nullable(SeedImage), + banner: t.Nullable(SeedImage), + logo: t.Nullable(SeedImage), + }), + ]), + { + minProperties: 1, + }, + ), + videos: t.Optional(t.Array(SeedVideo)), }), ]); export type SeedMovie = typeof SeedMovie.static; + +registerExamples(SeedMovie, bubble); diff --git a/api/src/models/utils/image.ts b/api/src/models/utils/image.ts index 407de4251..c1883d606 100644 --- a/api/src/models/utils/image.ts +++ b/api/src/models/utils/image.ts @@ -6,3 +6,5 @@ export const Image = t.Object({ blurhash: t.String(), }); export type Image = typeof Image.static; + +export const SeedImage = t.String({ format: "uri" }); diff --git a/api/src/models/utils/resource.ts b/api/src/models/utils/resource.ts index 0de064362..4c47cf488 100644 --- a/api/src/models/utils/resource.ts +++ b/api/src/models/utils/resource.ts @@ -1,6 +1,8 @@ import { FormatRegistry } from "@sinclair/typebox"; import { t } from "elysia"; +export const slugPattern = "^[a-z0-9-]+$"; + FormatRegistry.Set("slug", (slug) => { return /^[a-z0-9-]+$/g.test(slug); }); diff --git a/api/src/models/video.ts b/api/src/models/video.ts index bb6f9cb65..12346e50b 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,11 +1,9 @@ import { t } from "elysia"; import { comment } from "../utils"; -import { bubble, registerExamples } from "./examples"; -import { Movie } from "./movie"; export const Video = t.Object({ id: t.String({ format: "uuid" }), - slug: t.String(), + slug: t.String({ format: "slug" }), path: t.String(), rendering: t.String({ description: comment` @@ -36,4 +34,7 @@ export const Video = t.Object({ export type Video = typeof Video.static; -registerExamples(Video, ...bubble.videos); +export const SeedVideo = t.Union([ + t.Omit(Video, ["id", "slug", "createdAt"]), + t.String({ format: "uuid" }), +]); From b47c38ca764258a3ace25b57ec2d3b24720214e8 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 23 Nov 2024 16:02:37 +0100 Subject: [PATCH 055/105] Cleanup movie example in swagger --- api/bun.lockb | Bin 39908 -> 39908 bytes api/package.json | 10 +++--- api/src/controllers/movies.ts | 3 +- api/src/db/schema/shows.ts | 2 +- api/src/index.ts | 2 +- api/src/models/examples/bubble.ts | 50 ++++++++++++++++-------------- api/src/models/examples/index.ts | 4 +-- api/src/models/movie.ts | 23 +++++++------- api/src/models/utils/index.ts | 2 -- 9 files changed, 49 insertions(+), 47 deletions(-) diff --git a/api/bun.lockb b/api/bun.lockb index 4a5df0ec1fe08dd60425144f6e74a0916ed44688..7c84570b68663ab83fe343db37afb327f53287dd 100755 GIT binary patch delta 4535 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 I<6evZ0M&Fla{vGU diff --git a/api/package.json b/api/package.json index fc45391f7..f9cbdac2d 100644 --- a/api/package.json +++ b/api/package.json @@ -9,15 +9,15 @@ }, "dependencies": { "@elysiajs/jwt": "^1.1.1", - "@elysiajs/swagger": "^1.1.5", - "drizzle-kit": "^0.28.0", - "drizzle-orm": "^0.36.1", - "elysia": "^1.1.24", + "@elysiajs/swagger": "^1.1.6", + "drizzle-kit": "^0.28.1", + "drizzle-orm": "^0.36.4", + "elysia": "^1.1.25", "pg": "^8.13.1" }, "devDependencies": { "@types/pg": "^8.11.10", - "bun-types": "^1.1.34" + "bun-types": "^1.1.36" }, "module": "src/index.js" } diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index b0aa6c88c..ce61824f3 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -44,7 +44,7 @@ const findMovie = db .limit(1) .prepare("findMovie"); -export const movies = new Elysia({ prefix: "/movies" }) +export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .model({ movie: Movie, "movie-translation": MovieTranslation, @@ -58,7 +58,6 @@ export const movies = new Elysia({ prefix: "/movies" }) }), }), response: { 200: "movie", 404: "error" }, - tags: ["Movies"], }) .get( "/:id", diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index d59022177..ce406f564 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -100,11 +100,11 @@ export const showTranslations = schema.table( tagline: text(), aliases: text().array().notNull(), tags: text().array().notNull(), - trailerUrl: text(), poster: image(), thumbnail: image(), banner: image(), logo: image(), + trailerUrl: text(), }, (t) => [primaryKey({ columns: [t.pk, t.language] })], ); diff --git a/api/src/index.ts b/api/src/index.ts index 07bd1d77e..fd1572735 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -58,7 +58,7 @@ const app = new Elysia() description: "Kyoo's demo server", }, ], - tags: [{ name: "Movies", description: "Routes about movies" }], + tags: [{ name: "movies", description: "Routes about movies" }], }, }), ) diff --git a/api/src/models/examples/bubble.ts b/api/src/models/examples/bubble.ts index ff9f8ad02..3e9c38507 100644 --- a/api/src/models/examples/bubble.ts +++ b/api/src/models/examples/bubble.ts @@ -10,25 +10,12 @@ export const bubble: SeedMovie = { "In an abandoned Tokyo overrun by bubbles and gravitational abnormalities, one gifted young man has a fateful meeting with a mysterious girl.", aliases: ["Baburu", "バブル:2022", "Bubble"], tags: ["adolescence", "disaster", "battle", "gravity", "anime"], - poster: { - id: "befdc7dd-2a67-0704-92af-90d49eee0315", - source: - "https://image.tmdb.org/t/p/original/65dad96VE8FJPEdrAkhdsuWMWH9.jpg", - blurhash: "LFC@2F;K$%xZ5?W.MwNF0iD~MxR:", - }, - thumbnail: { - id: "b29908f3-a64d-ae98-923b-18bf7995ab04", - source: - "https://image.tmdb.org/t/p/original/a8Q2g0g7XzAF6gcB8qgn37ccb9Y.jpg", - blurhash: "LpH3afE1XAveyGS7t6V[R4xZn+S6", - }, + poster: + "https://image.tmdb.org/t/p/original/65dad96VE8FJPEdrAkhdsuWMWH9.jpg", + thumbnail: + "https://image.tmdb.org/t/p/original/a8Q2g0g7XzAF6gcB8qgn37ccb9Y.jpg", banner: null, - logo: { - id: "3357fad0-de40-4ca5-15e6-eb065d35be86", - source: - "https://image.tmdb.org/t/p/original/ihIs7fayAmZieMlMQbs6TWM77uf.png", - blurhash: "LMDc5#MwE0,sTKE0R*S~4mxunhb_", - }, + logo: "https://image.tmdb.org/t/p/original/ihIs7fayAmZieMlMQbs6TWM77uf.png", trailerUrl: "https://www.youtube.com/watch?v=vs7zsyIZkMM", }, }, @@ -38,8 +25,6 @@ export const bubble: SeedMovie = { runtime: 101, airDate: "2022-02-14", originalLanguage: "ja", - createdAt: "2023-11-29T11:42:06.030838Z", - nextRefresh: "2025-01-07T22:40:59.960952Z", externalId: { themoviedatabase: { dataId: "912598", @@ -52,14 +37,33 @@ export const bubble: SeedMovie = { }, videos: [ { - id: "0934da28-4a49-404e-920b-a150404a3b6d", - slug: "bubble", path: "/video/Bubble/Bubble (2022).mkv", rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd", part: null, version: 1, - createdAt: "2023-11-29T11:42:06.030838Z", }, ], }; + +export const bubbleImages = { + poster: { + id: "befdc7dd-2a67-0704-92af-90d49eee0315", + source: + "https://image.tmdb.org/t/p/original/65dad96VE8FJPEdrAkhdsuWMWH9.jpg", + blurhash: "LFC@2F;K$%xZ5?W.MwNF0iD~MxR:", + }, + thumbnail: { + id: "b29908f3-a64d-ae98-923b-18bf7995ab04", + source: + "https://image.tmdb.org/t/p/original/a8Q2g0g7XzAF6gcB8qgn37ccb9Y.jpg", + blurhash: "LpH3afE1XAveyGS7t6V[R4xZn+S6", + }, + banner: null, + logo: { + id: "3357fad0-de40-4ca5-15e6-eb065d35be86", + source: + "https://image.tmdb.org/t/p/original/ihIs7fayAmZieMlMQbs6TWM77uf.png", + blurhash: "LMDc5#MwE0,sTKE0R*S~4mxunhb_", + }, +}; diff --git a/api/src/models/examples/index.ts b/api/src/models/examples/index.ts index 2dd5b9f60..16bdb874e 100644 --- a/api/src/models/examples/index.ts +++ b/api/src/models/examples/index.ts @@ -1,9 +1,9 @@ import type { TSchema } from "elysia"; -import { KindGuard } from "@sinclair/typebox" +import { KindGuard } from "@sinclair/typebox"; export const registerExamples = ( schema: T, - ...examples: (T["static"] | undefined)[] + ...examples: (Partial| undefined)[] ) => { if (KindGuard.IsUnion(schema)) { for (const union of schema.anyOf) { diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index c19ee5be1..d5036079f 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -1,19 +1,15 @@ import { t } from "elysia"; -import { - ExternalId, - Genre, - Image, - Language, - Resource, - SeedImage, -} from "./utils"; +import { ExternalId, Genre, Image, Language, SeedImage } from "./utils"; import { SeedVideo } from "./video"; import { bubble, registerExamples } from "./examples"; +import { bubbleImages } from "./examples/bubble"; export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]); export type MovieStatus = typeof MovieStatus.static; const BaseMovie = t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug" }), genres: t.Array(Genre), rating: t.Nullable(t.Number({ minimum: 0, maximum: 100 })), status: MovieStatus, @@ -33,6 +29,7 @@ const BaseMovie = t.Object({ externalId: ExternalId, }); + export const MovieTranslation = t.Object({ name: t.String(), description: t.Nullable(t.String()), @@ -48,13 +45,12 @@ export const MovieTranslation = t.Object({ }); export type MovieTranslation = typeof MovieTranslation.static; -export const Movie = t.Intersect([Resource, MovieTranslation, BaseMovie]); +export const Movie = t.Intersect([BaseMovie, MovieTranslation]); export type Movie = typeof Movie.static; export const SeedMovie = t.Intersect([ - t.Omit(BaseMovie, ["createdAt", "nextRefresh"]), + t.Omit(BaseMovie, ["id", "createdAt", "nextRefresh"]), t.Object({ - slug: t.String({ format: "slug" }), translations: t.Record( Language(), t.Intersect([ @@ -75,4 +71,9 @@ export const SeedMovie = t.Intersect([ ]); export type SeedMovie = typeof SeedMovie.static; +registerExamples(Movie, { + ...bubble, + ...bubble.translations.en, + ...bubbleImages, +}); registerExamples(SeedMovie, bubble); diff --git a/api/src/models/utils/index.ts b/api/src/models/utils/index.ts index 0b7bef196..98134fc57 100644 --- a/api/src/models/utils/index.ts +++ b/api/src/models/utils/index.ts @@ -1,5 +1,3 @@ -export const ref = (id: string) => `#/components/schemas/${id}`; - export * from "./external-id"; export * from "./genres"; export * from "./image"; From b63391d7446b797c68a5876497089e54809b406c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 24 Nov 2024 23:26:20 +0100 Subject: [PATCH 056/105] Cleanup schemas --- api/src/db/index.ts | 12 ++---------- api/src/db/schema/entries.ts | 8 ++++---- api/src/db/schema/index.ts | 4 ++++ api/src/db/schema/seasons.ts | 6 +++--- api/src/db/schema/videos.ts | 6 ++++-- 5 files changed, 17 insertions(+), 19 deletions(-) create mode 100644 api/src/db/schema/index.ts diff --git a/api/src/db/index.ts b/api/src/db/index.ts index 5be8aa52f..c2b0d0032 100644 --- a/api/src/db/index.ts +++ b/api/src/db/index.ts @@ -1,16 +1,8 @@ import { drizzle } from "drizzle-orm/node-postgres"; -import * as entries from "./schema/entries"; -import * as seasons from "./schema/seasons"; -import * as shows from "./schema/shows"; -import * as videos from "./schema/videos"; +import * as schema from "./schema"; export const db = drizzle({ - schema: { - ...entries, - ...shows, - ...seasons, - ...videos, - }, + schema, connection: { user: process.env.POSTGRES_USER ?? "kyoo", password: process.env.POSTGRES_PASSWORD ?? "password", diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index b1bf583b4..1a5e747b6 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -23,7 +23,7 @@ export const entryType = schema.enum("entry_type", [ "extra", ]); -export const entryid = () => +export const entry_extid = () => jsonb() .$type< Record< @@ -60,7 +60,7 @@ export const entries = schema.table( runtime: integer(), thumbnails: image(), - externalId: entryid(), + externalId: entry_extid(), createdAt: timestamp({ withTimezone: true, mode: "string" }).defaultNow(), nextRefresh: timestamp({ withTimezone: true, mode: "string" }), @@ -71,8 +71,8 @@ export const entries = schema.table( ], ); -export const entriesTranslation = schema.table( - "entries_translation", +export const entryTranslations = schema.table( + "entry_translations", { pk: integer() .notNull() diff --git a/api/src/db/schema/index.ts b/api/src/db/schema/index.ts new file mode 100644 index 000000000..c817ce8c2 --- /dev/null +++ b/api/src/db/schema/index.ts @@ -0,0 +1,4 @@ +export * from "./entries"; +export * from "./seasons"; +export * from "./shows"; +export * from "./videos"; diff --git a/api/src/db/schema/seasons.ts b/api/src/db/schema/seasons.ts index da65f3d02..85405c0f7 100644 --- a/api/src/db/schema/seasons.ts +++ b/api/src/db/schema/seasons.ts @@ -12,7 +12,7 @@ import { import { image, language, schema } from "./utils"; import { shows } from "./shows"; -export const entryid = () => +export const season_extid = () => jsonb() .$type< Record< @@ -38,7 +38,7 @@ export const seasons = schema.table( startAir: date(), endAir: date(), - externalId: entryid(), + externalId: season_extid(), createdAt: timestamp({ withTimezone: true, mode: "string" }).defaultNow(), nextRefresh: timestamp({ withTimezone: true, mode: "string" }), @@ -47,7 +47,7 @@ export const seasons = schema.table( ); export const seasonTranslation = schema.table( - "season_translation", + "season_translations", { pk: integer() .notNull() diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index 101287ea8..cb82704fc 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -15,14 +15,16 @@ export const videos = schema.table( { pk: integer().primaryKey().generatedAlwaysAsIdentity(), id: uuid().notNull().unique().defaultRandom(), - slug: varchar({ length: 255 }).notNull().unique(), + slug: varchar({ length: 255 }).unique(), path: text().notNull().unique(), rendering: text().notNull(), part: integer(), version: integer().notNull().default(1), guess: jsonb().notNull().default({}), - createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + createdAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .defaultNow(), }, (t) => [ check("part_pos", sql`${t.part} >= 0`), From 1309749e463e8134b85766b8e5d6c7cd645645ba Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 24 Nov 2024 23:27:06 +0100 Subject: [PATCH 057/105] Remove the option to create videos from post /movies --- api/src/models/examples/bubble.ts | 21 ++++++++++++--------- api/src/models/examples/index.ts | 4 ++-- api/src/models/movie.ts | 2 +- api/src/models/video.ts | 8 +++----- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/api/src/models/examples/bubble.ts b/api/src/models/examples/bubble.ts index 3e9c38507..27b469ad1 100644 --- a/api/src/models/examples/bubble.ts +++ b/api/src/models/examples/bubble.ts @@ -1,4 +1,15 @@ import type { SeedMovie } from "../movie"; +import type { Video } from "../video"; + +export const bubbleVideo: Video = { + id: "3cd436ee-01ff-4f45-ba98-62aabeb22f25", + slug: "bubble", + path: "/video/Bubble/Bubble (2022).mkv", + rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd", + part: null, + version: 1, + createdAt: "2024-11-23T15:01:24.968Z", +}; export const bubble: SeedMovie = { slug: "bubble", @@ -35,15 +46,7 @@ export const bubble: SeedMovie = { link: "https://www.imdb.com/title/tt16360006", }, }, - videos: [ - { - path: "/video/Bubble/Bubble (2022).mkv", - rendering: - "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd", - part: null, - version: 1, - }, - ], + videos: [bubbleVideo.id], }; export const bubbleImages = { diff --git a/api/src/models/examples/index.ts b/api/src/models/examples/index.ts index 16bdb874e..2d917578a 100644 --- a/api/src/models/examples/index.ts +++ b/api/src/models/examples/index.ts @@ -30,5 +30,5 @@ export const registerExamples = ( } }; -export { bubble } from "./bubble"; -export { madeInAbyss } from "./made-in-abyss"; +export * from "./bubble"; +export * from "./made-in-abyss"; diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index d5036079f..984f67c7e 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -66,7 +66,7 @@ export const SeedMovie = t.Intersect([ minProperties: 1, }, ), - videos: t.Optional(t.Array(SeedVideo)), + videos: t.Optional(t.Array(t.String({ format: "uuid" }))), }), ]); export type SeedMovie = typeof SeedMovie.static; diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 12346e50b..bf3bbde11 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,5 +1,6 @@ import { t } from "elysia"; import { comment } from "../utils"; +import { registerExamples, bubbleVideo } from "./examples"; export const Video = t.Object({ id: t.String({ format: "uuid" }), @@ -31,10 +32,7 @@ export const Video = t.Object({ createdAt: t.String({ format: "date-time" }), }); - export type Video = typeof Video.static; +registerExamples(Video, bubbleVideo); -export const SeedVideo = t.Union([ - t.Omit(Video, ["id", "slug", "createdAt"]), - t.String({ format: "uuid" }), -]); +export const SeedVideo = t.Omit(Video, ["id", "slug", "createdAt"]); From 30d5d6575520244c15d30ef0bd636f74a022836e Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 24 Nov 2024 23:28:43 +0100 Subject: [PATCH 058/105] Create movie seed route handler --- api/src/controllers/seed/images.ts | 19 +++++++ api/src/controllers/seed/index.ts | 79 +++++++++++++++++++++++++++++ api/src/controllers/seed/refresh.ts | 12 +++++ api/src/models/utils/image.ts | 2 +- api/tsconfig.json | 12 ++++- 5 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 api/src/controllers/seed/images.ts create mode 100644 api/src/controllers/seed/index.ts create mode 100644 api/src/controllers/seed/refresh.ts diff --git a/api/src/controllers/seed/images.ts b/api/src/controllers/seed/images.ts new file mode 100644 index 000000000..b13dfbcbd --- /dev/null +++ b/api/src/controllers/seed/images.ts @@ -0,0 +1,19 @@ +import type { Image } from "~/models/utils"; + +export const processImage = async (url: string): Promise => { + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(url); + + // TODO: download source, save it in multiples qualities & process blurhash + + return { + id: hasher.digest().toString(), + source: url, + blurhash: "", + }; +}; + +export const processOptImage = (url: string | null): Promise => { + if (!url) return Promise.resolve(null); + return processImage(url); +}; diff --git a/api/src/controllers/seed/index.ts b/api/src/controllers/seed/index.ts new file mode 100644 index 000000000..142e1bd2b --- /dev/null +++ b/api/src/controllers/seed/index.ts @@ -0,0 +1,79 @@ +import Elysia, { t } from "elysia"; +import { Movie, SeedMovie } from "~/models/movie"; +import { db } from "~/db"; +import { + shows, + showTranslations, + entries, + entryTranslations, +} from "~/db/schema"; +import { guessNextRefresh } from "./refresh"; +import { processOptImage } from "./images"; + +type Show = typeof shows.$inferInsert; +type ShowTrans = typeof showTranslations.$inferInsert; +type Entry = typeof entries.$inferInsert; + +export const seed = new Elysia() + .model({ + movie: Movie, + "seed-movie": SeedMovie, + error: t.String(), + }) + .post( + "/movies", + async ({ body }) => { + const { translations, videos, ...bMovie } = body; + + const ret = await db.transaction(async (tx) => { + const movie: Show = { + kind: "movie", + startAir: bMovie.airDate, + nextRefresh: guessNextRefresh(bMovie.airDate ?? new Date()), + ...bMovie, + }; + const [ret] = await tx + .insert(shows) + .values(movie) + .returning({ pk: shows.pk, id: shows.id }); + + // 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) + .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); + + const entryTrans = trans.map((x) => ({ ...x, pk: entry.pk })); + await tx.insert(entryTranslations).values(entryTrans); + + return { ...ret, entry: entry.pk }; + }); + + // TODO: insert entry-video links + // await db.transaction(async tx => { + // await tx.insert(videos).values(videos); + // }); + + return ret.id; + }, + { + body: "seed-movie", + response: { 200: "movie", 400: "error" }, + tags: ["movies"], + }, + ); diff --git a/api/src/controllers/seed/refresh.ts b/api/src/controllers/seed/refresh.ts new file mode 100644 index 000000000..2f142eaab --- /dev/null +++ b/api/src/controllers/seed/refresh.ts @@ -0,0 +1,12 @@ +// oh i hate js dates so much. +export const guessNextRefresh = (airDate: Date | string) => { + if (typeof airDate === "string") airDate = new Date(airDate); + const diff = new Date().getTime() - airDate.getTime(); + const days = diff / (24 * 60 * 60 * 1000); + + const ret = new Date(); + if (days <= 4) ret.setDate(ret.getDate() + 4); + else if (days <= 21) ret.setDate(ret.getDate() + 14); + else ret.setMonth(ret.getMonth() + 2); + return ret.toISOString().substring(0, 10); +}; diff --git a/api/src/models/utils/image.ts b/api/src/models/utils/image.ts index c1883d606..6d79a4c36 100644 --- a/api/src/models/utils/image.ts +++ b/api/src/models/utils/image.ts @@ -1,7 +1,7 @@ import { t } from "elysia"; export const Image = t.Object({ - id: t.String({ format: "uuid" }), + id: t.String(), source: t.String({ format: "uri" }), blurhash: t.String(), }); diff --git a/api/tsconfig.json b/api/tsconfig.json index ec10ebde4..b2e97422f 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -3,11 +3,19 @@ "target": "ES2021", "module": "ES2022", "moduleResolution": "node", - "types": ["bun-types"], + "types": [ + "bun-types" + ], "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, - "noErrorTruncation": true + "noErrorTruncation": true, + "baseUrl": ".", + "paths": { + "~/*": [ + "./src/*" + ] + } } } From 31b7c0e035ab709b6a650a38b1d600f9d6542c94 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 25 Nov 2024 18:27:32 +0100 Subject: [PATCH 059/105] Move seed function to separate file --- api/src/controllers/seed/index.ts | 62 +---------------------------- api/src/controllers/seed/movies.ts | 64 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 60 deletions(-) create mode 100644 api/src/controllers/seed/movies.ts diff --git a/api/src/controllers/seed/index.ts b/api/src/controllers/seed/index.ts index 142e1bd2b..699450ac1 100644 --- a/api/src/controllers/seed/index.ts +++ b/api/src/controllers/seed/index.ts @@ -1,18 +1,6 @@ import Elysia, { t } from "elysia"; import { Movie, SeedMovie } from "~/models/movie"; -import { db } from "~/db"; -import { - shows, - showTranslations, - entries, - entryTranslations, -} from "~/db/schema"; -import { guessNextRefresh } from "./refresh"; -import { processOptImage } from "./images"; - -type Show = typeof shows.$inferInsert; -type ShowTrans = typeof showTranslations.$inferInsert; -type Entry = typeof entries.$inferInsert; +import { seedMovie } from "./movies"; export const seed = new Elysia() .model({ @@ -23,53 +11,7 @@ export const seed = new Elysia() .post( "/movies", async ({ body }) => { - const { translations, videos, ...bMovie } = body; - - const ret = await db.transaction(async (tx) => { - const movie: Show = { - kind: "movie", - startAir: bMovie.airDate, - nextRefresh: guessNextRefresh(bMovie.airDate ?? new Date()), - ...bMovie, - }; - const [ret] = await tx - .insert(shows) - .values(movie) - .returning({ pk: shows.pk, id: shows.id }); - - // 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) - .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); - - const entryTrans = trans.map((x) => ({ ...x, pk: entry.pk })); - await tx.insert(entryTranslations).values(entryTrans); - - return { ...ret, entry: entry.pk }; - }); - - // TODO: insert entry-video links - // await db.transaction(async tx => { - // await tx.insert(videos).values(videos); - // }); - - return ret.id; + return await seedMovie(body); }, { body: "seed-movie", diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts new file mode 100644 index 000000000..bbac40038 --- /dev/null +++ b/api/src/controllers/seed/movies.ts @@ -0,0 +1,64 @@ +import { db } from "~/db"; +import { + entries, + entryTranslations, + shows, + showTranslations, +} from "~/db/schema"; +import type { SeedMovie } from "~/models/movie"; +import { processOptImage } from "./images"; +import { guessNextRefresh } from "./refresh"; + +type Show = typeof shows.$inferInsert; +type ShowTrans = typeof showTranslations.$inferInsert; +type Entry = typeof entries.$inferInsert; + +export const seedMovie = async (seed: SeedMovie) => { + const { translations, videos, ...bMovie } = seed; + + const ret = await db.transaction(async (tx) => { + const movie: Show = { + kind: "movie", + startAir: bMovie.airDate, + nextRefresh: guessNextRefresh(bMovie.airDate ?? new Date()), + ...bMovie, + }; + const [ret] = await tx + .insert(shows) + .values(movie) + .returning({ pk: shows.pk, id: shows.id }); + + // 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) + .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); + + const entryTrans = trans.map((x) => ({ ...x, pk: entry.pk })); + await tx.insert(entryTranslations).values(entryTrans); + + return { ...ret, entry: entry.pk }; + }); + + // TODO: insert entry-video links + // await db.transaction(async tx => { + // await tx.insert(videos).values(videos); + // }); + + return ret.id; +}; From 825355430451dc1799c0ea44252e2b6b4940e766 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 25 Nov 2024 21:00:17 +0100 Subject: [PATCH 060/105] Add many-to-many jointure between entries & videos --- api/src/db/schema/videos.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index cb82704fc..07f129f65 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, @@ -7,8 +7,10 @@ import { timestamp, uuid, varchar, + primaryKey, } from "drizzle-orm/pg-core"; import { schema } from "./utils"; +import { entries } from "./entries"; export const videos = schema.table( "videos", @@ -31,3 +33,16 @@ export const videos = schema.table( check("version_pos", sql`${t.version} >= 0`), ], ); + +export const entryVideoJointure = schema.table( + "entry_video_jointure", + { + entry: integer() + .notNull() + .references(() => entries.pk, { onDelete: "cascade" }), + video: integer() + .notNull() + .references(() => videos.pk, { onDelete: "cascade" }), + }, + (t) => [primaryKey({ columns: [t.entry, t.video] })], +); From 92ee0b2e7fff478c75d4735eed066bb7af6765b6 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 25 Nov 2024 21:00:41 +0100 Subject: [PATCH 061/105] wip: Allow videos to be joined on a post /movies --- api/src/controllers/seed/movies.ts | 57 +++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index bbac40038..25ab49007 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -2,19 +2,22 @@ import { db } from "~/db"; import { entries, entryTranslations, + entryVideoJointure, shows, showTranslations, + videos, } from "~/db/schema"; import type { SeedMovie } from "~/models/movie"; import { processOptImage } from "./images"; import { guessNextRefresh } from "./refresh"; +import { count, eq, inArray, sql } from "drizzle-orm"; type Show = typeof shows.$inferInsert; type ShowTrans = typeof showTranslations.$inferInsert; type Entry = typeof entries.$inferInsert; export const seedMovie = async (seed: SeedMovie) => { - const { translations, videos, ...bMovie } = seed; + const { translations, videos: vids, ...bMovie } = seed; const ret = await db.transaction(async (tx) => { const movie: Show = { @@ -55,10 +58,54 @@ export const seedMovie = async (seed: SeedMovie) => { return { ...ret, entry: entry.pk }; }); - // TODO: insert entry-video links - // await db.transaction(async tx => { - // await tx.insert(videos).values(videos); - // }); + if (vids) { + await db.transaction(async (tx) => { + const pks = await tx + .insert(entryVideoJointure) + .select( + tx + .select({ + entry: sql`${ret.entry}`.as("entry"), + video: videos.pk, + }) + .from(videos) + .where(inArray(videos.id, vids)), + ) + .onConflictDoNothing() + .returning({ pk: entryVideoJointure.video }); + + + const toto = tx + .select({ count: count(videos.rendering) }) + .from(videos) + .innerJoin( + entryVideoJointure, + eq(videos.pk, entryVideoJointure.video), + ) + .where(entryVideoJointure.entry, ""); + + return await tx + .update(videos) + .set({ + slug: sql` + concat( + ${entries.slug}, + case when ${videos.part} <> null then concat("-p", ${videos.part}) else "" end, + case when ${videos.version} <> 1 then concat("-v", ${videos.version}) else "" end, + ${} + ) + `, + }) + .from(entries) + .where( + inArray( + videos.pk, + pks.map((x) => x.pk), + ), + ) + .returning({ id: videos.id, slug: videos.slug }); + }); + } return ret.id; }; From c20aa862a911c75e2ab71afeecabdd31e4359aee Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 25 Nov 2024 21:19:58 +0100 Subject: [PATCH 062/105] Move video slug to jointure --- api/src/controllers/seed/index.ts | 5 ++- api/src/controllers/seed/movies.ts | 69 ++++++++++++++---------------- api/src/db/schema/videos.ts | 4 +- 3 files changed, 36 insertions(+), 42 deletions(-) diff --git a/api/src/controllers/seed/index.ts b/api/src/controllers/seed/index.ts index 699450ac1..776dc4f1e 100644 --- a/api/src/controllers/seed/index.ts +++ b/api/src/controllers/seed/index.ts @@ -1,11 +1,12 @@ import Elysia, { t } from "elysia"; import { Movie, SeedMovie } from "~/models/movie"; -import { seedMovie } from "./movies"; +import { seedMovie, SeedMovieResponse } from "./movies"; export const seed = new Elysia() .model({ movie: Movie, "seed-movie": SeedMovie, + "seed-movie-response": SeedMovieResponse, error: t.String(), }) .post( @@ -15,7 +16,7 @@ export const seed = new Elysia() }, { body: "seed-movie", - response: { 200: "movie", 400: "error" }, + response: { 200: "seed-movie-response", 400: "error" }, tags: ["movies"], }, ); diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 25ab49007..d35bc6613 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -10,13 +10,23 @@ import { import type { SeedMovie } from "~/models/movie"; import { processOptImage } from "./images"; import { guessNextRefresh } from "./refresh"; -import { count, eq, inArray, sql } from "drizzle-orm"; +import { inArray, sql } from "drizzle-orm"; +import { t } from "elysia"; +import { Resource } from "~/models/utils"; type Show = typeof shows.$inferInsert; type ShowTrans = typeof showTranslations.$inferInsert; type Entry = typeof entries.$inferInsert; -export const seedMovie = async (seed: SeedMovie) => { +export const SeedMovieResponse = t.Intersect([ + Resource, + t.Object({ videos: t.Array(Resource) }), +]); +export type SeedMovieResponse = typeof SeedMovieResponse.static; + +export const seedMovie = async ( + seed: SeedMovie, +): Promise => { const { translations, videos: vids, ...bMovie } = seed; const ret = await db.transaction(async (tx) => { @@ -29,7 +39,7 @@ export const seedMovie = async (seed: SeedMovie) => { const [ret] = await tx .insert(shows) .values(movie) - .returning({ pk: shows.pk, id: shows.id }); + .returning({ pk: shows.pk, id: shows.id, slug: shows.slug }); // even if never shown to the user, a movie still has an entry. const movieEntry: Entry = { type: "movie", ...bMovie }; @@ -58,54 +68,37 @@ export const seedMovie = async (seed: SeedMovie) => { return { ...ret, entry: entry.pk }; }); + let retVideos: { id: string; slug: string }[] = []; if (vids) { - await db.transaction(async (tx) => { - const pks = await tx + retVideos = await db.transaction(async (tx) => { + return await tx .insert(entryVideoJointure) .select( tx .select({ entry: sql`${ret.entry}`.as("entry"), video: videos.pk, + // TODO: do not add rendering if all videos of the entry have the same rendering + slug: sql` + concat( + ${entries.slug}, + case when ${videos.part} <> null then concat("-p", ${videos.part}) else "" end, + case when ${videos.version} <> 1 then concat("-v", ${videos.version}) else "" end, + "-", ${videos.rendering} + ) + `.as("slug"), }) .from(videos) .where(inArray(videos.id, vids)), ) .onConflictDoNothing() - .returning({ pk: entryVideoJointure.video }); - - - const toto = tx - .select({ count: count(videos.rendering) }) - .from(videos) - .innerJoin( - entryVideoJointure, - eq(videos.pk, entryVideoJointure.video), - ) - .where(entryVideoJointure.entry, ""); - - return await tx - .update(videos) - .set({ - slug: sql` - concat( - ${entries.slug}, - case when ${videos.part} <> null then concat("-p", ${videos.part}) else "" end, - case when ${videos.version} <> 1 then concat("-v", ${videos.version}) else "" end, - ${} - ) - `, - }) - .from(entries) - .where( - inArray( - videos.pk, - pks.map((x) => x.pk), - ), - ) - .returning({ id: videos.id, slug: videos.slug }); + .returning({ id: videos.id, slug: entryVideoJointure.slug }); }); } - return ret.id; + return { + id: ret.id, + slug: ret.slug, + videos: retVideos, + }; }; diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index 07f129f65..dde8959af 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -1,4 +1,4 @@ -import { relations, sql } from "drizzle-orm"; +import { sql } from "drizzle-orm"; import { check, integer, @@ -17,7 +17,6 @@ export const videos = schema.table( { pk: integer().primaryKey().generatedAlwaysAsIdentity(), id: uuid().notNull().unique().defaultRandom(), - slug: varchar({ length: 255 }).unique(), path: text().notNull().unique(), rendering: text().notNull(), part: integer(), @@ -43,6 +42,7 @@ export const entryVideoJointure = schema.table( video: integer() .notNull() .references(() => videos.pk, { onDelete: "cascade" }), + slug: varchar({ length: 255 }).notNull().unique(), }, (t) => [primaryKey({ columns: [t.entry, t.video] })], ); From 3d20f063e99c3d879cd10d29d7f6ff2dbea02fc3 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 25 Nov 2024 21:39:17 +0100 Subject: [PATCH 063/105] Create post /videos route --- api/src/controllers/videos.ts | 36 ++++++++++++++++++++++++++++++++--- api/src/index.ts | 11 ++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 080ec1a9f..f5b7551f4 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -1,11 +1,41 @@ import { Elysia, t } from "elysia"; -import { Video } from "../models/video"; +import { SeedVideo, Video } from "~/models/video"; +import { db } from "~/db"; +import { videos as videosT } from "~/db/schema"; +import { comment } from "~/utils"; +import { bubbleVideo } from "~/models/examples"; -export const videos = new Elysia({ prefix: "/videos" }) +const CreatedVideo = t.Object({ + id: t.String({ format: "uuid" }), + path: t.String({ example: bubbleVideo.path }), +}); + +export const videos = new Elysia({ prefix: "/videos", tags: ["videos"] }) .model({ video: Video, + "created-videos": t.Array(CreatedVideo), error: t.Object({}), }) .get("/:id", () => "hello" as unknown as Video, { response: { 200: "video" }, - }); + }) + .post( + "/", + async ({ body }) => { + return await db + .insert(videosT) + .values(body) + .onConflictDoNothing() + .returning({ id: videosT.id, path: videosT.path }); + }, + { + body: t.Array(SeedVideo), + response: { 201: "created-videos" }, + detail: { + description: comment` + Create videos in bulk. + Duplicated videos will simply be ignored. + `, + }, + }, + ); diff --git a/api/src/index.ts b/api/src/index.ts index fd1572735..a7689af17 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -58,7 +58,16 @@ const app = new Elysia() description: "Kyoo's demo server", }, ], - tags: [{ name: "movies", description: "Routes about movies" }], + tags: [ + { name: "movies", description: "Routes about movies" }, + { + name: "videos", + description: comment` + Used by the scanner internally to list & create videos. + Can be used for administration or third party apps. + `, + }, + ], }, }), ) From 55b3f1cc8c2c7d31beb5673c3e30d54e73f12fdc Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 30 Nov 2024 04:56:53 +0100 Subject: [PATCH 064/105] wip: upsert things --- api/drizzle/0004_jointures.sql | 56 ++ api/drizzle/meta/0004_snapshot.json | 853 +++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/src/controllers/seed/examples.ts | 11 + api/src/controllers/seed/movies.ts | 28 +- api/src/index.ts | 2 + api/src/models/entry/base-entry.ts | 13 + 7 files changed, 969 insertions(+), 1 deletion(-) create mode 100644 api/drizzle/0004_jointures.sql create mode 100644 api/drizzle/meta/0004_snapshot.json create mode 100644 api/src/controllers/seed/examples.ts diff --git a/api/drizzle/0004_jointures.sql b/api/drizzle/0004_jointures.sql new file mode 100644 index 000000000..be53ad83d --- /dev/null +++ b/api/drizzle/0004_jointures.sql @@ -0,0 +1,56 @@ +CREATE TABLE IF NOT EXISTS "kyoo"."entry_video_jointure" ( + "entry" integer NOT NULL, + "video" integer NOT NULL, + "slug" varchar(255) NOT NULL, + CONSTRAINT "entry_video_jointure_entry_video_pk" PRIMARY KEY("entry","video"), + CONSTRAINT "entry_video_jointure_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +ALTER TABLE "kyoo"."entries_translation" RENAME TO "entry_translations";--> statement-breakpoint +ALTER TABLE "kyoo"."season_translation" RENAME TO "season_translations";--> statement-breakpoint +ALTER TABLE "kyoo"."videos" DROP CONSTRAINT "videos_slug_unique";--> statement-breakpoint +ALTER TABLE "kyoo"."entries" DROP CONSTRAINT "order_positive";--> statement-breakpoint +ALTER TABLE "kyoo"."shows" DROP CONSTRAINT "rating_valid";--> statement-breakpoint +ALTER TABLE "kyoo"."shows" DROP CONSTRAINT "runtime_valid";--> statement-breakpoint +ALTER TABLE "kyoo"."videos" DROP CONSTRAINT "part_pos";--> statement-breakpoint +ALTER TABLE "kyoo"."videos" DROP CONSTRAINT "version_pos";--> statement-breakpoint +ALTER TABLE "kyoo"."entry_translations" DROP CONSTRAINT "entries_translation_pk_entries_pk_fk"; +--> statement-breakpoint +ALTER TABLE "kyoo"."season_translations" DROP CONSTRAINT "season_translation_pk_seasons_pk_fk"; +--> statement-breakpoint +ALTER TABLE "kyoo"."entry_translations" DROP CONSTRAINT "entries_translation_pk_language_pk";--> statement-breakpoint +ALTER TABLE "kyoo"."season_translations" DROP CONSTRAINT "season_translation_pk_language_pk";--> statement-breakpoint +ALTER TABLE "kyoo"."entry_translations" ADD CONSTRAINT "entry_translations_pk_language_pk" PRIMARY KEY("pk","language");--> statement-breakpoint +ALTER TABLE "kyoo"."season_translations" ADD CONSTRAINT "season_translations_pk_language_pk" PRIMARY KEY("pk","language");--> statement-breakpoint +ALTER TABLE "kyoo"."entry_translations" ADD COLUMN "poster" jsonb;--> statement-breakpoint +ALTER TABLE "kyoo"."videos" ADD COLUMN "guess" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "kyoo"."entry_video_jointure" ADD CONSTRAINT "entry_video_jointure_entry_entries_pk_fk" FOREIGN KEY ("entry") REFERENCES "kyoo"."entries"("pk") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "kyoo"."entry_video_jointure" ADD CONSTRAINT "entry_video_jointure_video_videos_pk_fk" FOREIGN KEY ("video") REFERENCES "kyoo"."videos"("pk") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "kyoo"."entry_translations" ADD CONSTRAINT "entry_translations_pk_entries_pk_fk" FOREIGN KEY ("pk") REFERENCES "kyoo"."entries"("pk") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "kyoo"."season_translations" ADD CONSTRAINT "season_translations_pk_seasons_pk_fk" FOREIGN KEY ("pk") REFERENCES "kyoo"."seasons"("pk") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +ALTER TABLE "kyoo"."videos" DROP COLUMN IF EXISTS "slug";--> statement-breakpoint +ALTER TABLE "kyoo"."entries" ADD CONSTRAINT "order_positive" CHECK ("kyoo"."entries"."order" >= 0);--> statement-breakpoint +ALTER TABLE "kyoo"."shows" ADD CONSTRAINT "rating_valid" CHECK ("kyoo"."shows"."rating" between 0 and 100);--> statement-breakpoint +ALTER TABLE "kyoo"."shows" ADD CONSTRAINT "runtime_valid" CHECK ("kyoo"."shows"."runtime" >= 0);--> statement-breakpoint +ALTER TABLE "kyoo"."videos" ADD CONSTRAINT "part_pos" CHECK ("kyoo"."videos"."part" >= 0);--> statement-breakpoint +ALTER TABLE "kyoo"."videos" ADD CONSTRAINT "version_pos" CHECK ("kyoo"."videos"."version" >= 0); \ No newline at end of file diff --git a/api/drizzle/meta/0004_snapshot.json b/api/drizzle/meta/0004_snapshot.json new file mode 100644 index 000000000..bd24b9f3d --- /dev/null +++ b/api/drizzle/meta/0004_snapshot.json @@ -0,0 +1,853 @@ +{ + "id": "0d5d6d22-dc13-4f3d-9975-cb7b38f628d4", + "prevId": "2210fd60-8e6a-4503-a2b3-56cc7f3cf15a", + "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": false + }, + "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 + }, + "type": { + "name": "type", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "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": false, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "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": false, + "default": "now()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": {}, + "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": {}, + "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 00781abde..4d647cd1d 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1731258712255, "tag": "0003_order", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1732738409330, + "tag": "0004_jointures", + "breakpoints": true } ] } diff --git a/api/src/controllers/seed/examples.ts b/api/src/controllers/seed/examples.ts new file mode 100644 index 000000000..0030285ec --- /dev/null +++ b/api/src/controllers/seed/examples.ts @@ -0,0 +1,11 @@ +import { db } from "~/db"; +import { videos } from "~/db/schema"; +import { bubble, bubbleVideo } from "~/models/examples"; +import { seedMovie } from "./movies"; + +const videoExamples = [bubbleVideo]; + +export const seedTests = async () => { + await db.insert(videos).values(videoExamples).onConflictDoNothing(); + await seedMovie(bubble) +}; diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index d35bc6613..cf46f8a2f 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -39,7 +39,28 @@ export const seedMovie = async ( const [ret] = await tx .insert(shows) .values(movie) - .returning({ pk: shows.pk, id: shows.id, slug: shows.slug }); + .onConflictDoUpdate({ + target: shows.slug, + // we actually don't want to update anything, but we want to return the existing row. + // using a conflict update with a where false locks the database and ensure we don't have race conditions. + // it WONT work if we use triggers or need to handle conflicts on multiples collumns + // see https://stackoverflow.com/questions/34708509/how-to-use-returning-with-on-conflict-in-postgresql for more + set: { id: sql`excluded.id` }, + setWhere: sql`false`, + }) + .returning({ + pk: shows.pk, + id: shows.id, + slug: shows.slug, + startAir: shows.startAir, + // https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667 + conflict: sql`xmax = 0`.as("conflict"), + }); + if (ret.conflict) { + if (getYear(ret.startAir) === getYear(movie.startAir)) { + return + } + } // even if never shown to the user, a movie still has an entry. const movieEntry: Entry = { type: "movie", ...bMovie }; @@ -102,3 +123,8 @@ export const seedMovie = async ( videos: retVideos, }; }; + +function getYear(date?: string | null) { + if (!date) return null; + return new Date(date).getUTCFullYear(); +} diff --git a/api/src/index.ts b/api/src/index.ts index a7689af17..92dabc75e 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -6,6 +6,7 @@ import { entries } from "./controllers/entries"; import { movies } from "./controllers/movies"; import { seasons } from "./controllers/seasons"; import { seed } from "./controllers/seed"; +import { seedTests } from "./controllers/seed/examples"; import { series } from "./controllers/series"; import { videos } from "./controllers/videos"; import { db } from "./db"; @@ -15,6 +16,7 @@ import { comment } from "./utils"; await migrate(db, { migrationsSchema: "kyoo", migrationsFolder: "./drizzle" }); if (process.env.SEED) { + await seedTests(); } let secret = process.env.JWT_SECRET; diff --git a/api/src/models/entry/base-entry.ts b/api/src/models/entry/base-entry.ts index dc7a880cb..4da51f6c9 100644 --- a/api/src/models/entry/base-entry.ts +++ b/api/src/models/entry/base-entry.ts @@ -16,3 +16,16 @@ 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; From cfe2cabfa446f0c58d8e98bf0b04c4fbf5ea4d23 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 30 Nov 2024 17:30:12 +0100 Subject: [PATCH 065/105] wip: push movies could update items --- api/src/controllers/seed/movies.ts | 25 +++++++++++++---------- api/src/db/schema/utils.ts | 32 ++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index cf46f8a2f..2ddde9d08 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -10,9 +10,10 @@ import { import type { SeedMovie } from "~/models/movie"; import { processOptImage } from "./images"; import { guessNextRefresh } from "./refresh"; -import { inArray, sql } from "drizzle-orm"; +import { eq, getTableColumns, inArray, sql } from "drizzle-orm"; import { t } from "elysia"; import { Resource } from "~/models/utils"; +import { conflictUpdateAllExcept } from "~/db/schema/utils"; type Show = typeof shows.$inferInsert; type ShowTrans = typeof showTranslations.$inferInsert; @@ -41,12 +42,9 @@ export const seedMovie = async ( .values(movie) .onConflictDoUpdate({ target: shows.slug, - // we actually don't want to update anything, but we want to return the existing row. - // using a conflict update with a where false locks the database and ensure we don't have race conditions. - // it WONT work if we use triggers or need to handle conflicts on multiples collumns - // see https://stackoverflow.com/questions/34708509/how-to-use-returning-with-on-conflict-in-postgresql for more - set: { id: sql`excluded.id` }, - setWhere: sql`false`, + 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, @@ -54,11 +52,18 @@ export const seedMovie = async ( slug: shows.slug, startAir: shows.startAir, // https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667 - conflict: sql`xmax = 0`.as("conflict"), + updated: sql`(xmax = 0)`.as("updated"), + xmin: sql`xmin`, + xmax: sql`xmax`, + created: shows.createdAt, }); - if (ret.conflict) { + // TODO: the `updated` bool is always false :c + console.log(`slug: ${ret.slug}, updated: ${ret.updated}`); + console.log(ret) + if (ret.updated) { + console.log("Updated!"); if (getYear(ret.startAir) === getYear(movie.startAir)) { - return + return; } } diff --git a/api/src/db/schema/utils.ts b/api/src/db/schema/utils.ts index f2de152cf..9ba146da0 100644 --- a/api/src/db/schema/utils.ts +++ b/api/src/db/schema/utils.ts @@ -5,6 +5,9 @@ import { Table, View, ViewBaseConfig, + getTableColumns, + sql, + SQL, } from "drizzle-orm"; import type { AnyMySqlSelect } from "drizzle-orm/mysql-core"; import { @@ -15,6 +18,8 @@ import { } from "drizzle-orm/pg-core"; import type { AnySQLiteSelect } from "drizzle-orm/sqlite-core"; import type { WithSubquery } from "drizzle-orm/subquery"; +import { db } from ".."; +import { CasingCache } from "drizzle-orm/casing"; export const schema = pgSchema("kyoo"); @@ -24,11 +29,13 @@ export const image = () => jsonb().$type<{ id: string; source: string; blurhash: string }>(); // https://github.com/sindresorhus/type-fest/blob/main/source/simplify.d.ts#L58 -type Simplify = {[KeyType in keyof T]: T[KeyType]} & {}; +type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; // See https://github.com/drizzle-team/drizzle-orm/pull/1789 type Select = AnyPgSelect | AnyMySqlSelect | AnySQLiteSelect; -type AnySelect = Simplify & Partial>>; +type AnySelect = Simplify< + Omit & Partial> +>; export function getColumns< T extends | Table @@ -49,3 +56,24 @@ export function getColumns< ? (table as any)[ViewBaseConfig].selectedFields : table._.selectedFields; } + +// See https://github.com/drizzle-team/drizzle-orm/issues/1728 +export function conflictUpdateAllExcept< + T extends Table, + E extends (keyof T["_"]["columns"])[], +>(table: T, except: E) { + const columns = getTableColumns(table); + const updateColumns = Object.entries(columns).filter( + ([col]) => !except.includes(col), + ); + + return updateColumns.reduce( + (acc, [colName, col]) => { + // @ts-ignore: drizzle internal + const name = (db.dialect.casing as CasingCache).getColumnCasing(col); + acc[colName as keyof typeof acc] = sql.raw(`excluded."${name}"`); + return acc; + }, + {} as Omit, E[number]>, + ); +} From 5d24dcafd589581cc0b21a96594a70466991629d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 1 Dec 2024 21:11:48 +0100 Subject: [PATCH 066/105] Handle conflicts as updates --- api/bun.lockb | Bin 39908 -> 39908 bytes api/src/controllers/seed/movies.ts | 96 +++++++++++++++++------------ 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/api/bun.lockb b/api/bun.lockb index 7c84570b68663ab83fe343db37afb327f53287dd..54079d1b0c6dc55c692f587f3747ce00f30d486d 100755 GIT binary patch delta 206 zcmaE|o$1MTrVW`n^^9>AdWMF2h6bDr3=BZrQ2i$TY=_WmB@V_ops=2?8G{f|h8HOF zI==b(Iks<%Ob{7E1_pVcj37|P>V8^$MBw>*P#H4@1_PiBH&90T;)dL=u=FILjDemJ z&?*LNs7$}w!kW%`_JO;=QpRR_CJd=LmBpEf3=GZ-9{;#!d$Sd4oRKL*K{`+u5Il8$ Wv@f(>NC+qcG|O1eaI<>uUOfPl1~?r6 delta 206 zcmaE|o$1MTrVW`n^-MX5C8@b|B z7@6QQ@<16upp4c1wD^d?^Y;)k20$5Zpp5dx4Y^%m=}8C~Yp6`W+QOR7dG>+35K^f* qmBpEf3=GZ-9{;#!d$ScGQ;-hS1q4r>AMFcm7ZQTYY*x?Rs|Nt;C_tV7 diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 2ddde9d08..f24202776 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -1,3 +1,5 @@ +import { inArray, sql } from "drizzle-orm"; +import { t } from "elysia"; import { db } from "~/db"; import { entries, @@ -7,13 +9,11 @@ import { showTranslations, videos, } from "~/db/schema"; +import { conflictUpdateAllExcept } from "~/db/schema/utils"; import type { SeedMovie } from "~/models/movie"; +import { Resource } from "~/models/utils"; import { processOptImage } from "./images"; import { guessNextRefresh } from "./refresh"; -import { eq, getTableColumns, inArray, sql } from "drizzle-orm"; -import { t } from "elysia"; -import { Resource } from "~/models/utils"; -import { conflictUpdateAllExcept } from "~/db/schema/utils"; type Show = typeof shows.$inferInsert; type ShowTrans = typeof showTranslations.$inferInsert; @@ -21,7 +21,9 @@ type Entry = typeof entries.$inferInsert; export const SeedMovieResponse = t.Intersect([ Resource, - t.Object({ videos: t.Array(Resource) }), + t.Object({ + videos: t.Array(t.Object({ slug: t.String({ format: "slug" }) })), + }), ]); export type SeedMovieResponse = typeof SeedMovieResponse.static; @@ -52,19 +54,14 @@ export const seedMovie = async ( slug: shows.slug, startAir: shows.startAir, // https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667 - updated: sql`(xmax = 0)`.as("updated"), - xmin: sql`xmin`, - xmax: sql`xmax`, - created: shows.createdAt, + updated: sql`(xmax <> 0)`.as("updated"), }); - // TODO: the `updated` bool is always false :c - console.log(`slug: ${ret.slug}, updated: ${ret.updated}`); - console.log(ret) if (ret.updated) { - console.log("Updated!"); - if (getYear(ret.startAir) === getYear(movie.startAir)) { - return; - } + // TODO: if updated, differenciates updates with conflicts. + // if the start year is different or external ids, it's a conflict. + // if (getYear(ret.startAir) === getYear(movie.startAir)) { + // return; + // } } // even if never shown to the user, a movie still has an entry. @@ -72,6 +69,15 @@ export const seedMovie = async ( 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( @@ -86,40 +92,50 @@ export const seedMovie = async ( banner: await processOptImage(tr.banner), })), ); - await tx.insert(showTranslations).values(trans); + 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); + await tx + .insert(entryTranslations) + .values(entryTrans) + .onConflictDoUpdate({ + target: [entryTranslations.pk, entryTranslations.language], + set: conflictUpdateAllExcept(entryTranslations, ["pk", "language"]), + }); return { ...ret, entry: entry.pk }; }); - let retVideos: { id: string; slug: string }[] = []; + let retVideos: { slug: string }[] = []; if (vids) { - retVideos = await db.transaction(async (tx) => { - return await tx - .insert(entryVideoJointure) - .select( - tx - .select({ - entry: sql`${ret.entry}`.as("entry"), - video: videos.pk, - // TODO: do not add rendering if all videos of the entry have the same rendering - slug: sql` + retVideos = await db + .insert(entryVideoJointure) + .select( + db + .select({ + entry: sql`${ret.entry}`.as("entry"), + video: videos.pk, + // TODO: do not add rendering if all videos of the entry have the same rendering + slug: sql` concat( - ${entries.slug}, - case when ${videos.part} <> null then concat("-p", ${videos.part}) else "" end, - case when ${videos.version} <> 1 then concat("-v", ${videos.version}) else "" end, - "-", ${videos.rendering} + ${ret.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, + '-', ${videos.rendering} ) `.as("slug"), - }) - .from(videos) - .where(inArray(videos.id, vids)), - ) - .onConflictDoNothing() - .returning({ id: videos.id, slug: entryVideoJointure.slug }); - }); + }) + .from(videos) + .where(inArray(videos.id, vids)), + ) + .onConflictDoNothing() + .returning({ slug: entryVideoJointure.slug }); } return { From 24035c15bf29f7e3d794020ce280c529e50fa726 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 2 Dec 2024 22:55:58 +0100 Subject: [PATCH 067/105] Add 200/201 handling on post /movies + doc --- api/src/controllers/seed/index.ts | 20 ++++++++++++++++---- api/src/controllers/seed/movies.ts | 17 +++++++++-------- api/src/index.ts | 1 - 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/api/src/controllers/seed/index.ts b/api/src/controllers/seed/index.ts index 776dc4f1e..11de1774e 100644 --- a/api/src/controllers/seed/index.ts +++ b/api/src/controllers/seed/index.ts @@ -11,12 +11,24 @@ export const seed = new Elysia() }) .post( "/movies", - async ({ body }) => { - return await seedMovie(body); + async ({ body, error }) => { + const { status, ...ret } = await seedMovie(body); + return error(status === "created" ? 201 : 200, ret); }, { body: "seed-movie", - response: { 200: "seed-movie-response", 400: "error" }, - tags: ["movies"], + response: { + 200: { + ...SeedMovieResponse, + description: "Existing movie edited/updated.", + }, + 201: { ...SeedMovieResponse, description: "Created a new movie." }, + 400: "error", + }, + detail: { + tags: ["movies"], + description: + "Create a movie & all related metadata. Can also link videos.", + }, }, ); diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index f24202776..e7cd33080 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -11,7 +11,6 @@ import { } from "~/db/schema"; import { conflictUpdateAllExcept } from "~/db/schema/utils"; import type { SeedMovie } from "~/models/movie"; -import { Resource } from "~/models/utils"; import { processOptImage } from "./images"; import { guessNextRefresh } from "./refresh"; @@ -19,17 +18,18 @@ type Show = typeof shows.$inferInsert; type ShowTrans = typeof showTranslations.$inferInsert; type Entry = typeof entries.$inferInsert; -export const SeedMovieResponse = t.Intersect([ - Resource, - t.Object({ - videos: t.Array(t.Object({ slug: t.String({ format: "slug" }) })), - }), -]); +export const SeedMovieResponse = t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug", examples: ["bubble"] }), + videos: t.Array( + t.Object({ slug: t.String({ format: "slug", examples: ["bubble-v2"] }) }), + ), +}); export type SeedMovieResponse = typeof SeedMovieResponse.static; export const seedMovie = async ( seed: SeedMovie, -): Promise => { +): Promise => { const { translations, videos: vids, ...bMovie } = seed; const ret = await db.transaction(async (tx) => { @@ -139,6 +139,7 @@ export const seedMovie = async ( } return { + status: ret.updated ? "updated" : "created", id: ret.id, slug: ret.slug, videos: retVideos, diff --git a/api/src/index.ts b/api/src/index.ts index 92dabc75e..00f1b0829 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -73,7 +73,6 @@ const app = new Elysia() }, }), ) - .get("/", () => "Hello Elysia") .model({ image: Image }) .use(movies) .use(series) From 5e1e2fb6e221c88748356bb320a58cd47934d75a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 2 Dec 2024 22:56:14 +0100 Subject: [PATCH 068/105] Add tests setup for post /movies --- api/bunfig.toml | 2 + api/src/models/examples/dune-1984.ts | 72 ++++++++++++++++++++++++++++ api/src/models/examples/dune-2021.ts | 72 ++++++++++++++++++++++++++++ api/tests/seed-movies.test.ts | 50 +++++++++++++++++++ api/tests/setup.ts | 10 ++++ 5 files changed, 206 insertions(+) create mode 100644 api/bunfig.toml create mode 100644 api/src/models/examples/dune-1984.ts create mode 100644 api/src/models/examples/dune-2021.ts create mode 100644 api/tests/seed-movies.test.ts create mode 100644 api/tests/setup.ts diff --git a/api/bunfig.toml b/api/bunfig.toml new file mode 100644 index 000000000..8370a0154 --- /dev/null +++ b/api/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./tests/setup.ts"] diff --git a/api/src/models/examples/dune-1984.ts b/api/src/models/examples/dune-1984.ts new file mode 100644 index 000000000..6567d8c9f --- /dev/null +++ b/api/src/models/examples/dune-1984.ts @@ -0,0 +1,72 @@ +import type { SeedMovie } from "../movie"; +import type { Video } from "../video"; + +export const dune1984Video: Video = { + id: "d1a62b87-9cfd-4f9c-9ad7-21f9b7fa6290", + slug: "dune-1984", + path: "/video/Dune_1984/Dune (1984).mkv", + rendering: "ea3a0f8f2f2c5b61a07f61e4e8d9f8e01b2b92bcbb6f5ed1151e1f61619c2c0f", + part: null, + version: 1, + createdAt: "2024-12-02T11:45:12.968Z", +}; + +export const dune1984: SeedMovie = { + slug: "dune-1984", + translations: { + en: { + name: "Dune", + tagline: "A journey to the stars begins with a single step.", + description: + "On the planet Arrakis, the young Paul Atreides and his family are thrust into a world of political intrigue and warfare over control of the spice melange, the most valuable substance in the universe.", + aliases: ["Dune 1984", "Dune: David Lynch's Vision", "Dune: The Movie"], + tags: ["sci-fi", "adventure", "drama", "cult-classic", "epic"], + poster: + "https://image.tmdb.org/t/p/original/eVnVrIWkT8esL3XsTc4BjhDhQKq.jpg", + thumbnail: + "https://image.tmdb.org/t/p/original/pCHV6BntWLO2H6wQOj4LwzAWqpa.jpg", + banner: null, + logo: "https://image.tmdb.org/t/p/original/olbKnk2VvFcM2STl0dJAf6kfydo.png", + trailerUrl: "https://www.youtube.com/watch?v=vczYTLQ6oiE", + }, + }, + genres: ["adventure", "drama", "science-fiction"], + rating: 60, + status: "finished", + runtime: 137, + airDate: "1984-12-14", + originalLanguage: "en", + externalId: { + themoviedatabase: { + dataId: "9495", + link: "https://www.themoviedb.org/movie/9495", + }, + imdb: { + dataId: "tt0087182", + link: "https://www.imdb.com/title/tt0087182", + }, + }, + videos: [dune1984Video.id], +}; + +export const dune1984Images = { + poster: { + id: "a5e1c5e4-4176-42f0-a279-8ab6f1ae2d30", + source: + "https://image.tmdb.org/t/p/original/eVnVrIWkT8esL3XsTc4BjhDhQKq.jpg", + blurhash: "L32^9tc~%8~U%OItfNGq9FoLV@X9", + }, + thumbnail: { + id: "fe44141b-58bc-42b7-a5c5-e10b801e99ae", + source: + "https://image.tmdb.org/t/p/original/pCHV6BntWLO2H6wQOj4LwzAWqpa.jpg", + blurhash: "L56~XM~q9ZZX4wbD9Wa|ECxvS~V@", + }, + banner: null, + logo: { + id: "515d7d72-b4f0-4a7d-a27a-eac3495ea8b3", + source: + "https://image.tmdb.org/t/p/original/olbKnk2VvFcM2STl0dJAf6kfydo.png", + blurhash: "LJ4XXK*]JFMzM]V?~Xz$sV?tMdm+", + }, +}; diff --git a/api/src/models/examples/dune-2021.ts b/api/src/models/examples/dune-2021.ts new file mode 100644 index 000000000..64f5985f0 --- /dev/null +++ b/api/src/models/examples/dune-2021.ts @@ -0,0 +1,72 @@ +import type { SeedMovie } from "../movie"; +import type { Video } from "../video"; + +export const duneVideo: Video = { + id: "c9a0d02e-6b8e-4ac1-b431-45b022ec0708", + slug: "dune", + path: "/video/Dune/Dune (2021).mkv", + rendering: "f1953a4fb58247efb6c15b76468b6a9d13b4155b02094863b1a4f0c3fbb6db58", + part: null, + version: 1, + createdAt: "2024-12-02T10:10:24.968Z", +}; + +export const dune: SeedMovie = { + slug: "dune", + translations: { + en: { + name: "Dune", + tagline: "A mythic and emotionally charged hero's journey.", + description: + "On the desert planet Arrakis, a young nobleman becomes embroiled in a complex struggle for control of the planet's valuable resource, the spice melange.", + aliases: ["Dune: Part One", "Dune 2021"], + tags: ["sci-fi", "adventure", "drama", "action", "epic"], + poster: + "https://image.tmdb.org/t/p/original/wD57HqZ6fXwwDdfQLo4hXLRwGV1.jpg", + thumbnail: + "https://image.tmdb.org/t/p/original/k2ocXnNkmvE6rJomRkExIStFq3v.jpg", + banner: null, + logo: "https://image.tmdb.org/t/p/original/5nDsd3u1c6kDphbtIqkHseLg7HL.png", + trailerUrl: "https://www.youtube.com/watch?v=n9xhJrPXop4", + }, + }, + genres: ["adventure", "drama", "science-fiction", "action"], + rating: 83, + status: "finished", + runtime: 155, + airDate: "2021-10-22", + originalLanguage: "en", + externalId: { + themoviedatabase: { + dataId: "496243", + link: "https://www.themoviedb.org/movie/496243", + }, + imdb: { + dataId: "tt1160419", + link: "https://www.imdb.com/title/tt1160419", + }, + }, + videos: [duneVideo.id], +}; + +export const duneImages = { + poster: { + id: "ea0426d1-4d16-4be9-9e6f-08e5fdf8f209", + source: + "https://image.tmdb.org/t/p/original/wD57HqZ6fXwwDdfQLo4hXLRwGV1.jpg", + blurhash: "L3D8AK$A5l=j~Bt7_4Mw-;WBt4Gf", + }, + thumbnail: { + id: "1b629b7f-3b44-45b9-9432-cb5505045899", + source: + "https://image.tmdb.org/t/p/original/k2ocXnNkmvE6rJomRkExIStFq3v.jpg", + blurhash: "L6l5}7$S0nt7p~2R.9W9tQ%NflWC", + }, + banner: null, + logo: { + id: "c02ec0d2-d04e-4f51-8d4e-4cdd9ca75a7e", + source: + "https://image.tmdb.org/t/p/original/5nDsd3u1c6kDphbtIqkHseLg7HL.png", + blurhash: "LLOQ0t-7e,X6jY?qBtt6c8A4gYof", + }, +}; diff --git a/api/tests/seed-movies.test.ts b/api/tests/seed-movies.test.ts new file mode 100644 index 000000000..0b0a7d168 --- /dev/null +++ b/api/tests/seed-movies.test.ts @@ -0,0 +1,50 @@ +import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; +import { inArray } from "drizzle-orm"; +import Elysia from "elysia"; +import { seed } from "~/controllers/seed"; +import { db } from "~/db"; +import { shows, videos } from "~/db/schema"; +import { dune, duneVideo } from "~/models/examples/dune-2021"; + +const app = new Elysia().use(seed); + +const cleanup = async () => { + await db.delete(shows).where(inArray(shows.slug, [dune.slug])); + await db.delete(videos).where(inArray(videos.id, [duneVideo.id])); +}; +// cleanup db beforehand to unsure tests are consistent +beforeAll(cleanup); +afterAll(cleanup); + +describe("Movie seeding", () => { + it("Can create a movie", async () => { + // create video beforehand to test linking + await db.insert(videos).values(duneVideo); + + const resp = await app.handle( + new Request("http://localhost/movies", { + method: "POST", + body: JSON.stringify(dune), + headers: { + "Content-Type": "application/json", + }, + }), + ); + const body = await resp.json(); + + expect(resp.status).toBe(201); + expect(body.id).toBeString(); + expect(body.slug).toBe("dune"); + expect(body.videos).toContain({ slug: "dune" }); + }); + + test.todo("Conflicting slug auto-correct", async () => {}); + test.todo("Conflict in slug+year fails", async () => {}); + test.todo("Missing videos send info", async () => {}); + test.todo("Schema error", async () => {}); + test.todo("Invalid translation name", async () => {}); + test.todo("Update existing movie", async () => {}); + test.todo("Create correct video slug (version)", async () => {}); + test.todo("Create correct video slug (part)", async () => {}); + test.todo("Create correct video slug (rendering)", async () => {}); +}); diff --git a/api/tests/setup.ts b/api/tests/setup.ts new file mode 100644 index 000000000..bd83271a1 --- /dev/null +++ b/api/tests/setup.ts @@ -0,0 +1,10 @@ +import { beforeAll } from "bun:test"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { db } from "~/db"; + +beforeAll(async () => { + await migrate(db, { + migrationsSchema: "kyoo", + migrationsFolder: "./drizzle", + }); +}); From caa394e0da03d17cddb87c8770dafb78c6e16e2c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 4 Dec 2024 21:30:45 +0100 Subject: [PATCH 069/105] Create tests & cleanup for movies seeding --- api/src/controllers/seed/movies.ts | 10 +-- api/tests/seed-movies.test.ts | 111 +++++++++++++++++++++++------ 2 files changed, 95 insertions(+), 26 deletions(-) diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index e7cd33080..16daf8054 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -4,7 +4,7 @@ import { db } from "~/db"; import { entries, entryTranslations, - entryVideoJointure, + entryVideoJointure as evj, shows, showTranslations, videos, @@ -115,7 +115,7 @@ export const seedMovie = async ( let retVideos: { slug: string }[] = []; if (vids) { retVideos = await db - .insert(entryVideoJointure) + .insert(evj) .select( db .select({ @@ -126,16 +126,16 @@ export const seedMovie = async ( concat( ${ret.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, - '-', ${videos.rendering} + 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: entryVideoJointure.slug }); + .returning({ slug: evj.slug }); } return { diff --git a/api/tests/seed-movies.test.ts b/api/tests/seed-movies.test.ts index 0b0a7d168..df2d717a2 100644 --- a/api/tests/seed-movies.test.ts +++ b/api/tests/seed-movies.test.ts @@ -1,41 +1,103 @@ import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; -import { inArray } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import Elysia from "elysia"; import { seed } from "~/controllers/seed"; import { db } from "~/db"; -import { shows, videos } from "~/db/schema"; +import { shows, showTranslations, videos } from "~/db/schema"; import { dune, duneVideo } from "~/models/examples/dune-2021"; +import type { SeedMovie } from "~/models/movie"; const app = new Elysia().use(seed); - -const cleanup = async () => { - await db.delete(shows).where(inArray(shows.slug, [dune.slug])); - await db.delete(videos).where(inArray(videos.id, [duneVideo.id])); +const createMovie = async (movie: SeedMovie) => { + const resp = await app.handle( + new Request("http://localhost/movies", { + method: "POST", + body: JSON.stringify(movie), + headers: { + "Content-Type": "application/json", + }, + }), + ); + const body = await resp.json(); + return [resp, body] as const; }; -// cleanup db beforehand to unsure tests are consistent -beforeAll(cleanup); -afterAll(cleanup); + +function expectStatus(resp: Response, body: object) { + const matcher = expect({ ...body, status: resp.status }); + return { + toBe: (status: number) => { + matcher.toMatchObject({ status: status }); + }, + }; +} describe("Movie seeding", () => { it("Can create a movie", async () => { // create video beforehand to test linking await db.insert(videos).values(duneVideo); - const resp = await app.handle( - new Request("http://localhost/movies", { - method: "POST", - body: JSON.stringify(dune), - headers: { - "Content-Type": "application/json", + const [resp, body] = await createMovie(dune); + expectStatus(resp, body).toBe(201); + expect(body.id).toBeString(); + expect(body.slug).toBe("dune"); + expect(body.videos).toContainEqual({ slug: "dune" }); + }); + + it("Update existing movie", async () => { + // confirm that db is in the correct state (from previous tests) + const [existing] = await db + .select() + .from(shows) + .where(eq(shows.slug, dune.slug)) + .limit(1); + expect(existing).toMatchObject({ slug: dune.slug, startAir: dune.airDate }); + + const [resp, body] = await createMovie({ + ...dune, + airDate: "2159-12-09", + translations: { + ...dune.translations, + en: { ...dune.translations.en, description: "edited translation" }, + fr: { + name: "dune-but-in-french", + description: null, + tagline: null, + aliases: [], + tags: [], + poster: null, + thumbnail: null, + banner: null, + logo: null, + trailerUrl: null, }, - }), - ); - const body = await resp.json(); + }, + }); + const [edited] = await db + .select() + .from(shows) + .where(eq(shows.slug, dune.slug)) + .limit(1); + const translations = await db + .select() + .from(showTranslations) + .where(eq(showTranslations.pk, edited.pk)); - expect(resp.status).toBe(201); + expectStatus(resp, body).toBe(200); expect(body.id).toBeString(); expect(body.slug).toBe("dune"); - expect(body.videos).toContain({ slug: "dune" }); + expect(body.videos).toBe([]); + expect(edited.startAir).toBe("2159-12-09"); + expect(edited.status).toBe(dune.status); + expect(translations).toMatchObject({ + language: "en", + name: dune.translations.en.name, + description: "edited translation", + }); + expect(translations).toMatchObject({ + language: "fr", + name: "dune-but-in-french", + description: null, + }); }); test.todo("Conflicting slug auto-correct", async () => {}); @@ -43,8 +105,15 @@ describe("Movie seeding", () => { test.todo("Missing videos send info", async () => {}); test.todo("Schema error", async () => {}); test.todo("Invalid translation name", async () => {}); - test.todo("Update existing movie", async () => {}); test.todo("Create correct video slug (version)", async () => {}); test.todo("Create correct video slug (part)", async () => {}); test.todo("Create correct video slug (rendering)", async () => {}); }); + +const cleanup = async () => { + await db.delete(shows).where(inArray(shows.slug, [dune.slug])); + await db.delete(videos).where(inArray(videos.id, [duneVideo.id])); +}; +// cleanup db beforehand to unsure tests are consistent +beforeAll(cleanup); +afterAll(cleanup); From c8c6cccf6af9d31decf3b200ec584b3068a5bb5c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 6 Dec 2024 20:55:51 +0100 Subject: [PATCH 070/105] Handle and test slug reconciliation & conflicts --- api/src/controllers/seed/index.ts | 11 +++- api/src/controllers/seed/movies.ts | 87 ++++++++++++++++++++---------- api/tests/seed-movies.test.ts | 44 +++++++++++---- 3 files changed, 104 insertions(+), 38 deletions(-) diff --git a/api/src/controllers/seed/index.ts b/api/src/controllers/seed/index.ts index 11de1774e..645835fe6 100644 --- a/api/src/controllers/seed/index.ts +++ b/api/src/controllers/seed/index.ts @@ -1,6 +1,8 @@ import Elysia, { t } from "elysia"; import { Movie, SeedMovie } from "~/models/movie"; import { seedMovie, SeedMovieResponse } from "./movies"; +import { Resource } from "~/models/utils"; +import { comment } from "~/utils"; export const seed = new Elysia() .model({ @@ -13,7 +15,7 @@ export const seed = new Elysia() "/movies", async ({ body, error }) => { const { status, ...ret } = await seedMovie(body); - return error(status === "created" ? 201 : 200, ret); + return error(status, ret); }, { body: "seed-movie", @@ -24,6 +26,13 @@ export const seed = new Elysia() }, 201: { ...SeedMovieResponse, description: "Created a new movie." }, 400: "error", + 409: { + ...Resource, + description: comment` + A movie with the same slug but a different air date already exists. + Change the slug and re-run the request. + `, + }, }, detail: { tags: ["movies"], diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 16daf8054..08ac4d353 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -1,4 +1,4 @@ -import { inArray, sql } from "drizzle-orm"; +import { inArray, sql, eq } from "drizzle-orm"; import { t } from "elysia"; import { db } from "~/db"; import { @@ -29,7 +29,9 @@ export type SeedMovieResponse = typeof SeedMovieResponse.static; export const seedMovie = async ( seed: SeedMovie, -): Promise => { +): Promise< + SeedMovieResponse & { status: "Created" | "OK" | "Conflict" } +> => { const { translations, videos: vids, ...bMovie } = seed; const ret = await db.transaction(async (tx) => { @@ -39,29 +41,57 @@ export const seedMovie = async ( nextRefresh: guessNextRefresh(bMovie.airDate ?? new Date()), ...bMovie, }; - const [ret] = await 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, - startAir: shows.startAir, - // https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667 - updated: sql`(xmax <> 0)`.as("updated"), - }); - if (ret.updated) { - // TODO: if updated, differenciates updates with conflicts. - // if the start year is different or external ids, it's a conflict. - // if (getYear(ret.startAir) === getYear(movie.startAir)) { - // return; - // } + + 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. @@ -112,6 +142,8 @@ export const seedMovie = async ( return { ...ret, entry: entry.pk }; }); + if (ret.status === "Conflict") return ret; + let retVideos: { slug: string }[] = []; if (vids) { retVideos = await db @@ -139,14 +171,13 @@ export const seedMovie = async ( } return { - status: ret.updated ? "updated" : "created", + status: ret.updated ? "Ok" : "Created", id: ret.id, slug: ret.slug, videos: retVideos, }; }; -function getYear(date?: string | null) { - if (!date) return null; +function getYear(date: string) { return new Date(date).getUTCFullYear(); } diff --git a/api/tests/seed-movies.test.ts b/api/tests/seed-movies.test.ts index df2d717a2..49e384a42 100644 --- a/api/tests/seed-movies.test.ts +++ b/api/tests/seed-movies.test.ts @@ -54,7 +54,7 @@ describe("Movie seeding", () => { const [resp, body] = await createMovie({ ...dune, - airDate: "2159-12-09", + runtime: 200_000, translations: { ...dune.translations, en: { ...dune.translations.en, description: "edited translation" }, @@ -85,23 +85,49 @@ describe("Movie seeding", () => { expectStatus(resp, body).toBe(200); expect(body.id).toBeString(); expect(body.slug).toBe("dune"); - expect(body.videos).toBe([]); - expect(edited.startAir).toBe("2159-12-09"); + expect(body.videos).toBeArrayOfSize(0); + expect(edited.runtime).toBe(200_000); expect(edited.status).toBe(dune.status); - expect(translations).toMatchObject({ - language: "en", + expect(translations.find((x) => x.language === "en")).toMatchObject({ name: dune.translations.en.name, description: "edited translation", }); - expect(translations).toMatchObject({ - language: "fr", + expect(translations.find((x) => x.language === "fr")).toMatchObject({ name: "dune-but-in-french", description: null, }); }); - test.todo("Conflicting slug auto-correct", async () => {}); - test.todo("Conflict in slug+year fails", async () => {}); + it("Conflicting slug auto-correct", async () => { + // confirm that db is in the correct state (from previous tests) + const [existing] = await db + .select() + .from(shows) + .where(eq(shows.slug, dune.slug)) + .limit(1); + expect(existing).toMatchObject({ slug: dune.slug, startAir: dune.airDate }); + + const [resp, body] = await createMovie({ ...dune, airDate: "2158-12-13" }); + expectStatus(resp, body).toBe(200); + expect(body.id).toBeString(); + expect(body.slug).toBe("dune-2158"); + }); + + it("Conflict in slug w/out year fails", async () => { + // confirm that db is in the correct state (from conflict auto-correct test) + const [existing] = await db + .select() + .from(shows) + .where(eq(shows.slug, dune.slug)) + .limit(1); + expect(existing).toMatchObject({ slug: dune.slug, startAir: dune.airDate }); + + const [resp, body] = await createMovie({ ...dune, airDate: null }); + expectStatus(resp, body).toBe(409); + expect(body.id).toBe(existing.id); + expect(body.slug).toBe(existing.slug); + }); + test.todo("Missing videos send info", async () => {}); test.todo("Schema error", async () => {}); test.todo("Invalid translation name", async () => {}); From cdceb1a73422edc934d5f6e1dd1c1346f95e797c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 8 Dec 2024 22:23:49 +0100 Subject: [PATCH 071/105] Add proper error type & error handling --- api/src/base.ts | 14 +++++++++++++ api/src/controllers/seed/index.ts | 7 ++++--- api/src/controllers/seed/movies.ts | 2 +- api/src/models/error.ts | 8 ++++++++ api/tests/seed-movies.test.ts | 32 +++++++++++++++++++++++++++--- 5 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 api/src/base.ts create mode 100644 api/src/models/error.ts diff --git a/api/src/base.ts b/api/src/base.ts new file mode 100644 index 000000000..d348f900a --- /dev/null +++ b/api/src/base.ts @@ -0,0 +1,14 @@ +import Elysia from "elysia"; +import type { KError } from "./models/error"; + +export const base = new Elysia({ name: "base" }) + .onError(({ code, error }) => { + if (code === "VALIDATION") { + return { + status: error.status, + message: error.message, + details: error, + } as KError; + } + }) + .as("plugin"); diff --git a/api/src/controllers/seed/index.ts b/api/src/controllers/seed/index.ts index 645835fe6..467462dae 100644 --- a/api/src/controllers/seed/index.ts +++ b/api/src/controllers/seed/index.ts @@ -1,15 +1,15 @@ -import Elysia, { t } from "elysia"; +import Elysia from "elysia"; import { Movie, SeedMovie } from "~/models/movie"; import { seedMovie, SeedMovieResponse } from "./movies"; import { Resource } from "~/models/utils"; import { comment } from "~/utils"; +import { KError } from "~/models/error"; export const seed = new Elysia() .model({ movie: Movie, "seed-movie": SeedMovie, "seed-movie-response": SeedMovieResponse, - error: t.String(), }) .post( "/movies", @@ -25,7 +25,7 @@ export const seed = new Elysia() description: "Existing movie edited/updated.", }, 201: { ...SeedMovieResponse, description: "Created a new movie." }, - 400: "error", + 400: { ...KError, description: "Invalid translation name" }, 409: { ...Resource, description: comment` @@ -33,6 +33,7 @@ export const seed = new Elysia() Change the slug and re-run the request. `, }, + 422: { ...KError, description: "Invalid schema in body." }, }, detail: { tags: ["movies"], diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 08ac4d353..45b3b8a47 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -171,7 +171,7 @@ export const seedMovie = async ( } return { - status: ret.updated ? "Ok" : "Created", + status: ret.updated ? "OK" : "Created", id: ret.id, slug: ret.slug, videos: retVideos, diff --git a/api/src/models/error.ts b/api/src/models/error.ts new file mode 100644 index 000000000..c795ad123 --- /dev/null +++ b/api/src/models/error.ts @@ -0,0 +1,8 @@ +import { t } from "elysia"; + +export const KError = t.Object({ + status: t.Integer(), + message: t.String(), + details: t.Any(), +}); +export type KError = typeof KError.static; diff --git a/api/tests/seed-movies.test.ts b/api/tests/seed-movies.test.ts index 49e384a42..1173116c2 100644 --- a/api/tests/seed-movies.test.ts +++ b/api/tests/seed-movies.test.ts @@ -128,9 +128,35 @@ describe("Movie seeding", () => { expect(body.slug).toBe(existing.slug); }); - test.todo("Missing videos send info", async () => {}); - test.todo("Schema error", async () => {}); - test.todo("Invalid translation name", async () => {}); + it("Missing videos send info", async () => { + const vid = "a0ddf0ce-3258-4452-a670-aff36c76d524"; + const [existing] = await db + .select() + .from(videos) + .where(eq(videos.id, vid)) + .limit(1); + expect(existing).toBeUndefined(); + + const [resp, body] = await createMovie({ + ...dune, + videos: [vid], + }); + + expectStatus(resp, body).toBe(200); + expect(body.videos).toBeArrayOfSize(0); + }); + + it("Schema error (missing fields)", async () => { + const [resp, body] = await createMovie({ + name: "dune", + } as any); + + expectStatus(resp, body).toBe(422); + expect(body.status).toBe(422); + expect(body.message).toBeString(); + expect(body.details).toBeObject(); + // TODO: handle additional fields too + }); test.todo("Create correct video slug (version)", async () => {}); test.todo("Create correct video slug (part)", async () => {}); test.todo("Create correct video slug (rendering)", async () => {}); From 0c0628529cc66805a5839fa7a9fdfa04344ed898 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 8 Dec 2024 22:24:05 +0100 Subject: [PATCH 072/105] Validate language tags --- api/src/controllers/seed/index.ts | 5 ++- api/src/db/schema/shows.ts | 13 ++++++- api/src/index.ts | 2 + api/src/models/utils/language.ts | 22 +++++++++++ api/tests/seed-movies.test.ts | 63 ++++++++++++++++++++++++++++++- 5 files changed, 102 insertions(+), 3 deletions(-) diff --git a/api/src/controllers/seed/index.ts b/api/src/controllers/seed/index.ts index 467462dae..8a5dac11c 100644 --- a/api/src/controllers/seed/index.ts +++ b/api/src/controllers/seed/index.ts @@ -1,7 +1,7 @@ import Elysia from "elysia"; import { Movie, SeedMovie } from "~/models/movie"; import { seedMovie, SeedMovieResponse } from "./movies"; -import { Resource } from "~/models/utils"; +import { Resource, validateTranslations } from "~/models/utils"; import { comment } from "~/utils"; import { KError } from "~/models/error"; @@ -14,6 +14,9 @@ export const seed = new Elysia() .post( "/movies", async ({ body, error }) => { + const err = validateTranslations(body.translations); + if (err) return error(400, err); + const { status, ...ret } = await seedMovie(body); return error(status, ret); }, diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index ce406f564..2f43e7361 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -1,4 +1,4 @@ -import { sql } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { check, date, @@ -108,3 +108,14 @@ export const showTranslations = schema.table( }, (t) => [primaryKey({ columns: [t.pk, t.language] })], ); + +export const showsRelations = relations(shows, ({ many }) => ({ + translations: many(showTranslations, { relationName: "showTranslations" }), +})); +export const showsTrRelations = relations(showTranslations, ({ one }) => ({ + show: one(shows, { + relationName: "showTranslations", + fields: [showTranslations.pk], + references: [shows.pk], + }), +})); diff --git a/api/src/index.ts b/api/src/index.ts index 00f1b0829..5fb5021f3 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -12,6 +12,7 @@ import { videos } from "./controllers/videos"; import { db } from "./db"; import { Image } from "./models/utils"; import { comment } from "./utils"; +import { base } from "./base"; await migrate(db, { migrationsSchema: "kyoo", migrationsFolder: "./drizzle" }); @@ -37,6 +38,7 @@ if (!secret) { } const app = new Elysia() + .use(base) .use(jwt({ secret })) .use( swagger({ diff --git a/api/src/models/utils/language.ts b/api/src/models/utils/language.ts index 302e67f99..b843df0b7 100644 --- a/api/src/models/utils/language.ts +++ b/api/src/models/utils/language.ts @@ -1,6 +1,28 @@ import { FormatRegistry } from "@sinclair/typebox"; import { t } from "elysia"; import { comment } from "../../utils"; +import type { KError } from "../error"; + +export const validateTranslations = ( + translations: Record, +): KError | null => { + for (const lang of Object.keys(translations)) { + try { + const valid = new Intl.Locale(lang).baseName; + if (lang !== valid) { + translations[valid] = translations[lang]; + delete translations[lang]; + } + } catch (e) { + return { + status: 400, + message: `Invalid translation name: '${lang}'.`, + details: null, + }; + } + } + return null; +}; FormatRegistry.Set("language", (lang) => { try { diff --git a/api/tests/seed-movies.test.ts b/api/tests/seed-movies.test.ts index 1173116c2..b3e1aba41 100644 --- a/api/tests/seed-movies.test.ts +++ b/api/tests/seed-movies.test.ts @@ -1,13 +1,15 @@ import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; import { eq, inArray } from "drizzle-orm"; import Elysia from "elysia"; +import { base } from "~/base"; import { seed } from "~/controllers/seed"; import { db } from "~/db"; import { shows, showTranslations, videos } from "~/db/schema"; +import { bubble } from "~/models/examples"; import { dune, duneVideo } from "~/models/examples/dune-2021"; import type { SeedMovie } from "~/models/movie"; -const app = new Elysia().use(seed); +const app = new Elysia().use(base).use(seed); const createMovie = async (movie: SeedMovie) => { const resp = await app.handle( new Request("http://localhost/movies", { @@ -157,6 +159,65 @@ describe("Movie seeding", () => { expect(body.details).toBeObject(); // TODO: handle additional fields too }); + + it("Invalid translation name", async () => { + const [resp, body] = await createMovie({ + ...dune, + translations: { + ...dune.translations, + test: { + name: "foo", + description: "bar", + tags: [], + aliases: [], + tagline: "toto", + banner: null, + poster: null, + thumbnail: null, + logo: null, + trailerUrl: null, + }, + }, + }); + + expectStatus(resp, body).toBe(400); + expect(body.status).toBe(400); + expect(body.message).toBe("Invalid translation name: 'test'."); + }); + + it("Correct translations casing.", async () => { + const [resp, body] = await createMovie({ + ...bubble, + slug: "casing-test", + translations: { + "en-us": { + name: "foo", + description: "bar", + tags: [], + aliases: [], + tagline: "toto", + banner: null, + poster: null, + thumbnail: null, + logo: null, + trailerUrl: null, + }, + }, + }); + + expect(resp.status).toBeWithin(200, 299); + expect(body.id).toBeString(); + const ret = await db.query.shows.findFirst({ + where: eq(shows.id, body.id), + with: { translations: true }, + }); + expect(ret!.translations).toBeArrayOfSize(1); + expect(ret!.translations[0]).toMatchObject({ + language: "en-US", + name: "foo", + }); + }); + test.todo("Create correct video slug (version)", async () => {}); test.todo("Create correct video slug (part)", async () => {}); test.todo("Create correct video slug (rendering)", async () => {}); From 0b77072b04a054fee9f66a15ce4ec526aee296f0 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 11 Dec 2024 16:42:51 +0100 Subject: [PATCH 073/105] Small cleanups --- api/src/base.ts | 7 +++++++ api/src/controllers/seed/examples.ts | 11 ----------- api/src/index.ts | 5 ----- 3 files changed, 7 insertions(+), 16 deletions(-) delete mode 100644 api/src/controllers/seed/examples.ts diff --git a/api/src/base.ts b/api/src/base.ts index d348f900a..88920b2bb 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -10,5 +10,12 @@ export const base = new Elysia({ name: "base" }) details: error, } as KError; } + if (code === "INTERNAL_SERVER_ERROR") { + return { + status: 500, + message: error.message, + details: error, + } as KError; + } }) .as("plugin"); diff --git a/api/src/controllers/seed/examples.ts b/api/src/controllers/seed/examples.ts deleted file mode 100644 index 0030285ec..000000000 --- a/api/src/controllers/seed/examples.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { db } from "~/db"; -import { videos } from "~/db/schema"; -import { bubble, bubbleVideo } from "~/models/examples"; -import { seedMovie } from "./movies"; - -const videoExamples = [bubbleVideo]; - -export const seedTests = async () => { - await db.insert(videos).values(videoExamples).onConflictDoNothing(); - await seedMovie(bubble) -}; diff --git a/api/src/index.ts b/api/src/index.ts index 5fb5021f3..f3af37c3d 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -6,7 +6,6 @@ import { entries } from "./controllers/entries"; import { movies } from "./controllers/movies"; import { seasons } from "./controllers/seasons"; import { seed } from "./controllers/seed"; -import { seedTests } from "./controllers/seed/examples"; import { series } from "./controllers/series"; import { videos } from "./controllers/videos"; import { db } from "./db"; @@ -16,10 +15,6 @@ import { base } from "./base"; await migrate(db, { migrationsSchema: "kyoo", migrationsFolder: "./drizzle" }); -if (process.env.SEED) { - await seedTests(); -} - let secret = process.env.JWT_SECRET; if (!secret) { const auth = process.env.AUTH_SERVER ?? "http://auth:4568"; From c263dd770ee4a08617cf10e2a167fc98f8ef6a3e Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 11 Dec 2024 16:56:42 +0100 Subject: [PATCH 074/105] Ensure image ids are human readable --- api/src/controllers/seed/images.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/controllers/seed/images.ts b/api/src/controllers/seed/images.ts index b13dfbcbd..bbb595f84 100644 --- a/api/src/controllers/seed/images.ts +++ b/api/src/controllers/seed/images.ts @@ -7,7 +7,7 @@ export const processImage = async (url: string): Promise => { // TODO: download source, save it in multiples qualities & process blurhash return { - id: hasher.digest().toString(), + id: hasher.digest().toString("hex"), source: url, blurhash: "", }; From 9e1afca9ec3da35b164c5ed1230cf15f0d22af20 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 11 Dec 2024 17:01:59 +0100 Subject: [PATCH 075/105] Fix existing get movie & add test --- api/src/controllers/movies.ts | 56 ++++++++++++++++++++++---------- api/src/models/utils/language.ts | 15 +++++++++ api/tests/get-movies.test.ts | 52 +++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 api/tests/get-movies.test.ts diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index ce61824f3..35647ab79 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -2,21 +2,24 @@ import { Elysia, t } from "elysia"; import { Movie, MovieTranslation } from "../models/movie"; import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; -import { eq, and, sql, or, inArray } from "drizzle-orm"; +import { eq, and, sql, or } from "drizzle-orm"; import { getColumns } from "../db/schema/utils"; import { bubble } from "../models/examples"; +import { comment } from "~/utils"; +import { processLanguages } from "~/models/utils"; const translations = db - .selectDistinctOn([showTranslations.language]) + .selectDistinctOn([showTranslations.pk]) .from(showTranslations) - .where( - or( - inArray(showTranslations.language, sql.placeholder("langs")), - eq(showTranslations.language, shows.originalLanguage), - ), - ) + // .where( + // or( + // eq(showTranslations.language, sql`any(${sql.placeholder("langs")})`), + // eq(showTranslations.language, shows.originalLanguage), + // ), + // ) .orderBy( - sql`array_position(${showTranslations.language}, ${sql.placeholder("langs")})`, + showTranslations.pk, + sql`array_position(${sql.placeholder("langs")}, ${showTranslations.language})`, ) .as("t"); @@ -26,21 +29,21 @@ const { pk, language, ...translationsCol } = getColumns(translations); const findMovie = db .select({ ...moviesCol, + ...translationsCol, airDate: startAir, - translations: translationsCol, }) .from(shows) .innerJoin(translations, eq(shows.pk, translations.pk)) .where( and( eq(shows.kind, "movie"), - or( - eq(shows.id, sql.placeholder("id")), + // or( + // eq(shows.id, sql.placeholder("id")), eq(shows.slug, sql.placeholder("id")), - ), + // ), ), ) - .orderBy() + // .orderBy() .limit(1) .prepare("findMovie"); @@ -57,12 +60,31 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) examples: [bubble.slug], }), }), + headers: t.Object({ + "Accept-Language": t.String({ + default: "*", + examples: "en-us, ja;q=0.5", + description: comment` + List of languages you want the data in. + This follows the Accept-Language offical specification + (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) + `, + }), + }), response: { 200: "movie", 404: "error" }, }) .get( "/:id", - async ({ params: { id }, error }) => { - const ret = await findMovie.execute({ id }); + async ({ + params: { id }, + headers: { "Accept-Language": languages }, + error, + }) => { + const langs = processLanguages(languages); + console.log(langs); + console.log(findMovie.getQuery()); + const ret = await findMovie.execute({ id, langs }); + console.log(ret); if (ret.length !== 1) return error(404, {}); return ret[0]; }, @@ -71,4 +93,4 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) description: "Get a movie by id or slug", }, }, - ); + ) diff --git a/api/src/models/utils/language.ts b/api/src/models/utils/language.ts index b843df0b7..4706cd400 100644 --- a/api/src/models/utils/language.ts +++ b/api/src/models/utils/language.ts @@ -48,3 +48,18 @@ export const Language = (props?: StringProps) => error: "Expected a valid (and NORMALIZED) bcp-47 language code.", ...props, }); + +export const processLanguages = (languages: string) => { + return languages + .split(",") + .map((x) => { + const [lang, q] = x.trim().split(";q="); + return [lang, q ? Number.parseFloat(q) : 1] as const; + }) + .sort(([_, q1], [__, q2]) => q1 - q2) + .flatMap(([lang]) => { + const [base, spec] = lang.split("-"); + if (spec) return [lang, base]; + return [lang]; + }); +}; diff --git a/api/tests/get-movies.test.ts b/api/tests/get-movies.test.ts new file mode 100644 index 000000000..e6cefacb6 --- /dev/null +++ b/api/tests/get-movies.test.ts @@ -0,0 +1,52 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; +import Elysia from "elysia"; +import { base } from "~/base"; +import { movies } from "~/controllers/movies"; +import { seedMovie } from "~/controllers/seed/movies"; +import { db } from "~/db"; +import { shows } from "~/db/schema"; +import { bubble } from "~/models/examples"; + +const app = new Elysia().use(base).use(movies); +const getMovie = async (id: string, langs: string) => { + const resp = await app.handle( + new Request(`http://localhost/movies/${id}`, { + method: "GET", + headers: { + "Accept-Language": langs, + }, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; + +function expectStatus(resp: Response, body: object) { + const matcher = expect({ ...body, status: resp.status }); + return { + toBe: (status: number) => { + matcher.toMatchObject({ status: status }); + }, + }; +} + +describe("Get movie", () => { + it("Retrive by slug", async () => { + const [resp, body] = await getMovie(bubble.slug, "en"); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + slug: bubble.slug, + name: bubble.translations.en.name, + }); + }); +}); + +beforeAll(async () => { + const ret = await seedMovie(bubble); + console.log("seed bubble", ret); +}); +afterAll(async () => { + // await db.delete(shows).where(eq(shows.slug, bubble.slug)); +}); From eea0f688a0ecd4bfc8441610cd96b013ffff0563 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 15 Dec 2024 18:28:05 +0100 Subject: [PATCH 076/105] Make movie get work --- api/src/base.ts | 1 + api/src/controllers/movies.ts | 130 +++++++++++++++++++------------ api/src/models/utils/resource.ts | 4 + api/tests/get-movies.test.ts | 44 ++++++++++- 4 files changed, 126 insertions(+), 53 deletions(-) diff --git a/api/src/base.ts b/api/src/base.ts index 88920b2bb..fa1573592 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -11,6 +11,7 @@ export const base = new Elysia({ name: "base" }) } as KError; } if (code === "INTERNAL_SERVER_ERROR") { + console.error(error); return { status: 500, message: error.message, diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 35647ab79..fbe9440d9 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -1,57 +1,45 @@ +import { and, eq, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; -import { Movie, MovieTranslation } from "../models/movie"; +import { KError } from "~/models/error"; +import { isUuid, processLanguages } from "~/models/utils"; +import { comment } from "~/utils"; import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; -import { eq, and, sql, or } from "drizzle-orm"; import { getColumns } from "../db/schema/utils"; import { bubble } from "../models/examples"; -import { comment } from "~/utils"; -import { processLanguages } from "~/models/utils"; +import { Movie, MovieTranslation } from "../models/movie"; -const translations = db - .selectDistinctOn([showTranslations.pk]) - .from(showTranslations) - // .where( - // or( - // eq(showTranslations.language, sql`any(${sql.placeholder("langs")})`), - // eq(showTranslations.language, shows.originalLanguage), - // ), - // ) - .orderBy( - showTranslations.pk, - sql`array_position(${sql.placeholder("langs")}, ${showTranslations.language})`, - ) - .as("t"); +// drizzle is bugged and doesn't allow js arrays to be used in raw sql. +export function sqlarr(array: unknown[]) { + return `{${array.map((item) => `"${item}"`).join(",")}}`; +} -const { pk: _, kind, startAir, endAir, ...moviesCol } = getColumns(shows); -const { pk, language, ...translationsCol } = getColumns(translations); +const getTranslationQuery = (languages: string[]) => { + const fallback = languages.includes("*"); + const query = db + .selectDistinctOn([showTranslations.pk]) + .from(showTranslations) + .where( + fallback + ? undefined + : eq(showTranslations.language, sql`any(${sqlarr(languages)})`), + ) + .orderBy( + showTranslations.pk, + sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, + ) + .as("t"); -const findMovie = db - .select({ - ...moviesCol, - ...translationsCol, - airDate: startAir, - }) - .from(shows) - .innerJoin(translations, eq(shows.pk, translations.pk)) - .where( - and( - eq(shows.kind, "movie"), - // or( - // eq(shows.id, sql.placeholder("id")), - eq(shows.slug, sql.placeholder("id")), - // ), - ), - ) - // .orderBy() - .limit(1) - .prepare("findMovie"); + const { pk, ...col } = getColumns(query); + return [query, col] as const; +}; + +const { pk: _, kind, startAir, endAir, ...moviesCol } = getColumns(shows); export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .model({ movie: Movie, "movie-translation": MovieTranslation, - error: t.Object({}), }) .guard({ params: t.Object({ @@ -61,7 +49,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }), }), headers: t.Object({ - "Accept-Language": t.String({ + "accept-language": t.String({ default: "*", examples: "en-us, ja;q=0.5", description: comment` @@ -71,26 +59,66 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) `, }), }), - response: { 200: "movie", 404: "error" }, + response: { + 200: "movie", + 404: { + ...KError, + description: "No movie found with the given id or slug.", + }, + 422: { + ...KError, + description: comment` + The Accept-Language header can't be satisfied (all languages listed are + unavailable). Try with another languages or add * to the list of languages + to fallback to any language. + `, + }, + }, }) .get( "/:id", async ({ params: { id }, - headers: { "Accept-Language": languages }, + headers: { "accept-language": languages }, error, + set, }) => { const langs = processLanguages(languages); - console.log(langs); - console.log(findMovie.getQuery()); - const ret = await findMovie.execute({ id, langs }); - console.log(ret); - if (ret.length !== 1) return error(404, {}); - return ret[0]; + const [transQ, transCol] = getTranslationQuery(langs); + + const idFilter = isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id); + + const [ret] = await db + .select({ + ...moviesCol, + ...transCol, + airDate: startAir, + }) + .from(shows) + .leftJoin(transQ, eq(shows.pk, transQ.pk)) + .where(and(eq(shows.kind, "movie"), idFilter)) + .limit(1); + + if (!ret) { + return error(404, { + status: 404, + message: "Movie not found", + details: undefined, + }); + } + if (!ret.language) { + return error(422, { + status: 422, + message: "Accept-Language header could not be satisfied.", + details: undefined, + }); + } + set.headers["content-language"] = ret.language; + return ret; }, { detail: { description: "Get a movie by id or slug", }, }, - ) + ); diff --git a/api/src/models/utils/resource.ts b/api/src/models/utils/resource.ts index 4c47cf488..6e837397c 100644 --- a/api/src/models/utils/resource.ts +++ b/api/src/models/utils/resource.ts @@ -1,4 +1,5 @@ import { FormatRegistry } from "@sinclair/typebox"; +import { TypeCompiler } from "@sinclair/typebox/compiler"; import { t } from "elysia"; export const slugPattern = "^[a-z0-9-]+$"; @@ -11,3 +12,6 @@ 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); diff --git a/api/tests/get-movies.test.ts b/api/tests/get-movies.test.ts index e6cefacb6..95f5e86bb 100644 --- a/api/tests/get-movies.test.ts +++ b/api/tests/get-movies.test.ts @@ -22,6 +22,8 @@ const getMovie = async (id: string, langs: string) => { return [resp, body] as const; }; +let bubbleId = ""; + function expectStatus(resp: Response, body: object) { const matcher = expect({ ...body, status: resp.status }); return { @@ -41,12 +43,50 @@ describe("Get movie", () => { name: bubble.translations.en.name, }); }); + it("Retrive by id", async () => { + const [resp, body] = await getMovie(bubbleId, "en"); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + id: bubbleId, + slug: bubble.slug, + name: bubble.translations.en.name, + }); + }); + it("Get non available translation", async () => { + const [resp, body] = await getMovie(bubble.slug, "fr"); + + expectStatus(resp, body).toBe(422); + expect(body).toMatchObject({ + status: 422, + }); + }); + it("Get first available language", async () => { + const [resp, body] = await getMovie(bubble.slug, "fr,en"); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + slug: bubble.slug, + name: bubble.translations.en.name, + }); + expect(resp.headers.get("Content-Language")).toBe("en"); + }); + it("Use language fallback", async () => { + const [resp, body] = await getMovie(bubble.slug, "fr,ja,*"); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + slug: bubble.slug, + name: bubble.translations.en.name, + }); + expect(resp.headers.get("Content-Language")).toBe("en"); + }); }); beforeAll(async () => { const ret = await seedMovie(bubble); - console.log("seed bubble", ret); + bubbleId = ret.id; }); afterAll(async () => { - // await db.delete(shows).where(eq(shows.slug, bubble.slug)); + await db.delete(shows).where(eq(shows.slug, bubble.slug)); }); From 43ae26679adcd5b8af0b7f570cf6e2247954aada Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 15 Dec 2024 20:22:27 +0100 Subject: [PATCH 077/105] Fix type issues on get /movies --- api/src/controllers/movies.ts | 77 +++++++++++++++++------------------ api/src/models/movie.ts | 1 - 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index fbe9440d9..452e3299f 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -7,7 +7,7 @@ import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; import { getColumns } from "../db/schema/utils"; import { bubble } from "../models/examples"; -import { Movie, MovieTranslation } from "../models/movie"; +import { Movie, type MovieStatus, MovieTranslation } from "../models/movie"; // drizzle is bugged and doesn't allow js arrays to be used in raw sql. export function sqlarr(array: unknown[]) { @@ -41,40 +41,6 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) movie: Movie, "movie-translation": MovieTranslation, }) - .guard({ - params: t.Object({ - id: t.String({ - description: "The id or slug of the movie to retrieve", - examples: [bubble.slug], - }), - }), - headers: t.Object({ - "accept-language": t.String({ - default: "*", - examples: "en-us, ja;q=0.5", - description: comment` - List of languages you want the data in. - This follows the Accept-Language offical specification - (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) - `, - }), - }), - response: { - 200: "movie", - 404: { - ...KError, - description: "No movie found with the given id or slug.", - }, - 422: { - ...KError, - description: comment` - The Accept-Language header can't be satisfied (all languages listed are - unavailable). Try with another languages or add * to the list of languages - to fallback to any language. - `, - }, - }, - }) .get( "/:id", async ({ @@ -91,8 +57,9 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) const [ret] = await db .select({ ...moviesCol, - ...transCol, + status: sql`${moviesCol.status}`, airDate: startAir, + translation: transCol, }) .from(shows) .leftJoin(transQ, eq(shows.pk, transQ.pk)) @@ -106,19 +73,51 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) details: undefined, }); } - if (!ret.language) { + if (!ret.translation) { return error(422, { status: 422, message: "Accept-Language header could not be satisfied.", details: undefined, }); } - set.headers["content-language"] = ret.language; - return ret; + set.headers["content-language"] = ret.translation.language; + return { ...ret, ...ret.translation }; }, { detail: { description: "Get a movie by id or slug", }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the movie to retrieve", + examples: [bubble.slug], + }), + }), + headers: t.Object({ + "accept-language": t.String({ + default: "*", + examples: "en-us, ja;q=0.5", + description: comment` + List of languages you want the data in. + This follows the Accept-Language offical specification + (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) + `, + }), + }), + response: { + 200: "movie", + 404: { + ...KError, + description: "No movie found with the given id or slug.", + }, + 422: { + ...KError, + description: comment` + The Accept-Language header can't be satisfied (all languages listed are + unavailable). Try with another languages or add * to the list of languages + to fallback to any language. + `, + }, + }, }, ); diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 984f67c7e..4fbf86235 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -1,6 +1,5 @@ import { t } from "elysia"; import { ExternalId, Genre, Image, Language, SeedImage } from "./utils"; -import { SeedVideo } from "./video"; import { bubble, registerExamples } from "./examples"; import { bubbleImages } from "./examples/bubble"; From 3a7a12bfd36ea3c973dbbb61cc8c1972b6e841a8 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 19 Dec 2024 16:19:13 +0100 Subject: [PATCH 078/105] Cleanup swagger examples --- api/src/controllers/movies.ts | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 452e3299f..f5b6fbbe4 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -89,34 +89,44 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }, params: t.Object({ id: t.String({ - description: "The id or slug of the movie to retrieve", - examples: [bubble.slug], + description: "The id or slug of the movie to retrieve.", + example: bubble.slug, }), }), headers: t.Object({ "accept-language": t.String({ default: "*", - examples: "en-us, ja;q=0.5", + example: "en-us, ja;q=0.5", description: comment` - List of languages you want the data in. - This follows the Accept-Language offical specification - (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) - `, + List of languages you want the data in. + This follows the Accept-Language offical specification + (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language). + `, }), }), response: { - 200: "movie", + 200: { ...Movie, description: "Found" }, 404: { ...KError, description: "No movie found with the given id or slug.", + examples: [ + { status: 404, message: "Movie not found", details: undefined }, + ], }, 422: { ...KError, description: comment` - The Accept-Language header can't be satisfied (all languages listed are - unavailable). Try with another languages or add * to the list of languages - to fallback to any language. - `, + The Accept-Language header can't be satisfied (all languages listed are + unavailable). Try with another languages or add * to the list of languages + to fallback to any language. + `, + examples: [ + { + status: 422, + message: "Accept-Language header could not be satisfied.", + details: undefined, + }, + ], }, }, }, From a4853cb186ae729fe7b7232ee04ab0a7d204c70f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 19 Dec 2024 16:20:35 +0100 Subject: [PATCH 079/105] Test missing accept-language endpoint --- api/src/models/utils/language.ts | 3 ++- api/tests/get-movies.test.ts | 20 ++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/api/src/models/utils/language.ts b/api/src/models/utils/language.ts index 4706cd400..afc58ea1f 100644 --- a/api/src/models/utils/language.ts +++ b/api/src/models/utils/language.ts @@ -49,7 +49,8 @@ export const Language = (props?: StringProps) => ...props, }); -export const processLanguages = (languages: string) => { +export const processLanguages = (languages?: string) => { + if (!languages) return ["*"]; return languages .split(",") .map((x) => { diff --git a/api/tests/get-movies.test.ts b/api/tests/get-movies.test.ts index 95f5e86bb..fca0d08aa 100644 --- a/api/tests/get-movies.test.ts +++ b/api/tests/get-movies.test.ts @@ -9,13 +9,15 @@ import { shows } from "~/db/schema"; import { bubble } from "~/models/examples"; const app = new Elysia().use(base).use(movies); -const getMovie = async (id: string, langs: string) => { +const getMovie = async (id: string, langs?: string) => { const resp = await app.handle( new Request(`http://localhost/movies/${id}`, { method: "GET", - headers: { - "Accept-Language": langs, - }, + headers: langs + ? { + "Accept-Language": langs, + } + : {}, }), ); const body = await resp.json(); @@ -74,6 +76,16 @@ describe("Get movie", () => { it("Use language fallback", async () => { const [resp, body] = await getMovie(bubble.slug, "fr,ja,*"); + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + slug: bubble.slug, + name: bubble.translations.en.name, + }); + expect(resp.headers.get("Content-Language")).toBe("en"); + }); + it("Works without accept-language header", async () => { + const [resp, body] = await getMovie(bubble.slug, undefined); + expectStatus(resp, body).toBe(200); expect(body).toMatchObject({ slug: bubble.slug, From 587dc4f97099a0445153732c989d0fa89e5461ff Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 19 Dec 2024 16:21:12 +0100 Subject: [PATCH 080/105] Add get /movies & sort api --- api/src/controllers/movies.ts | 103 +++++++++++++++++++++++++++++++++- api/src/models/utils/page.ts | 13 +++++ api/src/utils.ts | 5 ++ 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 api/src/models/utils/page.ts diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index f5b6fbbe4..8a5d4220c 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -1,13 +1,14 @@ -import { and, eq, sql } from "drizzle-orm"; +import { and, desc, eq, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { KError } from "~/models/error"; import { isUuid, processLanguages } from "~/models/utils"; -import { comment } from "~/utils"; +import { comment, RemovePrefix } from "~/utils"; import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; import { getColumns } from "../db/schema/utils"; import { bubble } from "../models/examples"; import { Movie, type MovieStatus, MovieTranslation } from "../models/movie"; +import { Page } from "~/models/utils/page"; // drizzle is bugged and doesn't allow js arrays to be used in raw sql. export function sqlarr(array: unknown[]) { @@ -130,4 +131,102 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }, }, }, + ) + .get( + "", + async ({ + query: { limit, after, sort }, + headers: { "accept-language": languages }, + }) => { + const langs = processLanguages(languages); + const [transQ, transCol] = getTranslationQuery(langs); + const order = sort.map((x) => { + const desc = x[0] === "-"; + const key = (desc ? x.substring(1) : x) as RemovePrefix; + if (key === "airDate") return { key: "startAir" as const, desc }; + return { key, desc }; + }); + + const items = await db + .select({ + ...moviesCol, + ...transCol, + status: sql`${moviesCol.status}`, + airDate: startAir, + }) + .from(shows) + .innerJoin(transQ, eq(shows.pk, transQ.pk)) + .orderBy( + ...order.map((x) => (x.desc ? desc(shows[x.key]) : shows[x.key])), + shows.pk, + ) + .limit(limit); + + return { items, next: "", prev: "", this: "" }; + }, + { + detail: { description: "Get all movies" }, + query: t.Object({ + sort: t.Array( + t.UnionEnum([ + "slug", + "-slug", + "rating", + "-rating", + "airDate", + "-airDate", + "createdAt", + "-createdAt", + "nextRefresh", + "-nextRefresh", + ]), + // TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia + { explode: false, default: ["slug"] }, + ), + limit: t.Integer({ + minimum: 1, + maximum: 250, + default: 50, + description: "Max page size.", + }), + after: t.Optional( + t.String({ + format: "uuid", + description: comment` + Id of the cursor in the pagination. + You can ignore this and only use the prev/next field in the response. + `, + }), + ), + }), + headers: t.Object({ + "accept-language": t.String({ + default: "*", + example: "en-us, ja;q=0.5", + description: comment` + List of languages you want the data in. + This follows the Accept-Language offical specification + (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language). + + In this request, * is always implied (if no language could satisfy the request, kyoo will use any language available). + `, + }), + }), + // response: { + // 200: Page(Movie, { + // description: "Paginated list of movies that match filters.", + // }), + // 422: { + // ...KError, + // description: "Invalid query parameters.", + // examples: [ + // { + // status: 422, + // message: "Accept-Language header could not be satisfied.", + // details: undefined, + // }, + // ], + // }, + // }, + }, ); diff --git a/api/src/models/utils/page.ts b/api/src/models/utils/page.ts new file mode 100644 index 000000000..a41b8504b --- /dev/null +++ b/api/src/models/utils/page.ts @@ -0,0 +1,13 @@ +import type { ObjectOptions } from "@sinclair/typebox"; +import { t, type TSchema } from "elysia"; + +export const Page = (schema: T, options?: ObjectOptions) => + t.Object( + { + items: t.Array(schema), + this: t.String({ format: "uri" }), + prev: t.String({ format: "uri" }), + next: t.String({ format: "uri" }), + }, + options, + ); diff --git a/api/src/utils.ts b/api/src/utils.ts index eefe2e397..1a7ab6e50 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -1,3 +1,8 @@ // remove indent in multi-line comments export const comment = (str: TemplateStringsArray, ...values: any[]) => str.reduce((acc, str, i) => `${acc}${str}${values[i]}`).replace(/^\s+/gm, ""); + +export type RemovePrefix< + T extends string, + Prefix extends string, +> = T extends `${Prefix}${infer Ret}` ? Ret : T; From 2fd6b85d7e910a8c71233ee9febd16fe7b8a0f2a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 19 Dec 2024 20:06:35 +0100 Subject: [PATCH 081/105] Fix validation errors --- api/src/base.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api/src/base.ts b/api/src/base.ts index fa1573592..5dc64f29f 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -2,12 +2,13 @@ import Elysia from "elysia"; import type { KError } from "./models/error"; export const base = new Elysia({ name: "base" }) - .onError(({ code, error }) => { + .onError(({code, error}) => { if (code === "VALIDATION") { + const details = JSON.parse(error.message); return { status: error.status, - message: error.message, - details: error, + message: `Validation error on ${details.on}.`, + details: details, } as KError; } if (code === "INTERNAL_SERVER_ERROR") { From 05d5ac5a75e7135c53cbbcb960fb1f80d1cf7bc4 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 19 Dec 2024 20:07:18 +0100 Subject: [PATCH 082/105] Allow \n in doc comments --- api/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/utils.ts b/api/src/utils.ts index 1a7ab6e50..9e3e16d8b 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -1,6 +1,6 @@ // remove indent in multi-line comments export const comment = (str: TemplateStringsArray, ...values: any[]) => - str.reduce((acc, str, i) => `${acc}${str}${values[i]}`).replace(/^\s+/gm, ""); + str.reduce((acc, str, i) => `${acc}${str}${values[i]}`).replace(/^[^\S\n]+/gm, ""); export type RemovePrefix< T extends string, From df416948112a7b2516ce3c15ba97a3585de9c4bc Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 19 Dec 2024 22:51:20 +0100 Subject: [PATCH 083/105] Start a filter parser --- api/src/models/utils/filters.ts | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 api/src/models/utils/filters.ts diff --git a/api/src/models/utils/filters.ts b/api/src/models/utils/filters.ts new file mode 100644 index 000000000..63ac4252d --- /dev/null +++ b/api/src/models/utils/filters.ts @@ -0,0 +1,62 @@ +import { digit, float, int, noCharOf, type Parjser, string } from "parjs"; +import { + exactly, + many, + many1, + map, + or, + stringify, + then, + thenq, +} from "parjs/combinators"; + +export type Filter = { + [key: string]: any; +}; + +type Property = string; +type Value = + | { type: "int"; value: number } + | { type: "float"; value: number } + | { type: "date"; value: string } + | { type: "string"; value: string }; +type Operator = "eq" | "ne" | "gt" | "ge" | "lt" | "le" | "has" | "in"; +type Expression = + | { type: "op"; operator: Operator; property: Property; value: Value } + | { type: "and"; first: Expression; second: Expression } + | { type: "or"; first: Expression; second: Expression } + | { type: "not"; expression: Expression }; + +function t(parser: Parjser): Parjser { + return parser.pipe(thenq(string(" ").pipe(many()))); +} + +const str = t(noCharOf(" ")).pipe(many1(), stringify()).expects("a string"); +const property = str.expects("a property"); + +const intVal = t(int().pipe(map((i) => ({ type: "int" as const, value: i })))); +const floatVal = t( + float().pipe(map((f) => ({ type: "float" as const, value: f }))), +); +const dateVal = t( + digit(10).pipe( + exactly(4), + thenq(string("-")), + then( + digit(10).pipe(exactly(2), thenq(string("-"))), + digit(10).pipe(exactly(2)), + ), + map(([year, month, day]) => ({ + type: "date" as const, + value: `${year}-${month}-${day}`, + })), + ), +); +const strVal = t(str.pipe(map((s) => ({ type: "string" as const, value: s })))); +const value = intVal + .pipe(or(floatVal, dateVal, strVal)) + .expects("a valid value"); + +const operator = null; + +export const parseFilter = (filter: string, config: Filter) => {}; From 81b7d5558e8e696e63314e51dbcc68f1f7e6c957 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 22 Dec 2024 16:42:42 +0100 Subject: [PATCH 084/105] Finish filter parser --- api/src/models/utils/filters.ts | 78 ++++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/api/src/models/utils/filters.ts b/api/src/models/utils/filters.ts index 63ac4252d..7e468d819 100644 --- a/api/src/models/utils/filters.ts +++ b/api/src/models/utils/filters.ts @@ -1,4 +1,13 @@ -import { digit, float, int, noCharOf, type Parjser, string } from "parjs"; +import { + anyStringOf, + digit, + float, + int, + letter, + noCharOf, + type Parjser, + string, +} from "parjs"; import { exactly, many, @@ -8,7 +17,11 @@ import { stringify, then, thenq, + qthen, + later, + between, } from "parjs/combinators"; +import type { KError } from "../error"; export type Filter = { [key: string]: any; @@ -19,12 +32,14 @@ type Value = | { type: "int"; value: number } | { type: "float"; value: number } | { type: "date"; value: string } - | { type: "string"; value: string }; -type Operator = "eq" | "ne" | "gt" | "ge" | "lt" | "le" | "has" | "in"; -type Expression = + | { type: "string"; value: string } + | { type: "enum"; value: string }; +const operators = ["eq", "ne", "gt", "ge", "lt", "le", "has", "in"] as const; +type Operator = (typeof operators)[number]; +export type Expression = | { type: "op"; operator: Operator; property: Property; value: Value } - | { type: "and"; first: Expression; second: Expression } - | { type: "or"; first: Expression; second: Expression } + | { type: "and"; lhs: Expression; rhs: Expression } + | { type: "or"; lhs: Expression; rhs: Expression } | { type: "not"; expression: Expression }; function t(parser: Parjser): Parjser { @@ -32,6 +47,8 @@ function t(parser: Parjser): Parjser { } const str = t(noCharOf(" ")).pipe(many1(), stringify()).expects("a string"); +const enumP = t(letter()).pipe(many1(), stringify()).expects("an enum value"); + const property = str.expects("a property"); const intVal = t(int().pipe(map((i) => ({ type: "int" as const, value: i })))); @@ -52,11 +69,52 @@ const dateVal = t( })), ), ); -const strVal = t(str.pipe(map((s) => ({ type: "string" as const, value: s })))); +const strVal = str.pipe(map((s) => ({ type: "string" as const, value: s }))); +const enumVal = enumP.pipe(map((e) => ({ type: "enum" as const, value: e }))); const value = intVal - .pipe(or(floatVal, dateVal, strVal)) + .pipe(or(floatVal, dateVal, strVal, enumVal)) .expects("a valid value"); -const operator = null; +const operator = t(anyStringOf(...operators)).expects("an operator"); + +const operation = property + .pipe( + then(operator, value), + map(([property, operator, value]) => ({ + type: "op" as const, + property, + operator, + value, + })), + ) + .expects("an operation"); + +export const expression = later(); + +const not = t(string("not")).pipe( + qthen(expression), + map((expression) => ({ type: "not" as const, expression })), +); + +const andor = operation.pipe( + then(anyStringOf("and", "or").pipe(then(expression), many())), + map(([first, expr]) => + expr.reduce( + (lhs, [op, rhs]) => ({ type: op, lhs, rhs }), + first, + ), + ), +); -export const parseFilter = (filter: string, config: Filter) => {}; +expression.init( + not.pipe(or(operation, expression.pipe(or(andor), between("(", ")")))), +); + +export const parseFilter = ( + filter: string, + config: Filter, +): Expression | KError => { + const ret = expression.parse(filter); + if (ret.isOk) return ret.value; + return { status: 422, message: `Invalid filter: ${filter}.`, details: ret }; +}; From e20e32728681b755c4577311e9ddd4a522435c3d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 22 Dec 2024 18:42:26 +0100 Subject: [PATCH 085/105] Filter fixes --- api/src/models/utils/filters.ts | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/api/src/models/utils/filters.ts b/api/src/models/utils/filters.ts index 7e468d819..04fb8b33e 100644 --- a/api/src/models/utils/filters.ts +++ b/api/src/models/utils/filters.ts @@ -20,6 +20,7 @@ import { qthen, later, between, + recover, } from "parjs/combinators"; import type { KError } from "../error"; @@ -46,8 +47,8 @@ function t(parser: Parjser): Parjser { return parser.pipe(thenq(string(" ").pipe(many()))); } -const str = t(noCharOf(" ")).pipe(many1(), stringify()).expects("a string"); -const enumP = t(letter()).pipe(many1(), stringify()).expects("an enum value"); +const str = t(noCharOf(" ").pipe(many1(), stringify()).expects("a string")); +const enumP = t(letter().pipe(many1(), stringify()).expects("an enum value")); const property = str.expects("a property"); @@ -58,26 +59,35 @@ const floatVal = t( const dateVal = t( digit(10).pipe( exactly(4), + stringify(), thenq(string("-")), then( - digit(10).pipe(exactly(2), thenq(string("-"))), - digit(10).pipe(exactly(2)), + digit(10).pipe(exactly(2), stringify(), thenq(string("-"))), + digit(10).pipe(exactly(2), stringify()), ), map(([year, month, day]) => ({ type: "date" as const, value: `${year}-${month}-${day}`, })), ), +).expects("a date"); +const strVal = str.pipe( + between('"'), + or(str.pipe(between("'"))), + map((s) => ({ type: "string" as const, value: s })), ); -const strVal = str.pipe(map((s) => ({ type: "string" as const, value: s }))); const enumVal = enumP.pipe(map((e) => ({ type: "enum" as const, value: e }))); -const value = intVal - .pipe(or(floatVal, dateVal, strVal, enumVal)) +const value = dateVal + .pipe( + // until we get the `-` character, this could be an int or a float. + recover(() => ({ kind: "Soft" })), + or(intVal, floatVal, strVal, enumVal), + ) .expects("a valid value"); const operator = t(anyStringOf(...operators)).expects("an operator"); -const operation = property +export const operation = property .pipe( then(operator, value), map(([property, operator, value]) => ({ @@ -89,7 +99,7 @@ const operation = property ) .expects("an operation"); -export const expression = later(); +const expression = later(); const not = t(string("not")).pipe( qthen(expression), @@ -110,6 +120,8 @@ expression.init( not.pipe(or(operation, expression.pipe(or(andor), between("(", ")")))), ); +export const filterParser = andor.pipe(or(expression)); + export const parseFilter = ( filter: string, config: Filter, From e960307172b7056bb3c9519401f85ec62a5f1ca2 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 22 Dec 2024 19:25:30 +0100 Subject: [PATCH 086/105] Write filter grammar & fix priorities --- api/src/models/utils/filters.ts | 34 ++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/api/src/models/utils/filters.ts b/api/src/models/utils/filters.ts index 04fb8b33e..a1e17fb18 100644 --- a/api/src/models/utils/filters.ts +++ b/api/src/models/utils/filters.ts @@ -99,15 +99,24 @@ export const operation = property ) .expects("an operation"); -const expression = later(); +// grammar: +// +// operation = property operator value +// property = letter { letter } +// operator = "eq" | "lt" | ... +// value = ... +// +// expression = expr { binn expr } +// expr = +// | "not" expr +// | "(" expression ")" +// | operation +// bin = "and" | "or" +// +const expr = later(); -const not = t(string("not")).pipe( - qthen(expression), - map((expression) => ({ type: "not" as const, expression })), -); - -const andor = operation.pipe( - then(anyStringOf("and", "or").pipe(then(expression), many())), +export const expression = expr.pipe( + then(t(anyStringOf("and", "or")).pipe(then(expr), many())), map(([first, expr]) => expr.reduce( (lhs, [op, rhs]) => ({ type: op, lhs, rhs }), @@ -116,11 +125,14 @@ const andor = operation.pipe( ), ); -expression.init( - not.pipe(or(operation, expression.pipe(or(andor), between("(", ")")))), +const not = t(string("not")).pipe( + qthen(expr), + map((expression) => ({ type: "not" as const, expression })), ); -export const filterParser = andor.pipe(or(expression)); +const brackets = expression.pipe(between("(", ")")); + +expr.init(not.pipe(or(brackets, operation))); export const parseFilter = ( filter: string, From 85310497162019f451eb5545c308fc025454526f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 22 Dec 2024 19:26:13 +0100 Subject: [PATCH 087/105] Add tests for the filter parser --- api/tests/misc/filter.test.ts | 126 +++++++++++++++++++++ api/tests/{ => movies}/get-movies.test.ts | 0 api/tests/{ => movies}/seed-movies.test.ts | 0 3 files changed, 126 insertions(+) create mode 100644 api/tests/misc/filter.test.ts rename api/tests/{ => movies}/get-movies.test.ts (100%) rename api/tests/{ => movies}/seed-movies.test.ts (100%) diff --git a/api/tests/misc/filter.test.ts b/api/tests/misc/filter.test.ts new file mode 100644 index 000000000..3fbee3f6f --- /dev/null +++ b/api/tests/misc/filter.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "bun:test"; +import type { ParjsFailure } from "parjs/internal"; +import { type Expression, expression } from "~/models/utils/filters"; + +function parse( + filter: string, +): { ok: true; value: Expression } | { ok: false } { + const ret = expression.parse(filter); + if (ret.isOk) return { ok: true, value: ret.value }; + const fail = ret as ParjsFailure; + console.log(fail.toString()); + return { + ok: false, + reason: fail.reason, + trace: { + ...fail.trace, + location: fail.trace.location, + leftover: fail.trace.input.substring(fail.trace.location.column), + }, + } as any; +} + +describe("Parse filter", () => { + it("Handle eq", () => { + const ret = parse("status eq finished"); + expect(ret).toMatchObject({ + ok: true, + value: { + type: "op", + operator: "eq", + property: "status", + value: { type: "enum", value: "finished" }, + }, + }); + }); + it("Handle lt", () => { + const ret = parse("rating lt 10"); + expect(ret).toMatchObject({ + ok: true, + value: { + type: "op", + operator: "lt", + property: "rating", + value: { type: "int", value: 10 }, + }, + }); + }); + it("Handle dates", () => { + const ret = parse("airDate ge 2022-10-12"); + expect(ret).toMatchObject({ + ok: true, + value: { + type: "op", + operator: "ge", + property: "airDate", + value: { type: "date", value: "2022-10-12" }, + }, + }); + }); + it("Handle not", () => { + const ret = parse("not rating lt 10"); + expect(ret).toMatchObject({ + ok: true, + value: { + type: "not", + expression: { + type: "op", + operator: "lt", + property: "rating", + value: { type: "int", value: 10 }, + }, + }, + }); + }); + it("Handle top level brackets", () => { + const ret = parse("(rating lt 10)"); + expect(ret).toMatchObject({ + ok: true, + value: { + type: "op", + operator: "lt", + property: "rating", + value: { type: "int", value: 10 }, + }, + }); + }); + it("Handle top level brackets with not", () => { + const ret = parse("(not rating lt 10)"); + expect(ret).toMatchObject({ + ok: true, + value: { + type: "not", + expression: { + type: "op", + operator: "lt", + property: "rating", + value: { type: "int", value: 10 }, + }, + }, + }); + }); + it("Handle and", () => { + const ret = parse("not rating lt 10 and rating lt 20"); + expect(ret).toMatchObject({ + ok: true, + value: { + type: "and", + lhs: { + type: "not", + expression: { + type: "op", + operator: "lt", + property: "rating", + value: { type: "int", value: 10 }, + }, + }, + rhs: { + type: "op", + operator: "lt", + property: "rating", + value: { type: "int", value: 20 }, + }, + }, + }); + }); +}); diff --git a/api/tests/get-movies.test.ts b/api/tests/movies/get-movies.test.ts similarity index 100% rename from api/tests/get-movies.test.ts rename to api/tests/movies/get-movies.test.ts diff --git a/api/tests/seed-movies.test.ts b/api/tests/movies/seed-movies.test.ts similarity index 100% rename from api/tests/seed-movies.test.ts rename to api/tests/movies/seed-movies.test.ts From c71da66bb6b7283649699876a01944d2fbcca615 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 22 Dec 2024 19:33:48 +0100 Subject: [PATCH 088/105] Add more complex tests --- api/tests/misc/filter.test.ts | 47 +++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/api/tests/misc/filter.test.ts b/api/tests/misc/filter.test.ts index 3fbee3f6f..d392158b5 100644 --- a/api/tests/misc/filter.test.ts +++ b/api/tests/misc/filter.test.ts @@ -123,4 +123,51 @@ describe("Parse filter", () => { }, }); }); + it("Handle or", () => { + const ret = parse( + "not rating lt 10 and rating lt 20 or (status eq finished and not status ne airing)", + ); + expect(ret).toMatchObject({ + ok: true, + value: { + type: "or", + lhs: { + type: "and", + lhs: { + type: "not", + expression: { + type: "op", + operator: "lt", + property: "rating", + value: { type: "int", value: 10 }, + }, + }, + rhs: { + type: "op", + operator: "lt", + property: "rating", + value: { type: "int", value: 20 }, + }, + }, + rhs: { + type: "and", + lhs: { + type: "op", + operator: "eq", + property: "status", + value: { type: "enum", value: "finished" }, + }, + rhs: { + type: "not", + expression: { + type: "op", + operator: "ne", + property: "status", + value: { type: "enum", value: "airing" }, + }, + }, + }, + }, + }); + }); }); From c14d4e09112b9edc41ccb5ff840532232792cdfe Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 22 Dec 2024 21:50:51 +0100 Subject: [PATCH 089/105] Add filter to drizzle converter --- api/src/controllers/movies.ts | 46 ++++++++-- api/src/db/schema/utils.ts | 2 +- api/src/models/error.ts | 2 +- api/src/models/utils/filters-sql.ts | 133 ++++++++++++++++++++++++++++ api/src/models/utils/filters.ts | 22 +---- 5 files changed, 177 insertions(+), 28 deletions(-) create mode 100644 api/src/models/utils/filters-sql.ts diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 8a5d4220c..0905eaadc 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -1,14 +1,15 @@ import { and, desc, eq, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { KError } from "~/models/error"; -import { isUuid, processLanguages } from "~/models/utils"; +import { Genre, isUuid, processLanguages } from "~/models/utils"; import { comment, RemovePrefix } from "~/utils"; import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; import { getColumns } from "../db/schema/utils"; import { bubble } from "../models/examples"; -import { Movie, type MovieStatus, MovieTranslation } from "../models/movie"; +import { Movie, MovieStatus, MovieTranslation } from "../models/movie"; import { Page } from "~/models/utils/page"; +import { type Filter, parseFilters } from "~/models/utils/filter-sql"; // drizzle is bugged and doesn't allow js arrays to be used in raw sql. export function sqlarr(array: unknown[]) { @@ -37,6 +38,20 @@ const getTranslationQuery = (languages: string[]) => { const { pk: _, kind, startAir, endAir, ...moviesCol } = getColumns(shows); +const movieFilters: Filter = { + genres: { + column: shows.genres, + type: "enum", + values: Genre.enum, + isArray: true, + }, + rating: { column: shows.rating, type: "int" }, + status: { column: shows.status, type: "enum", values: MovieStatus.enum }, + runtime: { column: shows.runtime, type: "float" }, + airDate: { column: shows.startAir, type: "date" }, + originalLanguage: { column: shows.originalLanguage, type: "string" }, +}; + export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .model({ movie: Movie, @@ -100,8 +115,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) example: "en-us, ja;q=0.5", description: comment` List of languages you want the data in. - This follows the Accept-Language offical specification - (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language). + This follows the [Accept-Language offical specification](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language). `, }), }), @@ -135,7 +149,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .get( "", async ({ - query: { limit, after, sort }, + query: { limit, after, sort, filter }, headers: { "accept-language": languages }, }) => { const langs = processLanguages(languages); @@ -146,6 +160,9 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) if (key === "airDate") return { key: "startAir" as const, desc }; return { key, desc }; }); + const filters = parseFilters(filter, movieFilters); + + // TODO: Add sql indexes on order keys const items = await db .select({ @@ -156,6 +173,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }) .from(shows) .innerJoin(transQ, eq(shows.pk, transQ.pk)) + .where(filters) .orderBy( ...order.map((x) => (x.desc ? desc(shows[x.key]) : shows[x.key])), shows.pk, @@ -168,6 +186,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) detail: { description: "Get all movies" }, query: t.Object({ sort: t.Array( + // TODO: Add random t.UnionEnum([ "slug", "-slug", @@ -183,6 +202,18 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) // TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia { explode: false, default: ["slug"] }, ), + filter: t.Optional( + t.String({ + description: comment` + Filters to apply to the query. + This is based on [odata's filter specification](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter). + + Filters available: ${Object.keys(movieFilters).join(", ")} + `, + example: + "(rating gt 75 and genres has action) or status eq planned", + }), + ), limit: t.Integer({ minimum: 1, maximum: 250, @@ -205,10 +236,9 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) example: "en-us, ja;q=0.5", description: comment` List of languages you want the data in. - This follows the Accept-Language offical specification - (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language). + This follows the [Accept-Language offical specification](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language). - In this request, * is always implied (if no language could satisfy the request, kyoo will use any language available). + In this request, * is always implied (if no language could satisfy the request, kyoo will use any language available.) `, }), }), diff --git a/api/src/db/schema/utils.ts b/api/src/db/schema/utils.ts index 9ba146da0..e7963287d 100644 --- a/api/src/db/schema/utils.ts +++ b/api/src/db/schema/utils.ts @@ -19,7 +19,7 @@ import { import type { AnySQLiteSelect } from "drizzle-orm/sqlite-core"; import type { WithSubquery } from "drizzle-orm/subquery"; import { db } from ".."; -import { CasingCache } from "drizzle-orm/casing"; +import type { CasingCache } from "drizzle-orm/casing"; export const schema = pgSchema("kyoo"); diff --git a/api/src/models/error.ts b/api/src/models/error.ts index c795ad123..a2bf9822f 100644 --- a/api/src/models/error.ts +++ b/api/src/models/error.ts @@ -3,6 +3,6 @@ import { t } from "elysia"; export const KError = t.Object({ status: t.Integer(), message: t.String(), - details: t.Any(), + details: t.Optional(t.Any()), }); export type KError = typeof KError.static; diff --git a/api/src/models/utils/filters-sql.ts b/api/src/models/utils/filters-sql.ts new file mode 100644 index 000000000..ad79ce9e6 --- /dev/null +++ b/api/src/models/utils/filters-sql.ts @@ -0,0 +1,133 @@ +import { + and, + type Column, + eq, + gt, + gte, + lt, + lte, + ne, + not, + or, + type SQL, + sql, +} from "drizzle-orm"; +import { comment } from "~/utils"; +import type { KError } from "../error"; +import { type Expression, expression, type Operator } from "./filters"; + +export type Filter = { + [key: string]: + | { + column: Column; + type: "int" | "float" | "date" | "string"; + isArray?: boolean; + } + | { column: Column; type: "enum"; values: string[]; isArray?: boolean }; +}; + +export const parseFilters = (filter: string | undefined, config: Filter) => { + if (!filter) return undefined; + const ret = expression.parse(filter); + if (!ret.isOk) { + throw new Error("todo"); + // return { status: 422, message: `Invalid filter: ${filter}.`, details: ret } + } + + return toDrizzle(ret.value, config); +}; + +const opMap: Record = { + eq: eq, + ne: ne, + gt: gt, + ge: gte, + lt: lt, + le: lte, + has: eq, +}; + +const toDrizzle = (expr: Expression, config: Filter): SQL | KError => { + switch (expr.type) { + case "op": { + const where = `${expr.property} ${expr.operator} ${expr.value}`; + const prop = config[expr.property]; + + if (!prop) { + return { + status: 422, + message: comment` + Invalid property: ${expr.property}. + Expected one of ${Object.keys(config).join(", ")}. + `, + details: { in: where }, + }; + } + + if (prop.type !== expr.value.type) { + return { + status: 422, + message: comment` + Invalid value for property ${expr.property}. + Got ${expr.value.type} but expected ${prop.type}. + `, + details: { in: where }, + }; + } + if ( + prop.type === "enum" && + (expr.value.type === "enum" || expr.value.type === "string") && + !prop.values.includes(expr.value.value) + ) { + return { + status: 422, + message: comment` + Invalid value ${expr.value.value} for property ${expr.property}. + Expected one of ${prop.values.join(", ")} but got ${expr.value.value}. + `, + details: { in: where }, + }; + } + + if (prop.isArray) { + if (expr.operator !== "has" && expr.operator !== "eq") { + return { + status: 422, + message: comment` + Property ${expr.property} is an array but you wanted to use the + operator ${expr.operator}. Only "has" is supported ("eq" is also aliased to "has") + `, + details: { in: where }, + }; + } + return sql`${expr.value.value} = any(${prop.column})`; + } + return opMap[expr.operator](prop.column, expr.value.value); + } + case "and": { + const lhs = toDrizzle(expr.lhs, config); + const rhs = toDrizzle(expr.rhs, config); + if ("status" in lhs) return lhs; + if ("status" in rhs) return rhs; + return and(lhs, rhs)!; + } + case "or": { + const lhs = toDrizzle(expr.lhs, config); + const rhs = toDrizzle(expr.rhs, config); + if ("status" in lhs) return lhs; + if ("status" in rhs) return rhs; + return or(lhs, rhs)!; + } + case "not": { + const lhs = toDrizzle(expr.expression, config); + if ("status" in lhs) return lhs; + return not(lhs); + } + default: + return exhaustiveCheck(expr); + } +}; + +function exhaustiveCheck(v: never): never { + return v; +} diff --git a/api/src/models/utils/filters.ts b/api/src/models/utils/filters.ts index a1e17fb18..35ccce00d 100644 --- a/api/src/models/utils/filters.ts +++ b/api/src/models/utils/filters.ts @@ -22,21 +22,16 @@ import { between, recover, } from "parjs/combinators"; -import type { KError } from "../error"; -export type Filter = { - [key: string]: any; -}; - -type Property = string; -type Value = +export type Property = string; +export type Value = | { type: "int"; value: number } | { type: "float"; value: number } | { type: "date"; value: string } | { type: "string"; value: string } | { type: "enum"; value: string }; -const operators = ["eq", "ne", "gt", "ge", "lt", "le", "has", "in"] as const; -type Operator = (typeof operators)[number]; +const operators = ["eq", "ne", "gt", "ge", "lt", "le", "has"] as const; +export type Operator = (typeof operators)[number]; export type Expression = | { type: "op"; operator: Operator; property: Property; value: Value } | { type: "and"; lhs: Expression; rhs: Expression } @@ -133,12 +128,3 @@ const not = t(string("not")).pipe( const brackets = expression.pipe(between("(", ")")); expr.init(not.pipe(or(brackets, operation))); - -export const parseFilter = ( - filter: string, - config: Filter, -): Expression | KError => { - const ret = expression.parse(filter); - if (ret.isOk) return ret.value; - return { status: 422, message: `Invalid filter: ${filter}.`, details: ret }; -}; From efbec85b67497f37cf3798569bc7ed2045151802 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 1 Jan 2025 20:43:43 +0100 Subject: [PATCH 090/105] Update dependencies & switch to text lockfile --- api/bun.lock | 237 ++++++++++++++++++++++++++++++++++ api/bun.lockb | Bin 39908 -> 0 bytes api/package.json | 13 +- api/src/controllers/movies.ts | 2 +- 4 files changed, 245 insertions(+), 7 deletions(-) create mode 100755 api/bun.lock delete mode 100755 api/bun.lockb diff --git a/api/bun.lock b/api/bun.lock new file mode 100755 index 000000000..f6e763fb3 --- /dev/null +++ b/api/bun.lock @@ -0,0 +1,237 @@ +{ + "lockfileVersion": 0, + "workspaces": { + "": { + "dependencies": { + "@elysiajs/jwt": "^1.2.0", + "@elysiajs/swagger": "zoriya/elysia-swagger#build", + "drizzle-kit": "^0.30.1", + "drizzle-orm": "^0.38.3", + "elysia": "^1.2.10", + "parjs": "^1.3.9", + "pg": "^8.13.1", + }, + "devDependencies": { + "@types/pg": "^8.11.10", + "bun-types": "^1.1.42", + }, + }, + }, + "packages": { + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + + "@elysiajs/jwt": ["@elysiajs/jwt@1.2.0", "", { "dependencies": { "jose": "^4.14.4" }, "peerDependencies": { "elysia": ">= 1.2.0" } }, "sha512-5iMoZucIKNAqPKW3n6RBIyCnDWG3kOcqA4WZKtqEff+IjV6AN3dlMSE2XsS0xjIvusLD0UBXS8cxQ9NwIcj6ew=="], + + "@elysiajs/swagger": ["@elysiajs/swagger@github:zoriya/elysia-swagger#bb8047e", { "dependencies": { "@scalar/themes": "^0.9.58", "@scalar/types": "^0.0.25", "openapi-types": "^12.1.3", "pathe": "^2.0.0" }, "peerDependencies": { "elysia": ">= 1.2.0" } }, "zoriya-elysia-swagger-bb8047e"], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], + + "@scalar/openapi-types": ["@scalar/openapi-types@0.1.5", "", {}, "sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw=="], + + "@scalar/themes": ["@scalar/themes@0.9.58", "", { "dependencies": { "@scalar/types": "0.0.25" } }, "sha512-voMgCIq0N19N8Ehjs8rSS0j5P1mpgWbpN5dXIToGUbVj7KcxMnOfkH3P1/cy2CoUd1gRYe0newUBEcI1+tQi1g=="], + + "@scalar/types": ["@scalar/types@0.0.25", "", { "dependencies": { "@scalar/openapi-types": "0.1.5", "@unhead/schema": "^1.11.11" } }, "sha512-sGnOFnfiSn4o23rklU/jrg81hO+630bsFIdHg8MZ/w2Nc6IoUwARA2hbe4d4Fg+D0KBu40Tan/L+WAYDXkTJQg=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.13", "", {}, "sha512-ceVKqyCEgC355Kw0s/0tyfY9MzMQINSykJ/pG2w6YnaZyrcjV48svZpr8lVZrYgWjzOmrIPBhQRAtr/7eJpA5g=="], + + "@types/node": ["@types/node@22.10.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ=="], + + "@types/pg": ["@types/pg@8.11.10", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg=="], + + "@types/ws": ["@types/ws@8.5.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA=="], + + "@unhead/schema": ["@unhead/schema@1.11.14", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-V9W9u5tF1/+TiLqxu+Qvh1ShoMDkPEwHoEo4DKdDG6ko7YlbzFfDxV6el9JwCren45U/4Vy/4Xi7j8OH02wsiA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.1.42", "", { "dependencies": { "@types/node": "~20.12.8", "@types/ws": "~8.5.10" } }, "sha512-beMbnFqWbbBQHll/bn3phSwmoOQmnX2nt8NI9iOQKFbgR5Z6rlH3YuaMdlid8vp5XGct3/W4QVQBmhoOEoe4nw=="], + + "char-info": ["char-info@0.3.5", "", { "dependencies": { "node-interval-tree": "^1.3.3" } }, "sha512-gRslEBFEcuLMGLNO1EFIrdN1MMUfO+aqa7y8iWzNyAzB3mYKnTIvP+ioW3jpyeEvqA5WapVLIPINGtFjEIH4cQ=="], + + "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + + "drizzle-kit": ["drizzle-kit@0.30.1", "", { "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-HmA/NeewvHywhJ2ENXD3KvOuM/+K2dGLJfxVfIHsGwaqKICJnS+Ke2L6UcSrSrtMJLJaT0Im1Qv4TFXfaZShyw=="], + + "drizzle-orm": ["drizzle-orm@0.38.3", "", { "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-w41Y+PquMpSff/QDRGdItG0/aWca+/J3Sda9PPGkTxBtjWQvgU1jxlFBXdjog5tYvTu58uvi3PwR1NuCx0KeZg=="], + + "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=="], + + "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], + + "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], + + "get-tsconfig": ["get-tsconfig@4.8.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg=="], + + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + + "jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], + + "memoirist": ["memoirist@0.2.0", "", {}, "sha512-DA1V11OWsKmYjgYHfT1luus0FtTjUbILfI9s5M+ckK29tBLON6GDhH5GwxDz7E1ou4Bdzm9vhbeCaRAWxwG+0g=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-interval-tree": ["node-interval-tree@1.3.3", "", { "dependencies": { "shallowequal": "^1.0.2" } }, "sha512-K9vk96HdTK5fEipJwxSvIIqwTqr4e3HRJeJrNxBSeVMNSC/JWARRaX7etOLOuTmrRMeOI/K5TCJu3aWIwZiNTw=="], + + "obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "parjs": ["parjs@1.3.9", "", { "dependencies": { "char-info": "0.3.*" } }, "sha512-zmQhbzWM3M391tjwTGvNvvtoT8rRE/bBTjw6+54g8ANaPpnyekDF1d8q5tzN4kxmVud82cNj8zSd+uxSL4LE0A=="], + + "pathe": ["pathe@2.0.0", "", {}, "sha512-G7n4uhtk9qJt2hlD+UFfsIGY854wpF+zs2bUbQ3CQEUTcn7v25LRsrmurOxTo4bJgjE4qkyshd9ldsEuY9M6xg=="], + + "pg": ["pg@8.13.1", "", { "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.7.0", "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ=="], + + "pg-cloudflare": ["pg-cloudflare@1.1.1", "", {}, "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q=="], + + "pg-connection-string": ["pg-connection-string@2.7.0", "", {}, "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="], + + "pg-pool": ["pg-pool@3.7.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g=="], + + "pg-protocol": ["pg-protocol@1.7.0", "", {}, "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ=="], + + "pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "postgres-array": ["postgres-array@3.0.2", "", {}, "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog=="], + + "postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="], + + "postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="], + + "postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="], + + "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "bun-types/@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], + + "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], + + "pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + } +} diff --git a/api/bun.lockb b/api/bun.lockb deleted file mode 100755 index 54079d1b0c6dc55c692f587f3747ce00f30d486d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39908 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 Date: Mon, 6 Jan 2025 00:51:35 +0100 Subject: [PATCH 091/105] Handle filter parsing with typebox --- api/src/base.ts | 6 +- api/src/controllers/movies.ts | 30 +++----- api/src/models/error.ts | 6 ++ api/src/models/utils/filters/index.ts | 49 +++++++++++++ .../utils/{filters.ts => filters/parser.ts} | 0 .../{filters-sql.ts => filters/to-sql.ts} | 72 ++++++------------- api/src/models/utils/index.ts | 2 + api/src/utils.ts | 4 +- api/tests/misc/filter.test.ts | 2 +- api/tests/movies/get-movies.test.ts | 43 +++++++++++ 10 files changed, 141 insertions(+), 73 deletions(-) create mode 100644 api/src/models/utils/filters/index.ts rename api/src/models/utils/{filters.ts => filters/parser.ts} (100%) rename api/src/models/utils/{filters-sql.ts => filters/to-sql.ts} (58%) diff --git a/api/src/base.ts b/api/src/base.ts index 5dc64f29f..0acbd54b2 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -2,9 +2,13 @@ import Elysia from "elysia"; import type { KError } from "./models/error"; export const base = new Elysia({ name: "base" }) - .onError(({code, error}) => { + .onError(({ code, error }) => { if (code === "VALIDATION") { const details = JSON.parse(error.message); + if (details.code === "KError") { + delete details.code; + return details; + } return { status: error.status, message: `Validation error on ${details.on}.`, diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index e6307278a..a73e1c3cb 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -1,15 +1,19 @@ import { and, desc, eq, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { KError } from "~/models/error"; -import { Genre, isUuid, processLanguages } from "~/models/utils"; -import { comment, RemovePrefix } from "~/utils"; +import { + type FilterDef, + Genre, + isUuid, + processLanguages, +} from "~/models/utils"; +import { comment, type RemovePrefix } from "~/utils"; import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; import { getColumns } from "../db/schema/utils"; import { bubble } from "../models/examples"; import { Movie, MovieStatus, MovieTranslation } from "../models/movie"; -import { Page } from "~/models/utils/page"; -import { type Filter, parseFilters } from "~/models/utils/filters-sql"; +import { Filter, type Page } from "~/models/utils"; // drizzle is bugged and doesn't allow js arrays to be used in raw sql. export function sqlarr(array: unknown[]) { @@ -38,7 +42,7 @@ const getTranslationQuery = (languages: string[]) => { const { pk: _, kind, startAir, endAir, ...moviesCol } = getColumns(shows); -const movieFilters: Filter = { +const movieFilters: FilterDef = { genres: { column: shows.genres, type: "enum", @@ -160,7 +164,6 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) if (key === "airDate") return { key: "startAir" as const, desc }; return { key, desc }; }); - const filters = parseFilters(filter, movieFilters); // TODO: Add sql indexes on order keys @@ -173,7 +176,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }) .from(shows) .innerJoin(transQ, eq(shows.pk, transQ.pk)) - .where(filters) + .where(filter) .orderBy( ...order.map((x) => (x.desc ? desc(shows[x.key]) : shows[x.key])), shows.pk, @@ -202,18 +205,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) // TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia { explode: false, default: ["slug"] }, ), - filter: t.Optional( - t.String({ - description: comment` - Filters to apply to the query. - This is based on [odata's filter specification](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter). - - Filters available: ${Object.keys(movieFilters).join(", ")} - `, - example: - "(rating gt 75 and genres has action) or status eq planned", - }), - ), + filter: t.Optional(Filter({ def: movieFilters })), limit: t.Integer({ minimum: 1, maximum: 250, diff --git a/api/src/models/error.ts b/api/src/models/error.ts index a2bf9822f..4be8a9685 100644 --- a/api/src/models/error.ts +++ b/api/src/models/error.ts @@ -6,3 +6,9 @@ export const KError = t.Object({ details: t.Optional(t.Any()), }); export type KError = typeof KError.static; + +export class KErrorT extends Error { + constructor(message: string, details?: any) { + super(JSON.stringify({ code: "KError", status: 422, message, details })); + } +} diff --git a/api/src/models/utils/filters/index.ts b/api/src/models/utils/filters/index.ts new file mode 100644 index 000000000..0255f7da4 --- /dev/null +++ b/api/src/models/utils/filters/index.ts @@ -0,0 +1,49 @@ +import type { Column } from "drizzle-orm"; +import { t } from "elysia"; +import { comment } from "~/utils"; +import { expression } from "./parser"; +import { toDrizzle } from "./to-sql"; +import { KErrorT } from "~/models/error"; + +export type FilterDef = { + [key: string]: + | { + column: Column; + type: "int" | "float" | "date" | "string"; + isArray?: boolean; + } + | { column: Column; type: "enum"; values: string[]; isArray?: boolean }; +}; + +export const Filter = ({ + def, + description = "Filters to apply to the query.", +}: { def: FilterDef; description?: string }) => + t + .Transform( + t.String({ + description: comment` + ${description} + This is based on [odata's filter specification](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter). + + Filters available: ${Object.keys(def).join(", ")} + `, + example: "(rating gt 75 and genres has action) or status eq planned", + }), + ) + .Decode((filter) => { + return parseFilters(filter, def); + }) + .Encode(() => { + throw new Error("Can't encode filters"); + }); + +export const parseFilters = (filter: string | undefined, config: FilterDef) => { + if (!filter) return undefined; + const ret = expression.parse(filter); + if (!ret.isOk) { + throw new KErrorT(`Invalid filter: ${filter}.`, ret); + } + + return toDrizzle(ret.value, config); +}; diff --git a/api/src/models/utils/filters.ts b/api/src/models/utils/filters/parser.ts similarity index 100% rename from api/src/models/utils/filters.ts rename to api/src/models/utils/filters/parser.ts diff --git a/api/src/models/utils/filters-sql.ts b/api/src/models/utils/filters/to-sql.ts similarity index 58% rename from api/src/models/utils/filters-sql.ts rename to api/src/models/utils/filters/to-sql.ts index ad79ce9e6..ad551dd88 100644 --- a/api/src/models/utils/filters-sql.ts +++ b/api/src/models/utils/filters/to-sql.ts @@ -1,6 +1,5 @@ import { and, - type Column, eq, gt, gte, @@ -13,29 +12,9 @@ import { sql, } from "drizzle-orm"; import { comment } from "~/utils"; -import type { KError } from "../error"; -import { type Expression, expression, type Operator } from "./filters"; - -export type Filter = { - [key: string]: - | { - column: Column; - type: "int" | "float" | "date" | "string"; - isArray?: boolean; - } - | { column: Column; type: "enum"; values: string[]; isArray?: boolean }; -}; - -export const parseFilters = (filter: string | undefined, config: Filter) => { - if (!filter) return undefined; - const ret = expression.parse(filter); - if (!ret.isOk) { - throw new Error("todo"); - // return { status: 422, message: `Invalid filter: ${filter}.`, details: ret } - } - - return toDrizzle(ret.value, config); -}; +import type { FilterDef } from "./index"; +import type { Expression, Operator } from "./parser"; +import { KErrorT } from "~/models/error"; const opMap: Record = { eq: eq, @@ -47,58 +26,54 @@ const opMap: Record = { has: eq, }; -const toDrizzle = (expr: Expression, config: Filter): SQL | KError => { +export const toDrizzle = (expr: Expression, config: FilterDef): SQL => { switch (expr.type) { case "op": { - const where = `${expr.property} ${expr.operator} ${expr.value}`; + const where = `${expr.property} ${expr.operator} ${expr.value.value}`; const prop = config[expr.property]; if (!prop) { - return { - status: 422, - message: comment` + throw new KErrorT( + comment` Invalid property: ${expr.property}. Expected one of ${Object.keys(config).join(", ")}. `, - details: { in: where }, - }; + { in: where }, + ); } if (prop.type !== expr.value.type) { - return { - status: 422, - message: comment` + throw new KErrorT( + comment` Invalid value for property ${expr.property}. Got ${expr.value.type} but expected ${prop.type}. `, - details: { in: where }, - }; + { in: where }, + ); } if ( prop.type === "enum" && (expr.value.type === "enum" || expr.value.type === "string") && !prop.values.includes(expr.value.value) ) { - return { - status: 422, - message: comment` + throw new KErrorT( + comment` Invalid value ${expr.value.value} for property ${expr.property}. Expected one of ${prop.values.join(", ")} but got ${expr.value.value}. `, - details: { in: where }, - }; + { in: where }, + ); } if (prop.isArray) { if (expr.operator !== "has" && expr.operator !== "eq") { - return { - status: 422, - message: comment` + throw new KErrorT( + comment` Property ${expr.property} is an array but you wanted to use the operator ${expr.operator}. Only "has" is supported ("eq" is also aliased to "has") `, - details: { in: where }, - }; + { in: where }, + ); } return sql`${expr.value.value} = any(${prop.column})`; } @@ -107,20 +82,15 @@ const toDrizzle = (expr: Expression, config: Filter): SQL | KError => { case "and": { const lhs = toDrizzle(expr.lhs, config); const rhs = toDrizzle(expr.rhs, config); - if ("status" in lhs) return lhs; - if ("status" in rhs) return rhs; return and(lhs, rhs)!; } case "or": { const lhs = toDrizzle(expr.lhs, config); const rhs = toDrizzle(expr.rhs, config); - if ("status" in lhs) return lhs; - if ("status" in rhs) return rhs; return or(lhs, rhs)!; } case "not": { const lhs = toDrizzle(expr.expression, config); - if ("status" in lhs) return lhs; return not(lhs); } default: diff --git a/api/src/models/utils/index.ts b/api/src/models/utils/index.ts index 98134fc57..31076b9a4 100644 --- a/api/src/models/utils/index.ts +++ b/api/src/models/utils/index.ts @@ -3,3 +3,5 @@ export * from "./genres"; export * from "./image"; export * from "./language"; export * from "./resource"; +export * from "./filters"; +export * from "./page"; diff --git a/api/src/utils.ts b/api/src/utils.ts index 9e3e16d8b..78f8181f7 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -1,6 +1,8 @@ // remove indent in multi-line comments export const comment = (str: TemplateStringsArray, ...values: any[]) => - str.reduce((acc, str, i) => `${acc}${str}${values[i]}`).replace(/^[^\S\n]+/gm, ""); + str + .reduce((acc, str, i) => `${acc}${values[i - 1]}${str}`) + .replace(/(^[^\S\n]+|\s+$|^\s+)/gm, ""); export type RemovePrefix< T extends string, diff --git a/api/tests/misc/filter.test.ts b/api/tests/misc/filter.test.ts index d392158b5..d1630e88f 100644 --- a/api/tests/misc/filter.test.ts +++ b/api/tests/misc/filter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { ParjsFailure } from "parjs/internal"; -import { type Expression, expression } from "~/models/utils/filters"; +import { type Expression, expression } from "~/models/utils/filters/parser"; function parse( filter: string, diff --git a/api/tests/movies/get-movies.test.ts b/api/tests/movies/get-movies.test.ts index fca0d08aa..53a38f95a 100644 --- a/api/tests/movies/get-movies.test.ts +++ b/api/tests/movies/get-movies.test.ts @@ -23,6 +23,30 @@ const getMovie = async (id: string, langs?: string) => { const body = await resp.json(); return [resp, body] as const; }; +const getMovies = async ({ + langs, + ...query +}: { filter?: string; langs?: string }) => { + // const params = Object.entries(query).reduce( + // (acc, [param, value]) => `${param}=${value}&`, + // "?", + // ); + const resp = await app.handle( + new Request( + `http://localhost/movies?${new URLSearchParams(query).toString()}`, + { + method: "GET", + headers: langs + ? { + "Accept-Language": langs, + } + : {}, + }, + ), + ); + const body = await resp.json(); + return [resp, body] as const; +}; let bubbleId = ""; @@ -95,6 +119,25 @@ describe("Get movie", () => { }); }); +describe("Get all movies", () => { + it("Invalid filter params", async () => { + const [resp, body] = await getMovies({ + filter: `slug eq ${bubble.slug}`, + langs: "en", + }); + + expectStatus(resp, body).toBe(422); + expect(body).toMatchObject({ + status: 422, + message: + "Invalid property: slug.\nExpected one of genres, rating, status, runtime, airDate, originalLanguage.", + details: { + in: "slug eq bubble", + }, + }); + }); +}); + beforeAll(async () => { const ret = await seedMovie(bubble); bubbleId = ret.id; From 0499be41945ae9eaea88e6a750a6ec3ab66c4456 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 6 Jan 2025 01:31:34 +0100 Subject: [PATCH 092/105] Fix comment newline handling --- api/src/controllers/movies.ts | 9 +++++++-- api/src/models/utils/filters/index.ts | 4 ++-- api/src/utils.ts | 5 ++++- api/tests/movies/get-movies.test.ts | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index a73e1c3cb..aa0469356 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -136,7 +136,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) ...KError, description: comment` The Accept-Language header can't be satisfied (all languages listed are - unavailable). Try with another languages or add * to the list of languages + unavailable.) Try with another languages or add * to the list of languages to fallback to any language. `, examples: [ @@ -158,6 +158,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }) => { const langs = processLanguages(languages); const [transQ, transCol] = getTranslationQuery(langs); + // TODO: move this to typebox transform const order = sort.map((x) => { const desc = x[0] === "-"; const key = (desc ? x.substring(1) : x) as RemovePrefix; @@ -203,7 +204,11 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) "-nextRefresh", ]), // TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia - { explode: false, default: ["slug"] }, + { + explode: false, + default: ["slug"], + description: "How to sort the query", + }, ), filter: t.Optional(Filter({ def: movieFilters })), limit: t.Integer({ diff --git a/api/src/models/utils/filters/index.ts b/api/src/models/utils/filters/index.ts index 0255f7da4..22f19bce9 100644 --- a/api/src/models/utils/filters/index.ts +++ b/api/src/models/utils/filters/index.ts @@ -24,9 +24,9 @@ export const Filter = ({ t.String({ description: comment` ${description} - This is based on [odata's filter specification](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter). - Filters available: ${Object.keys(def).join(", ")} + This is based on [odata's filter specification](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter). + Filters available: ${Object.keys(def).join(", ")}. `, example: "(rating gt 75 and genres has action) or status eq planned", }), diff --git a/api/src/utils.ts b/api/src/utils.ts index 78f8181f7..e24a0ad61 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -2,7 +2,10 @@ export const comment = (str: TemplateStringsArray, ...values: any[]) => str .reduce((acc, str, i) => `${acc}${values[i - 1]}${str}`) - .replace(/(^[^\S\n]+|\s+$|^\s+)/gm, ""); + .replace(/(^\s)|(\s+$)/g, "") // first & last whitespaces + .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 type RemovePrefix< T extends string, diff --git a/api/tests/movies/get-movies.test.ts b/api/tests/movies/get-movies.test.ts index 53a38f95a..689a34c93 100644 --- a/api/tests/movies/get-movies.test.ts +++ b/api/tests/movies/get-movies.test.ts @@ -130,7 +130,7 @@ describe("Get all movies", () => { expect(body).toMatchObject({ status: 422, message: - "Invalid property: slug.\nExpected one of genres, rating, status, runtime, airDate, originalLanguage.", + "Invalid property: slug. Expected one of genres, rating, status, runtime, airDate, originalLanguage.", details: { in: "slug eq bubble", }, From 482ad0dda242047ec47c30c867029a974f69a971 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 6 Jan 2025 18:30:48 +0100 Subject: [PATCH 093/105] Move sort parsing to typebox --- api/src/controllers/movies.ts | 30 ++++++-------------- api/src/models/utils/sort.ts | 53 +++++++++++++++++++++++++++++++++++ api/src/utils.ts | 5 ---- 3 files changed, 62 insertions(+), 26 deletions(-) create mode 100644 api/src/models/utils/sort.ts diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index aa0469356..093ff70c4 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -7,13 +7,14 @@ import { isUuid, processLanguages, } from "~/models/utils"; -import { comment, type RemovePrefix } from "~/utils"; +import { comment } from "~/utils"; import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; import { getColumns } from "../db/schema/utils"; import { bubble } from "../models/examples"; import { Movie, MovieStatus, MovieTranslation } from "../models/movie"; import { Filter, type Page } from "~/models/utils"; +import { Sort } from "~/models/utils/sort"; // drizzle is bugged and doesn't allow js arrays to be used in raw sql. export function sqlarr(array: unknown[]) { @@ -158,15 +159,8 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }) => { const langs = processLanguages(languages); const [transQ, transCol] = getTranslationQuery(langs); - // TODO: move this to typebox transform - const order = sort.map((x) => { - const desc = x[0] === "-"; - const key = (desc ? x.substring(1) : x) as RemovePrefix; - if (key === "airDate") return { key: "startAir" as const, desc }; - return { key, desc }; - }); - // TODO: Add sql indexes on order keys + // TODO: Add sql indexes on sort keys const items = await db .select({ @@ -179,7 +173,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .innerJoin(transQ, eq(shows.pk, transQ.pk)) .where(filter) .orderBy( - ...order.map((x) => (x.desc ? desc(shows[x.key]) : shows[x.key])), + ...sort.map((x) => (x.desc ? desc(shows[x.key]) : shows[x.key])), shows.pk, ) .limit(limit); @@ -189,23 +183,17 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) { detail: { description: "Get all movies" }, query: t.Object({ - sort: t.Array( - // TODO: Add random - t.UnionEnum([ + sort: Sort( + [ "slug", - "-slug", "rating", - "-rating", "airDate", - "-airDate", "createdAt", - "-createdAt", "nextRefresh", - "-nextRefresh", - ]), - // TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia + ], { - explode: false, + // TODO: Add random + remap: { airDate: "startAir" }, default: ["slug"], description: "How to sort the query", }, diff --git a/api/src/models/utils/sort.ts b/api/src/models/utils/sort.ts new file mode 100644 index 000000000..5dcf2c964 --- /dev/null +++ b/api/src/models/utils/sort.ts @@ -0,0 +1,53 @@ +import { t } from "elysia"; + +type Sort< + T extends string[], + Remap extends Partial>, +> = { + key: Exclude | Remap[keyof Remap]; + desc: boolean; +}[]; + +type NonEmptyArray = [T, ...T[]]; + +export const Sort = < + const T extends NonEmptyArray, + const Remap extends Partial>, +>( + values: T, + { + description = "How to sort the query", + default: def, + remap, + }: { + default?: T[number][]; + description: string; + remap: Remap; + }, +) => + t + .Transform( + t.Array( + t.UnionEnum([ + ...values, + ...values.map((x: T[number]) => `-${x}` as const), + ]), + { + // TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia + explode: false, + default: def, + description: description, + }, + ), + ) + .Decode((sort): Sort => { + return sort.map((x) => { + const desc = x[0] === "-"; + const key = (desc ? x.substring(1) : x) as T[number]; + if (key in remap) return { key: remap[key], desc }; + return { key: key as Exclude, desc }; + }); + }) + .Encode(() => { + throw new Error("Encode not supported for sort"); + }); diff --git a/api/src/utils.ts b/api/src/utils.ts index e24a0ad61..00c2f5ab7 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -6,8 +6,3 @@ 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 type RemovePrefix< - T extends string, - Prefix extends string, -> = T extends `${Prefix}${infer Ret}` ? Ret : T; From 50002907e32f5e8584659a8afa60579af6cc5d3d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 6 Jan 2025 21:44:25 +0100 Subject: [PATCH 094/105] Create keyset pagination function --- api/src/controllers/movies.ts | 47 +++++++++++-------------- api/src/db/schema/utils.ts | 2 +- api/src/models/utils/index.ts | 2 ++ api/src/models/utils/keyset-paginate.ts | 47 +++++++++++++++++++++++++ api/src/models/utils/sort.ts | 8 ++--- 5 files changed, 75 insertions(+), 31 deletions(-) create mode 100644 api/src/models/utils/keyset-paginate.ts diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 093ff70c4..3633ea855 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -1,20 +1,23 @@ import { and, desc, eq, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { KError } from "~/models/error"; -import { - type FilterDef, - Genre, - isUuid, - processLanguages, -} from "~/models/utils"; import { comment } from "~/utils"; import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; import { getColumns } from "../db/schema/utils"; import { bubble } from "../models/examples"; import { Movie, MovieStatus, MovieTranslation } from "../models/movie"; -import { Filter, type Page } from "~/models/utils"; -import { Sort } from "~/models/utils/sort"; +import { + Filter, + Sort, + type FilterDef, + Genre, + isUuid, + keysetPaginate, + processLanguages, + type Page, + createPage, +} from "~/models/utils"; // drizzle is bugged and doesn't allow js arrays to be used in raw sql. export function sqlarr(array: unknown[]) { @@ -156,6 +159,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) async ({ query: { limit, after, sort, filter }, headers: { "accept-language": languages }, + request: { url }, }) => { const langs = processLanguages(languages); const [transQ, transCol] = getTranslationQuery(langs); @@ -171,33 +175,24 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }) .from(shows) .innerJoin(transQ, eq(shows.pk, transQ.pk)) - .where(filter) + .where(and(filter, keysetPaginate({ table: shows, after, sort }))) .orderBy( ...sort.map((x) => (x.desc ? desc(shows[x.key]) : shows[x.key])), shows.pk, ) .limit(limit); - return { items, next: "", prev: "", this: "" }; + return createPage(items, { url, sort }); }, { detail: { description: "Get all movies" }, query: t.Object({ - sort: Sort( - [ - "slug", - "rating", - "airDate", - "createdAt", - "nextRefresh", - ], - { - // TODO: Add random - remap: { airDate: "startAir" }, - default: ["slug"], - description: "How to sort the query", - }, - ), + sort: Sort(["slug", "rating", "airDate", "createdAt", "nextRefresh"], { + // TODO: Add random + remap: { airDate: "startAir" }, + default: ["slug"], + description: "How to sort the query", + }), filter: t.Optional(Filter({ def: movieFilters })), limit: t.Integer({ minimum: 1, @@ -207,7 +202,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }), after: t.Optional( t.String({ - format: "uuid", + format: "byte", description: comment` Id of the cursor in the pagination. You can ignore this and only use the prev/next field in the response. diff --git a/api/src/db/schema/utils.ts b/api/src/db/schema/utils.ts index e7963287d..dae8a8011 100644 --- a/api/src/db/schema/utils.ts +++ b/api/src/db/schema/utils.ts @@ -69,7 +69,7 @@ export function conflictUpdateAllExcept< return updateColumns.reduce( (acc, [colName, col]) => { - // @ts-ignore: drizzle internal + // @ts-expect-error: drizzle internal const name = (db.dialect.casing as CasingCache).getColumnCasing(col); acc[colName as keyof typeof acc] = sql.raw(`excluded."${name}"`); return acc; diff --git a/api/src/models/utils/index.ts b/api/src/models/utils/index.ts index 31076b9a4..70c086260 100644 --- a/api/src/models/utils/index.ts +++ b/api/src/models/utils/index.ts @@ -5,3 +5,5 @@ export * from "./language"; export * from "./resource"; export * from "./filters"; export * from "./page"; +export * from "./sort"; +export * from "./keyset-paginate"; diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts new file mode 100644 index 000000000..f083d92af --- /dev/null +++ b/api/src/models/utils/keyset-paginate.ts @@ -0,0 +1,47 @@ +import type { NonEmptyArray, Sort } from "./sort"; +import { eq, or, type Column, and, gt, lt } from "drizzle-orm"; + +type Table = Record; + +// Create a filter (where) expression on the query to skip everything before/after the referenceID. +// The generalized expression for this in pseudocode is: +// (x > a) OR +// (x = a AND y > b) OR +// (x = a AND y = b AND z > c) OR... +// +// Of course, this will be a bit more complex when ASC and DESC are mixed. +// Assume x is ASC, y is DESC, and z is ASC: +// (x > a) OR +// (x = a AND y < b) OR +// (x = a AND y = b AND z > c) OR... +export const keysetPaginate = < + const T extends NonEmptyArray, + const Remap extends Partial>, +>({ + table, + sort, + after, +}: { + table: Table<"pk" | Sort[number]["key"]>; + after: string | undefined; + sort: Sort; +}) => { + if (!after) return undefined; + const cursor: Record = JSON.parse( + Buffer.from(after, "base64").toString("utf-8"), + ); + + // TODO: Add an outer query >= for perf + // PERF: See https://use-the-index-luke.com/sql/partial-results/fetch-next-page#sb-equivalent-logic + let where = undefined; + let previous = undefined; + for (const by of [...sort, { key: "pk" as const, desc: false }]) { + const cmp = by.desc ? lt : gt; + where = or(where, and(previous, cmp(table[by.key], cursor[by.key]))); + previous = and(previous, eq(table[by.key], cursor[by.key])); + } + + return where; +}; + + diff --git a/api/src/models/utils/sort.ts b/api/src/models/utils/sort.ts index 5dcf2c964..fe4358f07 100644 --- a/api/src/models/utils/sort.ts +++ b/api/src/models/utils/sort.ts @@ -1,14 +1,14 @@ import { t } from "elysia"; -type Sort< +export type Sort< T extends string[], Remap extends Partial>, > = { - key: Exclude | Remap[keyof Remap]; + key: Exclude | NonNullable; desc: boolean; }[]; -type NonEmptyArray = [T, ...T[]]; +export type NonEmptyArray = [T, ...T[]]; export const Sort = < const T extends NonEmptyArray, @@ -44,7 +44,7 @@ export const Sort = < return sort.map((x) => { const desc = x[0] === "-"; const key = (desc ? x.substring(1) : x) as T[number]; - if (key in remap) return { key: remap[key], desc }; + if (key in remap) return { key: remap[key]!, desc }; return { key: key as Exclude, desc }; }); }) From 879d2959d5bafddd5429140a484fd4dfba2d2767 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 6 Jan 2025 22:05:13 +0100 Subject: [PATCH 095/105] Create page with next/prev url --- api/src/controllers/movies.ts | 34 ++++++++++++------------- api/src/models/utils/keyset-paginate.ts | 20 ++++++++++++--- api/src/models/utils/page.ts | 25 ++++++++++++++++-- 3 files changed, 56 insertions(+), 23 deletions(-) diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 3633ea855..258aa7ad2 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -14,8 +14,8 @@ import { Genre, isUuid, keysetPaginate, + Page, processLanguages, - type Page, createPage, } from "~/models/utils"; @@ -147,7 +147,6 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) { status: 422, message: "Accept-Language header could not be satisfied.", - details: undefined, }, ], }, @@ -222,21 +221,20 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) `, }), }), - // response: { - // 200: Page(Movie, { - // description: "Paginated list of movies that match filters.", - // }), - // 422: { - // ...KError, - // description: "Invalid query parameters.", - // examples: [ - // { - // status: 422, - // message: "Accept-Language header could not be satisfied.", - // details: undefined, - // }, - // ], - // }, - // }, + response: { + 200: Page(Movie, { + description: "Paginated list of movies that match filters.", + }), + 422: { + ...KError, + description: "Invalid query parameters.", + examples: [ + { + status: 422, + message: "Accept-Language header could not be satisfied.", + }, + ], + }, + }, }, ); diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts index f083d92af..d6710af33 100644 --- a/api/src/models/utils/keyset-paginate.ts +++ b/api/src/models/utils/keyset-paginate.ts @@ -3,6 +3,10 @@ import { eq, or, type Column, and, gt, lt } from "drizzle-orm"; type Table = Record; +type After = Record & { + reverse?: boolean; +}; + // Create a filter (where) expression on the query to skip everything before/after the referenceID. // The generalized expression for this in pseudocode is: // (x > a) OR @@ -27,7 +31,7 @@ export const keysetPaginate = < sort: Sort; }) => { if (!after) return undefined; - const cursor: Record = JSON.parse( + const { reverse, ...cursor }: After = JSON.parse( Buffer.from(after, "base64").toString("utf-8"), ); @@ -36,7 +40,7 @@ export const keysetPaginate = < let where = undefined; let previous = undefined; for (const by of [...sort, { key: "pk" as const, desc: false }]) { - const cmp = by.desc ? lt : gt; + const cmp = by.desc !== reverse ? lt : gt; where = or(where, and(previous, cmp(table[by.key], cursor[by.key]))); previous = and(previous, eq(table[by.key], cursor[by.key])); } @@ -44,4 +48,14 @@ export const keysetPaginate = < return where; }; - +export const generateAfter = ( + cursor: any, + sort: Sort, + reverse?: boolean, +) => { + const ret: After = { reverse }; + for (const by of sort) { + ret[by.key] = cursor[by.key]; + } + return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64"); +}; diff --git a/api/src/models/utils/page.ts b/api/src/models/utils/page.ts index a41b8504b..2a8773b84 100644 --- a/api/src/models/utils/page.ts +++ b/api/src/models/utils/page.ts @@ -1,13 +1,34 @@ import type { ObjectOptions } from "@sinclair/typebox"; import { t, type TSchema } from "elysia"; +import type { Sort } from "./sort"; +import { generateAfter } from "./keyset-paginate"; export const Page = (schema: T, options?: ObjectOptions) => t.Object( { items: t.Array(schema), this: t.String({ format: "uri" }), - prev: t.String({ format: "uri" }), - next: t.String({ format: "uri" }), + prev: t.Nullable(t.String({ format: "uri" })), + next: t.Nullable(t.String({ format: "uri" })), }, options, ); + +export const createPage = ( + items: T[], + { url, sort }: { url: string; sort: Sort }, +) => { + let prev: string | null = null; + let next: string | null = null; + const uri = new URL(url); + + if (uri.searchParams.has("after")) { + uri.searchParams.set("after", generateAfter(items[0], sort, true)); + prev = uri.toString(); + } + if (items.length) { + uri.searchParams.set("after", generateAfter(items[items.length - 1], sort)); + next = uri.toString(); + } + return { items, this: url, prev, next }; +}; From 641ce4237ef3fec6752f097a695d81d2949e6920 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 7 Jan 2025 16:28:37 +0100 Subject: [PATCH 096/105] Use an array as a backing store for after --- api/src/models/utils/keyset-paginate.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts index d6710af33..81610499f 100644 --- a/api/src/models/utils/keyset-paginate.ts +++ b/api/src/models/utils/keyset-paginate.ts @@ -3,9 +3,7 @@ import { eq, or, type Column, and, gt, lt } from "drizzle-orm"; type Table = Record; -type After = Record & { - reverse?: boolean; -}; +type After = [boolean, ...[string | number | boolean | undefined]]; // Create a filter (where) expression on the query to skip everything before/after the referenceID. // The generalized expression for this in pseudocode is: @@ -31,18 +29,20 @@ export const keysetPaginate = < sort: Sort; }) => { if (!after) return undefined; - const { reverse, ...cursor }: After = JSON.parse( + const [reverse, ...cursor]: After = JSON.parse( Buffer.from(after, "base64").toString("utf-8"), ); + const pkSort = { key: "pk" as const, desc: false }; + // TODO: Add an outer query >= for perf // PERF: See https://use-the-index-luke.com/sql/partial-results/fetch-next-page#sb-equivalent-logic let where = undefined; let previous = undefined; - for (const by of [...sort, { key: "pk" as const, desc: false }]) { + for (const [i, by] of [...sort, pkSort].entries()) { const cmp = by.desc !== reverse ? lt : gt; - where = or(where, and(previous, cmp(table[by.key], cursor[by.key]))); - previous = and(previous, eq(table[by.key], cursor[by.key])); + where = or(where, and(previous, cmp(table[by.key], cursor[i]))); + previous = and(previous, eq(table[by.key], cursor[i])); } return where; @@ -53,9 +53,10 @@ export const generateAfter = ( sort: Sort, reverse?: boolean, ) => { - const ret: After = { reverse }; - for (const by of sort) { - ret[by.key] = cursor[by.key]; - } + const ret = [ + reverse ?? false, + ...sort.map((by) => cursor[by.key]), + cursor.pk, + ]; return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64"); }; From 1389abb94625260ac3b5dc4028679c750b59ee4d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 7 Jan 2025 17:28:43 +0100 Subject: [PATCH 097/105] Fix prev/next generation --- api/src/controllers/movies.ts | 6 +++--- api/src/models/utils/keyset-paginate.ts | 5 ++++- api/src/models/utils/page.ts | 18 ++++++++++++++---- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 258aa7ad2..9bdc8ad1c 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -44,7 +44,8 @@ const getTranslationQuery = (languages: string[]) => { return [query, col] as const; }; -const { pk: _, kind, startAir, endAir, ...moviesCol } = getColumns(shows); +// we keep the pk for after handling. it will be removed by elysia's validators after. +const { kind, startAir, endAir, ...moviesCol } = getColumns(shows); const movieFilters: FilterDef = { genres: { @@ -181,7 +182,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) ) .limit(limit); - return createPage(items, { url, sort }); + return createPage(items, { url, sort, limit }); }, { detail: { description: "Get all movies" }, @@ -201,7 +202,6 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }), after: t.Optional( t.String({ - format: "byte", description: comment` Id of the cursor in the pagination. You can ignore this and only use the prev/next field in the response. diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts index 81610499f..6ee144379 100644 --- a/api/src/models/utils/keyset-paginate.ts +++ b/api/src/models/utils/keyset-paginate.ts @@ -58,5 +58,8 @@ export const generateAfter = ( ...sort.map((by) => cursor[by.key]), cursor.pk, ]; - return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64"); + return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64url"); }; + +const reverseStart = Buffer.from("[true,", "utf-8").toString("base64url"); +export const isReverse = (x: string) => x.startsWith(reverseStart); diff --git a/api/src/models/utils/page.ts b/api/src/models/utils/page.ts index 2a8773b84..3e3df633a 100644 --- a/api/src/models/utils/page.ts +++ b/api/src/models/utils/page.ts @@ -1,7 +1,7 @@ import type { ObjectOptions } from "@sinclair/typebox"; import { t, type TSchema } from "elysia"; import type { Sort } from "./sort"; -import { generateAfter } from "./keyset-paginate"; +import { generateAfter, isReverse } from "./keyset-paginate"; export const Page = (schema: T, options?: ObjectOptions) => t.Object( @@ -16,17 +16,27 @@ export const Page = (schema: T, options?: ObjectOptions) => export const createPage = ( items: T[], - { url, sort }: { url: string; sort: Sort }, + { url, sort, limit }: { url: string; sort: Sort; limit: number }, ) => { let prev: string | null = null; let next: string | null = null; + const uri = new URL(url); + const after = uri.searchParams.get("after"); + const reverse = after && isReverse(after) ? 1 : 0; + + const has = [ + // prev + items.length > 0 && after, + // next + items.length === limit && limit > 0, + ]; - if (uri.searchParams.has("after")) { + if (has[0 + reverse]) { uri.searchParams.set("after", generateAfter(items[0], sort, true)); prev = uri.toString(); } - if (items.length) { + if (has[1 - reverse]) { uri.searchParams.set("after", generateAfter(items[items.length - 1], sort)); next = uri.toString(); } From 371d9148f43b66af95fc8b0a2e2503e02ab574af Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 8 Jan 2025 18:23:30 +0100 Subject: [PATCH 098/105] wip --- api/tests/movies/get-all-movies.test.ts | 108 ++++++++++++++++++++++++ api/tests/movies/get-movies.test.ts | 43 ---------- 2 files changed, 108 insertions(+), 43 deletions(-) create mode 100644 api/tests/movies/get-all-movies.test.ts diff --git a/api/tests/movies/get-all-movies.test.ts b/api/tests/movies/get-all-movies.test.ts new file mode 100644 index 000000000..1b76eea1d --- /dev/null +++ b/api/tests/movies/get-all-movies.test.ts @@ -0,0 +1,108 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; +import Elysia from "elysia"; +import { base } from "~/base"; +import { movies } from "~/controllers/movies"; +import { seedMovie } from "~/controllers/seed/movies"; +import { db } from "~/db"; +import { shows } from "~/db/schema"; +import { bubble } from "~/models/examples"; +import { dune1984 } from "~/models/examples/dune-1984"; +import { dune } from "~/models/examples/dune-2021"; + +const app = new Elysia().use(base).use(movies); +const getMovies = async ({ + langs, + ...query +}: { + filter?: string; + limit?: number; + after?: string; + sort?: string[]; + langs?: string; +}) => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (!Array.isArray(value)) { + params.append(key, value.toString()); + continue; + } + for (const v of value) params.append(key, v.toString()); + } + + const resp = await app.handle( + new Request(`http://localhost/movies?${params}`, { + method: "GET", + headers: langs + ? { + "Accept-Language": langs, + } + : {}, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; + +function expectStatus(resp: Response, body: object) { + const matcher = expect({ ...body, status: resp.status }); + return { + toBe: (status: number) => { + matcher.toMatchObject({ status: status }); + }, + }; +} + +describe("Get all movies", () => { + it("Invalid filter params", async () => { + const [resp, body] = await getMovies({ + filter: `slug eq ${bubble.slug}`, + langs: "en", + }); + + expectStatus(resp, body).toBe(422); + expect(body).toMatchObject({ + status: 422, + message: + "Invalid property: slug. Expected one of genres, rating, status, runtime, airDate, originalLanguage.", + details: { + in: "slug eq bubble", + }, + }); + }); + it("Invalid filter syntax", async () => { + const [resp, body] = await getMovies({ + filter: `slug eq gt ${bubble.slug}`, + langs: "en", + }); + + expectStatus(resp, body).toBe(422); + expect(body).toMatchObject({ + details: expect.objectContaining( { + in: "slug eq gt bubble", + }), + message: "Invalid filter: slug eq gt bubble.", + status: 422, + }); + }); + it("Limit 1, default sort", async () => { + const [resp, body] = await getMovies({ + limit: 1, + langs: "en", + }); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + items: [bubble], + this: "", + }); + }); +}); + +beforeAll(async () => { + await db.delete(shows); + for (const movie of [bubble, dune1984, dune]) await seedMovie(movie); +}); +afterAll(async () => { + await db.delete(shows); +}); diff --git a/api/tests/movies/get-movies.test.ts b/api/tests/movies/get-movies.test.ts index 689a34c93..fca0d08aa 100644 --- a/api/tests/movies/get-movies.test.ts +++ b/api/tests/movies/get-movies.test.ts @@ -23,30 +23,6 @@ const getMovie = async (id: string, langs?: string) => { const body = await resp.json(); return [resp, body] as const; }; -const getMovies = async ({ - langs, - ...query -}: { filter?: string; langs?: string }) => { - // const params = Object.entries(query).reduce( - // (acc, [param, value]) => `${param}=${value}&`, - // "?", - // ); - const resp = await app.handle( - new Request( - `http://localhost/movies?${new URLSearchParams(query).toString()}`, - { - method: "GET", - headers: langs - ? { - "Accept-Language": langs, - } - : {}, - }, - ), - ); - const body = await resp.json(); - return [resp, body] as const; -}; let bubbleId = ""; @@ -119,25 +95,6 @@ describe("Get movie", () => { }); }); -describe("Get all movies", () => { - it("Invalid filter params", async () => { - const [resp, body] = await getMovies({ - filter: `slug eq ${bubble.slug}`, - langs: "en", - }); - - expectStatus(resp, body).toBe(422); - expect(body).toMatchObject({ - status: 422, - message: - "Invalid property: slug. Expected one of genres, rating, status, runtime, airDate, originalLanguage.", - details: { - in: "slug eq bubble", - }, - }); - }); -}); - beforeAll(async () => { const ret = await seedMovie(bubble); bubbleId = ret.id; From 6e293efc2b3ac445d57b4b00575a1fa2c5cbd9d9 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 9 Jan 2025 22:34:32 +0100 Subject: [PATCH 099/105] Remove page's prev & weird reverse handling --- api/src/models/utils/keyset-paginate.ts | 21 ++++---------- api/src/models/utils/page.ts | 24 +++------------- api/tests/movies/get-all-movies.test.ts | 38 ++++++++++++++++++++----- api/tests/movies/seed-movies.test.ts | 4 +-- 4 files changed, 42 insertions(+), 45 deletions(-) diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts index 6ee144379..0c08db34e 100644 --- a/api/src/models/utils/keyset-paginate.ts +++ b/api/src/models/utils/keyset-paginate.ts @@ -3,7 +3,7 @@ import { eq, or, type Column, and, gt, lt } from "drizzle-orm"; type Table = Record; -type After = [boolean, ...[string | number | boolean | undefined]]; +type After = (string | number | boolean | undefined)[]; // Create a filter (where) expression on the query to skip everything before/after the referenceID. // The generalized expression for this in pseudocode is: @@ -29,7 +29,7 @@ export const keysetPaginate = < sort: Sort; }) => { if (!after) return undefined; - const [reverse, ...cursor]: After = JSON.parse( + const cursor: After = JSON.parse( Buffer.from(after, "base64").toString("utf-8"), ); @@ -40,7 +40,7 @@ export const keysetPaginate = < let where = undefined; let previous = undefined; for (const [i, by] of [...sort, pkSort].entries()) { - const cmp = by.desc !== reverse ? lt : gt; + const cmp = by.desc ? lt : gt; where = or(where, and(previous, cmp(table[by.key], cursor[i]))); previous = and(previous, eq(table[by.key], cursor[i])); } @@ -48,18 +48,7 @@ export const keysetPaginate = < return where; }; -export const generateAfter = ( - cursor: any, - sort: Sort, - reverse?: boolean, -) => { - const ret = [ - reverse ?? false, - ...sort.map((by) => cursor[by.key]), - cursor.pk, - ]; +export const generateAfter = (cursor: any, sort: Sort) => { + const ret = [...sort.map((by) => cursor[by.key]), cursor.pk]; return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64url"); }; - -const reverseStart = Buffer.from("[true,", "utf-8").toString("base64url"); -export const isReverse = (x: string) => x.startsWith(reverseStart); diff --git a/api/src/models/utils/page.ts b/api/src/models/utils/page.ts index 3e3df633a..a533dcfad 100644 --- a/api/src/models/utils/page.ts +++ b/api/src/models/utils/page.ts @@ -1,14 +1,13 @@ import type { ObjectOptions } from "@sinclair/typebox"; import { t, type TSchema } from "elysia"; import type { Sort } from "./sort"; -import { generateAfter, isReverse } from "./keyset-paginate"; +import { generateAfter } from "./keyset-paginate"; export const Page = (schema: T, options?: ObjectOptions) => t.Object( { items: t.Array(schema), this: t.String({ format: "uri" }), - prev: t.Nullable(t.String({ format: "uri" })), next: t.Nullable(t.String({ format: "uri" })), }, options, @@ -18,27 +17,12 @@ export const createPage = ( items: T[], { url, sort, limit }: { url: string; sort: Sort; limit: number }, ) => { - let prev: string | null = null; let next: string | null = null; - const uri = new URL(url); - const after = uri.searchParams.get("after"); - const reverse = after && isReverse(after) ? 1 : 0; - - const has = [ - // prev - items.length > 0 && after, - // next - items.length === limit && limit > 0, - ]; - - if (has[0 + reverse]) { - uri.searchParams.set("after", generateAfter(items[0], sort, true)); - prev = uri.toString(); - } - if (has[1 - reverse]) { + if (items.length === limit && limit > 0) { + const uri = new URL(url); uri.searchParams.set("after", generateAfter(items[items.length - 1], sort)); next = uri.toString(); } - return { items, this: url, prev, next }; + return { items, this: url, next }; }; diff --git a/api/tests/movies/get-all-movies.test.ts b/api/tests/movies/get-all-movies.test.ts index 1b76eea1d..c3ee89a6a 100644 --- a/api/tests/movies/get-all-movies.test.ts +++ b/api/tests/movies/get-all-movies.test.ts @@ -78,23 +78,47 @@ describe("Get all movies", () => { expectStatus(resp, body).toBe(422); expect(body).toMatchObject({ - details: expect.objectContaining( { - in: "slug eq gt bubble", - }), + details: expect.anything(), message: "Invalid filter: slug eq gt bubble.", status: 422, }); }); - it("Limit 1, default sort", async () => { + it("Limit 2, default sort", async () => { const [resp, body] = await getMovies({ - limit: 1, + limit: 2, langs: "en", }); expectStatus(resp, body).toBe(200); expect(body).toMatchObject({ - items: [bubble], - this: "", + items: [ + expect.objectContaining({ slug: bubble.slug }), + expect.objectContaining({ slug: dune.slug }), + ], + this: "http://localhost/movies?limit=2", + // we can't have the exact after since it contains the pk that changes with every tests. + next: expect.stringContaining( + "http://localhost/movies?limit=2&after=WyJkdW5lIiw0", + ), + }); + }); + it("Limit 2, default sort, page 2", async () => { + let [resp, body] = await getMovies({ + limit: 2, + langs: "en", + }); + expectStatus(resp, body).toBe(200); + + resp = await app.handle(new Request(body.next)); + body = await resp.json(); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + items: [expect.objectContaining({ slug: dune1984.slug })], + this: expect.stringContaining( + "http://localhost/movies?limit=2&after=WyJkdW5lIiw0", + ), + next: null, }); }); }); diff --git a/api/tests/movies/seed-movies.test.ts b/api/tests/movies/seed-movies.test.ts index b3e1aba41..8e950fe89 100644 --- a/api/tests/movies/seed-movies.test.ts +++ b/api/tests/movies/seed-movies.test.ts @@ -110,7 +110,7 @@ describe("Movie seeding", () => { expect(existing).toMatchObject({ slug: dune.slug, startAir: dune.airDate }); const [resp, body] = await createMovie({ ...dune, airDate: "2158-12-13" }); - expectStatus(resp, body).toBe(200); + expectStatus(resp, body).toBe(201); expect(body.id).toBeString(); expect(body.slug).toBe("dune-2158"); }); @@ -224,7 +224,7 @@ describe("Movie seeding", () => { }); const cleanup = async () => { - await db.delete(shows).where(inArray(shows.slug, [dune.slug])); + await db.delete(shows).where(inArray(shows.slug, [dune.slug, "dune-2158"])); await db.delete(videos).where(inArray(videos.id, [duneVideo.id])); }; // cleanup db beforehand to unsure tests are consistent From 81958f6c3bdf57488d86e748297bd56587f0fc64 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 9 Jan 2025 23:27:47 +0100 Subject: [PATCH 100/105] Fix `after` when sorting with remmapped keys --- api/src/models/utils/keyset-paginate.ts | 5 +++- api/src/models/utils/sort.ts | 3 +- api/tests/movies/get-all-movies.test.ts | 39 ++++++++++++++++++++++--- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts index 0c08db34e..a5a7dc431 100644 --- a/api/src/models/utils/keyset-paginate.ts +++ b/api/src/models/utils/keyset-paginate.ts @@ -49,6 +49,9 @@ export const keysetPaginate = < }; export const generateAfter = (cursor: any, sort: Sort) => { - const ret = [...sort.map((by) => cursor[by.key]), cursor.pk]; + const ret = [ + ...sort.map((by) => cursor[by.remmapedKey ?? by.key]), + cursor.pk, + ]; return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64url"); }; diff --git a/api/src/models/utils/sort.ts b/api/src/models/utils/sort.ts index fe4358f07..650ffb795 100644 --- a/api/src/models/utils/sort.ts +++ b/api/src/models/utils/sort.ts @@ -5,6 +5,7 @@ export type Sort< Remap extends Partial>, > = { key: Exclude | NonNullable; + remmapedKey?: keyof Remap; desc: boolean; }[]; @@ -44,7 +45,7 @@ export const Sort = < return sort.map((x) => { const desc = x[0] === "-"; const key = (desc ? x.substring(1) : x) as T[number]; - if (key in remap) return { key: remap[key]!, desc }; + if (key in remap) return { key: remap[key]!, remmapedKey: key, desc }; return { key: key as Exclude, desc }; }); }) diff --git a/api/tests/movies/get-all-movies.test.ts b/api/tests/movies/get-all-movies.test.ts index c3ee89a6a..9e3aa5bc9 100644 --- a/api/tests/movies/get-all-movies.test.ts +++ b/api/tests/movies/get-all-movies.test.ts @@ -1,5 +1,4 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { eq } from "drizzle-orm"; import Elysia from "elysia"; import { base } from "~/base"; import { movies } from "~/controllers/movies"; @@ -18,7 +17,7 @@ const getMovies = async ({ filter?: string; limit?: number; after?: string; - sort?: string[]; + sort?: string | string[]; langs?: string; }) => { const params = new URLSearchParams(); @@ -98,7 +97,7 @@ describe("Get all movies", () => { this: "http://localhost/movies?limit=2", // we can't have the exact after since it contains the pk that changes with every tests. next: expect.stringContaining( - "http://localhost/movies?limit=2&after=WyJkdW5lIiw0", + "http://localhost/movies?limit=2&after=WyJkdW5lIiw", ), }); }); @@ -116,11 +115,43 @@ describe("Get all movies", () => { expect(body).toMatchObject({ items: [expect.objectContaining({ slug: dune1984.slug })], this: expect.stringContaining( - "http://localhost/movies?limit=2&after=WyJkdW5lIiw0", + "http://localhost/movies?limit=2&after=WyJkdW5lIiw", ), next: null, }); }); + it("Limit 2, sort by dates desc, page 2", async () => { + let [resp, body] = await getMovies({ + limit: 2, + sort: "-airDate", + langs: "en", + }); + expectStatus(resp, body).toBe(200); + + // we copy this due to https://github.com/oven-sh/bun/issues/3521 + const next = body.next; + expect(body).toMatchObject({ + items: [ + expect.objectContaining({ slug: bubble.slug, airDate: bubble.airDate }), + expect.objectContaining({ slug: dune.slug, airDate: dune.airDate }), + ], + this: "http://localhost/movies?limit=2&sort=-airDate", + next: expect.stringContaining( + "http://localhost/movies?limit=2&sort=-airDate&after=WyIyMDIxLTEwLTIyIiw", + ), + }); + + resp = await app.handle(new Request(next)); + body = await resp.json(); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + items: [expect.objectContaining({ slug: dune1984.slug })], + this: next, + next: null, + }); + }); + // TODO: sort with an item that has null in it. We want it to always be last (in both asc & desc). }); beforeAll(async () => { From 0555fcb9a58d6db67d4908f78b69bd20dea58573 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 9 Jan 2025 23:50:13 +0100 Subject: [PATCH 101/105] Handle forced fallback on /movies --- api/src/controllers/movies.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 9bdc8ad1c..4f14221b1 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -24,8 +24,8 @@ export function sqlarr(array: unknown[]) { return `{${array.map((item) => `"${item}"`).join(",")}}`; } -const getTranslationQuery = (languages: string[]) => { - const fallback = languages.includes("*"); +const getTranslationQuery = (languages: string[], forceFallback = false) => { + const fallback = forceFallback || languages.includes("*"); const query = db .selectDistinctOn([showTranslations.pk]) .from(showTranslations) @@ -162,7 +162,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) request: { url }, }) => { const langs = processLanguages(languages); - const [transQ, transCol] = getTranslationQuery(langs); + const [transQ, transCol] = getTranslationQuery(langs, true); // TODO: Add sql indexes on sort keys @@ -231,7 +231,11 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) examples: [ { status: 422, - message: "Accept-Language header could not be satisfied.", + message: + "Invalid property: slug. Expected one of genres, rating, status, runtime, airDate, originalLanguage.", + details: { + in: "slug eq bubble", + }, }, ], }, From e78f28ea71172a5fad7dd5502ebd532da115a391 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 10 Jan 2025 00:25:47 +0100 Subject: [PATCH 102/105] Add test helpers --- api/tests/movies/get-all-movies.test.ts | 74 +++++-------------- .../{get-movies.test.ts => get-movie.test.ts} | 44 +++-------- api/tests/movies/movies-helper.ts | 61 +++++++++++++++ api/tests/movies/seed-movies.test.ts | 30 +------- api/tests/utils.ts | 23 ++++++ 5 files changed, 112 insertions(+), 120 deletions(-) rename api/tests/movies/{get-movies.test.ts => get-movie.test.ts} (75%) create mode 100644 api/tests/movies/movies-helper.ts create mode 100644 api/tests/utils.ts diff --git a/api/tests/movies/get-all-movies.test.ts b/api/tests/movies/get-all-movies.test.ts index 9e3aa5bc9..09adc4572 100644 --- a/api/tests/movies/get-all-movies.test.ts +++ b/api/tests/movies/get-all-movies.test.ts @@ -1,56 +1,20 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import Elysia from "elysia"; -import { base } from "~/base"; -import { movies } from "~/controllers/movies"; +import { expectStatus } from "tests/utils"; import { seedMovie } from "~/controllers/seed/movies"; import { db } from "~/db"; 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 { getMovies, movieApp } from "./movies-helper"; -const app = new Elysia().use(base).use(movies); -const getMovies = async ({ - langs, - ...query -}: { - filter?: string; - limit?: number; - after?: string; - sort?: string | string[]; - langs?: string; -}) => { - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(query)) { - if (!Array.isArray(value)) { - params.append(key, value.toString()); - continue; - } - for (const v of value) params.append(key, v.toString()); - } - - const resp = await app.handle( - new Request(`http://localhost/movies?${params}`, { - method: "GET", - headers: langs - ? { - "Accept-Language": langs, - } - : {}, - }), - ); - const body = await resp.json(); - return [resp, body] as const; -}; - -function expectStatus(resp: Response, body: object) { - const matcher = expect({ ...body, status: resp.status }); - return { - toBe: (status: number) => { - matcher.toMatchObject({ status: status }); - }, - }; -} +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 () => { @@ -108,7 +72,7 @@ describe("Get all movies", () => { }); expectStatus(resp, body).toBe(200); - resp = await app.handle(new Request(body.next)); + resp = await movieApp.handle(new Request(body.next)); body = await resp.json(); expectStatus(resp, body).toBe(200); @@ -141,23 +105,19 @@ describe("Get all movies", () => { ), }); - resp = await app.handle(new Request(next)); + resp = await movieApp.handle(new Request(next)); body = await resp.json(); expectStatus(resp, body).toBe(200); expect(body).toMatchObject({ - items: [expect.objectContaining({ slug: dune1984.slug })], + items: [ + expect.objectContaining({ + slug: dune1984.slug, + airDate: dune1984.airDate, + }), + ], this: next, next: null, }); }); - // TODO: sort with an item that has null in it. We want it to always be last (in both asc & desc). -}); - -beforeAll(async () => { - await db.delete(shows); - for (const movie of [bubble, dune1984, dune]) await seedMovie(movie); -}); -afterAll(async () => { - await db.delete(shows); }); diff --git a/api/tests/movies/get-movies.test.ts b/api/tests/movies/get-movie.test.ts similarity index 75% rename from api/tests/movies/get-movies.test.ts rename to api/tests/movies/get-movie.test.ts index fca0d08aa..d43b39b23 100644 --- a/api/tests/movies/get-movies.test.ts +++ b/api/tests/movies/get-movie.test.ts @@ -1,39 +1,21 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { eq } from "drizzle-orm"; -import Elysia from "elysia"; -import { base } from "~/base"; -import { movies } from "~/controllers/movies"; import { seedMovie } from "~/controllers/seed/movies"; import { db } from "~/db"; import { shows } from "~/db/schema"; import { bubble } from "~/models/examples"; - -const app = new Elysia().use(base).use(movies); -const getMovie = async (id: string, langs?: string) => { - const resp = await app.handle( - new Request(`http://localhost/movies/${id}`, { - method: "GET", - headers: langs - ? { - "Accept-Language": langs, - } - : {}, - }), - ); - const body = await resp.json(); - return [resp, body] as const; -}; +import { getMovie } from "./movies-helper"; +import { expectStatus } from "tests/utils"; let bubbleId = ""; -function expectStatus(resp: Response, body: object) { - const matcher = expect({ ...body, status: resp.status }); - return { - toBe: (status: number) => { - matcher.toMatchObject({ status: status }); - }, - }; -} +beforeAll(async () => { + const ret = await seedMovie(bubble); + bubbleId = ret.id; +}); +afterAll(async () => { + await db.delete(shows).where(eq(shows.slug, bubble.slug)); +}); describe("Get movie", () => { it("Retrive by slug", async () => { @@ -94,11 +76,3 @@ describe("Get movie", () => { expect(resp.headers.get("Content-Language")).toBe("en"); }); }); - -beforeAll(async () => { - const ret = await seedMovie(bubble); - bubbleId = ret.id; -}); -afterAll(async () => { - await db.delete(shows).where(eq(shows.slug, bubble.slug)); -}); diff --git a/api/tests/movies/movies-helper.ts b/api/tests/movies/movies-helper.ts new file mode 100644 index 000000000..9d160b150 --- /dev/null +++ b/api/tests/movies/movies-helper.ts @@ -0,0 +1,61 @@ +import Elysia from "elysia"; +import { buildUrl } from "tests/utils"; +import { base } from "~/base"; +import { movies } from "~/controllers/movies"; +import { seed } from "~/controllers/seed"; +import type { SeedMovie } from "~/models/movie"; + +export const movieApp = new Elysia().use(base).use(movies).use(seed); + +export const getMovie = async (id: string, langs?: string) => { + const resp = await movieApp.handle( + new Request(`http://localhost/movies/${id}`, { + method: "GET", + headers: langs + ? { + "Accept-Language": langs, + } + : {}, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; + +export const getMovies = async ({ + langs, + ...query +}: { + filter?: string; + limit?: number; + after?: string; + sort?: string | string[]; + langs?: string; +}) => { + const resp = await movieApp.handle( + new Request(buildUrl("movies", query), { + method: "GET", + headers: langs + ? { + "Accept-Language": langs, + } + : {}, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; + +export const createMovie = async (movie: SeedMovie) => { + const resp = await movieApp.handle( + new Request("http://localhost/movies", { + method: "POST", + body: JSON.stringify(movie), + headers: { + "Content-Type": "application/json", + }, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; diff --git a/api/tests/movies/seed-movies.test.ts b/api/tests/movies/seed-movies.test.ts index 8e950fe89..cb32428ab 100644 --- a/api/tests/movies/seed-movies.test.ts +++ b/api/tests/movies/seed-movies.test.ts @@ -1,37 +1,11 @@ import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; import { eq, inArray } from "drizzle-orm"; -import Elysia from "elysia"; -import { base } from "~/base"; -import { seed } from "~/controllers/seed"; +import { expectStatus } from "tests/utils"; import { db } from "~/db"; import { shows, showTranslations, videos } from "~/db/schema"; import { bubble } from "~/models/examples"; import { dune, duneVideo } from "~/models/examples/dune-2021"; -import type { SeedMovie } from "~/models/movie"; - -const app = new Elysia().use(base).use(seed); -const createMovie = async (movie: SeedMovie) => { - const resp = await app.handle( - new Request("http://localhost/movies", { - method: "POST", - body: JSON.stringify(movie), - headers: { - "Content-Type": "application/json", - }, - }), - ); - const body = await resp.json(); - return [resp, body] as const; -}; - -function expectStatus(resp: Response, body: object) { - const matcher = expect({ ...body, status: resp.status }); - return { - toBe: (status: number) => { - matcher.toMatchObject({ status: status }); - }, - }; -} +import { createMovie } from "./movies-helper"; describe("Movie seeding", () => { it("Can create a movie", async () => { diff --git a/api/tests/utils.ts b/api/tests/utils.ts new file mode 100644 index 000000000..be2480d8e --- /dev/null +++ b/api/tests/utils.ts @@ -0,0 +1,23 @@ +import { expect } from "bun:test"; +import Elysia from "elysia"; + +export function expectStatus(resp: Response, body: object) { + const matcher = expect({ ...body, status: resp.status }); + return { + toBe: (status: number) => { + matcher.toMatchObject({ status: status }); + }, + }; +} + +export const buildUrl = (route: string, query: Record) => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (!Array.isArray(value)) { + params.append(key, value.toString()); + continue; + } + for (const v of value) params.append(key, v.toString()); + } + return `http://localhost/${route}?${params}`; +}; From c71650d386ee7ea315a347ba32efeb3ec67d1e26 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 10 Jan 2025 00:35:39 +0100 Subject: [PATCH 103/105] Test sort order with null values --- .../movies/get-all-movies-with-null.test.ts | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 api/tests/movies/get-all-movies-with-null.test.ts diff --git a/api/tests/movies/get-all-movies-with-null.test.ts b/api/tests/movies/get-all-movies-with-null.test.ts new file mode 100644 index 000000000..1504a0bc6 --- /dev/null +++ b/api/tests/movies/get-all-movies-with-null.test.ts @@ -0,0 +1,145 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { seedMovie } from "~/controllers/seed/movies"; +import { db } from "~/db"; +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 { eq } from "drizzle-orm"; +import { expectStatus } from "tests/utils"; +import { createMovie, getMovies, movieApp } from "./movies-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 + // instead we just make a new file for those /shrug + // see: https://github.com/oven-sh/bun/issues/5738 + beforeAll(async () => { + await createMovie({ + slug: "no-air-date", + translations: { + en: { + name: "no air date", + description: null, + aliases: [], + banner: null, + logo: null, + poster: null, + tagline: null, + tags: [], + thumbnail: null, + trailerUrl: null, + }, + }, + genres: [], + status: "unknown", + rating: null, + runtime: null, + airDate: null, + originalLanguage: null, + externalId: {}, + }); + }); + afterAll(async () => { + await db.delete(shows).where(eq(shows.slug, "no-air-date")); + }); + + it("sort by dates desc with a null value", async () => { + console.log( + ( + await getMovies({ + sort: "-airDate", + langs: "en", + }) + )[1].items, + ); + let [resp, body] = await getMovies({ + limit: 2, + sort: "-airDate", + langs: "en", + }); + expectStatus(resp, body).toBe(200); + + // we copy this due to https://github.com/oven-sh/bun/issues/3521 + const next = body.next; + expect(body).toMatchObject({ + items: [ + expect.objectContaining({ slug: bubble.slug, airDate: bubble.airDate }), + expect.objectContaining({ slug: dune.slug, airDate: dune.airDate }), + ], + this: "http://localhost/movies?limit=2&sort=-airDate", + next: expect.stringContaining( + "http://localhost/movies?limit=2&sort=-airDate&after=WyIyMDIxLTEwLTIyIiw", + ), + }); + + resp = await movieApp.handle(new Request(next)); + body = await resp.json(); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + items: [ + expect.objectContaining({ + slug: dune1984.slug, + airDate: dune1984.airDate, + }), + expect.objectContaining({ + slug: "no-air-date", + airDate: null, + }), + ], + this: next, + next: null, + }); + }); + it("sort by dates asc with a null value", async () => { + let [resp, body] = await getMovies({ + limit: 2, + sort: "airDate", + langs: "en", + }); + expectStatus(resp, body).toBe(200); + + // we copy this due to https://github.com/oven-sh/bun/issues/3521 + const next = body.next; + expect(body).toMatchObject({ + items: [ + expect.objectContaining({ + slug: dune1984.slug, + airDate: dune1984.airDate, + }), + expect.objectContaining({ slug: dune.slug, airDate: dune.airDate }), + ], + this: "http://localhost/movies?limit=2&sort=airDate", + next: expect.stringContaining( + "http://localhost/movies?limit=2&sort=airDate&after=WyIyMDIxLTEwLTIyIiw", + ), + }); + + resp = await movieApp.handle(new Request(next)); + body = await resp.json(); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + items: [ + expect.objectContaining({ + slug: bubble.slug, + airDate: bubble.airDate, + }), + expect.objectContaining({ + slug: "no-air-date", + airDate: null, + }), + ], + this: next, + next: null, + }); + }); +}); From 3547799079c0386b53945300aa688ae789e1fa9a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 10 Jan 2025 00:35:52 +0100 Subject: [PATCH 104/105] Sort nulls at the end even in desc order --- api/src/controllers/movies.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 4f14221b1..21683381b 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -177,7 +177,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .innerJoin(transQ, eq(shows.pk, transQ.pk)) .where(and(filter, keysetPaginate({ table: shows, after, sort }))) .orderBy( - ...sort.map((x) => (x.desc ? desc(shows[x.key]) : shows[x.key])), + ...sort.map((x) => (x.desc ? sql`${shows[x.key]} desc nulls last` : shows[x.key])), shows.pk, ) .limit(limit); From fe6f4fd43b660ec238cb6395085c0d21074d72d1 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 10 Jan 2025 12:15:31 +0100 Subject: [PATCH 105/105] Fix null sorting --- api/src/models/utils/keyset-paginate.ts | 18 +++++++++++++--- api/src/models/utils/page.ts | 3 +++ .../movies/get-all-movies-with-null.test.ts | 21 ++++++++++--------- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts index a5a7dc431..6bcba2238 100644 --- a/api/src/models/utils/keyset-paginate.ts +++ b/api/src/models/utils/keyset-paginate.ts @@ -1,5 +1,5 @@ import type { NonEmptyArray, Sort } from "./sort"; -import { eq, or, type Column, and, gt, lt } from "drizzle-orm"; +import { eq, or, type Column, and, gt, lt, isNull } from "drizzle-orm"; type Table = Record; @@ -41,8 +41,20 @@ export const keysetPaginate = < let previous = undefined; for (const [i, by] of [...sort, pkSort].entries()) { const cmp = by.desc ? lt : gt; - where = or(where, and(previous, cmp(table[by.key], cursor[i]))); - previous = and(previous, eq(table[by.key], cursor[i])); + where = or( + where, + and( + previous, + or( + cmp(table[by.key], cursor[i]), + !table[by.key].notNull ? isNull(table[by.key]) : undefined, + ), + ), + ); + previous = and( + previous, + cursor[i] === null ? isNull(table[by.key]) : eq(table[by.key], cursor[i]), + ); } return where; diff --git a/api/src/models/utils/page.ts b/api/src/models/utils/page.ts index a533dcfad..daad081ec 100644 --- a/api/src/models/utils/page.ts +++ b/api/src/models/utils/page.ts @@ -19,6 +19,9 @@ export const createPage = ( ) => { let next: string | null = null; + // we can't know for sure if there's a next page when the current page is full. + // maybe the next page is empty, this is a bit weird but it allows us to handle pages + // without making a new request to the db so it's fine. if (items.length === limit && limit > 0) { const uri = new URL(url); uri.searchParams.set("after", generateAfter(items[items.length - 1], sort)); 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 1504a0bc6..a6cc6477d 100644 --- a/api/tests/movies/get-all-movies-with-null.test.ts +++ b/api/tests/movies/get-all-movies-with-null.test.ts @@ -52,14 +52,6 @@ describe("with a null value", () => { }); it("sort by dates desc with a null value", async () => { - console.log( - ( - await getMovies({ - sort: "-airDate", - langs: "en", - }) - )[1].items, - ); let [resp, body] = await getMovies({ limit: 2, sort: "-airDate", @@ -67,6 +59,11 @@ describe("with a null value", () => { }); expectStatus(resp, body).toBe(200); + expect(body.items.map((x: any) => x.slug)).toMatchObject([ + bubble.slug, + dune.slug, + ]); + // we copy this due to https://github.com/oven-sh/bun/issues/3521 const next = body.next; expect(body).toMatchObject({ @@ -84,6 +81,10 @@ describe("with a null value", () => { body = await resp.json(); expectStatus(resp, body).toBe(200); + expect(body.items.map((x: any) => x.slug)).toMatchObject([ + dune1984.slug, + "no-air-date", + ]); expect(body).toMatchObject({ items: [ expect.objectContaining({ @@ -96,7 +97,7 @@ describe("with a null value", () => { }), ], this: next, - next: null, + next: expect.anything(), }); }); it("sort by dates asc with a null value", async () => { @@ -139,7 +140,7 @@ describe("with a null value", () => { }), ], this: next, - next: null, + next: expect.anything(), }); }); });