diff --git a/api/bun.lock b/api/bun.lock index becedc29..a1983672 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -6,23 +6,23 @@ "dependencies": { "@elysiajs/swagger": "zoriya/elysia-swagger#build", "blurhash": "^2.0.5", - "drizzle-kit": "^0.30.4", - "drizzle-orm": "0.39.0", - "elysia": "^1.2.23", + "drizzle-kit": "^0.31.0", + "drizzle-orm": "0.43.1", + "elysia": "^1.2.25", "jose": "^6.0.10", "parjs": "^1.3.9", - "pg": "^8.13.3", - "sharp": "^0.34.0", + "pg": "^8.15.6", + "sharp": "^0.34.1", }, "devDependencies": { - "@types/pg": "^8.11.11", - "bun-types": "^1.2.4", + "@types/pg": "^8.11.14", + "bun-types": "^1.2.11", "node-addon-api": "^8.3.1", }, }, }, "patchedDependencies": { - "drizzle-orm@0.39.0": "patches/drizzle-orm@0.39.0.patch", + "drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch", }, "packages": { "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -35,51 +35,55 @@ "@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/aix-ppc64": ["@esbuild/aix-ppc64@0.25.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.3", "", { "os": "android", "cpu": "arm" }, "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.3", "", { "os": "android", "cpu": "arm64" }, "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.3", "", { "os": "android", "cpu": "x64" }, "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.3", "", { "os": "linux", "cpu": "arm" }, "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.3", "", { "os": "linux", "cpu": "none" }, "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.3", "", { "os": "linux", "cpu": "none" }, "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.3", "", { "os": "linux", "cpu": "none" }, "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.3", "", { "os": "linux", "cpu": "x64" }, "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.3", "", { "os": "none", "cpu": "arm64" }, "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.3", "", { "os": "none", "cpu": "x64" }, "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.1.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A=="], @@ -121,21 +125,17 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.1", "", { "os": "win32", "cpu": "x64" }, "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw=="], - "@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="], - "@scalar/openapi-types": ["@scalar/openapi-types@0.1.9", "", {}, "sha512-HQQudOSQBU7ewzfnBW9LhDmBE2XOJgSfwrh5PlUB7zJup/kaRkBGNgV2wMjNz9Af/uztiU/xNrO179FysmUT+g=="], "@scalar/themes": ["@scalar/themes@0.9.81", "", { "dependencies": { "@scalar/types": "0.1.3" } }, "sha512-asTgdqo8ZYibBBWVYy0503qPx3cvwDlYNuc/cLbrCmTav0MAEL4wNb/gz9iScMVSMwhdkSkL5g9LPdr2mQrHzw=="], "@scalar/types": ["@scalar/types@0.1.3", "", { "dependencies": { "@scalar/openapi-types": "0.1.9", "@unhead/schema": "^1.11.11", "zod": "^3.23.8" } }, "sha512-Fxtgjp5wHhTzXiyODYWIoTsTy3oFC70vme+0I7MNwd8i6D8qplFNnpURueqBuP4MglBM2ZhFv3hPLw4D69anDA=="], - "@sinclair/typebox": ["@sinclair/typebox@0.34.31", "", {}, "sha512-qQ71T9DsITbX3dVCrcBERbs11YuSMg3wZPnT472JhqhWGPdiLgyvihJXU8m+ADJtJvRdjATIiACJD22dEknBrQ=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.33", "", {}, "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g=="], "@types/node": ["@types/node@22.13.13", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ=="], - "@types/pg": ["@types/pg@8.11.11", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw=="], - - "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + "@types/pg": ["@types/pg@8.11.14", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-qyD11E5R3u0eJmd1lB0WnWKXJGA7s015nyARWljfz5DcX83TKAIlY+QrmvzQTsbIe+hkiFtkyL2gHC6qwF6Fbg=="], "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="], @@ -143,7 +143,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.2.6", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-FbCKyr5KDiPULUzN/nm5oqQs9nXCHD8dVc64BArxJadCvbNzAI6lUWGh9fSJZWeDIRD38ikceBU8Kj/Uh+53oQ=="], + "bun-types": ["bun-types@1.2.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-dbkp5Lo8HDrXkLrONm6bk+yiiYQSntvFUzQp0v3pzTAsXk6FtgVMjdQ+lzFNVAmQFUkPQZ3WMZqH5tTo+Dp/IA=="], "char-info": ["char-info@0.3.5", "", { "dependencies": { "node-interval-tree": "^1.3.3" } }, "sha512-gRslEBFEcuLMGLNO1EFIrdN1MMUfO+aqa7y8iWzNyAzB3mYKnTIvP+ioW3jpyeEvqA5WapVLIPINGtFjEIH4cQ=="], @@ -161,28 +161,22 @@ "detect-libc": ["detect-libc@2.0.3", "", {}, "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw=="], - "drizzle-kit": ["drizzle-kit@0.30.5", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-l6dMSE100u7sDaTbLczibrQZjA35jLsHNqIV+jmhNVO3O8jzM6kywMOmV9uOz9ZVSCMPQhAZEFjL/qDPVrqpUA=="], + "drizzle-kit": ["drizzle-kit@0.31.0", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.2", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-pcKVT+GbfPA+bUovPIilgVOoq+onNBo/YQBG86sf3/GFHkN6lRJPm1l7dKN0IMAk57RQoIm4GUllRrasLlcaSg=="], - "drizzle-orm": ["drizzle-orm@0.39.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-kkZwo3Jvht0fdJD/EWGx0vYcEK0xnGrlNVaY07QYluRZA9N21B9VFbY+54bnb/1xvyzcg97tE65xprSAP/fFGQ=="], + "drizzle-orm": ["drizzle-orm@0.43.1", "", { "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.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "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/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-dUcDaZtE/zN4RV/xqGrVSMpnEczxd5cIaoDeor7Zst9wOe/HzC/7eAaulywWGYXdDEc9oBPMjayVEDg0ziTLJA=="], "elysia": ["elysia@1.2.25", "", { "dependencies": { "@sinclair/typebox": "^0.34.27", "cookie": "^1.0.2", "memoirist": "^0.3.0", "openapi-types": "^12.1.3" }, "peerDependencies": { "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-WsdQpORJvb4uszzeqYT0lg97knw1iBW1NTzJ1Jm57tiHg+DfAotlWXYbjmvQ039ssV0fYELDHinLLoUazZkEHg=="], - "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], - - "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": ["esbuild@0.25.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.3", "@esbuild/android-arm": "0.25.3", "@esbuild/android-arm64": "0.25.3", "@esbuild/android-x64": "0.25.3", "@esbuild/darwin-arm64": "0.25.3", "@esbuild/darwin-x64": "0.25.3", "@esbuild/freebsd-arm64": "0.25.3", "@esbuild/freebsd-x64": "0.25.3", "@esbuild/linux-arm": "0.25.3", "@esbuild/linux-arm64": "0.25.3", "@esbuild/linux-ia32": "0.25.3", "@esbuild/linux-loong64": "0.25.3", "@esbuild/linux-mips64el": "0.25.3", "@esbuild/linux-ppc64": "0.25.3", "@esbuild/linux-riscv64": "0.25.3", "@esbuild/linux-s390x": "0.25.3", "@esbuild/linux-x64": "0.25.3", "@esbuild/netbsd-arm64": "0.25.3", "@esbuild/netbsd-x64": "0.25.3", "@esbuild/openbsd-arm64": "0.25.3", "@esbuild/openbsd-x64": "0.25.3", "@esbuild/sunos-x64": "0.25.3", "@esbuild/win32-arm64": "0.25.3", "@esbuild/win32-ia32": "0.25.3", "@esbuild/win32-x64": "0.25.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], - "gel": ["gel@2.0.1", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-gfem3IGvqKqXwEq7XseBogyaRwGsQGuE7Cw/yQsjLGdgiyqX92G1xENPCE0ltunPGcsJIa6XBOTx/PK169mOqw=="], - "get-tsconfig": ["get-tsconfig@4.10.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A=="], "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], - "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], - "jose": ["jose@6.0.10", "", {}, "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw=="], "memoirist": ["memoirist@0.3.0", "", {}, "sha512-wR+4chMgVPq+T6OOsk40u9Wlpw1Pjx66NMNiYxCQQ4EUJ7jDs3D9kTCeKdBOkvAiqXlHLVJlvYL01PvIJ1MPNg=="], @@ -201,19 +195,19 @@ "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], - "pg": ["pg@8.14.1", "", { "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.8.0", "pg-protocol": "^1.8.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-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw=="], + "pg": ["pg@8.15.6", "", { "dependencies": { "pg-connection-string": "^2.8.5", "pg-pool": "^3.9.6", "pg-protocol": "^1.9.5", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.2.5" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-yvao7YI3GdmmrslNVsZgx9PfntfWrnXwtR+K/DjI0I/sTKif4Z623um+sjVZ1hk5670B+ODjvHDAckKdjmPTsg=="], - "pg-cloudflare": ["pg-cloudflare@1.1.1", "", {}, "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q=="], + "pg-cloudflare": ["pg-cloudflare@1.2.5", "", {}, "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg=="], - "pg-connection-string": ["pg-connection-string@2.7.0", "", {}, "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA=="], + "pg-connection-string": ["pg-connection-string@2.8.5", "", {}, "sha512-Ni8FuZ8yAF+sWZzojvtLE2b03cqjO5jNULcHFfM9ZZ0/JXrgom5pBREbtnAw7oxsxJqHw9Nz/XWORUEL3/IFow=="], "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.8.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw=="], + "pg-pool": ["pg-pool@3.9.6", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-rFen0G7adh1YmgvrmE5IPIqbb+IgEzENUm+tzm6MLLDSlPRoZVhzU1WdML9PV2W5GOdRA9qBKURlbt1OsXOsPw=="], - "pg-protocol": ["pg-protocol@1.8.0", "", {}, "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g=="], + "pg-protocol": ["pg-protocol@1.9.5", "", {}, "sha512-DYTWtWpfd5FOro3UnAfwvhD8jh59r2ig8bPtc9H8Ds7MscE/9NYruUQWFAOuraRl29jwcT2kyMFQ3MxeaVjUhg=="], "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=="], @@ -237,8 +231,6 @@ "sharp": ["sharp@0.34.1", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.7.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.1", "@img/sharp-darwin-x64": "0.34.1", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.1", "@img/sharp-linux-arm64": "0.34.1", "@img/sharp-linux-s390x": "0.34.1", "@img/sharp-linux-x64": "0.34.1", "@img/sharp-linuxmusl-arm64": "0.34.1", "@img/sharp-linuxmusl-x64": "0.34.1", "@img/sharp-wasm32": "0.34.1", "@img/sharp-win32-ia32": "0.34.1", "@img/sharp-win32-x64": "0.34.1" } }, "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg=="], - "shell-quote": ["shell-quote@1.8.2", "", {}, "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA=="], - "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -251,8 +243,6 @@ "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], diff --git a/api/drizzle/0020_video_unique.sql b/api/drizzle/0020_video_unique.sql new file mode 100644 index 00000000..2aac2c26 --- /dev/null +++ b/api/drizzle/0020_video_unique.sql @@ -0,0 +1,5 @@ +ALTER TABLE "kyoo"."entries" ALTER COLUMN "kind" SET DATA TYPE text;--> statement-breakpoint +DROP TYPE "kyoo"."entry_type";--> statement-breakpoint +CREATE TYPE "kyoo"."entry_type" AS ENUM('episode', 'movie', 'special', 'extra');--> statement-breakpoint +ALTER TABLE "kyoo"."entries" ALTER COLUMN "kind" SET DATA TYPE "kyoo"."entry_type" USING "kind"::"kyoo"."entry_type";--> statement-breakpoint +ALTER TABLE "kyoo"."videos" ADD CONSTRAINT "rendering_unique" UNIQUE NULLS NOT DISTINCT("rendering","part","version"); \ No newline at end of file diff --git a/api/drizzle/meta/0020_snapshot.json b/api/drizzle/meta/0020_snapshot.json new file mode 100644 index 00000000..8aa20c91 --- /dev/null +++ b/api/drizzle/meta/0020_snapshot.json @@ -0,0 +1,1851 @@ +{ + "id": "0c44c1f6-0b4d-4beb-8f67-b6250f92c5e2", + "prevId": "4a892e3b-fbc3-426f-a86b-b298c6f89d71", + "version": "7", + "dialect": "postgresql", + "tables": { + "kyoo.entries": { + "name": "entries", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "entries_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "extra_kind": { + "name": "extra_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "air_date": { + "name": "air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "available_since": { + "name": "available_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "entry_kind": { + "name": "entry_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "entry_order": { + "name": "entry_order", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": { + "entry_name_trgm": { + "name": "entry_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "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.history": { + "name": "history", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "history_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "profile_pk": { + "name": "profile_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entry_pk": { + "name": "entry_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_pk": { + "name": "video_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "percent": { + "name": "percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "time": { + "name": "time", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "played_date": { + "name": "played_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "history_play_date": { + "name": "history_play_date", + "columns": [ + { + "expression": "played_date", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "history_profile_pk_profiles_pk_fk": { + "name": "history_profile_pk_profiles_pk_fk", + "tableFrom": "history", + "tableTo": "profiles", + "schemaTo": "kyoo", + "columnsFrom": ["profile_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "history_entry_pk_entries_pk_fk": { + "name": "history_entry_pk_entries_pk_fk", + "tableFrom": "history", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["entry_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "history_video_pk_videos_pk_fk": { + "name": "history_video_pk_videos_pk_fk", + "tableFrom": "history", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": ["video_pk"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "percent_valid": { + "name": "percent_valid", + "value": "\"kyoo\".\"history\".\"percent\" between 0 and 100" + } + }, + "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": { + "season_name_trgm": { + "name": "season_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "season_translations_pk_seasons_pk_fk": { + "name": "season_translations_pk_seasons_pk_fk", + "tableFrom": "season_translations", + "tableTo": "seasons", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "season_translations_pk_language_pk": { + "name": "season_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.seasons": { + "name": "seasons", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "seasons_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "show_fk": { + "name": "show_fk", + "columns": [ + { + "expression": "show_pk", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "season_nbr": { + "name": "season_nbr", + "columns": [ + { + "expression": "season_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seasons_show_pk_shows_pk_fk": { + "name": "seasons_show_pk_shows_pk_fk", + "tableFrom": "seasons", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "seasons_id_unique": { + "name": "seasons_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "seasons_slug_unique": { + "name": "seasons_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "seasons_showPk_seasonNumber_unique": { + "name": "seasons_showPk_seasonNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_pk", "season_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.show_translations": { + "name": "show_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trailer_url": { + "name": "trailer_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "name_trgm": { + "name": "name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "tags": { + "name": "tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "show_translations_pk_shows_pk_fk": { + "name": "show_translations_pk_shows_pk_fk", + "tableFrom": "show_translations", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_translations_pk_language_pk": { + "name": "show_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.shows": { + "name": "shows", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "shows_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "show_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "genres": { + "name": "genres", + "type": "genres[]", + "typeSchema": "kyoo", + "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": { + "name": "original", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collection_pk": { + "name": "collection_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "entries_count": { + "name": "entries_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "available_count": { + "name": "available_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kind": { + "name": "kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "rating": { + "name": "rating", + "columns": [ + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "startAir": { + "name": "startAir", + "columns": [ + { + "expression": "start_air", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shows_collection_pk_shows_pk_fk": { + "name": "shows_collection_pk_shows_pk_fk", + "tableFrom": "shows", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["collection_pk"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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.show_studio_join": { + "name": "show_studio_join", + "schema": "kyoo", + "columns": { + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "studio_pk": { + "name": "studio_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "show_studio_join_show_pk_shows_pk_fk": { + "name": "show_studio_join_show_pk_shows_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "show_studio_join_studio_pk_studios_pk_fk": { + "name": "show_studio_join_studio_pk_studios_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": ["studio_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_studio_join_show_pk_studio_pk_pk": { + "name": "show_studio_join_show_pk_studio_pk_pk", + "columns": ["show_pk", "studio_pk"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.studio_translations": { + "name": "studio_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 + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "studio_name_trgm": { + "name": "studio_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "studio_translations_pk_studios_pk_fk": { + "name": "studio_translations_pk_studios_pk_fk", + "tableFrom": "studio_translations", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "studio_translations_pk_language_pk": { + "name": "studio_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.studios": { + "name": "studios", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "studios_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 + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "studios_id_unique": { + "name": "studios_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "studios_slug_unique": { + "name": "studios_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.roles": { + "name": "roles", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "roles_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "staff_pk": { + "name": "staff_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "role_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "character": { + "name": "character", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "role_kind": { + "name": "role_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "role_order": { + "name": "role_order", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "roles_show_pk_shows_pk_fk": { + "name": "roles_show_pk_shows_pk_fk", + "tableFrom": "roles", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "roles_staff_pk_staff_pk_fk": { + "name": "roles_staff_pk_staff_pk_fk", + "tableFrom": "roles", + "tableTo": "staff", + "schemaTo": "kyoo", + "columnsFrom": ["staff_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.staff": { + "name": "staff", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "staff_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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latin_name": { + "name": "latin_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_id_unique": { + "name": "staff_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "staff_slug_unique": { + "name": "staff_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.entry_video_join": { + "name": "entry_video_join", + "schema": "kyoo", + "columns": { + "entry_pk": { + "name": "entry_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_pk": { + "name": "video_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "entry_video_join_entry_pk_entries_pk_fk": { + "name": "entry_video_join_entry_pk_entries_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["entry_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_video_join_video_pk_videos_pk_fk": { + "name": "entry_video_join_video_pk_videos_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": ["video_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_video_join_entry_pk_video_pk_pk": { + "name": "entry_video_join_entry_pk_video_pk_pk", + "columns": ["entry_pk", "video_pk"] + } + }, + "uniqueConstraints": { + "entry_video_join_slug_unique": { + "name": "entry_video_join_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.videos": { + "name": "videos", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "videos_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendering": { + "name": "rendering", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "guess": { + "name": "guess", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "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"] + }, + "rendering_unique": { + "name": "rendering_unique", + "nullsNotDistinct": true, + "columns": ["rendering", "part", "version"] + } + }, + "policies": {}, + "checkConstraints": { + "part_pos": { + "name": "part_pos", + "value": "\"kyoo\".\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"kyoo\".\"videos\".\"version\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.profiles": { + "name": "profiles", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "profiles_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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profiles_id_unique": { + "name": "profiles_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.mqueue": { + "name": "mqueue", + "schema": "kyoo", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mqueue_created": { + "name": "mqueue_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.watchlist": { + "name": "watchlist", + "schema": "kyoo", + "columns": { + "profile_pk": { + "name": "profile_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "watchlist_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "seen_count": { + "name": "seen_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_entry": { + "name": "next_entry", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_played_at": { + "name": "last_played_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "watchlist_profile_pk_profiles_pk_fk": { + "name": "watchlist_profile_pk_profiles_pk_fk", + "tableFrom": "watchlist", + "tableTo": "profiles", + "schemaTo": "kyoo", + "columnsFrom": ["profile_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_show_pk_shows_pk_fk": { + "name": "watchlist_show_pk_shows_pk_fk", + "tableFrom": "watchlist", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_next_entry_entries_pk_fk": { + "name": "watchlist_next_entry_entries_pk_fk", + "tableFrom": "watchlist", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["next_entry"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watchlist_profile_pk_show_pk_pk": { + "name": "watchlist_profile_pk_show_pk_pk", + "columns": ["profile_pk", "show_pk"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "score_percent": { + "name": "score_percent", + "value": "\"kyoo\".\"watchlist\".\"score\" between 0 and 100" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "kyoo.entry_type": { + "name": "entry_type", + "schema": "kyoo", + "values": ["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", "collection"] + }, + "kyoo.show_status": { + "name": "show_status", + "schema": "kyoo", + "values": ["unknown", "finished", "airing", "planned"] + }, + "kyoo.role_kind": { + "name": "role_kind", + "schema": "kyoo", + "values": ["actor", "director", "writter", "producer", "music", "other"] + }, + "kyoo.watchlist_status": { + "name": "watchlist_status", + "schema": "kyoo", + "values": ["watching", "rewatching", "completed", "dropped", "planned"] + } + }, + "schemas": { + "kyoo": "kyoo" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index a16ce389..34ec2fa1 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1744120518941, "tag": "0019_nextup", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1746198322219, + "tag": "0020_video_unique", + "breakpoints": true } ] } diff --git a/api/package.json b/api/package.json index 87c27bd2..1206b686 100644 --- a/api/package.json +++ b/api/package.json @@ -11,21 +11,21 @@ "dependencies": { "@elysiajs/swagger": "zoriya/elysia-swagger#build", "blurhash": "^2.0.5", - "drizzle-kit": "^0.30.4", - "drizzle-orm": "0.39.0", - "elysia": "^1.2.23", + "drizzle-kit": "^0.31.0", + "drizzle-orm": "0.43.1", + "elysia": "^1.2.25", "jose": "^6.0.10", "parjs": "^1.3.9", - "pg": "^8.13.3", - "sharp": "^0.34.0" + "pg": "^8.15.6", + "sharp": "^0.34.1" }, "devDependencies": { - "@types/pg": "^8.11.11", + "@types/pg": "^8.11.14", "node-addon-api": "^8.3.1", - "bun-types": "^1.2.4" + "bun-types": "^1.2.11" }, "module": "src/index.js", "patchedDependencies": { - "drizzle-orm@0.39.0": "patches/drizzle-orm@0.39.0.patch" + "drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch" } } diff --git a/api/patches/drizzle-orm@0.39.0.patch b/api/patches/drizzle-orm@0.39.0.patch deleted file mode 100644 index 52d2c30f..00000000 --- a/api/patches/drizzle-orm@0.39.0.patch +++ /dev/null @@ -1,251 +0,0 @@ -diff --git a/node_modules/drizzle-orm/.bun-tag-3622ae30f31c0d9a b/.bun-tag-3622ae30f31c0d9a -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -diff --git a/node_modules/drizzle-orm/.bun-tag-36446a2521398ee8 b/.bun-tag-36446a2521398ee8 -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -diff --git a/node_modules/drizzle-orm/.bun-tag-844efc51a55b820c b/.bun-tag-844efc51a55b820c -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -diff --git a/node_modules/drizzle-orm/.bun-tag-9fae835e61d5cc75 b/.bun-tag-9fae835e61d5cc75 -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -diff --git a/node_modules/drizzle-orm/.bun-tag-ce8efc9a806990a3 b/.bun-tag-ce8efc9a806990a3 -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -diff --git a/pg-core/dialect.cjs b/pg-core/dialect.cjs -index 52acbfb6038fb1bbba4e34115d75a22bb0f9ab1a..1f10884caf05329ab98b06a68c8e7803e5283d32 100644 ---- a/pg-core/dialect.cjs -+++ b/pg-core/dialect.cjs -@@ -347,7 +347,14 @@ class PgDialect { - buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) { - const valuesSqlList = []; - const columns = table[import_table2.Table.Symbol.Columns]; -- const colEntries = Object.entries(columns).filter(([_, col]) => !col.shouldDisableInsert()); -+ let colEntries = Object.entries(columns); -+ colEntries = select && !is(valuesOrSelect, SQL) -+ ? Object -+ .keys(valuesOrSelect._.selectedFields) -+ .map((key) => [key, columns[key]]) -+ : overridingSystemValue_ -+ ? colEntries -+ : colEntries.filter(([_, col]) => !col.shouldDisableInsert()); - const insertOrder = colEntries.map( - ([, column]) => import_sql2.sql.identifier(this.casing.getColumnCasing(column)) - ); -diff --git a/pg-core/dialect.js b/pg-core/dialect.js -index d7985c81f3d224f7671efe72e79b14153d5ca8ce..91d99ccd2ebda807a7d45c76f7164e571b922159 100644 ---- a/pg-core/dialect.js -+++ b/pg-core/dialect.js -@@ -345,7 +345,14 @@ class PgDialect { - buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) { - const valuesSqlList = []; - const columns = table[Table.Symbol.Columns]; -- const colEntries = Object.entries(columns).filter(([_, col]) => !col.shouldDisableInsert()); -+ let colEntries = Object.entries(columns); -+ colEntries = select && !is(valuesOrSelect, SQL) -+ ? Object -+ .keys(valuesOrSelect._.selectedFields) -+ .map((key) => [key, columns[key]]) -+ : overridingSystemValue_ -+ ? colEntries -+ : colEntries.filter(([_, col]) => !col.shouldDisableInsert()); - const insertOrder = colEntries.map( - ([, column]) => sql.identifier(this.casing.getColumnCasing(column)) - ); -diff --git a/pg-core/query-builders/insert.cjs b/pg-core/query-builders/insert.cjs -index 08bb0d7485ebf997e3f081e2254ea8fd8bc20f65..341d2513d4377acc33ee0606d05580566fd4b88c 100644 ---- a/pg-core/query-builders/insert.cjs -+++ b/pg-core/query-builders/insert.cjs -@@ -75,11 +75,6 @@ class PgInsertBuilder { - } - select(selectQuery) { - const select = typeof selectQuery === "function" ? selectQuery(new import_query_builder.QueryBuilder()) : selectQuery; -- if (!(0, import_entity.is)(select, import_sql.SQL) && !(0, import_utils.haveSameKeys)(this.table[import_table.Columns], select._.selectedFields)) { -- throw new Error( -- "Insert select error: selected fields are not the same or are in a different order compared to the table definition" -- ); -- } - return new PgInsertBase(this.table, select, this.session, this.dialect, this.withList, true); - } - } -diff --git a/pg-core/query-builders/insert.js b/pg-core/query-builders/insert.js -index 0fc8eeb80f4a5512f6c84f3d596832623a33b748..b993f226daf16f423db012dff828d89c522603c3 100644 ---- a/pg-core/query-builders/insert.js -+++ b/pg-core/query-builders/insert.js -@@ -51,11 +51,6 @@ class PgInsertBuilder { - } - select(selectQuery) { - const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery; -- if (!is(select, SQL) && !haveSameKeys(this.table[Columns], select._.selectedFields)) { -- throw new Error( -- "Insert select error: selected fields are not the same or are in a different order compared to the table definition" -- ); -- } - return new PgInsertBase(this.table, select, this.session, this.dialect, this.withList, true); - } - } -diff --git a/pg-core/query-builders/select.d.cts b/pg-core/query-builders/select.d.cts -index b968ebb3f563f37c8c36221dd17cc6f3603270ec..3fda6d0a97997f6bd07ec6a0c83397c0fdd2e97e 100644 ---- a/pg-core/query-builders/select.d.cts -+++ b/pg-core/query-builders/select.d.cts -@@ -98,7 +98,16 @@ export declare abstract class PgSelectQueryBuilderBase; -+ leftJoin: PgSelectJoinFn; -+ /** -+ * For each row of the table, include -+ * values from a matching row of the joined -+ * subquery, if there is a matching row. If not, -+ * all of the columns of the joined subquery -+ * will be set to null. The lateral keyword allows -+ * access to columns after the FROM statement. -+ */ -+ leftJoinLateral: PgSelectJoinFn; - /** - * Executes a `right join` operation by adding another table to the current query. - * -@@ -126,7 +135,7 @@ export declare abstract class PgSelectQueryBuilderBase; -+ rightJoin: PgSelectJoinFn; - /** - * Executes an `inner join` operation, creating a new table by combining rows from two tables that have matching values. - * -@@ -154,7 +163,14 @@ export declare abstract class PgSelectQueryBuilderBase; -+ innerJoin: PgSelectJoinFn; -+ /** -+ * For each row of the table, the joined subquery -+ * needs to have a matching row, or it will -+ * be excluded from results. The lateral keyword allows -+ * access to columns after the FROM statement. -+ */ -+ innerJoinLateral: PgSelectJoinFn; - /** - * Executes a `full join` operation by combining rows from two tables into a new table. - * -@@ -182,7 +198,7 @@ export declare abstract class PgSelectQueryBuilderBase; -+ fullJoin: PgSelectJoinFn; - private createSetOperator; - /** - * Adds `union` set operator to the query. -diff --git a/pg-core/query-builders/select.d.ts b/pg-core/query-builders/select.d.ts -index d44256289ffe7bd19d3f3af98cbd9ba0fc7efc57..f106eb28a919e0182f833632ace36ea7f87f9a88 100644 ---- a/pg-core/query-builders/select.d.ts -+++ b/pg-core/query-builders/select.d.ts -@@ -98,7 +98,16 @@ export declare abstract class PgSelectQueryBuilderBase; -+ leftJoin: PgSelectJoinFn; -+ /** -+ * For each row of the table, include -+ * values from a matching row of the joined -+ * subquery, if there is a matching row. If not, -+ * all of the columns of the joined subquery -+ * will be set to null. The lateral keyword allows -+ * access to columns after the FROM statement. -+ */ -+ leftJoinLateral: PgSelectJoinFn; - /** - * Executes a `right join` operation by adding another table to the current query. - * -@@ -126,7 +135,7 @@ export declare abstract class PgSelectQueryBuilderBase; -+ rightJoin: PgSelectJoinFn; - /** - * Executes an `inner join` operation, creating a new table by combining rows from two tables that have matching values. - * -@@ -154,7 +163,14 @@ export declare abstract class PgSelectQueryBuilderBase; -+ innerJoin: PgSelectJoinFn; -+ /** -+ * For each row of the table, the joined subquery -+ * needs to have a matching row, or it will -+ * be excluded from results. The lateral keyword allows -+ * access to columns after the FROM statement. -+ */ -+ innerJoinLateral: PgSelectJoinFn; - /** - * Executes a `full join` operation by combining rows from two tables into a new table. - * -@@ -182,7 +198,7 @@ export declare abstract class PgSelectQueryBuilderBase; -+ fullJoin: PgSelectJoinFn; - private createSetOperator; - /** - * Adds `union` set operator to the query. -diff --git a/pg-core/query-builders/select.js b/pg-core/query-builders/select.js -index e54406fcaf68ccfdaf32c8945d4d432212c4cf3f..5c514132f30366ee600b9530c284932d54f481f3 100644 ---- a/pg-core/query-builders/select.js -+++ b/pg-core/query-builders/select.js -@@ -98,7 +98,7 @@ class PgSelectQueryBuilderBase extends TypedQueryBuilder { - this.tableName = getTableLikeName(table); - this.joinsNotNullableMap = typeof this.tableName === "string" ? { [this.tableName]: true } : {}; - } -- createJoin(joinType) { -+ createJoin(joinType, lateral = false) { - return (table, on) => { - const baseTableName = this.tableName; - const tableName = getTableLikeName(table); -@@ -127,7 +127,7 @@ class PgSelectQueryBuilderBase extends TypedQueryBuilder { - if (!this.config.joins) { - this.config.joins = []; - } -- this.config.joins.push({ on, table, joinType, alias: tableName }); -+ this.config.joins.push({ on, table, joinType, alias: tableName, lateral }); - if (typeof tableName === "string") { - switch (joinType) { - case "left": { -@@ -185,6 +185,15 @@ class PgSelectQueryBuilderBase extends TypedQueryBuilder { - * ``` - */ - leftJoin = this.createJoin("left"); -+ /** -+ * For each row of the table, include -+ * values from a matching row of the joined -+ * subquery, if there is a matching row. If not, -+ * all of the columns of the joined subquery -+ * will be set to null. The lateral keyword allows -+ * access to columns after the FROM statement. -+ */ -+ leftJoinLateral = this.createJoin("left", true); - /** - * Executes a `right join` operation by adding another table to the current query. - * -@@ -241,6 +250,13 @@ class PgSelectQueryBuilderBase extends TypedQueryBuilder { - * ``` - */ - innerJoin = this.createJoin("inner"); -+ /** -+ * For each row of the table, the joined subquery -+ * needs to have a matching row, or it will -+ * be excluded from results. The lateral keyword allows -+ * access to columns after the FROM statement. -+ */ -+ innerJoinLateral = this.createJoin("inner", true); - /** - * Executes a `full join` operation by combining rows from two tables into a new table. - * diff --git a/api/patches/drizzle-orm@0.43.1.patch b/api/patches/drizzle-orm@0.43.1.patch new file mode 100644 index 00000000..92b380ff --- /dev/null +++ b/api/patches/drizzle-orm@0.43.1.patch @@ -0,0 +1,72 @@ +diff --git a/pg-core/dialect.cjs b/pg-core/dialect.cjs +index a0ef03142f21d319376bc50070ff7fdcd4d18132..45fc94e5a7c3fa4c201e636dd227122164e1bd02 100644 +--- a/pg-core/dialect.cjs ++++ b/pg-core/dialect.cjs +@@ -348,7 +348,14 @@ class PgDialect { + buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) { + const valuesSqlList = []; + const columns = table[import_table2.Table.Symbol.Columns]; +- const colEntries = Object.entries(columns).filter(([_, col]) => !col.shouldDisableInsert()); ++ let colEntries = Object.entries(columns); ++ colEntries = select && !is(valuesOrSelect, SQL) ++ ? Object ++ .keys(valuesOrSelect._.selectedFields) ++ .map((key) => [key, columns[key]]) ++ : overridingSystemValue_ ++ ? colEntries ++ : colEntries.filter(([_, col]) => !col.shouldDisableInsert()); + const insertOrder = colEntries.map( + ([, column]) => import_sql2.sql.identifier(this.casing.getColumnCasing(column)) + ); +diff --git a/pg-core/dialect.js b/pg-core/dialect.js +index 120aaed9c3e4ae0a24653893379b98506c866f6f..48df463c0a6d5864fe2c324c8f86432860e50e00 100644 +--- a/pg-core/dialect.js ++++ b/pg-core/dialect.js +@@ -346,7 +346,14 @@ class PgDialect { + buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) { + const valuesSqlList = []; + const columns = table[Table.Symbol.Columns]; +- const colEntries = Object.entries(columns).filter(([_, col]) => !col.shouldDisableInsert()); ++ let colEntries = Object.entries(columns); ++ colEntries = select && !is(valuesOrSelect, SQL) ++ ? Object ++ .keys(valuesOrSelect._.selectedFields) ++ .map((key) => [key, columns[key]]) ++ : overridingSystemValue_ ++ ? colEntries ++ : colEntries.filter(([_, col]) => !col.shouldDisableInsert()); + const insertOrder = colEntries.map( + ([, column]) => sql.identifier(this.casing.getColumnCasing(column)) + ); +diff --git a/pg-core/query-builders/insert.cjs b/pg-core/query-builders/insert.cjs +index 08bb0d7485ebf997e3f081e2254ea8fd8bc20f65..20c8036374a1f25f7c5880c40e8d3c42c05f3eee 100644 +--- a/pg-core/query-builders/insert.cjs ++++ b/pg-core/query-builders/insert.cjs +@@ -75,11 +75,6 @@ class PgInsertBuilder { + } + select(selectQuery) { + const select = typeof selectQuery === "function" ? selectQuery(new import_query_builder.QueryBuilder()) : selectQuery; +- if (!(0, import_entity.is)(select, import_sql.SQL) && !(0, import_utils.haveSameKeys)(this.table[import_table.Columns], select._.selectedFields)) { +- throw new Error( +- "Insert select error: selected fields are not the same or are in a different order compared to the table definition" +- ); +- } + return new PgInsertBase(this.table, select, this.session, this.dialect, this.withList, true); + } + } +diff --git a/pg-core/query-builders/insert.js b/pg-core/query-builders/insert.js +index 0fc8eeb80f4a5512f6c84f3d596832623a33b748..998e2ab0bfe3f322bf268a01f71ebd06c57d4d07 100644 +--- a/pg-core/query-builders/insert.js ++++ b/pg-core/query-builders/insert.js +@@ -51,11 +51,6 @@ class PgInsertBuilder { + } + select(selectQuery) { + const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery; +- if (!is(select, SQL) && !haveSameKeys(this.table[Columns], select._.selectedFields)) { +- throw new Error( +- "Insert select error: selected fields are not the same or are in a different order compared to the table definition" +- ); +- } + return new PgInsertBase(this.table, select, this.session, this.dialect, this.withList, true); + } + } diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 2e213a5d..dce5edae 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -26,7 +26,6 @@ import { ExtraType, MovieEntry, Special, - UnknownEntry, } from "~/models/entry"; import { KError } from "~/models/error"; import { madeInAbyss } from "~/models/examples"; @@ -81,11 +80,6 @@ const extraFilters: FilterDef = { playedDate: { column: entryProgressQ.playedDate, type: "date" }, }; -const unknownFilters: FilterDef = { - runtime: { column: entries.runtime, type: "float" }, - playedDate: { column: entryProgressQ.playedDate, type: "date" }, -}; - export const entrySort = Sort( { order: entries.order, @@ -149,13 +143,15 @@ export const entryVideosQ = db export const mapProgress = ({ aliased }: { aliased: boolean }) => { const { time, percent, playedDate, videoId } = getColumns(entryProgressQ); const ret = { - time: coalesce(time, sql`0`), - percent: coalesce(percent, sql`0`), - playedDate: sql`${playedDate}`, - videoId: sql`${videoId}`, + time: coalesce(time, sql`0`), + percent: coalesce(percent, sql`0`), + playedDate: sql`${playedDate}`, + videoId: sql`${videoId}`, }; if (!aliased) return ret; - return Object.fromEntries(Object.entries(ret).map(([k, v]) => [k, v.as(k)])); + return Object.fromEntries( + Object.entries(ret).map(([k, v]) => [k, v.as(k)]), + ) as unknown as typeof ret; }; export async function getEntries({ @@ -176,7 +172,7 @@ export async function getEntries({ languages: string[]; userId: string; progressQ?: typeof entryProgressQ; -}): Promise<(Entry | Extra | UnknownEntry)[]> { +}): Promise<(Entry | Extra)[]> { const transQ = db .selectDistinctOn([entryTranslations.pk]) .from(entryTranslations) @@ -203,7 +199,7 @@ export async function getEntries({ videos: entryVideosQ.videos, progress: mapProgress({ aliased: true }), // specials don't have an `episodeNumber` but a `number` field. - number: episodeNumber, + number: sql`${episodeNumber}`, // merge `extraKind` into `kind` kind: sql`case when ${kind} = 'extra' then ${extraKind} else ${kind}::text end`.as( @@ -219,7 +215,7 @@ export async function getEntries({ }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) - .leftJoinLateral(entryVideosQ, sql`true`) + .crossJoinLateral(entryVideosQ) .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) .where( and( @@ -244,7 +240,6 @@ export const entriesH = new Elysia({ tags: ["series"] }) movie_entry: MovieEntry, special: Special, extra: Extra, - unknown_entry: UnknownEntry, error: t.Object({}), }) .model((models) => ({ @@ -289,7 +284,6 @@ export const entriesH = new Elysia({ tags: ["series"] }) filter: and( eq(entries.showPk, serie.pk), ne(entries.kind, "extra"), - ne(entries.kind, "unknown"), filter, ), languages: langs, @@ -407,46 +401,6 @@ export const entriesH = new Elysia({ tags: ["series"] }) }, }, ) - .get( - "/unknowns", - async ({ - query: { limit, after, query, sort, filter }, - request: { url }, - jwt: { sub }, - }) => { - const items = (await getEntries({ - limit, - after, - query, - sort: sort, - filter: and(eq(entries.kind, "unknown"), filter), - languages: ["extra"], - userId: sub, - })) as UnknownEntry[]; - - return createPage(items, { url, sort, limit }); - }, - { - detail: { description: "Get unknown/unmatch videos." }, - query: t.Object({ - sort: extraSort, - filter: t.Optional(Filter({ def: unknownFilters })), - query: t.Optional(t.String({ description: description.query })), - limit: t.Integer({ - minimum: 1, - maximum: 250, - default: 50, - description: "Max page size.", - }), - after: t.Optional(t.String({ description: description.after })), - }), - response: { - 200: Page(UnknownEntry), - 422: KError, - }, - tags: ["videos"], - }, - ) .get( "/news", async ({ @@ -462,7 +416,6 @@ export const entriesH = new Elysia({ tags: ["series"] }) sort, filter: and( isNotNull(entries.availableSince), - ne(entries.kind, "unknown"), ne(entries.kind, "extra"), filter, ), @@ -489,6 +442,6 @@ export const entriesH = new Elysia({ tags: ["series"] }) 200: Page(Entry), 422: KError, }, - tags: ["videos"], + tags: ["shows"], }, ); diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index ec605ea1..fdf239c2 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -18,6 +18,7 @@ import { processLanguages, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; +import type { WatchlistStatus } from "~/models/watchlist"; import { entryFilters, entryProgressQ, @@ -79,7 +80,6 @@ export const historyH = new Elysia({ tags: ["profiles"] }) filter: and( isNotNull(entryProgressQ.playedDate), ne(entries.kind, "extra"), - ne(entries.kind, "unknown"), filter, ), languages: langs, @@ -125,7 +125,6 @@ export const historyH = new Elysia({ tags: ["profiles"] }) filter: and( isNotNull(entryProgressQ.playedDate), ne(entries.kind, "extra"), - ne(entries.kind, "unknown"), filter, ), languages: langs, @@ -167,8 +166,14 @@ export const historyH = new Elysia({ tags: ["profiles"] }) async ({ body, jwt: { sub }, error }) => { const profilePk = await getOrCreateProfile(sub); - const vals = values( + const hist = values( body.map((x) => ({ ...x, entryUseId: isUuid(x.entry) })), + { + percent: "integer", + time: "integer", + playedDate: "timestamptz", + videoId: "uuid", + }, ).as("hist"); const valEqEntries = sql` case @@ -182,16 +187,16 @@ export const historyH = new Elysia({ tags: ["profiles"] }) .select( db .select({ - profilePk: sql`${profilePk}`, + profilePk: sql`${profilePk}`.as("profilePk"), entryPk: entries.pk, videoPk: videos.pk, - percent: sql`hist.percent::integer`, - time: sql`hist.time::integer`, - playedDate: sql`hist.playedDate::timestamptz`, + percent: sql`hist.percent`.as("percent"), + time: sql`hist.time`.as("time"), + playedDate: sql`hist.playedDate`.as("playedDate"), }) - .from(vals) + .from(hist) .innerJoin(entries, valEqEntries) - .leftJoin(videos, eq(videos.id, sql`hist.videoId::uuid`)), + .leftJoin(videos, eq(videos.id, sql`hist.videoId`)), ) .returning({ pk: history.pk }); @@ -244,43 +249,43 @@ export const historyH = new Elysia({ tags: ["profiles"] }) .select( db .select({ - profilePk: sql`${profilePk}`, + profilePk: sql`${profilePk}`.as("profilePk"), showPk: entries.showPk, - status: sql` + status: sql` case when - hist.percent::integer >= 95 + hist.percent >= 95 and ${nextEntryQ.pk} is null then 'completed'::watchlist_status else 'watching'::watchlist_status end - `, + `.as("status"), seenCount: sql` case - when ${entries.kind} = 'movie' then hist.percent::integer - when hist.percent::integer >= 95 then 1 + when ${entries.kind} = 'movie' then hist.percent + when hist.percent >= 95 then 1 else 0 end - `, + `.as("seen_count"), nextEntry: sql` case - when hist.percent::integer >= 95 then ${nextEntryQ.pk} + when hist.percent >= 95 then ${nextEntryQ.pk} else ${entries.pk} end - `, - score: sql`null`, - startedAt: sql`hist.playedDate::timestamptz`, - lastPlayedAt: sql`hist.playedDate::timestamptz`, + `.as("next_entry"), + score: sql`null`.as("score"), + startedAt: sql`hist.playedDate`.as("startedAt"), + lastPlayedAt: sql`hist.playedDate`.as("lastPlayedAt"), completedAt: sql` case - when ${nextEntryQ.pk} is null then hist.playedDate::timestamptz + when ${nextEntryQ.pk} is null then hist.playedDate else null end - `, + `.as("completedAt"), // see https://github.com/drizzle-team/drizzle-orm/issues/3608 - updatedAt: sql`now()`, + updatedAt: sql`now()`.as("updatedAt"), }) - .from(vals) + .from(hist) .leftJoin(entries, valEqEntries) .leftJoinLateral(nextEntryQ, sql`true`), ) diff --git a/api/src/controllers/profiles/nextup.ts b/api/src/controllers/profiles/nextup.ts index 6df5d360..142ae634 100644 --- a/api/src/controllers/profiles/nextup.ts +++ b/api/src/controllers/profiles/nextup.ts @@ -90,6 +90,7 @@ export const nextup = new Elysia({ tags: ["profiles"] }) seasonNumber, episodeNumber, extraKind, + kind, ...entryCol } = getColumns(entries); @@ -100,9 +101,10 @@ export const nextup = new Elysia({ tags: ["profiles"] }) videos: entryVideosQ.videos, progress: mapProgress({ aliased: true }), // specials don't have an `episodeNumber` but a `number` field. - number: episodeNumber, + number: sql`${episodeNumber}`, // assign more restrained types to make typescript happy. + kind: sql`${kind}`, externalId: sql`${externalId}`, order: sql`${order}`, seasonNumber: sql`${seasonNumber}`, @@ -112,7 +114,7 @@ export const nextup = new Elysia({ tags: ["profiles"] }) .from(entries) .innerJoin(watchlist, eq(watchlist.nextEntry, entries.pk)) .innerJoin(transQ, eq(entries.pk, transQ.pk)) - .leftJoinLateral(entryVideosQ, sql`true`) + .crossJoinLateral(entryVideosQ) .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) .where( and( diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 10ce07fa..e91c882a 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -1,4 +1,4 @@ -import { type Column, type SQL, and, eq, isNull, sql } from "drizzle-orm"; +import { type Column, type SQL, eq, sql } from "drizzle-orm"; import { db } from "~/db"; import { entries, @@ -6,11 +6,11 @@ import { entryVideoJoin, videos, } from "~/db/schema"; -import { conflictUpdateAllExcept, sqlarr, values } from "~/db/utils"; +import { conflictUpdateAllExcept, values } from "~/db/utils"; import type { SeedEntry as SEntry, SeedExtra as SExtra } from "~/models/entry"; import { enqueueOptImage } from "../images"; import { guessNextRefresh } from "../refresh"; -import { updateAvailableCount } from "./shows"; +import { updateAvailableCount, updateAvailableSince } from "./shows"; type SeedEntry = SEntry & { video?: undefined; @@ -167,15 +167,21 @@ export const insertEntries = async ( .select( db .select({ - entryPk: sql`vids.entryPk::integer`.as("entry"), + entryPk: sql`vids.entryPk`.as("entry"), videoPk: videos.pk, slug: computeVideoSlug( - sql`vids.entrySlug::text`, - sql`vids.needRendering::boolean`, + sql`vids.entrySlug`, + sql`vids.needRendering`, ), }) - .from(values(vids).as("vids")) - .innerJoin(videos, eq(videos.id, sql`vids.videoId::uuid`)), + .from( + values(vids, { + entryPk: "integer", + needRendering: "boolean", + videoId: "uuid", + }).as("vids"), + ) + .innerJoin(videos, eq(videos.id, sql`vids.videoId`)), ) .onConflictDoNothing() .returning({ @@ -186,16 +192,7 @@ export const insertEntries = async ( if (!onlyExtras) await updateAvailableCount(tx, [show.pk], show.kind === "serie"); - const entriesPk = [...new Set(vids.map((x) => x.entryPk))]; - await tx - .update(entries) - .set({ availableSince: sql`now()` }) - .where( - and( - eq(entries.pk, sql`any(${sqlarr(entriesPk)})`), - isNull(entries.availableSince), - ), - ); + await updateAvailableSince(tx, [...new Set(vids.map((x) => x.entryPk))]); return ret; }); @@ -206,10 +203,10 @@ export const insertEntries = async ( })); }; -export function computeVideoSlug(showSlug: SQL | Column, needsRendering: SQL) { +export function computeVideoSlug(entrySlug: SQL | Column, needsRendering: SQL) { return sql` concat( - ${showSlug}, + ${entrySlug}, case when ${videos.part} is not null then ('-p' || ${videos.part}) else '' end, case when ${videos.version} <> 1 then ('-v' || ${videos.version}) else '' end, case when ${needsRendering} then concat('-', ${videos.rendering}) else '' end diff --git a/api/src/controllers/seed/insert/shows.ts b/api/src/controllers/seed/insert/shows.ts index fcc66ea3..6841e8fb 100644 --- a/api/src/controllers/seed/insert/shows.ts +++ b/api/src/controllers/seed/insert/shows.ts @@ -1,4 +1,13 @@ -import { and, count, eq, exists, ne, sql } from "drizzle-orm"; +import { + type SQLWrapper, + and, + count, + eq, + exists, + isNull, + ne, + sql, +} from "drizzle-orm"; import { type Transaction, db } from "~/db"; import { entries, entryVideoJoin, showTranslations, shows } from "~/db/schema"; import { conflictUpdateAllExcept, sqlarr } from "~/db/utils"; @@ -138,9 +147,10 @@ async function insertBaseShow(tx: Transaction, show: Show) { export async function updateAvailableCount( tx: Transaction, - showPks: number[], - updateEntryCount = true, + showPks: number[] | SQLWrapper, + updateEntryCount = false, ) { + const showPkQ = Array.isArray(showPks) ? sqlarr(showPks) : showPks; return await tx .update(shows) .set({ @@ -168,5 +178,20 @@ export async function updateAvailableCount( )}`, }), }) - .where(eq(shows.pk, sql`any(${sqlarr(showPks)})`)); + .where(eq(shows.pk, sql`any(${showPkQ})`)); +} + +export async function updateAvailableSince( + tx: Transaction, + entriesPk: number[], +) { + return await tx + .update(entries) + .set({ availableSince: sql`now()` }) + .where( + and( + eq(entries.pk, sql`any(${sqlarr(entriesPk)})`), + isNull(entries.availableSince), + ), + ); } diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 71c6e567..26c2af66 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -41,7 +41,7 @@ import { entryProgressQ, entryVideosQ, mapProgress } from "../entries"; export const watchStatusQ = db .select({ ...getColumns(watchlist), - percent: sql`${watchlist.seenCount}`.as("percent"), + percent: sql`${watchlist.seenCount}`.as("percent"), }) .from(watchlist) .leftJoin(profiles, eq(watchlist.profilePk, profiles.pk)) @@ -161,9 +161,9 @@ const showRelations = { ).as("videos"), }) .from(entryVideoJoin) + .innerJoin(entries, eq(entries.showPk, shows.pk)) + .innerJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .where(eq(entryVideoJoin.entryPk, entries.pk)) - .leftJoin(entries, eq(entries.showPk, shows.pk)) - .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); }, firstEntry: ({ languages }: { languages: string[] }) => { @@ -190,7 +190,7 @@ const showRelations = { .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) - .leftJoinLateral(entryVideosQ, sql`true`) + .crossJoinLateral(entryVideosQ) .where(and(eq(entries.showPk, shows.pk), ne(entries.kind, "extra"))) .orderBy(entries.order) .limit(1) @@ -220,7 +220,7 @@ const showRelations = { .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) - .leftJoinLateral(entryVideosQ, sql`true`) + .crossJoinLateral(entryVideosQ) .where(eq(watchStatusQ.nextEntry, entries.pk)) .as("nextEntry"); }, diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index ade6c208..0fe22fc5 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -1,24 +1,44 @@ -import { and, eq, exists, inArray, not, sql } from "drizzle-orm"; +import { and, eq, exists, inArray, not, notExists, or, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { Elysia, t } from "elysia"; import { db } from "~/db"; import { entries, entryVideoJoin, shows, videos } from "~/db/schema"; -import { sqlarr } from "~/db/utils"; +import { + conflictUpdateAllExcept, + isUniqueConstraint, + jsonbBuildObject, + jsonbObjectAgg, + sqlarr, + values, +} from "~/db/utils"; +import { KError } from "~/models/error"; import { bubbleVideo } from "~/models/examples"; -import { Page } from "~/models/utils"; -import { SeedVideo, Video } from "~/models/video"; +import { + Page, + type Resource, + Sort, + createPage, + isUuid, + keysetPaginate, + sortToSql, +} from "~/models/utils"; +import { desc as description } from "~/models/utils/descriptions"; +import { Guesses, SeedVideo, Video } from "~/models/video"; import { comment } from "~/utils"; import { computeVideoSlug } from "./seed/insert/entries"; -import { updateAvailableCount } from "./seed/insert/shows"; +import { + updateAvailableCount, + updateAvailableSince, +} from "./seed/insert/shows"; const CreatedVideo = t.Object({ id: t.String({ format: "uuid" }), path: t.String({ examples: [bubbleVideo.path] }), - // entries: t.Array( - // t.Object({ - // slug: t.String({ format: "slug", examples: ["bubble-v2"] }), - // }), - // ), + entries: t.Array( + t.Object({ + slug: t.String({ format: "slug", examples: ["bubble-v2"] }), + }), + ), }); export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) @@ -27,93 +47,326 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) "created-videos": t.Array(CreatedVideo), error: t.Object({}), }) + .get( + "", + async () => { + const years = db.$with("years").as( + db + .select({ + guess: sql`${videos.guess}->>'title'`.as("guess"), + year: sql`coalesce(year, 'unknown')`.as("year"), + id: shows.id, + slug: shows.slug, + }) + .from(videos) + .leftJoin( + sql`jsonb_array_elements_text(${videos.guess}->'year') as year`, + sql`true`, + ) + .innerJoin(entryVideoJoin, eq(entryVideoJoin.videoPk, videos.pk)) + .innerJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) + .innerJoin(shows, eq(shows.pk, entries.showPk)), + ); + + const guess = db.$with("guess").as( + db + .select({ + guess: years.guess, + years: jsonbObjectAgg( + years.year, + jsonbBuildObject({ id: years.id, slug: years.slug }), + ).as("years"), + }) + .from(years) + .groupBy(years.guess), + ); + + const [{ guesses }] = await db + .with(years, guess) + .select({ + guesses: jsonbObjectAgg>( + guess.guess, + guess.years, + ), + }) + .from(guess); + + const paths = await db.select({ path: videos.path }).from(videos); + + const unmatched = await db + .select({ path: videos.path }) + .from(videos) + .where( + notExists( + db + .select() + .from(entryVideoJoin) + .where(eq(entryVideoJoin.videoPk, videos.pk)), + ), + ); + + return { + paths: paths.map((x) => x.path), + guesses: guesses ?? {}, + unmatched: unmatched.map((x) => x.path), + }; + }, + { + detail: { description: "Get all video registered & guessed made" }, + response: { + 200: Guesses, + }, + }, + ) + .get( + "unknowns", + async ({ query: { sort, query, limit, after }, request: { url } }) => { + const ret = await db + .select() + .from(videos) + .where( + and( + notExists( + db + .select() + .from(entryVideoJoin) + .where(eq(videos.pk, entryVideoJoin.videoPk)), + ), + query + ? or( + sql`${videos.path} %> ${query}::text`, + sql`${videos.guess}->'title' %> ${query}::text`, + ) + : undefined, + keysetPaginate({ after, sort }), + ), + ) + .orderBy(...(query ? [] : sortToSql(sort)), videos.pk) + .limit(limit); + return createPage(ret, { url, sort, limit }); + }, + { + detail: { description: "Get unknown/unmatch videos." }, + query: t.Object({ + sort: Sort( + { createdAt: videos.createdAt, path: videos.path }, + { default: ["-createdAt"], tablePk: videos.pk }, + ), + query: t.Optional(t.String({ description: description.query })), + limit: t.Integer({ + minimum: 1, + maximum: 250, + default: 50, + description: "Max page size.", + }), + after: t.Optional(t.String({ description: description.after })), + }), + response: { + 200: Page(Video), + 422: KError, + }, + }, + ) .post( "", async ({ body, error }) => { - const oldRet = await db - .insert(videos) - .values(body) - .onConflictDoNothing() - .returning({ - pk: videos.pk, - id: videos.id, - path: videos.path, - guess: videos.guess, - }); - return error(201, oldRet); + return await db.transaction(async (tx) => { + let vids: { pk: number; id: string; path: string }[] = []; + try { + vids = await tx + .insert(videos) + .values(body) + .onConflictDoUpdate({ + target: [videos.path], + set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), + }) + .returning({ + pk: videos.pk, + id: videos.id, + path: videos.path, + }); + } catch (e) { + if (!isUniqueConstraint(e)) throw e; + return error(409, { + status: 409, + message: comment` + Invalid rendering. A video with the same (rendering, part, version) combo + (but with a different path) already exists in db. - // TODO: this is a huge untested wip - // const vidsI = db.$with("vidsI").as( - // db.insert(videos).values(body).onConflictDoNothing().returning({ - // pk: videos.pk, - // id: videos.id, - // path: videos.path, - // guess: videos.guess, - // }), - // ); - // - // const findEntriesQ = db - // .select({ - // guess: videos.guess, - // entryPk: entries.pk, - // showSlug: shows.slug, - // // TODO: handle extras here - // // guessit can't know if an episode is a special or not. treat specials like a normal episode. - // kind: sql` - // case when ${entries.kind} = 'movie' then 'movie' else 'episode' end - // `.as("kind"), - // season: entries.seasonNumber, - // episode: entries.episodeNumber, - // }) - // .from(entries) - // .leftJoin(entryVideoJoin, eq(entryVideoJoin.entry, entries.pk)) - // .leftJoin(videos, eq(videos.pk, entryVideoJoin.video)) - // .leftJoin(shows, eq(shows.pk, entries.showPk)) - // .as("find_entries"); - // - // const hasRenderingQ = db - // .select() - // .from(entryVideoJoin) - // .where(eq(entryVideoJoin.entry, findEntriesQ.entryPk)); - // - // const ret = await db - // .with(vidsI) - // .insert(entryVideoJoin) - // .select( - // db - // .select({ - // entry: findEntriesQ.entryPk, - // video: vidsI.pk, - // slug: computeVideoSlug( - // findEntriesQ.showSlug, - // sql`exists(${hasRenderingQ})`, - // ), - // }) - // .from(vidsI) - // .leftJoin( - // findEntriesQ, - // and( - // eq( - // sql`${findEntriesQ.guess}->'title'`, - // sql`${vidsI.guess}->'title'`, - // ), - // // TODO: find if @> with a jsonb created on the fly is - // // better than multiples checks - // sql`${vidsI.guess} @> {"kind": }::jsonb`, - // inArray(findEntriesQ.kind, sql`${vidsI.guess}->'type'`), - // inArray(findEntriesQ.episode, sql`${vidsI.guess}->'episode'`), - // inArray(findEntriesQ.season, sql`${vidsI.guess}->'season'`), - // ), - // ), - // ) - // .onConflictDoNothing() - // .returning({ - // slug: entryVideoJoin.slug, - // entryPk: entryVideoJoin.entry, - // id: vidsI.id, - // path: vidsI.path, - // }); - // return error(201, ret as any); + rendering should be computed by the sha of your path (excluding only the version & part numbers) + `, + }); + } + + const vidEntries = body.flatMap((x) => { + if (!x.for) return []; + return x.for.map((e) => ({ + video: vids.find((v) => v.path === x.path)!.pk, + path: x.path, + entry: { + ...e, + movie: + "movie" in e + ? isUuid(e.movie) + ? { id: e.movie } + : { slug: e.movie } + : undefined, + serie: + "serie" in e + ? isUuid(e.serie) + ? { id: e.serie } + : { slug: e.serie } + : undefined, + }, + })); + }); + + if (!vidEntries.length) { + return error( + 201, + vids.map((x) => ({ id: x.id, path: x.path, entries: [] })), + ); + } + + const entriesQ = tx + .select({ + pk: entries.pk, + id: entries.id, + slug: entries.slug, + kind: entries.kind, + seasonNumber: entries.seasonNumber, + episodeNumber: entries.episodeNumber, + order: entries.order, + showId: sql`${shows.id}`.as("showId"), + showSlug: sql`${shows.slug}`.as("showSlug"), + externalId: entries.externalId, + }) + .from(entries) + .innerJoin(shows, eq(entries.showPk, shows.pk)) + .as("entriesQ"); + + const hasRenderingQ = tx + .select() + .from(entryVideoJoin) + .where(eq(entryVideoJoin.entryPk, entriesQ.pk)); + + const ret = await tx + .insert(entryVideoJoin) + .select( + tx + .selectDistinctOn([entriesQ.pk, videos.pk], { + entryPk: entriesQ.pk, + videoPk: videos.pk, + slug: computeVideoSlug( + entriesQ.slug, + sql`exists(${hasRenderingQ})`, + ), + }) + .from( + values(vidEntries, { + video: "integer", + entry: "jsonb", + }).as("j"), + ) + .innerJoin(videos, eq(videos.pk, sql`j.video`)) + .innerJoin( + entriesQ, + or( + and( + sql`j.entry ? 'slug'`, + eq(entriesQ.slug, sql`j.entry->>'slug'`), + ), + and( + sql`j.entry ? 'movie'`, + or( + eq( + entriesQ.showId, + sql`(j.entry #>> '{movie, id}')::uuid`, + ), + eq(entriesQ.showSlug, sql`j.entry #>> '{movie, slug}'`), + ), + eq(entriesQ.kind, "movie"), + ), + and( + sql`j.entry ? 'serie'`, + or( + eq( + entriesQ.showId, + sql`(j.entry #>> '{serie, id}')::uuid`, + ), + eq(entriesQ.showSlug, sql`j.entry #>> '{serie, slug}'`), + ), + or( + and( + sql`j.entry ?& array['season', 'episode']`, + eq( + entriesQ.seasonNumber, + sql`(j.entry->>'season')::integer`, + ), + eq( + entriesQ.episodeNumber, + sql`(j.entry->>'episode')::integer`, + ), + ), + and( + sql`j.entry ? 'order'`, + eq(entriesQ.order, sql`(j.entry->>'order')::float`), + ), + and( + sql`j.entry ? 'special'`, + eq( + entriesQ.episodeNumber, + sql`(j.entry->>'special')::integer`, + ), + eq(entriesQ.kind, "special"), + ), + ), + ), + and( + sql`j.entry ? 'externalId'`, + sql`j.entry->'externalId' <@ ${entriesQ.externalId}`, + ), + ), + ), + ) + .onConflictDoUpdate({ + target: [entryVideoJoin.entryPk, entryVideoJoin.videoPk], + // this is basically a `.onConflictDoNothing()` but we want `returning` to give us the existing data + set: { entryPk: sql`excluded.entry_pk` }, + }) + .returning({ + slug: entryVideoJoin.slug, + entryPk: entryVideoJoin.entryPk, + videoPk: entryVideoJoin.videoPk, + }); + const entr = ret.reduce( + (acc, x) => { + acc[x.videoPk] ??= []; + acc[x.videoPk].push({ slug: x.slug }); + return acc; + }, + {} as Record, + ); + + const entriesPk = [...new Set(ret.map((x) => x.entryPk))]; + await updateAvailableCount( + tx, + tx + .selectDistinct({ pk: entries.showPk }) + .from(entries) + .where(eq(entries.pk, sql`any(${sqlarr(entriesPk)})`)), + ); + await updateAvailableSince(tx, entriesPk); + + return error( + 201, + vids.map((x) => ({ + id: x.id, + path: x.path, + entries: entr[x.pk] ?? [], + })), + ); + }); }, { detail: { @@ -126,56 +379,61 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) `, }, body: t.Array(SeedVideo), - response: { 201: t.Array(CreatedVideo) }, + response: { + 201: t.Array(CreatedVideo), + 409: { + ...KError, + description: + "Invalid rendering specified. (conflicts with an existing video)", + }, + }, }, ) .delete( "", async ({ body }) => { - await db.transaction(async (tx) => { + return await db.transaction(async (tx) => { const vids = tx.$with("vids").as( tx .delete(videos) - .where(eq(videos.path, sql`any(${body})`)) - .returning({ pk: videos.pk }), + .where(eq(videos.path, sql`any(${sqlarr(body)})`)) + .returning({ pk: videos.pk, path: videos.path }), ); - const evj = alias(entryVideoJoin, "evj"); - const delEntries = tx.$with("del_entries").as( - tx - .with(vids) - .select({ entry: entryVideoJoin.entryPk }) - .from(entryVideoJoin) - .where( - and( - inArray(entryVideoJoin.videoPk, tx.select().from(vids)), - not( - exists( - tx - .select() - .from(evj) - .where( - and( - eq(evj.entryPk, entryVideoJoin.entryPk), - not(inArray(evj.videoPk, db.select().from(vids))), - ), - ), - ), - ), - ), - ), - ); - const delShows = await tx - .with(delEntries) + + const deletedJoin = await tx + .with(vids) + .select({ entryPk: entryVideoJoin.entryPk, path: vids.path }) + .from(entryVideoJoin) + .rightJoin(vids, eq(vids.pk, entryVideoJoin.videoPk)); + + const delEntries = await tx .update(entries) .set({ availableSince: null }) - .where(inArray(entries.pk, db.select().from(delEntries))) + .where( + and( + eq( + entries.pk, + sql`any(${sqlarr( + deletedJoin.filter((x) => x.entryPk).map((x) => x.entryPk!), + )})`, + ), + notExists( + tx + .select() + .from(entryVideoJoin) + .where(eq(entries.pk, entryVideoJoin.entryPk)), + ), + ), + ) .returning({ show: entries.showPk }); await updateAvailableCount( tx, - delShows.map((x) => x.show), + delEntries.map((x) => x.show), false, ); + + return [...new Set(deletedJoin.map((x) => x.path))]; }); }, { @@ -186,6 +444,6 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) examples: [bubbleVideo.path], }), ), - response: { 204: t.Void() }, + response: { 200: t.Array(t.String()) }, }, ); diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index 63c6298d..0c3a4a60 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -18,7 +18,6 @@ import { image, language, schema } from "./utils"; import { entryVideoJoin } from "./videos"; export const entryType = schema.enum("entry_type", [ - "unknown", "episode", "movie", "special", diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index a7c60d42..2e4fda6c 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -6,6 +6,7 @@ import { primaryKey, text, timestamp, + unique, uuid, varchar, } from "drizzle-orm/pg-core"; @@ -34,6 +35,9 @@ export const videos = schema.table( (t) => [ check("part_pos", sql`${t.part} >= 0`), check("version_pos", sql`${t.version} >= 0`), + unique("rendering_unique") + .on(t.rendering, t.part, t.version) + .nullsNotDistinct(), ], ); diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index c8f6c940..c57acffa 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -74,14 +74,22 @@ export function sqlarr(array: unknown[]) { } // See https://github.com/drizzle-team/drizzle-orm/issues/4044 -// TODO: type values (everything is a `text` for now) -export function values(items: Record[]) { - const [firstProp, ...props] = Object.keys(items[0]); +export function values( + items: Record[], + typeInfo: Partial> = {}, +) { + if (items[0] === undefined) + throw new Error("Invalid values, expecting at least one items"); + const [firstProp, ...props] = Object.keys(items[0]) as K[]; const values = items - .map((x) => { + .map((x, i) => { let ret = sql`(${x[firstProp]}`; + if (i === 0 && typeInfo[firstProp]) + ret = sql`${ret}::${sql.raw(typeInfo[firstProp])}`; for (const val of props) { ret = sql`${ret}, ${x[val]}`; + if (i === 0 && typeInfo[val]) + ret = sql`${ret}::${sql.raw(typeInfo[val])}`; } return sql`${ret})`; }) @@ -103,7 +111,10 @@ export const nullif = (val: SQL | Column, eq: SQL) => { return sql`nullif(${val}, ${eq})`; }; -export const jsonbObjectAgg = (key: SQLWrapper, value: SQL) => { +export const jsonbObjectAgg = ( + key: SQLWrapper, + value: SQL | SQLWrapper, +) => { return sql< Record >`jsonb_object_agg(${sql.join([key, value], sql.raw(","))})`; @@ -131,3 +142,9 @@ export const jsonbBuildObject = (select: JsonFields) => { ); return sql`jsonb_build_object(${query})`; }; + +export const isUniqueConstraint = (e: unknown): boolean => { + return ( + typeof e === "object" && e != null && "code" in e && e.code === "23505" + ); +}; diff --git a/api/src/models/collections.ts b/api/src/models/collections.ts index 91f6f8df..f3221ae3 100644 --- a/api/src/models/collections.ts +++ b/api/src/models/collections.ts @@ -45,7 +45,7 @@ export const CollectionTranslation = t.Object({ logo: t.Nullable(Image), }); -export const Collection = t.Intersect([ +export const Collection = t.Composite([ Resource(), CollectionTranslation, BaseCollection, @@ -64,7 +64,7 @@ export const FullCollection = t.Intersect([ ]); export type FullCollection = Prettify; -export const SeedCollection = t.Intersect([ +export const SeedCollection = t.Composite([ t.Omit(BaseCollection, ["kind", "startAir", "endAir", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug" }), @@ -72,7 +72,7 @@ export const SeedCollection = t.Intersect([ description: "The language code this collection's items were made in.", }), translations: TranslationRecord( - t.Intersect([ + t.Composite([ t.Omit(CollectionTranslation, [ "poster", "thumbnail", diff --git a/api/src/models/entry/episode.ts b/api/src/models/entry/episode.ts index 1d1460b5..1c4bb99b 100644 --- a/api/src/models/entry/episode.ts +++ b/api/src/models/entry/episode.ts @@ -12,7 +12,7 @@ import { import { EmbeddedVideo } from "../video"; import { BaseEntry, EntryTranslation } from "./base-entry"; -export const BaseEpisode = t.Intersect([ +export const BaseEpisode = t.Composite([ t.Object({ kind: t.Literal("episode"), order: t.Number({ minimum: 1, description: "Absolute playback order." }), @@ -23,7 +23,7 @@ export const BaseEpisode = t.Intersect([ BaseEntry(), ]); -export const Episode = t.Intersect([ +export const Episode = t.Composite([ Resource(), EntryTranslation(), BaseEpisode, @@ -35,7 +35,7 @@ export const Episode = t.Intersect([ ]); export type Episode = Prettify; -export const SeedEpisode = t.Intersect([ +export const SeedEpisode = t.Composite([ t.Omit(BaseEpisode, ["thumbnail", "nextRefresh"]), t.Object({ thumbnail: t.Nullable(SeedImage), diff --git a/api/src/models/entry/extra.ts b/api/src/models/entry/extra.ts index fc248ff2..10a60873 100644 --- a/api/src/models/entry/extra.ts +++ b/api/src/models/entry/extra.ts @@ -16,7 +16,7 @@ export const ExtraType = t.UnionEnum([ ]); export type ExtraType = typeof ExtraType.static; -export const BaseExtra = t.Intersect( +export const BaseExtra = t.Composite( [ t.Object({ kind: ExtraType, @@ -32,7 +32,7 @@ export const BaseExtra = t.Intersect( }, ); -export const Extra = t.Intersect([ +export const Extra = t.Composite([ Resource(), BaseExtra, t.Object({ @@ -42,7 +42,7 @@ export const Extra = t.Intersect([ ]); export type Extra = Prettify; -export const SeedExtra = t.Intersect([ +export const SeedExtra = t.Composite([ t.Omit(BaseExtra, ["thumbnail"]), t.Object({ slug: t.String({ format: "slug" }), diff --git a/api/src/models/entry/index.ts b/api/src/models/entry/index.ts index 199622f6..d4a338a3 100644 --- a/api/src/models/entry/index.ts +++ b/api/src/models/entry/index.ts @@ -3,7 +3,6 @@ import { Episode, SeedEpisode } from "./episode"; import type { Extra } from "./extra"; import { MovieEntry, SeedMovieEntry } from "./movie-entry"; import { SeedSpecial, Special } from "./special"; -import type { UnknownEntry } from "./unknown-entry"; export const Entry = t.Union([Episode, MovieEntry, Special]); export type Entry = Episode | MovieEntry | Special; @@ -11,10 +10,9 @@ export type Entry = Episode | MovieEntry | Special; export const SeedEntry = t.Union([SeedEpisode, SeedMovieEntry, SeedSpecial]); export type SeedEntry = SeedEpisode | SeedMovieEntry | SeedSpecial; -export type EntryKind = Entry["kind"] | Extra["kind"] | UnknownEntry["kind"]; +export type EntryKind = Entry["kind"] | Extra["kind"]; 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 index 30bdbac2..1a8df313 100644 --- a/api/src/models/entry/movie-entry.ts +++ b/api/src/models/entry/movie-entry.ts @@ -13,7 +13,7 @@ import { import { EmbeddedVideo } from "../video"; import { BaseEntry, EntryTranslation } from "./base-entry"; -export const BaseMovieEntry = t.Intersect( +export const BaseMovieEntry = t.Composite( [ t.Object({ kind: t.Literal("movie"), @@ -33,7 +33,7 @@ export const BaseMovieEntry = t.Intersect( }, ); -export const MovieEntryTranslation = t.Intersect([ +export const MovieEntryTranslation = t.Composite([ EntryTranslation(), t.Object({ tagline: t.Nullable(t.String()), @@ -41,7 +41,7 @@ export const MovieEntryTranslation = t.Intersect([ }), ]); -export const MovieEntry = t.Intersect([ +export const MovieEntry = t.Composite([ Resource(), MovieEntryTranslation, BaseMovieEntry, @@ -53,7 +53,7 @@ export const MovieEntry = t.Intersect([ ]); export type MovieEntry = Prettify; -export const SeedMovieEntry = t.Intersect([ +export const SeedMovieEntry = t.Composite([ t.Omit(BaseMovieEntry, ["thumbnail", "nextRefresh"]), t.Object({ slug: t.Optional(t.String({ format: "slug" })), diff --git a/api/src/models/entry/special.ts b/api/src/models/entry/special.ts index b687e67d..90b8cdd0 100644 --- a/api/src/models/entry/special.ts +++ b/api/src/models/entry/special.ts @@ -12,7 +12,7 @@ import { import { EmbeddedVideo } from "../video"; import { BaseEntry, EntryTranslation } from "./base-entry"; -export const BaseSpecial = t.Intersect( +export const BaseSpecial = t.Composite( [ t.Object({ kind: t.Literal("special"), @@ -33,7 +33,7 @@ export const BaseSpecial = t.Intersect( }, ); -export const Special = t.Intersect([ +export const Special = t.Composite([ Resource(), EntryTranslation(), BaseSpecial, @@ -45,7 +45,7 @@ export const Special = t.Intersect([ ]); export type Special = Prettify; -export const SeedSpecial = t.Intersect([ +export const SeedSpecial = t.Composite([ t.Omit(BaseSpecial, ["thumbnail", "nextRefresh"]), t.Object({ thumbnail: t.Nullable(SeedImage), diff --git a/api/src/models/entry/unknown-entry.ts b/api/src/models/entry/unknown-entry.ts deleted file mode 100644 index 5ae1c811..00000000 --- a/api/src/models/entry/unknown-entry.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { t } from "elysia"; -import { type Prettify, comment } from "~/utils"; -import { bubbleImages, registerExamples, youtubeExample } from "../examples"; -import { Progress } from "../history"; -import { DbMetadata, Resource } from "../utils"; -import { BaseEntry, EntryTranslation } from "./base-entry"; - -export const BaseUnknownEntry = t.Intersect( - [ - t.Object({ - kind: t.Literal("unknown"), - }), - t.Omit(BaseEntry(), ["airDate"]), - ], - { - 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(), - UnknownEntryTranslation, - BaseUnknownEntry, - t.Object({ - progress: t.Omit(Progress, ["videoId"]), - }), - DbMetadata, -]); -export type UnknownEntry = Prettify; - -registerExamples(UnknownEntry, { ...youtubeExample, ...bubbleImages }); diff --git a/api/src/models/examples/bubble.ts b/api/src/models/examples/bubble.ts index 1326f7b4..573c0cf4 100644 --- a/api/src/models/examples/bubble.ts +++ b/api/src/models/examples/bubble.ts @@ -3,7 +3,6 @@ import type { Video } from "~/models/video"; export const bubbleVideo: Video = { id: "3cd436ee-01ff-4f45-ba98-62aabeb22f25", - slug: "bubble", path: "/video/Bubble/Bubble (2022).mkv", rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd", part: null, diff --git a/api/src/models/examples/dune-1984.ts b/api/src/models/examples/dune-1984.ts index 34084f77..b417ed7e 100644 --- a/api/src/models/examples/dune-1984.ts +++ b/api/src/models/examples/dune-1984.ts @@ -3,7 +3,6 @@ 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, diff --git a/api/src/models/examples/dune-2021.ts b/api/src/models/examples/dune-2021.ts index 08cb3499..1c6b9427 100644 --- a/api/src/models/examples/dune-2021.ts +++ b/api/src/models/examples/dune-2021.ts @@ -3,7 +3,6 @@ import type { Video } from "~/models/video"; export const duneVideo: Video = { id: "c9a0d02e-6b8e-4ac1-b431-45b022ec0708", - slug: "dune", path: "/video/Dune/Dune (2021).mkv", rendering: "f1953a4fb58247efb6c15b76468b6a9d13b4155b02094863b1a4f0c3fbb6db58", part: null, diff --git a/api/src/models/examples/index.ts b/api/src/models/examples/index.ts index 7bc7a3ba..a5601358 100644 --- a/api/src/models/examples/index.ts +++ b/api/src/models/examples/index.ts @@ -35,4 +35,3 @@ export * from "./made-in-abyss"; export * from "./dune-1984"; export * from "./dune-2021"; export * from "./dune-collection"; -export * from "./others"; diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts index addcd277..58d1dfcf 100644 --- a/api/src/models/examples/made-in-abyss.ts +++ b/api/src/models/examples/made-in-abyss.ts @@ -3,7 +3,6 @@ import type { Video } from "~/models/video"; export const madeInAbyssVideo: Video = { id: "3cd436ee-01ff-4f45-ba98-654282531234", - slug: "made-in-abyss-s1e13", path: "/video/Made in abyss S01E13.mkv", rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd", part: null, diff --git a/api/src/models/examples/others.ts b/api/src/models/examples/others.ts deleted file mode 100644 index 41de7f6e..00000000 --- a/api/src/models/examples/others.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { UnknownEntry } from "~/models/entry"; - -export const youtubeExample: Partial = { - kind: "unknown", - // idk if we'll keep non-ascii characters or if we can find a way to convert them - slug: "lisa-炎-the-first-take", - name: "LiSA - 炎 / THE FIRST TAKE", - runtime: 10, - thumbnail: null, -}; diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 31f28487..d107474a 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -48,7 +48,7 @@ export const MovieTranslation = t.Object({ }); export type MovieTranslation = typeof MovieTranslation.static; -export const Movie = t.Intersect([ +export const Movie = t.Composite([ Resource(), MovieTranslation, BaseMovie, @@ -71,7 +71,7 @@ export const FullMovie = t.Intersect([ ]); export type FullMovie = Prettify; -export const SeedMovie = t.Intersect([ +export const SeedMovie = t.Composite([ t.Omit(BaseMovie, ["kind", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug", examples: ["bubble"] }), @@ -79,7 +79,7 @@ export const SeedMovie = t.Intersect([ description: "The language code this movie was made in.", }), translations: TranslationRecord( - t.Intersect([ + t.Composite([ t.Omit(MovieTranslation, ["poster", "thumbnail", "banner", "logo"]), t.Object({ poster: t.Nullable(SeedImage), diff --git a/api/src/models/season.ts b/api/src/models/season.ts index 225bbce9..79c11976 100644 --- a/api/src/models/season.ts +++ b/api/src/models/season.ts @@ -27,7 +27,7 @@ export const SeasonTranslation = t.Object({ }); export type SeasonTranslation = typeof SeasonTranslation.static; -export const Season = t.Intersect([ +export const Season = t.Composite([ Resource(), SeasonTranslation, BaseSeason, @@ -35,11 +35,11 @@ export const Season = t.Intersect([ ]); export type Season = Prettify; -export const SeedSeason = t.Intersect([ +export const SeedSeason = t.Composite([ t.Omit(BaseSeason, ["nextRefresh"]), t.Object({ translations: TranslationRecord( - t.Intersect([ + t.Composite([ t.Omit(SeasonTranslation, ["poster", "thumbnail", "banner"]), t.Object({ poster: t.Nullable(SeedImage), diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index 8a9bd942..e16a0ea6 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -58,7 +58,7 @@ export const SerieTranslation = t.Object({ }); export type SerieTranslation = typeof SerieTranslation.static; -export const Serie = t.Intersect([ +export const Serie = t.Composite([ Resource(), SerieTranslation, BaseSerie, @@ -87,7 +87,7 @@ export const FullSerie = t.Intersect([ ]); export type FullSerie = Prettify; -export const SeedSerie = t.Intersect([ +export const SeedSerie = t.Composite([ t.Omit(BaseSerie, ["kind", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug" }), @@ -95,7 +95,7 @@ export const SeedSerie = t.Intersect([ description: "The language code this serie was made in.", }), translations: TranslationRecord( - t.Intersect([ + t.Composite([ t.Omit(SerieTranslation, ["poster", "thumbnail", "banner", "logo"]), t.Object({ poster: t.Nullable(SeedImage), diff --git a/api/src/models/show.ts b/api/src/models/show.ts index 915ce7b4..5bcf6237 100644 --- a/api/src/models/show.ts +++ b/api/src/models/show.ts @@ -4,7 +4,8 @@ import { Movie } from "./movie"; import { Serie } from "./serie"; export const Show = t.Union([ - t.Intersect([Movie, t.Object({ kind: t.Literal("movie") })]), - t.Intersect([Serie, t.Object({ kind: t.Literal("serie") })]), - t.Intersect([Collection, t.Object({ kind: t.Literal("collection") })]), + t.Intersect([t.Object({ kind: t.Literal("movie") }), Movie]), + t.Intersect([t.Object({ kind: t.Literal("serie") }), Serie]), + t.Intersect([t.Object({ kind: t.Literal("collection") }), Collection]), ]); +export type Show = typeof Show.static; diff --git a/api/src/models/staff.ts b/api/src/models/staff.ts index 37aad31e..9d6fe403 100644 --- a/api/src/models/staff.ts +++ b/api/src/models/staff.ts @@ -28,19 +28,19 @@ const StaffData = t.Object({ image: t.Nullable(Image), externalId: ExternalId(), }); -export const Staff = t.Intersect([Resource(), StaffData, DbMetadata]); +export const Staff = t.Composite([Resource(), StaffData, DbMetadata]); export type Staff = typeof Staff.static; -export const SeedStaff = t.Intersect([ +export const SeedStaff = t.Composite([ t.Omit(Role, ["character"]), t.Object({ - character: t.Intersect([ + character: t.Composite([ t.Omit(Character, ["image"]), t.Object({ image: t.Nullable(SeedImage), }), ]), - staff: t.Intersect([ + staff: t.Composite([ t.Object({ slug: t.String({ format: "slug" }), image: t.Nullable(SeedImage), diff --git a/api/src/models/studio.ts b/api/src/models/studio.ts index c7b8beff..ce19ec0f 100644 --- a/api/src/models/studio.ts +++ b/api/src/models/studio.ts @@ -14,7 +14,7 @@ export const StudioTranslation = t.Object({ }); export type StudioTranslation = typeof StudioTranslation.static; -export const Studio = t.Intersect([ +export const Studio = t.Composite([ Resource(), StudioTranslation, BaseStudio, @@ -22,12 +22,12 @@ export const Studio = t.Intersect([ ]); export type Studio = Prettify; -export const SeedStudio = t.Intersect([ +export const SeedStudio = t.Composite([ BaseStudio, t.Object({ slug: t.String({ format: "slug" }), translations: TranslationRecord( - t.Intersect([ + t.Composite([ t.Omit(StudioTranslation, ["logo"]), t.Object({ logo: t.Nullable(SeedImage), diff --git a/api/src/models/utils/relations.ts b/api/src/models/utils/relations.ts index 2b800024..443cdf30 100644 --- a/api/src/models/utils/relations.ts +++ b/api/src/models/utils/relations.ts @@ -4,7 +4,7 @@ import type { SelectResultField } from "drizzle-orm/query-builders/select.types" export const buildRelations = < R extends string, P extends object, - Rel extends Record Subquery>, + Rel extends Record Subquery>, >( enabled: R[], relations: Rel, diff --git a/api/src/models/utils/resource.ts b/api/src/models/utils/resource.ts index 2fe7465d..878b956b 100644 --- a/api/src/models/utils/resource.ts +++ b/api/src/models/utils/resource.ts @@ -13,6 +13,7 @@ export const Resource = () => id: t.String({ format: "uuid" }), slug: t.String({ format: "slug" }), }); +export type Resource = ReturnType["static"]; const checker = TypeCompiler.Compile(t.String({ format: "uuid" })); export const isUuid = (id: string) => checker.Check(id); diff --git a/api/src/models/video.ts b/api/src/models/video.ts index a61644aa..f9cefaf2 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,7 +1,21 @@ +import { PatternStringExact } from "@sinclair/typebox"; import { t } from "elysia"; import { type Prettify, comment } from "~/utils"; -import { bubbleVideo, registerExamples } from "./examples"; -import { DbMetadata, Resource } from "./utils"; +import { ExtraType } from "./entry/extra"; +import { bubble, bubbleVideo, registerExamples } from "./examples"; +import { DbMetadata, EpisodeId, ExternalId, Resource } from "./utils"; + +const ExternalIds = t.Record( + t.String(), + t.Omit( + t.Union([ + EpisodeId.patternProperties[PatternStringExact], + ExternalId().patternProperties[PatternStringExact], + ]), + ["link"], + ), +); +type ExternalIds = typeof ExternalIds.static; export const Guess = t.Recursive((Self) => t.Object( @@ -10,8 +24,9 @@ export const Guess = t.Recursive((Self) => year: t.Optional(t.Array(t.Integer(), { default: [] })), season: t.Optional(t.Array(t.Integer(), { default: [] })), episode: t.Optional(t.Array(t.Integer(), { default: [] })), - // TODO: maybe replace "extra" with the `extraKind` value (aka behind-the-scene, trailer, etc) kind: t.Optional(t.UnionEnum(["episode", "movie", "extra"])), + extraKind: t.Optional(ExtraType), + externalId: t.Optional(ExternalIds), from: t.String({ description: "Name of the tool that made the guess", @@ -66,14 +81,115 @@ export const SeedVideo = t.Object({ }), guess: Guess, -}); -export type SeedVideo = typeof SeedVideo.static; -export const Video = t.Intersect([Resource(), SeedVideo, DbMetadata]); + for: t.Optional( + t.Array( + t.Union([ + t.Object({ + slug: t.String({ + format: "slug", + examples: ["made-in-abyss-dawn-of-the-deep-soul"], + }), + }), + t.Object({ + externalId: ExternalIds, + }), + t.Object({ + movie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["bubble"] }), + ]), + }), + t.Object({ + serie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["made-in-abyss"] }), + ]), + season: t.Integer({ minimum: 1 }), + episode: t.Integer(), + }), + t.Object({ + serie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["made-in-abyss"] }), + ]), + order: t.Number(), + }), + t.Object({ + serie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["made-in-abyss"] }), + ]), + special: t.Integer(), + }), + ]), + { default: [] }, + ), + ), +}); +export type SeedVideo = Prettify; + +export const Video = t.Composite([ + t.Object({ + id: t.String({ format: "uuid" }), + }), + t.Omit(SeedVideo, ["for"]), + DbMetadata, +]); export type Video = Prettify; -// type used in entry responses -export const EmbeddedVideo = t.Omit(Video, ["guess", "createdAt", "updatedAt"]); +// type used in entry responses (the slug comes from the entryVideoJoin) +export const EmbeddedVideo = t.Composite( + [ + t.Object({ slug: t.String({ format: "slug" }) }), + t.Omit(Video, ["guess", "createdAt", "updatedAt"]), + ], + { additionalProperties: true }, +); export type EmbeddedVideo = Prettify; registerExamples(Video, bubbleVideo); +registerExamples(SeedVideo, { + ...bubbleVideo, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedatabase: { + dataId: bubble.externalId.themoviedatabase.dataId, + }, + }, + }, + ], +}); + +export const Guesses = t.Object({ + paths: t.Array(t.String()), + guesses: t.Record( + t.String(), + t.Record(t.String({ pattern: "^([1-9][0-9]{3})|unknown$" }), Resource()), + ), + unmatched: t.Array(t.String()), +}); +export type Guesses = typeof Guesses.static; + +registerExamples(Guesses, { + paths: [ + "/videos/Evangelion S01E02.mkv", + "/videos/Evangelion (1995) S01E26.mkv", + "/videos/SomeUnknownThing.mkv", + ], + guesses: { + Evangelion: { + unknown: { + id: "43b742f5-9ce6-467d-ad29-74460624020a", + slug: "evangelion", + }, + "1995": { + id: "43b742f5-9ce6-467d-ad29-74460624020a", + slug: "evangelion", + }, + }, + }, + unmatched: ["/videos/SomeUnknownThing.mkv"], +}); diff --git a/api/src/models/watchlist.ts b/api/src/models/watchlist.ts index 182b2b17..7ce5b557 100644 --- a/api/src/models/watchlist.ts +++ b/api/src/models/watchlist.ts @@ -7,6 +7,7 @@ export const WatchlistStatus = t.UnionEnum([ "dropped", "planned", ]); +export type WatchlistStatus = typeof WatchlistStatus.static; export const SerieWatchStatus = t.Object({ status: WatchlistStatus, @@ -20,7 +21,7 @@ export const SerieWatchStatus = t.Object({ }); export type SerieWatchStatus = typeof SerieWatchStatus.static; -export const MovieWatchStatus = t.Intersect([ +export const MovieWatchStatus = t.Composite([ t.Omit(SerieWatchStatus, ["startedAt", "seenCount"]), t.Object({ percent: t.Integer({ diff --git a/api/tests/helpers/videos-helper.ts b/api/tests/helpers/videos-helper.ts index 380cd81d..cc750a1a 100644 --- a/api/tests/helpers/videos-helper.ts +++ b/api/tests/helpers/videos-helper.ts @@ -17,3 +17,29 @@ export const createVideo = async (video: SeedVideo | SeedVideo[]) => { const body = await resp.json(); return [resp, body] as const; }; + +export const getVideos = async () => { + const resp = await app.handle( + new Request(buildUrl("videos"), { + method: "GET", + headers: await getJwtHeaders(), + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; + +export const deleteVideo = async (paths: string[]) => { + const resp = await app.handle( + new Request(buildUrl("videos"), { + method: "DELETE", + body: JSON.stringify(paths), + headers: { + "Content-Type": "application/json", + ...(await getJwtHeaders()), + }, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; diff --git a/api/tests/manual.ts b/api/tests/manual.ts index 6ad8c521..c038486b 100644 --- a/api/tests/manual.ts +++ b/api/tests/manual.ts @@ -1,29 +1,53 @@ import { db, migrate } from "~/db"; import { profiles, shows } from "~/db/schema"; -import { madeInAbyss } from "~/models/examples"; -import { createSerie, getSerie, setSerieStatus } from "./helpers"; -import { getJwtHeaders } from "./helpers/jwt"; +import { bubble, madeInAbyss } from "~/models/examples"; +import { createMovie, createSerie, createVideo, getVideos } from "./helpers"; // test file used to run manually using `bun tests/manual.ts` +// run those before running this script +// export JWT_SECRET="this is a secret"; +// export JWT_ISSUER="https://kyoo.zoriya.dev"; await migrate(); await db.delete(shows); await db.delete(profiles); -console.log(await getJwtHeaders()); - const [_, ser] = await createSerie(madeInAbyss); -console.log(ser); -const [__, ret] = await setSerieStatus(madeInAbyss.slug, { - status: "watching", - startedAt: "2024-12-21", - completedAt: "2024-12-21", - seenCount: 2, - score: 85, -}); -console.log(ret); - -const [___, got] = await getSerie(madeInAbyss.slug, {}); -console.log(JSON.stringify(got, undefined, 4)); +const [__, mov] = await createMovie(bubble); +const [resp, body] = await createVideo([ + { + guess: { title: "mia", season: [1], episode: [13], from: "test" }, + part: null, + path: "/video/mia s1e13.mkv", + rendering: "sha2", + version: 1, + for: [{ slug: `${madeInAbyss.slug}-s1e13` }], + }, + { + guess: { + title: "mia", + season: [2], + episode: [1], + year: [2017], + from: "test", + }, + part: null, + path: "/video/mia 2017 s2e1.mkv", + rendering: "sha8", + version: 1, + for: [{ slug: `${madeInAbyss.slug}-s2e1` }], + }, + { + guess: { title: "bubble", from: "test" }, + part: null, + path: "/video/bubble.mkv", + rendering: "sha5", + version: 1, + for: [{ movie: bubble.slug }], + }, +]); +console.log(body); +const [___, ret] = await getVideos(); +console.log(JSON.stringify(ret, undefined, 4)); process.exit(0); diff --git a/api/tests/movies/get-movie.test.ts b/api/tests/movies/get-movie.test.ts index f9afa6fe..5e7b9ee9 100644 --- a/api/tests/movies/get-movie.test.ts +++ b/api/tests/movies/get-movie.test.ts @@ -159,7 +159,7 @@ describe("Get movie", () => { expect(body.videos).toBeArrayOfSize(bubble.videos!.length); expect(body.videos[0]).toMatchObject({ path: bubbleVideo.path, - slug: bubbleVideo.slug, + slug: bubble.slug, version: bubbleVideo.version, rendering: bubbleVideo.rendering, part: bubbleVideo.part, diff --git a/api/tests/movies/seed-movies.test.ts b/api/tests/movies/seed-movies.test.ts index 611be355..242a1161 100644 --- a/api/tests/movies/seed-movies.test.ts +++ b/api/tests/movies/seed-movies.test.ts @@ -384,7 +384,7 @@ describe("Movie seeding", () => { path: "/video/bubble3.mkv", part: null, version: 1, - rendering: "oeunhtoeuth", + rendering: "oeunhtoeuthoeu", guess: { title: "bubble", from: "test" }, }, { diff --git a/api/tests/series/get-entries.test.ts b/api/tests/series/get-entries.test.ts index 1a31465d..67cd70c7 100644 --- a/api/tests/series/get-entries.test.ts +++ b/api/tests/series/get-entries.test.ts @@ -48,7 +48,7 @@ describe("Get entries", () => { expect(body.items[0].videos).toBeArrayOfSize(1); expect(body.items[0].videos[0]).toMatchObject({ path: madeInAbyssVideo.path, - slug: madeInAbyssVideo.slug, + slug: `${madeInAbyss.slug}-s1e13`, version: madeInAbyssVideo.version, rendering: madeInAbyssVideo.rendering, part: madeInAbyssVideo.part, @@ -63,7 +63,7 @@ describe("Get entries", () => { expect(body.items[0].videos).toBeArrayOfSize(1); expect(body.items[0].videos[0]).toMatchObject({ path: madeInAbyssVideo.path, - slug: madeInAbyssVideo.slug, + slug: `${madeInAbyss.slug}-s1e13`, version: madeInAbyssVideo.version, rendering: madeInAbyssVideo.rendering, part: madeInAbyssVideo.part, diff --git a/api/tests/series/seed-serie.test.ts b/api/tests/series/seed-serie.test.ts index d6a0111f..44c73f9d 100644 --- a/api/tests/series/seed-serie.test.ts +++ b/api/tests/series/seed-serie.test.ts @@ -57,7 +57,7 @@ describe("Serie seeding", () => { ], evj: [ expect.objectContaining({ - slug: madeInAbyssVideo.slug, + slug: `${madeInAbyss.slug}-s1e13`, video: expect.objectContaining({ path: madeInAbyssVideo.path }), }), ], diff --git a/api/tests/videos/getdel.test.ts b/api/tests/videos/getdel.test.ts new file mode 100644 index 00000000..9f5b8aea --- /dev/null +++ b/api/tests/videos/getdel.test.ts @@ -0,0 +1,236 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; +import { + createMovie, + createSerie, + createVideo, + deleteVideo, + getVideos, +} from "tests/helpers"; +import { expectStatus } from "tests/utils"; +import { db } from "~/db"; +import { entries, shows, videos } from "~/db/schema"; +import { bubble, madeInAbyss } from "~/models/examples"; + +beforeAll(async () => { + await db.delete(shows); + await db.delete(videos); + + let [ret, body] = await createSerie(madeInAbyss); + expectStatus(ret, body).toBe(201); + [ret, body] = await createMovie(bubble); + expectStatus(ret, body).toBe(201); + + [ret, body] = await createVideo([ + { + guess: { title: "mia", season: [1], episode: [13], from: "test" }, + part: null, + path: "/video/mia s1e13.mkv", + rendering: "sha2", + version: 1, + for: [{ slug: `${madeInAbyss.slug}-s1e13` }], + }, + { + guess: { + title: "mia", + season: [2], + episode: [1], + year: [2017], + from: "test", + }, + part: null, + path: "/video/mia 2017 s2e1.mkv", + rendering: "sha8", + version: 1, + for: [{ slug: `${madeInAbyss.slug}-s2e1` }], + }, + { + guess: { title: "bubble", from: "test" }, + part: null, + path: "/video/bubble.mkv", + rendering: "sha5", + version: 1, + for: [{ movie: bubble.slug }], + }, + ]); + expectStatus(ret, body).toBe(201); + expect(body).toBeArrayOfSize(3); + expect(body[0].entries).toBeArrayOfSize(1); + expect(body[1].entries).toBeArrayOfSize(1); + expect(body[2].entries).toBeArrayOfSize(1); + + const items = await db.query.shows.findMany(); + expect(items.find((x) => x.slug === "bubble")!.availableCount).toBe(1); + expect(items.find((x) => x.slug === "made-in-abyss")!.availableCount).toBe(2); + + const etrs = await db.query.entries.findMany({ + where: eq( + entries.showPk, + items.find((x) => x.slug === "made-in-abyss")!.pk, + ), + }); + expect( + etrs.find((x) => x.slug === "made-in-abyss-s1e13")!.availableSince, + ).not.toBe(null); + expect( + etrs.find((x) => x.slug === "made-in-abyss-s2e1")!.availableSince, + ).not.toBe(null); +}); + +describe("Video get/deletion", () => { + it("Get current state", async () => { + const [resp, body] = await getVideos(); + expectStatus(resp, body).toBe(200); + expect(body.guesses).toMatchObject({ + mia: { + unknown: { + id: expect.any(String), + slug: "made-in-abyss", + }, + "2017": { + id: expect.any(String), + slug: "made-in-abyss", + }, + }, + bubble: { + unknown: { + id: expect.any(String), + slug: "bubble", + }, + }, + }); + }); + + it("With unknown", async () => { + let [resp, body] = await createVideo({ + guess: { title: "mia", season: [1], episode: [13], from: "test" }, + part: null, + path: "/video/mia s1e13 unknown test.mkv", + rendering: "shanthnth", + version: 1, + }); + expectStatus(resp, body).toBe(201); + + [resp, body] = await getVideos(); + expectStatus(resp, body).toBe(200); + expect(body.guesses).toMatchObject({ + mia: { + unknown: { + id: expect.any(String), + slug: "made-in-abyss", + }, + "2017": { + id: expect.any(String), + slug: "made-in-abyss", + }, + }, + bubble: { + unknown: { + id: expect.any(String), + slug: "bubble", + }, + }, + }); + expect(body.unmatched).toBeArrayOfSize(1); + expect(body.unmatched[0]).toBe("/video/mia s1e13 unknown test.mkv"); + }); + + it("Mismatch title guess", async () => { + let [resp, body] = await createVideo({ + guess: { title: "mia", season: [1], episode: [13], from: "test" }, + part: null, + path: "/video/mia s1e13 mismatch.mkv", + rendering: "mismatch", + version: 1, + for: [{ movie: "bubble" }], + }); + expectStatus(resp, body).toBe(201); + + [resp, body] = await getVideos(); + expectStatus(resp, body).toBe(200); + expect(body.guesses).toMatchObject({ + mia: { + unknown: { + id: expect.any(String), + // take the latest slug + slug: "bubble", + }, + "2017": { + id: expect.any(String), + slug: "made-in-abyss", + }, + }, + bubble: { + unknown: { + id: expect.any(String), + slug: "bubble", + }, + }, + }); + }); + + it("Delete video", async () => { + const [resp, body] = await deleteVideo(["/video/mia s1e13 mismatch.mkv"]); + expectStatus(resp, body).toBe(200); + expect(body).toBeArrayOfSize(1); + expect(body).toContain("/video/mia s1e13 mismatch.mkv"); + + const bubble = await db.query.shows.findFirst({ + where: eq(shows.slug, "bubble"), + }); + expect(bubble!.availableCount).toBe(1); + }); + + it("Delete all videos of a movie", async () => { + const [resp, body] = await deleteVideo(["/video/bubble.mkv"]); + expectStatus(resp, body).toBe(200); + expect(body).toBeArrayOfSize(1); + expect(body).toContain("/video/bubble.mkv"); + + const bubble = await db.query.shows.findFirst({ + where: eq(shows.slug, "bubble"), + }); + expect(bubble!.availableCount).toBe(0); + }); + + it("Delete non existing video", async () => { + const [resp, body] = await deleteVideo(["/video/toto.mkv"]); + expectStatus(resp, body).toBe(200); + expect(body).toBeArrayOfSize(0); + }); + + it("Delete episodes", async () => { + const [resp, body] = await deleteVideo([ + "/video/mia s1e13.mkv", + "/video/mia 2017 s2e1.mkv", + ]); + expectStatus(resp, body).toBe(200); + expect(body).toBeArrayOfSize(2); + expect(body).toContain("/video/mia s1e13.mkv"); + expect(body).toContain("/video/mia 2017 s2e1.mkv"); + + const mia = await db.query.shows.findFirst({ + where: eq(shows.slug, "made-in-abyss"), + }); + expect(mia!.availableCount).toBe(0); + + const etrs = await db.query.entries.findMany({ + where: eq(entries.showPk, mia!.pk), + }); + expect( + etrs.find((x) => x.slug === "made-in-abyss-s1e13")!.availableSince, + ).toBe(null); + expect( + etrs.find((x) => x.slug === "made-in-abyss-s2e1")!.availableSince, + ).toBe(null); + }); + + it("Delete unmatched", async () => { + const [resp, body] = await deleteVideo([ + "/video/mia s1e13 unknown test.mkv", + ]); + expectStatus(resp, body).toBe(200); + expect(body).toBeArrayOfSize(1); + expect(body[0]).toBe("/video/mia s1e13 unknown test.mkv"); + }); +}); diff --git a/api/tests/videos/scanner.test.ts b/api/tests/videos/scanner.test.ts new file mode 100644 index 00000000..011c2094 --- /dev/null +++ b/api/tests/videos/scanner.test.ts @@ -0,0 +1,566 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; +import { createMovie, createSerie, createVideo } from "tests/helpers"; +import { expectStatus } from "tests/utils"; +import { db } from "~/db"; +import { entries, shows, videos } from "~/db/schema"; +import { bubble, madeInAbyss } from "~/models/examples"; + +beforeAll(async () => { + await db.delete(shows); + await db.delete(videos); + let [ret, body] = await createSerie(madeInAbyss); + expectStatus(ret, body).toBe(201); + [ret, body] = await createMovie(bubble); + expectStatus(ret, body).toBe(201); +}); + +describe("Video seeding", () => { + it("Can create a video without entry", async () => { + const [resp, body] = await createVideo({ + guess: { title: "unknown", from: "test" }, + part: null, + path: "/video/unknown s1e13.mkv", + rendering: "sha", + version: 1, + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/unknown s1e13.mkv"); + expect(vid!.guess).toMatchObject({ title: "unknown", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(0); + expect(vid!.evj).toBeArrayOfSize(0); + }); + + it("With slug", async () => { + const [resp, body] = await createVideo({ + guess: { title: "mia", season: [1], episode: [13], from: "test" }, + part: null, + path: "/video/mia s1e13.mkv", + rendering: "sha2", + version: 1, + for: [{ slug: `${madeInAbyss.slug}-s1e13` }], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia s1e13.mkv"); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe(`${madeInAbyss.slug}-s1e13`); + expect(vid!.evj[0].entry.slug).toBe(`${madeInAbyss.slug}-s1e13`); + }); + + it("With movie", async () => { + const [resp, body] = await createVideo({ + guess: { title: "bubble", from: "test" }, + part: null, + path: "/video/bubble.mkv", + rendering: "sha3", + version: 1, + for: [{ movie: bubble.slug }], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble.mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe(bubble.slug); + expect(vid!.evj[0].entry.slug).toBe(bubble.slug); + }); + + it("Conflicting path", async () => { + const [resp, body] = await createVideo({ + guess: { title: "bubble", from: "test" }, + part: null, + path: "/video/bubble.mkv", + rendering: "sha4", + version: 1, + for: [{ movie: bubble.slug }], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble.mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe(bubble.slug); + expect(vid!.evj[0].entry.slug).toBe(bubble.slug); + }); + + it("With season/episode", async () => { + const [resp, body] = await createVideo({ + guess: { title: "mia", season: [2], episode: [1], from: "test" }, + part: null, + path: "/video/mia s2e1.mkv", + rendering: "renderingsha", + version: 1, + for: [ + { + serie: madeInAbyss.slug, + season: madeInAbyss.entries[3].seasonNumber!, + episode: madeInAbyss.entries[3].episodeNumber!, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia s2e1.mkv"); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe(`${madeInAbyss.slug}-s2e1`); + expect(vid!.evj[0].entry.slug).toBe(`${madeInAbyss.slug}-s2e1`); + }); + + it("With special", async () => { + const [resp, body] = await createVideo({ + guess: { title: "mia", season: [0], episode: [3], from: "test" }, + part: null, + path: "/video/mia sp3.mkv", + rendering: "notehu", + version: 1, + for: [ + { + serie: madeInAbyss.slug, + special: madeInAbyss.entries[1].number!, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia sp3.mkv"); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe(`${madeInAbyss.slug}-sp3`); + expect(vid!.evj[0].entry.slug).toBe(`${madeInAbyss.slug}-sp3`); + }); + + it("With order", async () => { + const [resp, body] = await createVideo({ + guess: { title: "mia", season: [0], episode: [3], from: "test" }, + part: null, + path: "/video/mia 13.5.mkv", + rendering: "notehu2", + version: 1, + for: [ + { + serie: madeInAbyss.slug, + order: 13.5, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia 13.5.mkv"); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("made-in-abyss-dawn-of-the-deep-soul"); + expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-dawn-of-the-deep-soul"); + }); + + it("With external id", async () => { + const [resp, body] = await createVideo({ + guess: { + title: "mia", + season: [0], + episode: [3], + from: "test", + externalId: { + themoviedatabase: { serieId: "72636", season: 1, episode: 13 }, + }, + }, + part: null, + path: "/video/mia s1e13 [tmdb=72636].mkv", + rendering: "notehu3", + version: 1, + for: [ + { + externalId: { + themoviedatabase: { serieId: "72636", season: 1, episode: 13 }, + }, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia s1e13 [tmdb=72636].mkv"); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13-notehu3"); + expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-s1e13"); + }); + + it("With movie external id", async () => { + const [resp, body] = await createVideo({ + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + part: null, + path: "/video/bubble [tmdb=912598].mkv", + rendering: "onetuh", + version: 1, + for: [ + { + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble [tmdb=912598].mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("bubble-onetuh"); + expect(vid!.evj[0].entry.slug).toBe("bubble"); + }); + + it("Different path, same sha", async () => { + const [resp, body] = await createVideo({ + guess: { title: "bubble", from: "test" }, + part: null, + path: "/video/bubble invalid-sha.mkv", + rendering: "sha", + version: 1, + for: [{ movie: bubble.slug }], + }); + + // conflict with existing video, message will contain an explanation on how to fix this + expectStatus(resp, body).toBe(409); + expect(body.message).toBeString(); + }); + + it("Two for the same entry", async () => { + const [resp, body] = await createVideo({ + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + part: null, + path: "/video/bubble ue [tmdb=912598].mkv", + rendering: "aoeubnht", + version: 1, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble ue [tmdb=912598].mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("bubble-aoeubnht"); + expect(vid!.evj[0].entry.slug).toBe("bubble"); + }); + + it("Two for the same entry WITHOUT rendering", async () => { + await db.delete(videos); + const [resp, body] = await createVideo({ + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + part: null, + path: "/video/bubble [tmdb=912598].mkv", + rendering: "cwhtn", + version: 1, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble [tmdb=912598].mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("bubble"); + expect(vid!.evj[0].entry.slug).toBe("bubble"); + }); + + it("Multi part", async () => { + await db.delete(videos); + const [resp, body] = await createVideo([ + { + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + part: 1, + path: "/video/bubble p1 [tmdb=912598].mkv", + rendering: "cwhtn", + version: 1, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + ], + }, + { + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + part: 2, + path: "/video/bubble p2 [tmdb=912598].mkv", + rendering: "cwhtn", + version: 1, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + ], + }, + ]); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(2); + expect(body[0].id).toBeString(); + expect(body[1].id).toBeString(); + expect(body[0].entries).toBeArrayOfSize(1); + expect(body[1].entries).toBeArrayOfSize(1); + + const entr = (await db.query.entries.findFirst({ + where: eq(entries.slug, bubble.slug), + with: { + evj: { with: { video: true } }, + }, + }))!; + + expect(entr.evj).toBeArrayOfSize(2); + expect(entr.evj[0].video.path).toBe("/video/bubble p1 [tmdb=912598].mkv"); + + expect(entr.evj[0].slug).toBe("bubble-p1"); + expect(entr.evj[1].slug).toBe("bubble-p2"); + }); + + it("Multi entry", async () => { + await db.delete(videos); + const [resp, body] = await createVideo({ + guess: { + title: "mia", + season: [1, 2], + episode: [13, 1], + from: "test", + }, + part: null, + path: "/video/mia s1e13 & s2e1 [tmdb=72636].mkv", + rendering: "notehu5", + version: 1, + for: [ + { serie: madeInAbyss.slug, season: 1, episode: 13 }, + { + externalId: { + themoviedatabase: { serieId: "72636", season: 1, episode: 13 }, + }, + }, + { serie: madeInAbyss.slug, season: 2, episode: 1 }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia s1e13 & s2e1 [tmdb=72636].mkv"); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(2); + expect(vid!.evj).toBeArrayOfSize(2); + + expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13"); + expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-s1e13"); + expect(vid!.evj[1].slug).toBe("made-in-abyss-s2e1"); + expect(vid!.evj[1].entry.slug).toBe("made-in-abyss-s2e1"); + }); +}); diff --git a/auth/main.go b/auth/main.go index 837e5a05..96f106b2 100644 --- a/auth/main.go +++ b/auth/main.go @@ -80,7 +80,7 @@ func OpenDatabase() (*pgxpool.Pool, error) { } // Set default values - if config.ConnConfig.Host == "" { + if config.ConnConfig.Host == "/tmp" { config.ConnConfig.Host = "postgres" } if config.ConnConfig.Database == "" { @@ -96,7 +96,7 @@ func OpenDatabase() (*pgxpool.Pool, error) { // by the pgx library. This doesn't cover the case where the system username happens to be in some other part // of the connection string, but this cannot be checked without full connection string parsing. if currentUserName.Username == config.ConnConfig.User && !strings.Contains(connectionString, currentUserName.Username) { - config.ConnConfig.User = "kyoo" + config.ConnConfig.User = "kyoo" } } if config.ConnConfig.Password == "" { @@ -113,7 +113,7 @@ func OpenDatabase() (*pgxpool.Pool, error) { db, err := pgxpool.NewWithConfig(ctx, config) if err != nil { - fmt.Printf("Could not connect to database, check your env variables!") + fmt.Printf("Could not connect to database, check your env variables!\n") return nil, err } diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 4ba4057b..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,45 +0,0 @@ -[tool.robotidy] -diff = false -overwrite = true -verbose = false -separator = "space" -spacecount = 2 -line_length = 120 -lineseparator = "native" -skip_gitignore = true -ignore_git_dir = true -configure = [ - "AddMissingEnd:enabled=True", - "NormalizeSeparators:enabled=True", - "DiscardEmptySections:enabled=True", - "MergeAndOrderSections:enabled=True", - "RemoveEmptySettings:enabled=True", - "ReplaceEmptyValues:enabled=True", - "ReplaceWithVAR:enabled=False", - "NormalizeAssignments:enabled=True", - "GenerateDocumentation:enabled=False", - "OrderSettings:enabled=True", - "OrderSettingsSection:enabled=True", - "NormalizeTags:enabled=True", - "OrderTags:enabled=False", - "RenameVariables:enabled=False", - "IndentNestedKeywords:enabled=False", - "AlignSettingsSection:enabled=True", - "AlignVariablesSection:enabled=True", - "AlignTemplatedTestCases:enabled=False", - "AlignTestCasesSection:enabled=False", - "AlignKeywordsSection:enabled=False", - "NormalizeNewLines:enabled=True", - "NormalizeSectionHeaderName:enabled=True", - "NormalizeSettingName:enabled=True", - "ReplaceRunKeywordIf:enabled=True", - "SplitTooLongLine:enabled=True", - "SmartSortKeywords:enabled=False", - "RenameTestCases:enabled=False", - "RenameKeywords:enabled=False", - "ReplaceReturns:enabled=True", - "ReplaceBreakContinue:enabled=True", - "InlineIf:enabled=True", - "Translate:enabled=False", - "NormalizeComments:enabled=True", -] diff --git a/scanner/README.md b/scanner/README.md index f1ae907f..6247aed6 100644 --- a/scanner/README.md +++ b/scanner/README.md @@ -14,11 +14,12 @@ In order of action: part: number | null, rendering: sha(path except version & part), guess: { - kind: movie | episode | trailer | interview | ..., - name: string, - year: number | null, - season?: number, - episode?: number, + from: "guessit" + kind: movie | episode | extra + title: string, + year?: number[], + season?: number[], + episode?: number[], ... }, } @@ -32,17 +33,22 @@ In order of action: part: number | null, rendering: sha(path except version & part), guess: { - kind: movie | episode | trailer | interview | ..., + from: "anilist", + kind: movie | episode | extra name: string, year: number | null, - season?: number, - episodes?: number[], - absolutes?: number[], + season?: number[], + episode?: number[], + absolute?: number[], externalId: Record, - remap: { - from: "thexem", - oldSeason: number, - oldEpisodes: number[], + history: { + from: "guessit" + kind: movie | episode | extra + title: string, + year?: number, + season?: number[], + episode?: number[], + ... }, ... }, @@ -67,3 +73,4 @@ In order of action: - Matcher retrieves metadata from the movie/serie + ALL episodes/seasons (from an external provider) - Matcher pushes every metadata to the api (if there are 1000 episodes but only 1 video, still push the 1000 episodes) + diff --git a/shell.nix b/shell.nix index b7ce3bfb..1e3fb313 100644 --- a/shell.nix +++ b/shell.nix @@ -20,7 +20,7 @@ in pkgs.mkShell { packages = with pkgs; [ - nodejs-18_x + # nodejs-18_x nodePackages.yarn dotnet csharpier @@ -39,6 +39,7 @@ in go-swag bun pkg-config + nodejs node-gyp vips hurl