diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 000000000..7a77e2a11 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,14 @@ +# 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 +POSTGRES_DB=kyooDB +POSTGRES_SERVER=postgres +POSTGRES_PORT=5432 diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 000000000..ef720b3f4 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,2 @@ +node_modules +**/*.bun 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/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.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/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/drizzle.config.ts b/api/drizzle.config.ts new file mode 100644 index 000000000..cf35e7c29 --- /dev/null +++ b/api/drizzle.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + out: "./drizzle", + schema: "./src/db/schema", + dialect: "postgresql", + casing: "snake_case", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, + migrations: { + schema: "kyoo", + }, +}); diff --git a/api/drizzle/0000_init.sql b/api/drizzle/0000_init.sql new file mode 100644 index 000000000..a0ad12e8a --- /dev/null +++ b/api/drizzle/0000_init.sql @@ -0,0 +1,102 @@ +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_pk" integer, + "order" integer NOT NULL, + "season_number" integer, + "episode_number" integer, + "type" "kyoo"."entry_type" NOT NULL, + "air_date" date, + "runtime" integer, + "thumbnails" jsonb, + "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 "entries_showPk_seasonNumber_episodeNumber_unique" UNIQUE("show_pk","season_number","episode_number"), + CONSTRAINT "order_positive" 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 +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" "kyoo"."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_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 $$; +--> 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_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/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/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/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/0000_snapshot.json b/api/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..0282c254a --- /dev/null +++ b/api/drizzle/meta/0000_snapshot.json @@ -0,0 +1,589 @@ +{ + "id": "82560792-5f4a-4723-9543-808719ade682", + "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 + }, + "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()" + }, + "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": { + "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/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/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/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/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 new file mode 100644 index 000000000..4d647cd1d --- /dev/null +++ b/api/drizzle/meta/_journal.json @@ -0,0 +1,41 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1731105746157, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1731149082556, + "tag": "0001_video", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1731165599920, + "tag": "0002_seasons", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1731258712255, + "tag": "0003_order", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1732738409330, + "tag": "0004_jointures", + "breakpoints": true + } + ] +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 000000000..feffaf220 --- /dev/null +++ b/api/package.json @@ -0,0 +1,24 @@ +{ + "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": { + "@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" + }, + "module": "src/index.js" +} diff --git a/api/src/base.ts b/api/src/base.ts new file mode 100644 index 000000000..0acbd54b2 --- /dev/null +++ b/api/src/base.ts @@ -0,0 +1,27 @@ +import Elysia from "elysia"; +import type { KError } from "./models/error"; + +export const base = new Elysia({ name: "base" }) + .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}.`, + details: details, + } as KError; + } + if (code === "INTERNAL_SERVER_ERROR") { + console.error(error); + return { + status: 500, + message: error.message, + details: error, + } as KError; + } + }) + .as("plugin"); 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/controllers/movies.ts b/api/src/controllers/movies.ts new file mode 100644 index 000000000..21683381b --- /dev/null +++ b/api/src/controllers/movies.ts @@ -0,0 +1,244 @@ +import { and, desc, eq, sql } from "drizzle-orm"; +import { Elysia, t } from "elysia"; +import { KError } from "~/models/error"; +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, + Sort, + type FilterDef, + Genre, + isUuid, + keysetPaginate, + Page, + processLanguages, + createPage, +} from "~/models/utils"; + +// 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 getTranslationQuery = (languages: string[], forceFallback = false) => { + const fallback = forceFallback || 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 { pk, ...col } = getColumns(query); + return [query, col] as const; +}; + +// 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: { + 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, + "movie-translation": MovieTranslation, + }) + .get( + "/:id", + async ({ + params: { id }, + headers: { "accept-language": languages }, + error, + set, + }) => { + const langs = processLanguages(languages); + const [transQ, transCol] = getTranslationQuery(langs); + + const idFilter = isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id); + + const [ret] = await db + .select({ + ...moviesCol, + status: sql`${moviesCol.status}`, + airDate: startAir, + translation: transCol, + }) + .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.translation) { + return error(422, { + status: 422, + message: "Accept-Language header could not be satisfied.", + details: undefined, + }); + } + 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.", + example: bubble.slug, + }), + }), + 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). + `, + }), + }), + response: { + 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. + `, + examples: [ + { + status: 422, + message: "Accept-Language header could not be satisfied.", + }, + ], + }, + }, + }, + ) + .get( + "", + async ({ + query: { limit, after, sort, filter }, + headers: { "accept-language": languages }, + request: { url }, + }) => { + const langs = processLanguages(languages); + const [transQ, transCol] = getTranslationQuery(langs, true); + + // TODO: Add sql indexes on sort keys + + const items = await db + .select({ + ...moviesCol, + ...transCol, + status: sql`${moviesCol.status}`, + airDate: startAir, + }) + .from(shows) + .innerJoin(transQ, eq(shows.pk, transQ.pk)) + .where(and(filter, keysetPaginate({ table: shows, after, sort }))) + .orderBy( + ...sort.map((x) => (x.desc ? sql`${shows[x.key]} desc nulls last` : shows[x.key])), + shows.pk, + ) + .limit(limit); + + return createPage(items, { url, sort, limit }); + }, + { + 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", + }), + filter: t.Optional(Filter({ def: movieFilters })), + limit: t.Integer({ + minimum: 1, + maximum: 250, + default: 50, + description: "Max page size.", + }), + after: t.Optional( + t.String({ + 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: + "Invalid property: slug. Expected one of genres, rating, status, runtime, airDate, originalLanguage.", + details: { + in: "slug eq bubble", + }, + }, + ], + }, + }, + }, + ); 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/controllers/seed/images.ts b/api/src/controllers/seed/images.ts new file mode 100644 index 000000000..bbb595f84 --- /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("hex"), + 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..8a5dac11c --- /dev/null +++ b/api/src/controllers/seed/index.ts @@ -0,0 +1,47 @@ +import Elysia from "elysia"; +import { Movie, SeedMovie } from "~/models/movie"; +import { seedMovie, SeedMovieResponse } from "./movies"; +import { Resource, validateTranslations } 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, + }) + .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); + }, + { + body: "seed-movie", + response: { + 200: { + ...SeedMovieResponse, + description: "Existing movie edited/updated.", + }, + 201: { ...SeedMovieResponse, description: "Created a new movie." }, + 400: { ...KError, description: "Invalid translation name" }, + 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. + `, + }, + 422: { ...KError, description: "Invalid schema in body." }, + }, + 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 new file mode 100644 index 000000000..45b3b8a47 --- /dev/null +++ b/api/src/controllers/seed/movies.ts @@ -0,0 +1,183 @@ +import { inArray, sql, eq } from "drizzle-orm"; +import { t } from "elysia"; +import { db } from "~/db"; +import { + entries, + entryTranslations, + entryVideoJointure as evj, + shows, + showTranslations, + videos, +} from "~/db/schema"; +import { conflictUpdateAllExcept } from "~/db/schema/utils"; +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 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< + SeedMovieResponse & { status: "Created" | "OK" | "Conflict" } +> => { + const { translations, videos: vids, ...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 insert = () => + tx + .insert(shows) + .values(movie) + .onConflictDoUpdate({ + target: shows.slug, + set: conflictUpdateAllExcept(shows, [ + "pk", + "id", + "slug", + "createdAt", + ]), + // if year is different, this is not an update but a conflict (ex: dune-1984 vs dune-2021) + setWhere: sql`date_part('year', ${shows.startAir}) = date_part('year', excluded."start_air")`, + }) + .returning({ + pk: shows.pk, + id: shows.id, + slug: shows.slug, + // https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667 + updated: sql`(xmax <> 0)`.as("updated"), + }); + let [ret] = await insert(); + if (!ret) { + // ret is undefined when the conflict's where return false (meaning we have + // a conflicting slug but a different air year. + // try to insert adding the year at the end of the slug. + if ( + movie.startAir && + !movie.slug.endsWith(`${getYear(movie.startAir)}`) + ) { + movie.slug = `${movie.slug}-${getYear(movie.startAir)}`; + [ret] = await insert(); + } + + // if at this point ret is still undefined, we could not reconciliate. + // simply bail and let the caller handle this. + if (!ret) { + const [{ id }] = await db + .select({ id: shows.id }) + .from(shows) + .where(eq(shows.slug, movie.slug)) + .limit(1); + return { + status: "Conflict" as const, + id, + slug: movie.slug, + videos: [], + }; + } + } + + // even if never shown to the user, a movie still has an entry. + const movieEntry: Entry = { type: "movie", ...bMovie }; + const [entry] = await tx + .insert(entries) + .values(movieEntry) + .onConflictDoUpdate({ + target: entries.slug, + set: conflictUpdateAllExcept(entries, [ + "pk", + "id", + "slug", + "createdAt", + ]), + }) + .returning({ pk: entries.pk }); + + const trans: ShowTrans[] = await Promise.all( + Object.entries(translations).map(async ([lang, tr]) => ({ + pk: ret.pk, + // TODO: normalize lang or error if invalid + language: lang, + ...tr, + poster: await processOptImage(tr.poster), + thumbnail: await processOptImage(tr.thumbnail), + logo: await processOptImage(tr.logo), + banner: await processOptImage(tr.banner), + })), + ); + await tx + .insert(showTranslations) + .values(trans) + .onConflictDoUpdate({ + target: [showTranslations.pk, showTranslations.language], + set: conflictUpdateAllExcept(showTranslations, ["pk", "language"]), + }); + + const entryTrans = trans.map((x) => ({ ...x, pk: entry.pk })); + await tx + .insert(entryTranslations) + .values(entryTrans) + .onConflictDoUpdate({ + target: [entryTranslations.pk, entryTranslations.language], + set: conflictUpdateAllExcept(entryTranslations, ["pk", "language"]), + }); + + return { ...ret, entry: entry.pk }; + }); + + if (ret.status === "Conflict") return ret; + + let retVideos: { slug: string }[] = []; + if (vids) { + retVideos = await db + .insert(evj) + .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( + ${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 + ) + `.as("slug"), + // case when (select count(1) from ${evj} where ${evj.entry} = ${ret.entry}) <> 0 then concat('-', ${videos.rendering}) else '' end + }) + .from(videos) + .where(inArray(videos.id, vids)), + ) + .onConflictDoNothing() + .returning({ slug: evj.slug }); + } + + return { + status: ret.updated ? "OK" : "Created", + id: ret.id, + slug: ret.slug, + videos: retVideos, + }; +}; + +function getYear(date: string) { + return new Date(date).getUTCFullYear(); +} 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/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/controllers/videos.ts b/api/src/controllers/videos.ts new file mode 100644 index 000000000..f5b7551f4 --- /dev/null +++ b/api/src/controllers/videos.ts @@ -0,0 +1,41 @@ +import { Elysia, t } from "elysia"; +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"; + +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/db/index.ts b/api/src/db/index.ts new file mode 100644 index 000000000..c2b0d0032 --- /dev/null +++ b/api/src/db/index.ts @@ -0,0 +1,15 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import * as schema from "./schema"; + +export const db = drizzle({ + schema, + connection: { + 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: false, + }, + casing: "snake_case", +}); diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts new file mode 100644 index 000000000..1a5e747b6 --- /dev/null +++ b/api/src/db/schema/entries.ts @@ -0,0 +1,88 @@ +import { sql } from "drizzle-orm"; +import { + check, + date, + integer, + jsonb, + primaryKey, + real, + 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", + "episode", + "movie", + "special", + "extra", +]); + +export const entry_extid = () => + 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", + { + pk: integer().primaryKey().generatedAlwaysAsIdentity(), + id: uuid().notNull().unique().defaultRandom(), + slug: varchar({ length: 255 }).notNull().unique(), + showPk: integer().references(() => shows.pk, { onDelete: "cascade" }), + order: real(), + seasonNumber: integer(), + episodeNumber: integer(), + type: entryType().notNull(), + airDate: date(), + runtime: integer(), + thumbnails: image(), + + externalId: entry_extid(), + + createdAt: timestamp({ withTimezone: true, mode: "string" }).defaultNow(), + nextRefresh: timestamp({ withTimezone: true, mode: "string" }), + }, + (t) => [ + unique().on(t.showPk, t.seasonNumber, t.episodeNumber), + check("order_positive", sql`${t.order} >= 0`), + ], +); + +export const entryTranslations = schema.table( + "entry_translations", + { + pk: integer() + .notNull() + .references(() => entries.pk, { onDelete: "cascade" }), + 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/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 new file mode 100644 index 000000000..85405c0f7 --- /dev/null +++ b/api/src/db/schema/seasons.ts @@ -0,0 +1,63 @@ +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 season_extid = () => + 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: season_extid(), + + 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_translations", + { + pk: integer() + .notNull() + .references(() => seasons.pk, { onDelete: "cascade" }), + language: language().notNull(), + name: text(), + description: text(), + poster: image(), + thumbnail: image(), + banner: image(), + }, + (t) => [primaryKey({ columns: [t.pk, t.language] })], +); diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts new file mode 100644 index 000000000..2f43e7361 --- /dev/null +++ b/api/src/db/schema/shows.ts @@ -0,0 +1,121 @@ +import { relations, sql } from "drizzle-orm"; +import { + check, + date, + integer, + jsonb, + primaryKey, + smallint, + text, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; +import { 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 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 externalid = () => + jsonb() + .$type< + Record< + string, + { + dataId: string; + link: string | null; + } + > + >() + .notNull() + .default({}); + +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(), + runtime: integer(), + status: showStatus().notNull(), + startAir: date(), + endAir: date(), + originalLanguage: language(), + + externalId: externalid(), + + createdAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .defaultNow(), + nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(), + }, + (t) => [ + check("rating_valid", sql`${t.rating} between 0 and 100`), + check("runtime_valid", sql`${t.runtime} >= 0`), + ], +); + +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(), + poster: image(), + thumbnail: image(), + banner: image(), + logo: image(), + trailerUrl: text(), + }, + (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/db/schema/utils.ts b/api/src/db/schema/utils.ts new file mode 100644 index 000000000..dae8a8011 --- /dev/null +++ b/api/src/db/schema/utils.ts @@ -0,0 +1,79 @@ +import { + is, + type ColumnsSelection, + type Subquery, + Table, + View, + ViewBaseConfig, + getTableColumns, + sql, + SQL, +} 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"; +import { db } from ".."; +import type { CasingCache } from "drizzle-orm/casing"; + +export const schema = pgSchema("kyoo"); + +export const language = () => varchar({ length: 255 }); + +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] } & {}; + +// See https://github.com/drizzle-team/drizzle-orm/pull/1789 +type Select = AnyPgSelect | AnyMySqlSelect | AnySQLiteSelect; +type AnySelect = Simplify< + Omit & 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; +} + +// 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-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; + }, + {} as Omit, E[number]>, + ); +} diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts new file mode 100644 index 000000000..dde8959af --- /dev/null +++ b/api/src/db/schema/videos.ts @@ -0,0 +1,48 @@ +import { sql } from "drizzle-orm"; +import { + check, + integer, + jsonb, + text, + timestamp, + uuid, + varchar, + primaryKey, +} from "drizzle-orm/pg-core"; +import { schema } from "./utils"; +import { entries } from "./entries"; + +export const videos = schema.table( + "videos", + { + pk: integer().primaryKey().generatedAlwaysAsIdentity(), + id: uuid().notNull().unique().defaultRandom(), + path: text().notNull().unique(), + rendering: text().notNull(), + part: integer(), + version: integer().notNull().default(1), + guess: jsonb().notNull().default({}), + + createdAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .defaultNow(), + }, + (t) => [ + check("part_pos", sql`${t.part} >= 0`), + 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" }), + slug: varchar({ length: 255 }).notNull().unique(), + }, + (t) => [primaryKey({ columns: [t.entry, t.video] })], +); diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 000000000..f3af37c3d --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,82 @@ +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 { 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"; +import { base } from "./base"; + +await migrate(db, { migrationsSchema: "kyoo", migrationsFolder: "./drizzle" }); + +let secret = process.env.JWT_SECRET; +if (!secret) { + const auth = process.env.AUTH_SERVER ?? "http://auth:4568"; + 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"); + process.exit(1); +} + +const app = new Elysia() + .use(base) + .use(jwt({ secret })) + .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" }, + { + name: "videos", + description: comment` + Used by the scanner internally to list & create videos. + Can be used for administration or third party apps. + `, + }, + ], + }, + }), + ) + .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}`); diff --git a/api/src/models/entry/base-entry.ts b/api/src/models/entry/base-entry.ts new file mode 100644 index 000000000..4da51f6c9 --- /dev/null +++ b/api/src/models/entry/base-entry.ts @@ -0,0 +1,31 @@ +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()), +}); + + +// export const SeedEntry = t.Intersect([ +// Entry, +// t.Object({ videos: t.Optional(t.Array(Video)) }), +// ]); +// export type SeedEntry = typeof SeedEntry.static; +// +// export const SeedExtra = t.Intersect([ +// Extra, +// t.Object({ video: t.Optional(Video) }), +// ]); +// export type SeedExtra = typeof SeedExtra.static; diff --git a/api/src/models/entry/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; diff --git a/api/src/models/error.ts b/api/src/models/error.ts new file mode 100644 index 000000000..4be8a9685 --- /dev/null +++ b/api/src/models/error.ts @@ -0,0 +1,14 @@ +import { t } from "elysia"; + +export const KError = t.Object({ + status: t.Integer(), + message: t.String(), + 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/examples/bubble.ts b/api/src/models/examples/bubble.ts new file mode 100644 index 000000000..27b469ad1 --- /dev/null +++ b/api/src/models/examples/bubble.ts @@ -0,0 +1,72 @@ +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", + 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: + "https://image.tmdb.org/t/p/original/65dad96VE8FJPEdrAkhdsuWMWH9.jpg", + thumbnail: + "https://image.tmdb.org/t/p/original/a8Q2g0g7XzAF6gcB8qgn37ccb9Y.jpg", + banner: null, + logo: "https://image.tmdb.org/t/p/original/ihIs7fayAmZieMlMQbs6TWM77uf.png", + 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", + externalId: { + themoviedatabase: { + dataId: "912598", + link: "https://www.themoviedb.org/movie/912598", + }, + imdb: { + dataId: "tt16360006", + link: "https://www.imdb.com/title/tt16360006", + }, + }, + videos: [bubbleVideo.id], +}; + +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/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/src/models/examples/index.ts b/api/src/models/examples/index.ts new file mode 100644 index 000000000..2d917578a --- /dev/null +++ b/api/src/models/examples/index.ts @@ -0,0 +1,34 @@ +import type { TSchema } from "elysia"; +import { KindGuard } from "@sinclair/typebox"; + +export const registerExamples = ( + schema: T, + ...examples: (Partial| undefined)[] +) => { + if (KindGuard.IsUnion(schema)) { + for (const union of schema.anyOf) { + registerExamples(union, ...examples); + } + return; + } + if (KindGuard.IsIntersect(schema)) { + for (const intersec of schema.allOf) { + registerExamples(intersec, ...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 * from "./bubble"; +export * 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..81e97ec78 --- /dev/null +++ b/api/src/models/examples/made-in-abyss.ts @@ -0,0 +1,340 @@ +import type { SeedSerie } from "../seed"; + +export const madeInAbyss: SeedSerie = { + 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", + 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 new file mode 100644 index 000000000..4fbf86235 --- /dev/null +++ b/api/src/models/movie.ts @@ -0,0 +1,78 @@ +import { t } from "elysia"; +import { ExternalId, Genre, Image, Language, SeedImage } from "./utils"; +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, + runtime: t.Nullable( + t.Number({ minimum: 0, description: "Runtime of the movie in minutes." }), + ), + + airDate: t.Nullable(t.String({ format: "date" })), + originalLanguage: t.Nullable( + 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 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()), +}); +export type MovieTranslation = typeof MovieTranslation.static; + +export const Movie = t.Intersect([BaseMovie, MovieTranslation]); +export type Movie = typeof Movie.static; + +export const SeedMovie = t.Intersect([ + t.Omit(BaseMovie, ["id", "createdAt", "nextRefresh"]), + t.Object({ + 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(t.String({ format: "uuid" }))), + }), +]); +export type SeedMovie = typeof SeedMovie.static; + +registerExamples(Movie, { + ...bubble, + ...bubble.translations.en, + ...bubbleImages, +}); +registerExamples(SeedMovie, bubble); diff --git a/api/src/models/season.ts b/api/src/models/season.ts new file mode 100644 index 000000000..212ccb097 --- /dev/null +++ b/api/src/models/season.ts @@ -0,0 +1,36 @@ +import { t } from "elysia"; +import { Image } from "./utils/image"; +import { SeasonId } from "./utils/external-id"; +import { Resource } from "./utils/resource"; +import { Language } from "./utils/language"; + +export const BaseSeason = t.Object({ + seasonNumber: t.Number({ minimum: 1 }), + startAir: t.Nullable(t.String({ format: "date" })), + endAir: t.Nullable(t.String({ format: "date" })), + + 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; + +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 new file mode 100644 index 000000000..a5dbe3b7e --- /dev/null +++ b/api/src/models/serie.ts @@ -0,0 +1,70 @@ +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 { Resource } from "./utils/resource"; +import { Language } from "./utils/language"; +import { SeedSeason } from "./season"; + +export const SerieStatus = t.UnionEnum([ + "unknown", + "finished", + "airing", + "planned", +]); +export type SerieStatus = typeof SerieStatus.static; + +export const BaseSerie = t.Object({ + 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( + 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()), +}); +export type SerieTranslation = typeof SerieTranslation.static; + +export const Serie = t.Intersect([Resource, BaseSerie, SerieTranslation]); +export type Serie = typeof Serie.static; + +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; diff --git a/api/src/models/utils/external-id.ts b/api/src/models/utils/external-id.ts new file mode 100644 index 000000000..5f8f5b1da --- /dev/null +++ b/api/src/models/utils/external-id.ts @@ -0,0 +1,46 @@ +import { t } from "elysia"; +import { comment } from "../../utils"; + +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; + +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" })), + }), +); +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; diff --git a/api/src/models/utils/filters/index.ts b/api/src/models/utils/filters/index.ts new file mode 100644 index 000000000..22f19bce9 --- /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/parser.ts b/api/src/models/utils/filters/parser.ts new file mode 100644 index 000000000..35ccce00d --- /dev/null +++ b/api/src/models/utils/filters/parser.ts @@ -0,0 +1,130 @@ +import { + anyStringOf, + digit, + float, + int, + letter, + noCharOf, + type Parjser, + string, +} from "parjs"; +import { + exactly, + many, + many1, + map, + or, + stringify, + then, + thenq, + qthen, + later, + between, + recover, +} from "parjs/combinators"; + +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"] 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 } + | { type: "or"; lhs: Expression; rhs: 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 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 })))); +const floatVal = t( + float().pipe(map((f) => ({ type: "float" as const, value: f }))), +); +const dateVal = t( + digit(10).pipe( + exactly(4), + stringify(), + thenq(string("-")), + then( + 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 enumVal = enumP.pipe(map((e) => ({ type: "enum" as const, value: e }))); +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"); + +export const operation = property + .pipe( + then(operator, value), + map(([property, operator, value]) => ({ + type: "op" as const, + property, + operator, + value, + })), + ) + .expects("an operation"); + +// 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(); + +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 }), + first, + ), + ), +); + +const not = t(string("not")).pipe( + qthen(expr), + map((expression) => ({ type: "not" as const, expression })), +); + +const brackets = expression.pipe(between("(", ")")); + +expr.init(not.pipe(or(brackets, operation))); diff --git a/api/src/models/utils/filters/to-sql.ts b/api/src/models/utils/filters/to-sql.ts new file mode 100644 index 000000000..ad551dd88 --- /dev/null +++ b/api/src/models/utils/filters/to-sql.ts @@ -0,0 +1,103 @@ +import { + and, + eq, + gt, + gte, + lt, + lte, + ne, + not, + or, + type SQL, + sql, +} from "drizzle-orm"; +import { comment } from "~/utils"; +import type { FilterDef } from "./index"; +import type { Expression, Operator } from "./parser"; +import { KErrorT } from "~/models/error"; + +const opMap: Record = { + eq: eq, + ne: ne, + gt: gt, + ge: gte, + lt: lt, + le: lte, + has: eq, +}; + +export const toDrizzle = (expr: Expression, config: FilterDef): SQL => { + switch (expr.type) { + case "op": { + const where = `${expr.property} ${expr.operator} ${expr.value.value}`; + const prop = config[expr.property]; + + if (!prop) { + throw new KErrorT( + comment` + Invalid property: ${expr.property}. + Expected one of ${Object.keys(config).join(", ")}. + `, + { in: where }, + ); + } + + if (prop.type !== expr.value.type) { + throw new KErrorT( + comment` + Invalid value for property ${expr.property}. + Got ${expr.value.type} but expected ${prop.type}. + `, + { in: where }, + ); + } + if ( + prop.type === "enum" && + (expr.value.type === "enum" || expr.value.type === "string") && + !prop.values.includes(expr.value.value) + ) { + throw new KErrorT( + comment` + Invalid value ${expr.value.value} for property ${expr.property}. + Expected one of ${prop.values.join(", ")} but got ${expr.value.value}. + `, + { in: where }, + ); + } + + if (prop.isArray) { + if (expr.operator !== "has" && expr.operator !== "eq") { + 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") + `, + { 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); + return and(lhs, rhs)!; + } + case "or": { + const lhs = toDrizzle(expr.lhs, config); + const rhs = toDrizzle(expr.rhs, config); + return or(lhs, rhs)!; + } + case "not": { + const lhs = toDrizzle(expr.expression, config); + return not(lhs); + } + default: + return exhaustiveCheck(expr); + } +}; + +function exhaustiveCheck(v: never): never { + return v; +} diff --git a/api/src/models/utils/genres.ts b/api/src/models/utils/genres.ts new file mode 100644 index 000000000..dbac93b6d --- /dev/null +++ b/api/src/models/utils/genres.ts @@ -0,0 +1,28 @@ +import { t } from "elysia"; + +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; diff --git a/api/src/models/utils/image.ts b/api/src/models/utils/image.ts new file mode 100644 index 000000000..6d79a4c36 --- /dev/null +++ b/api/src/models/utils/image.ts @@ -0,0 +1,10 @@ +import { t } from "elysia"; + +export const Image = t.Object({ + id: t.String(), + source: t.String({ format: "uri" }), + blurhash: t.String(), +}); +export type Image = typeof Image.static; + +export const SeedImage = t.String({ format: "uri" }); diff --git a/api/src/models/utils/index.ts b/api/src/models/utils/index.ts new file mode 100644 index 000000000..70c086260 --- /dev/null +++ b/api/src/models/utils/index.ts @@ -0,0 +1,9 @@ +export * from "./external-id"; +export * from "./genres"; +export * from "./image"; +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..6bcba2238 --- /dev/null +++ b/api/src/models/utils/keyset-paginate.ts @@ -0,0 +1,69 @@ +import type { NonEmptyArray, Sort } from "./sort"; +import { eq, or, type Column, and, gt, lt, isNull } from "drizzle-orm"; + +type Table = Record; + +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: +// (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: 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 [i, by] of [...sort, pkSort].entries()) { + const cmp = by.desc ? lt : gt; + 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; +}; + +export const generateAfter = (cursor: any, sort: Sort) => { + 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/language.ts b/api/src/models/utils/language.ts new file mode 100644 index 000000000..afc58ea1f --- /dev/null +++ b/api/src/models/utils/language.ts @@ -0,0 +1,66 @@ +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 { + 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, + }); + +export const processLanguages = (languages?: string) => { + if (!languages) return ["*"]; + 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/src/models/utils/page.ts b/api/src/models/utils/page.ts new file mode 100644 index 000000000..daad081ec --- /dev/null +++ b/api/src/models/utils/page.ts @@ -0,0 +1,31 @@ +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" }), + next: t.Nullable(t.String({ format: "uri" })), + }, + options, + ); + +export const createPage = ( + items: T[], + { url, sort, limit }: { url: string; sort: Sort; limit: number }, +) => { + 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)); + next = uri.toString(); + } + return { items, this: url, next }; +}; diff --git a/api/src/models/utils/resource.ts b/api/src/models/utils/resource.ts new file mode 100644 index 000000000..6e837397c --- /dev/null +++ b/api/src/models/utils/resource.ts @@ -0,0 +1,17 @@ +import { FormatRegistry } from "@sinclair/typebox"; +import { TypeCompiler } from "@sinclair/typebox/compiler"; +import { t } from "elysia"; + +export const slugPattern = "^[a-z0-9-]+$"; + +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" }), +}); + +const checker = TypeCompiler.Compile(t.String({ format: "uuid" })); +export const isUuid = (id: string) => checker.Check(id); diff --git a/api/src/models/utils/sort.ts b/api/src/models/utils/sort.ts new file mode 100644 index 000000000..650ffb795 --- /dev/null +++ b/api/src/models/utils/sort.ts @@ -0,0 +1,54 @@ +import { t } from "elysia"; + +export type Sort< + T extends string[], + Remap extends Partial>, +> = { + key: Exclude | NonNullable; + remmapedKey?: keyof Remap; + desc: boolean; +}[]; + +export 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]!, remmapedKey: key, desc }; + return { key: key as Exclude, desc }; + }); + }) + .Encode(() => { + throw new Error("Encode not supported for sort"); + }); diff --git a/api/src/models/video.ts b/api/src/models/video.ts new file mode 100644 index 000000000..bf3bbde11 --- /dev/null +++ b/api/src/models/video.ts @@ -0,0 +1,38 @@ +import { t } from "elysia"; +import { comment } from "../utils"; +import { registerExamples, bubbleVideo } from "./examples"; + +export const Video = t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug" }), + path: t.String(), + 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: 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" }), +}); +export type Video = typeof Video.static; +registerExamples(Video, bubbleVideo); + +export const SeedVideo = t.Omit(Video, ["id", "slug", "createdAt"]); diff --git a/api/src/utils.ts b/api/src/utils.ts new file mode 100644 index 000000000..00c2f5ab7 --- /dev/null +++ b/api/src/utils.ts @@ -0,0 +1,8 @@ +// remove indent in multi-line comments +export const comment = (str: TemplateStringsArray, ...values: any[]) => + str + .reduce((acc, str, i) => `${acc}${values[i - 1]}${str}`) + .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 diff --git a/api/tests/misc/filter.test.ts b/api/tests/misc/filter.test.ts new file mode 100644 index 000000000..d1630e88f --- /dev/null +++ b/api/tests/misc/filter.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "bun:test"; +import type { ParjsFailure } from "parjs/internal"; +import { type Expression, expression } from "~/models/utils/filters/parser"; + +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 }, + }, + }, + }); + }); + 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" }, + }, + }, + }, + }, + }); + }); +}); 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..a6cc6477d --- /dev/null +++ b/api/tests/movies/get-all-movies-with-null.test.ts @@ -0,0 +1,146 @@ +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 () => { + let [resp, body] = await getMovies({ + limit: 2, + sort: "-airDate", + langs: "en", + }); + 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({ + 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.items.map((x: any) => x.slug)).toMatchObject([ + dune1984.slug, + "no-air-date", + ]); + expect(body).toMatchObject({ + items: [ + expect.objectContaining({ + slug: dune1984.slug, + airDate: dune1984.airDate, + }), + expect.objectContaining({ + slug: "no-air-date", + airDate: null, + }), + ], + this: next, + next: expect.anything(), + }); + }); + 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: expect.anything(), + }); + }); +}); 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..09adc4572 --- /dev/null +++ b/api/tests/movies/get-all-movies.test.ts @@ -0,0 +1,123 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +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"; + +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 () => { + 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.anything(), + message: "Invalid filter: slug eq gt bubble.", + status: 422, + }); + }); + it("Limit 2, default sort", async () => { + const [resp, body] = await getMovies({ + limit: 2, + langs: "en", + }); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + 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=WyJkdW5lIiw", + ), + }); + }); + it("Limit 2, default sort, page 2", async () => { + let [resp, body] = await getMovies({ + limit: 2, + langs: "en", + }); + expectStatus(resp, body).toBe(200); + + resp = await movieApp.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=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 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, + }), + ], + this: next, + next: null, + }); + }); +}); diff --git a/api/tests/movies/get-movie.test.ts b/api/tests/movies/get-movie.test.ts new file mode 100644 index 000000000..d43b39b23 --- /dev/null +++ b/api/tests/movies/get-movie.test.ts @@ -0,0 +1,78 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; +import { seedMovie } from "~/controllers/seed/movies"; +import { db } from "~/db"; +import { shows } from "~/db/schema"; +import { bubble } from "~/models/examples"; +import { getMovie } from "./movies-helper"; +import { expectStatus } from "tests/utils"; + +let bubbleId = ""; + +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 () => { + const [resp, body] = await getMovie(bubble.slug, "en"); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + slug: bubble.slug, + 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"); + }); + 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, + name: bubble.translations.en.name, + }); + expect(resp.headers.get("Content-Language")).toBe("en"); + }); +}); 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 new file mode 100644 index 000000000..cb32428ab --- /dev/null +++ b/api/tests/movies/seed-movies.test.ts @@ -0,0 +1,206 @@ +import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; +import { eq, inArray } from "drizzle-orm"; +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 { createMovie } from "./movies-helper"; + +describe("Movie seeding", () => { + it("Can create a movie", async () => { + // create video beforehand to test linking + await db.insert(videos).values(duneVideo); + + 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, + runtime: 200_000, + 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 [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)); + + expectStatus(resp, body).toBe(200); + expect(body.id).toBeString(); + expect(body.slug).toBe("dune"); + expect(body.videos).toBeArrayOfSize(0); + expect(edited.runtime).toBe(200_000); + expect(edited.status).toBe(dune.status); + expect(translations.find((x) => x.language === "en")).toMatchObject({ + name: dune.translations.en.name, + description: "edited translation", + }); + expect(translations.find((x) => x.language === "fr")).toMatchObject({ + name: "dune-but-in-french", + description: null, + }); + }); + + 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(201); + 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); + }); + + 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 + }); + + 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 () => {}); +}); + +const cleanup = async () => { + 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 +beforeAll(cleanup); +afterAll(cleanup); 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", + }); +}); 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}`; +}; diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 000000000..b2e97422f --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ES2022", + "moduleResolution": "node", + "types": [ + "bun-types" + ], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "noErrorTruncation": true, + "baseUrl": ".", + "paths": { + "~/*": [ + "./src/*" + ] + } + } +} 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}";