![{rec.title}]()
c.charCodeAt(0));
+ return thumbHashToDataURL(binary);
+}
diff --git a/bun.lock b/bun.lock
index 367ace8..7bbea7c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -27,7 +27,6 @@
"@react-navigation/elements": "2.9.10",
"@shopify/flash-list": "2.3.0",
"@sofa/api": "workspace:*",
- "@sofa/tmdb": "workspace:*",
"@tabler/icons-react-native": "3.40.0",
"@tanstack/query-async-storage-persister": "5.90.24",
"@tanstack/react-form": "1.28.5",
@@ -154,6 +153,7 @@
"shadcn": "4.0.6",
"sonner": "2.0.7",
"tailwind-merge": "catalog:",
+ "thumbhash": "catalog:",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",
"youtube-video-element": "1.9.0",
@@ -224,6 +224,8 @@
"@sofa/tmdb": "workspace:*",
"date-fns": "catalog:",
"node-vibrant": "4.0.4",
+ "sharp": "0.34.5",
+ "thumbhash": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
@@ -286,6 +288,7 @@
"react-dom": "19.2.0",
"tailwind-merge": "3.5.0",
"tailwindcss": "4.2.1",
+ "thumbhash": "0.1.1",
"typescript": "5.9.3",
"zod": "4.3.6",
},
@@ -566,6 +569,8 @@
"@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
+ "@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
+
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
@@ -704,6 +709,56 @@
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
+ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
+
+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
+
+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
+
+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
+
+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
+
+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
+
+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
+
+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
+
+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
+
+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
+
+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
+
+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
+
+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
+
+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
+
+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
+
+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
+
+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
+
+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
+
+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
+
+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
+
+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
+
+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
+
+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
+
+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
+
+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
+
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
"@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="],
@@ -2376,7 +2431,7 @@
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
- "semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+ "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
@@ -2400,6 +2455,8 @@
"shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="],
+ "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
+
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@@ -2510,6 +2567,8 @@
"throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="],
+ "thumbhash": ["thumbhash@0.1.1", "", {}, "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg=="],
+
"timm": ["timm@1.7.1", "", {}, "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
@@ -2718,22 +2777,16 @@
"@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
- "@expo/cli/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
"@expo/cli/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"@expo/cli/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@expo/config/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
- "@expo/config/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
"@expo/config-plugins/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@expo/config-plugins/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
- "@expo/config-plugins/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
"@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"@expo/devtools/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -2744,12 +2797,8 @@
"@expo/fingerprint/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
- "@expo/fingerprint/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
"@expo/image-utils/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
- "@expo/image-utils/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
"@expo/local-build-cache-provider/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@expo/metro/metro-runtime": ["metro-runtime@0.83.3", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw=="],
@@ -2766,8 +2815,6 @@
"@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
- "@expo/prebuild-config/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
"@expo/xcpretty/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
@@ -2816,8 +2863,6 @@
"@react-native/codegen/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
- "@react-native/community-cli-plugin/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
"@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
@@ -2898,6 +2943,8 @@
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
+ "expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+
"express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -2944,8 +2991,6 @@
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
- "jsonwebtoken/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
@@ -2978,8 +3023,6 @@
"node-vibrant/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
- "npm-package-arg/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
"npm-package-arg/validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
@@ -2996,12 +3039,10 @@
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
+ "react-native/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
+
"react-native-reanimated/react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="],
- "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
- "react-native-worklets/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
"readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
diff --git a/docs/openapi.json b/docs/openapi.json
index 1c9a084..104d9b0 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -163,6 +163,16 @@
}
]
},
+ "posterThumbHash": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"backdropPath": {
"anyOf": [
{
@@ -173,6 +183,16 @@
}
]
},
+ "backdropThumbHash": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"popularity": {
"anyOf": [
{
@@ -330,7 +350,9 @@
"releaseDate",
"firstAirDate",
"posterPath",
+ "posterThumbHash",
"backdropPath",
+ "backdropThumbHash",
"popularity",
"voteAverage",
"voteCount",
@@ -403,6 +425,16 @@
}
]
},
+ "stillThumbHash": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"airDate": {
"anyOf": [
{
@@ -430,6 +462,7 @@
"name",
"overview",
"stillPath",
+ "stillThumbHash",
"airDate",
"runtimeMinutes"
]
@@ -551,6 +584,16 @@
}
]
},
+ "profileThumbHash": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"tmdbId": {
"type": "number"
}
@@ -565,6 +608,7 @@
"displayOrder",
"episodeCount",
"profilePath",
+ "profileThumbHash",
"tmdbId"
]
}
@@ -986,6 +1030,16 @@
}
]
},
+ "posterThumbHash": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"releaseDate": {
"anyOf": [
{
@@ -1023,6 +1077,7 @@
"type",
"title",
"posterPath",
+ "posterThumbHash",
"releaseDate",
"firstAirDate",
"voteAverage"
@@ -1161,6 +1216,16 @@
}
]
},
+ "stillThumbHash": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"airDate": {
"anyOf": [
{
@@ -1188,6 +1253,7 @@
"name",
"overview",
"stillPath",
+ "stillThumbHash",
"airDate",
"runtimeMinutes"
]
@@ -1608,6 +1674,16 @@
}
]
},
+ "profileThumbHash": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"knownForDepartment": {
"anyOf": [
{
@@ -1638,6 +1714,7 @@
"deathday",
"placeOfBirth",
"profilePath",
+ "profileThumbHash",
"knownForDepartment",
"imdbId"
]
@@ -1672,6 +1749,16 @@
}
]
},
+ "posterThumbHash": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"releaseDate": {
"anyOf": [
{
@@ -1732,6 +1819,7 @@
"type",
"title",
"posterPath",
+ "posterThumbHash",
"releaseDate",
"firstAirDate",
"voteAverage",
@@ -1892,12 +1980,23 @@
"type": "null"
}
]
+ },
+ "backdropThumbHash": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
}
},
"required": [
"id",
"title",
- "backdropPath"
+ "backdropPath",
+ "backdropThumbHash"
]
},
"nextEpisode": {
@@ -1930,13 +2029,24 @@
"type": "null"
}
]
+ },
+ "stillThumbHash": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
}
},
"required": [
"seasonNumber",
"episodeNumber",
"name",
- "stillPath"
+ "stillPath",
+ "stillThumbHash"
]
},
{
@@ -2014,6 +2124,16 @@
}
]
},
+ "posterThumbHash": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"releaseDate": {
"anyOf": [
{
@@ -2065,6 +2185,7 @@
"type",
"title",
"posterPath",
+ "posterThumbHash",
"releaseDate",
"firstAirDate",
"voteAverage",
@@ -2127,6 +2248,16 @@
}
]
},
+ "posterThumbHash": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"releaseDate": {
"anyOf": [
{
@@ -2164,6 +2295,7 @@
"type",
"title",
"posterPath",
+ "posterThumbHash",
"releaseDate",
"firstAirDate",
"voteAverage"
diff --git a/package.json b/package.json
index 0db9c18..5a17c87 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"react-dom": "19.2.0",
"tailwind-merge": "3.5.0",
"tailwindcss": "4.2.1",
+ "thumbhash": "0.1.1",
"typescript": "5.9.3",
"zod": "4.3.6"
}
@@ -33,7 +34,7 @@
"format": "turbo run format",
"check-types": "turbo run check-types",
"test": "turbo run test",
- "generate:openapi": "bun scripts/generate-openapi-spec.ts"
+ "generate:openapi": "bun scripts/generate-openapi-spec.ts && cd docs && bun run generate:api-docs"
},
"devDependencies": {
"@biomejs/biome": "2.4.7",
diff --git a/packages/api/src/schemas.ts b/packages/api/src/schemas.ts
index abe12b9..84d8155 100644
--- a/packages/api/src/schemas.ts
+++ b/packages/api/src/schemas.ts
@@ -156,6 +156,7 @@ const EpisodeSchema = z.object({
name: z.string().nullable(),
overview: z.string().nullable(),
stillPath: z.string().nullable(),
+ stillThumbHash: z.string().nullable(),
airDate: z.string().nullable(),
runtimeMinutes: z.number().nullable(),
});
@@ -185,6 +186,7 @@ const CastMemberSchema = z.object({
displayOrder: z.number(),
episodeCount: z.number().nullable(),
profilePath: z.string().nullable(),
+ profileThumbHash: z.string().nullable(),
tmdbId: z.number(),
});
@@ -198,7 +200,9 @@ const ResolvedTitleSchema = z.object({
releaseDate: z.string().nullable(),
firstAirDate: z.string().nullable(),
posterPath: z.string().nullable(),
+ posterThumbHash: z.string().nullable(),
backdropPath: z.string().nullable(),
+ backdropThumbHash: z.string().nullable(),
popularity: z.number().nullable(),
voteAverage: z.number().nullable(),
voteCount: z.number().nullable(),
@@ -218,6 +222,7 @@ const PersonSchema = z.object({
deathday: z.string().nullable(),
placeOfBirth: z.string().nullable(),
profilePath: z.string().nullable(),
+ profileThumbHash: z.string().nullable(),
knownForDepartment: z.string().nullable(),
imdbId: z.string().nullable(),
});
@@ -228,6 +233,7 @@ const PersonCreditSchema = z.object({
type: mediaType,
title: z.string(),
posterPath: z.string().nullable(),
+ posterThumbHash: z.string().nullable(),
releaseDate: z.string().nullable(),
firstAirDate: z.string().nullable(),
voteAverage: z.number().nullable(),
@@ -242,6 +248,7 @@ const TmdbBrowseItem = z.object({
type: mediaType,
title: z.string(),
posterPath: z.string().nullable(),
+ posterThumbHash: z.string().nullable(),
releaseDate: z.string().nullable(),
firstAirDate: z.string().nullable(),
voteAverage: z.number().nullable(),
@@ -254,6 +261,7 @@ const RecommendationItemSchema = z.object({
type: mediaType,
title: z.string(),
posterPath: z.string().nullable(),
+ posterThumbHash: z.string().nullable(),
releaseDate: z.string().nullable(),
firstAirDate: z.string().nullable(),
voteAverage: z.number().nullable(),
@@ -324,6 +332,7 @@ export const ContinueWatchingOutput = z.object({
id: z.string(),
title: z.string(),
backdropPath: z.string().nullable(),
+ backdropThumbHash: z.string().nullable(),
}),
nextEpisode: z
.object({
@@ -331,6 +340,7 @@ export const ContinueWatchingOutput = z.object({
episodeNumber: z.number(),
name: z.string().nullable(),
stillPath: z.string().nullable(),
+ stillThumbHash: z.string().nullable(),
})
.nullable(),
totalEpisodes: z.number(),
@@ -347,6 +357,7 @@ export const LibraryOutput = z.object({
type: mediaType,
title: z.string(),
posterPath: z.string().nullable(),
+ posterThumbHash: z.string().nullable(),
releaseDate: z.string().nullable(),
firstAirDate: z.string().nullable(),
voteAverage: z.number().nullable(),
diff --git a/packages/api/src/utils.ts b/packages/api/src/utils.ts
index df8f1b7..9819cf4 100644
--- a/packages/api/src/utils.ts
+++ b/packages/api/src/utils.ts
@@ -6,6 +6,7 @@ interface NextEpisodeInfo {
episodeNumber: number;
name: string | null;
stillPath: string | null;
+ stillThumbHash: string | null;
}
export interface NextEpisodeResult {
@@ -41,6 +42,7 @@ export function getNextEpisode(
episodeNumber: ep.episodeNumber,
name: ep.name,
stillPath: ep.stillPath,
+ stillThumbHash: ep.stillThumbHash,
};
}
}
diff --git a/packages/core/package.json b/packages/core/package.json
index afd9dc6..0f15a7c 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -17,6 +17,7 @@
"./settings": "./src/settings.ts",
"./system-health": "./src/system-health.ts",
"./telemetry": "./src/telemetry.ts",
+ "./thumbhash": "./src/thumbhash.ts",
"./tracking": "./src/tracking.ts",
"./update-check": "./src/update-check.ts",
"./webhooks": "./src/webhooks.ts"
@@ -35,6 +36,8 @@
"@sofa/tmdb": "workspace:*",
"date-fns": "catalog:",
"node-vibrant": "4.0.4",
+ "sharp": "0.34.5",
+ "thumbhash": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
diff --git a/packages/core/src/colors.ts b/packages/core/src/colors.ts
index 16e815a..a9335e1 100644
--- a/packages/core/src/colors.ts
+++ b/packages/core/src/colors.ts
@@ -1,15 +1,9 @@
-import path from "node:path";
import { db } from "@sofa/db/client";
import { eq } from "@sofa/db/helpers";
import { titles } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { Vibrant } from "node-vibrant/node";
-import {
- downloadAndCacheImage,
- getLocalImagePath,
- imageCacheEnabled,
- isImageCached,
-} from "./image-cache";
+import { loadImageBuffer } from "./image-cache";
const log = createLogger("colors");
@@ -25,27 +19,23 @@ export interface ColorPalette {
export async function extractAndStoreColors(
titleId: string,
posterPath: string | null,
+ sourceBuffer?: Buffer | null,
): Promise
{
- if (!posterPath) return null;
-
- // Use local cached image, downloading first if needed
- let source: string;
- const filename = path.basename(posterPath);
- if (imageCacheEnabled()) {
- if (!(await isImageCached("posters", filename))) {
- await downloadAndCacheImage(posterPath, "posters");
- }
- source = getLocalImagePath("posters", filename);
- } else {
- const baseUrl =
- process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
- source = `${baseUrl}/w300${posterPath}`;
+ if (!posterPath) {
+ db.update(titles)
+ .set({ colorPalette: null })
+ .where(eq(titles.id, titleId))
+ .run();
+ return null;
}
+ const source =
+ sourceBuffer === undefined
+ ? await loadImageBuffer(posterPath, "posters")
+ : sourceBuffer;
+ if (!source) return null;
+
try {
- log.debug(
- `Extracting colors for title ${titleId} from ${imageCacheEnabled() ? "cache" : "remote"}`,
- );
const palette = await Vibrant.from(source).getPalette();
const colors: ColorPalette = {
diff --git a/packages/core/src/credits.ts b/packages/core/src/credits.ts
index 6ff32d1..4166380 100644
--- a/packages/core/src/credits.ts
+++ b/packages/core/src/credits.ts
@@ -6,6 +6,7 @@ import { createLogger } from "@sofa/logger";
import { getMovieCredits, getTvAggregateCredits } from "@sofa/tmdb/client";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { cacheProfilePhotos, imageCacheEnabled } from "./image-cache";
+import { generatePersonThumbHash } from "./thumbhash";
const log = createLogger("credits");
@@ -37,49 +38,107 @@ function batchUpsertPersons(people: PersonData[]): Map {
// Batch prefetch existing persons (1 query)
const existing = db
- .select({ id: persons.id, tmdbId: persons.tmdbId })
+ .select({
+ id: persons.id,
+ tmdbId: persons.tmdbId,
+ name: persons.name,
+ profilePath: persons.profilePath,
+ profileThumbHash: persons.profileThumbHash,
+ popularity: persons.popularity,
+ })
.from(persons)
.where(inArray(persons.tmdbId, tmdbIds))
.all();
+ const existingMap = new Map(existing.map((p) => [p.tmdbId, p]));
const idMap = new Map(existing.map((p) => [p.tmdbId, p.id]));
- // Insert only new persons in a transaction
- const newPeople = uniquePeople.filter((p) => !idMap.has(p.tmdbId));
- if (newPeople.length > 0) {
- db.transaction((tx) => {
- for (const p of newPeople) {
- const row = tx
- .insert(persons)
- .values({
- tmdbId: p.tmdbId,
+ db.transaction((tx) => {
+ for (const p of uniquePeople) {
+ const existingPerson = existingMap.get(p.tmdbId);
+ if (existingPerson) {
+ const nextPopularity = p.popularity ?? null;
+ const pathChanged = existingPerson.profilePath !== p.profilePath;
+ const needsUpdate =
+ existingPerson.name !== p.name ||
+ pathChanged ||
+ existingPerson.popularity !== nextPopularity;
+ if (!needsUpdate) continue;
+
+ tx.update(persons)
+ .set({
name: p.name,
profilePath: p.profilePath,
- popularity: p.popularity ?? null,
+ popularity: nextPopularity,
+ profileThumbHash: pathChanged
+ ? null
+ : existingPerson.profileThumbHash,
})
- .onConflictDoNothing()
- .returning()
- .get();
- if (row) idMap.set(p.tmdbId, row.id);
+ .where(eq(persons.id, existingPerson.id))
+ .run();
+ continue;
}
- });
- // One fallback query for any that conflicted
- const stillMissing = newPeople
- .filter((p) => !idMap.has(p.tmdbId))
- .map((p) => p.tmdbId);
- if (stillMissing.length > 0) {
- const fallbacks = db
- .select({ id: persons.id, tmdbId: persons.tmdbId })
- .from(persons)
- .where(inArray(persons.tmdbId, stillMissing))
- .all();
- for (const f of fallbacks) idMap.set(f.tmdbId, f.id);
+ const row = tx
+ .insert(persons)
+ .values({
+ tmdbId: p.tmdbId,
+ name: p.name,
+ profilePath: p.profilePath,
+ popularity: p.popularity ?? null,
+ })
+ .onConflictDoNothing()
+ .returning()
+ .get();
+ if (row) idMap.set(p.tmdbId, row.id);
}
+ });
+
+ // One fallback query for any concurrent inserts that conflicted
+ const stillMissing = uniquePeople
+ .filter((p) => !idMap.has(p.tmdbId))
+ .map((p) => p.tmdbId);
+ if (stillMissing.length > 0) {
+ const fallbacks = db
+ .select({ id: persons.id, tmdbId: persons.tmdbId })
+ .from(persons)
+ .where(inArray(persons.tmdbId, stillMissing))
+ .all();
+ for (const f of fallbacks) idMap.set(f.tmdbId, f.id);
}
return idMap;
}
+export async function syncCastProfileThumbHashes(
+ titleId: string,
+ personIds?: string[],
+ options?: { warmCache?: boolean },
+) {
+ if (options?.warmCache !== false && imageCacheEnabled()) {
+ await cacheProfilePhotos(titleId);
+ }
+
+ const personRows = db
+ .select({
+ id: persons.id,
+ profilePath: persons.profilePath,
+ profileThumbHash: persons.profileThumbHash,
+ })
+ .from(titleCast)
+ .innerJoin(persons, eq(titleCast.personId, persons.id))
+ .where(eq(titleCast.titleId, titleId))
+ .all();
+
+ const allowedIds = personIds ? new Set(personIds) : null;
+ const uniqueRows = new Map(personRows.map((p) => [p.id, p]));
+ await Promise.all(
+ [...uniqueRows.values()]
+ .filter((p) => (!allowedIds || allowedIds.has(p.id)) && p.profilePath)
+ .filter((p) => !p.profileThumbHash)
+ .map((p) => generatePersonThumbHash(p.id, p.profilePath)),
+ );
+}
+
export async function refreshCredits(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
@@ -276,11 +335,9 @@ export async function refreshCredits(titleId: string) {
log.debug(`Credits refreshed for "${title.title}"`);
- if (imageCacheEnabled()) {
- cacheProfilePhotos(titleId).catch((err) =>
- log.debug("Profile photo caching failed:", err),
- );
- }
+ (async () => {
+ await syncCastProfileThumbHashes(titleId, [...personIds.values()]);
+ })().catch((err) => log.debug("Profile cache/thumbhash failed:", err));
} catch (err) {
log.error(`Failed to refresh credits for title ${titleId}:`, err);
}
@@ -298,6 +355,7 @@ export function getCastForTitle(titleId: string): CastMember[] {
displayOrder: titleCast.displayOrder,
episodeCount: titleCast.episodeCount,
profilePath: persons.profilePath,
+ profileThumbHash: persons.profileThumbHash,
tmdbId: persons.tmdbId,
})
.from(titleCast)
diff --git a/packages/core/src/discovery.ts b/packages/core/src/discovery.ts
index bddd3c8..68ce6ab 100644
--- a/packages/core/src/discovery.ts
+++ b/packages/core/src/discovery.ts
@@ -177,6 +177,7 @@ export interface ContinueWatchingItem {
title: string;
posterPath: string | null;
backdropPath: string | null;
+ backdropThumbHash: string | null;
type: string;
};
nextEpisode: {
@@ -185,6 +186,7 @@ export interface ContinueWatchingItem {
episodeNumber: number;
name: string | null;
stillPath: string | null;
+ stillThumbHash: string | null;
overview: string | null;
} | null;
lastWatchedAt: Date | null;
@@ -322,6 +324,7 @@ export function getContinueWatchingFeed(
episodeNumber: ep.episodeNumber,
name: ep.name,
stillPath: ep.stillPath,
+ stillThumbHash: ep.stillThumbHash,
overview: ep.overview,
};
}
@@ -335,6 +338,7 @@ export function getContinueWatchingFeed(
title: title.title,
posterPath: title.posterPath,
backdropPath: title.backdropPath,
+ backdropThumbHash: title.backdropThumbHash,
type: title.type,
},
nextEpisode,
@@ -366,6 +370,7 @@ export function getNewAvailableFeed(userId: string, days = 14) {
type: titles.type,
tmdbId: titles.tmdbId,
posterPath: titles.posterPath,
+ posterThumbHash: titles.posterThumbHash,
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
@@ -524,6 +529,7 @@ export function getRecommendationsForTitle(titleId: string) {
type: r.type as "movie" | "tv",
title: r.title,
posterPath: tmdbImageUrl(r.posterPath, "posters"),
+ posterThumbHash: r.posterThumbHash,
releaseDate: r.releaseDate,
firstAirDate: r.firstAirDate,
voteAverage: r.voteAverage,
diff --git a/packages/core/src/image-cache.ts b/packages/core/src/image-cache.ts
index d09344b..508fea3 100644
--- a/packages/core/src/image-cache.ts
+++ b/packages/core/src/image-cache.ts
@@ -12,26 +12,22 @@ import {
titles,
} from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
-import type { ImageCategory } from "@sofa/tmdb/image";
+import {
+ IMAGE_CATEGORY_SIZES,
+ type ImageCategory,
+ tmdbCdnImageUrl,
+} from "@sofa/tmdb/image";
const log = createLogger("image-cache");
export type { ImageCategory };
-const CATEGORY_SIZES: Record = {
- posters: "w500",
- backdrops: "w1280",
- stills: "w1280",
- logos: "w92",
- profiles: "w185",
-};
-
export function imageCacheEnabled(): boolean {
return process.env.IMAGE_CACHE_ENABLED !== "false";
}
export async function ensureImageDirs() {
- for (const category of Object.keys(CATEGORY_SIZES)) {
+ for (const category of Object.keys(IMAGE_CATEGORY_SIZES) as ImageCategory[]) {
await mkdir(path.join(CACHE_DIR, category), { recursive: true });
}
}
@@ -60,20 +56,33 @@ export async function readCachedImage(
return Buffer.from(await file.arrayBuffer());
}
+async function fetchRemoteImage(
+ tmdbPath: string,
+ category: ImageCategory,
+): Promise<{ buffer: Buffer; contentType: string } | null> {
+ const url =
+ tmdbCdnImageUrl(tmdbPath, category) ?? `${TMDB_IMAGE_BASE_URL}${tmdbPath}`;
+
+ const res = await fetch(url);
+ if (!res.ok) {
+ log.warn(`Fetch failed: ${url} -> ${res.status}`);
+ return null;
+ }
+
+ return {
+ buffer: Buffer.from(await res.arrayBuffer()),
+ contentType: res.headers.get("content-type") || "image/jpeg",
+ };
+}
+
export async function downloadAndCacheImage(
tmdbPath: string,
category: ImageCategory,
): Promise {
- const size = CATEGORY_SIZES[category];
- const url = `${TMDB_IMAGE_BASE_URL}/${size}${tmdbPath}`;
+ const remote = await fetchRemoteImage(tmdbPath, category);
+ if (!remote) return null;
- const res = await fetch(url);
- if (!res.ok) {
- log.warn(`Download failed: ${url} -> ${res.status}`);
- return null;
- }
-
- const buffer = Buffer.from(await res.arrayBuffer());
+ const { buffer } = remote;
const filename = path.basename(tmdbPath);
const finalPath = getLocalImagePath(category, filename);
const tmpPath = `${finalPath}.tmp.${Date.now()}`;
@@ -109,16 +118,10 @@ export async function fetchAndMaybeCache(
}
// Fetch from TMDB
- const size = CATEGORY_SIZES[category];
- const url = `${TMDB_IMAGE_BASE_URL}/${size}${tmdbPath}`;
- const res = await fetch(url);
- if (!res.ok) {
- log.warn(`Fetch failed: ${url} -> ${res.status}`);
- return null;
- }
+ const remote = await fetchRemoteImage(tmdbPath, category);
+ if (!remote) return null;
- const buffer = Buffer.from(await res.arrayBuffer());
- const contentType = res.headers.get("content-type") || "image/jpeg";
+ const { buffer, contentType } = remote;
// Fire-and-forget save to disk
const finalPath = getLocalImagePath(category, filename);
@@ -130,6 +133,22 @@ export async function fetchAndMaybeCache(
return { buffer, contentType };
}
+export async function loadImageBuffer(
+ tmdbPath: string,
+ category: ImageCategory,
+): Promise {
+ const filename = path.basename(tmdbPath);
+
+ if (imageCacheEnabled()) {
+ const cached = await readCachedImage(category, filename);
+ if (cached) return cached;
+ return downloadAndCacheImage(tmdbPath, category);
+ }
+
+ const remote = await fetchRemoteImage(tmdbPath, category);
+ return remote?.buffer ?? null;
+}
+
export async function cacheImagesForTitle(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts
index 12b2515..81f705b 100644
--- a/packages/core/src/metadata.ts
+++ b/packages/core/src/metadata.ts
@@ -6,7 +6,7 @@ import type {
Season,
} from "@sofa/api/schemas";
import { db } from "@sofa/db/client";
-import { eq, inArray, sql } from "@sofa/db/helpers";
+import { and, eq, inArray, isNotNull, sql } from "@sofa/db/helpers";
import {
availabilityOffers,
episodes,
@@ -39,8 +39,15 @@ import {
cacheEpisodeStills,
cacheImagesForTitle,
imageCacheEnabled,
+ loadImageBuffer,
} from "./image-cache";
import { generateProviderUrl } from "./providers";
+import {
+ generateEpisodeThumbHash,
+ generateSeasonThumbHash,
+ generateTitleBackdropThumbHash,
+ generateTitlePosterThumbHash,
+} from "./thumbhash";
const log = createLogger("metadata");
@@ -86,6 +93,47 @@ function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
});
}
+export function updateTitleWithArtInvalidation(
+ title: Pick<
+ typeof titles.$inferSelect,
+ | "id"
+ | "posterPath"
+ | "backdropPath"
+ | "posterThumbHash"
+ | "backdropThumbHash"
+ | "colorPalette"
+ >,
+ values: Partial,
+) {
+ const nextPosterPath = Object.hasOwn(values, "posterPath")
+ ? (values.posterPath ?? null)
+ : title.posterPath;
+ const nextBackdropPath = Object.hasOwn(values, "backdropPath")
+ ? (values.backdropPath ?? null)
+ : title.backdropPath;
+
+ const posterPathChanged = nextPosterPath !== title.posterPath;
+ const backdropPathChanged = nextBackdropPath !== title.backdropPath;
+
+ db.update(titles)
+ .set({
+ ...values,
+ ...(posterPathChanged
+ ? {
+ posterThumbHash: null,
+ colorPalette: null,
+ }
+ : {}),
+ ...(backdropPathChanged
+ ? {
+ backdropThumbHash: null,
+ }
+ : {}),
+ })
+ .where(eq(titles.id, title.id))
+ .run();
+}
+
/** @internal */
export function extractMovieContentRating(
movie: TmdbMovieDetails,
@@ -148,18 +196,15 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
if (!hasSeason) {
const show = await getTvDetails(tmdbId);
if (!existing.lastFetchedAt) {
- db.update(titles)
- .set({
- overview: show.overview,
- posterPath: show.poster_path,
- backdropPath: show.backdrop_path,
- status: show.status,
- contentRating: extractTvContentRating(show),
- tvdbId: show.external_ids?.tvdb_id ?? null,
- lastFetchedAt: new Date(),
- })
- .where(eq(titles.id, existing.id))
- .run();
+ updateTitleWithArtInvalidation(existing, {
+ overview: show.overview,
+ posterPath: show.poster_path,
+ backdropPath: show.backdrop_path,
+ status: show.status,
+ contentRating: extractTvContentRating(show),
+ tvdbId: show.external_ids?.tvdb_id ?? null,
+ lastFetchedAt: new Date(),
+ });
}
upsertGenres(existing.id, show.genres ?? []);
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
@@ -169,23 +214,18 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
refreshRecommendations(existing.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
- extractAndStoreColors(existing.id, show.poster_path ?? null).catch(
- (err) => log.debug("Color extraction failed:", err),
- );
+ syncTitleArt(
+ existing.id,
+ show.poster_path,
+ show.backdrop_path,
+ "tv",
+ ).catch((err) => log.debug("Cache/thumbhash failed:", err));
refreshCredits(existing.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
refreshTrailer(existing.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
- if (imageCacheEnabled()) {
- cacheImagesForTitle(existing.id).catch((err) =>
- log.debug("Image caching failed:", err),
- );
- cacheEpisodeStills(existing.id).catch((err) =>
- log.debug("Episode stills caching failed:", err),
- );
- }
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
}
}
@@ -223,8 +263,8 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
- extractAndStoreColors(row.id, movie.poster_path ?? null).catch((err) =>
- log.debug("Color extraction failed:", err),
+ syncTitleArt(row.id, movie.poster_path, movie.backdrop_path, "movie").catch(
+ (err) => log.debug("Cache/thumbhash failed:", err),
);
refreshCredits(row.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
@@ -232,11 +272,6 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
refreshTrailer(row.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
- if (imageCacheEnabled()) {
- cacheImagesForTitle(row.id).catch((err) =>
- log.debug("Image caching failed:", err),
- );
- }
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
return row;
}
@@ -272,8 +307,8 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
- extractAndStoreColors(row.id, show.poster_path ?? null).catch((err) =>
- log.debug("Color extraction failed:", err),
+ syncTitleArt(row.id, show.poster_path, show.backdrop_path, "tv").catch(
+ (err) => log.debug("Cache/thumbhash failed:", err),
);
refreshCredits(row.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
@@ -281,14 +316,6 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
refreshTrailer(row.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
- if (imageCacheEnabled()) {
- cacheImagesForTitle(row.id).catch((err) =>
- log.debug("Image caching failed:", err),
- );
- cacheEpisodeStills(row.id).catch((err) =>
- log.debug("Episode stills caching failed:", err),
- );
- }
log.info(`Imported TV show "${show.name}" (TMDB ${tmdbId})`);
return row;
}
@@ -301,69 +328,56 @@ export async function refreshTitle(titleId: string) {
if (title.type === "movie") {
const movie = await getMovieDetails(title.tmdbId);
- db.update(titles)
- .set({
- title: movie.title,
- originalTitle: movie.original_title,
- overview: movie.overview,
- releaseDate: movie.release_date || null,
- posterPath: movie.poster_path,
- backdropPath: movie.backdrop_path,
- popularity: movie.popularity,
- voteAverage: movie.vote_average,
- voteCount: movie.vote_count,
- status: movie.status,
- contentRating: extractMovieContentRating(movie),
- lastFetchedAt: now,
- })
- .where(eq(titles.id, titleId))
- .run();
+ updateTitleWithArtInvalidation(title, {
+ title: movie.title,
+ originalTitle: movie.original_title,
+ overview: movie.overview,
+ releaseDate: movie.release_date || null,
+ posterPath: movie.poster_path,
+ backdropPath: movie.backdrop_path,
+ popularity: movie.popularity,
+ voteAverage: movie.vote_average,
+ voteCount: movie.vote_count,
+ status: movie.status,
+ contentRating: extractMovieContentRating(movie),
+ lastFetchedAt: now,
+ });
upsertGenres(titleId, movie.genres ?? []);
} else {
const show = await getTvDetails(title.tmdbId);
- db.update(titles)
- .set({
- title: show.name,
- originalTitle: show.original_name,
- overview: show.overview,
- firstAirDate: show.first_air_date || null,
- posterPath: show.poster_path,
- backdropPath: show.backdrop_path,
- popularity: show.popularity,
- voteAverage: show.vote_average,
- voteCount: show.vote_count,
- status: show.status,
- contentRating: extractTvContentRating(show),
- tvdbId: show.external_ids?.tvdb_id ?? null,
- lastFetchedAt: now,
- })
- .where(eq(titles.id, titleId))
- .run();
+ updateTitleWithArtInvalidation(title, {
+ title: show.name,
+ originalTitle: show.original_name,
+ overview: show.overview,
+ firstAirDate: show.first_air_date || null,
+ posterPath: show.poster_path,
+ backdropPath: show.backdrop_path,
+ popularity: show.popularity,
+ voteAverage: show.vote_average,
+ voteCount: show.vote_count,
+ status: show.status,
+ contentRating: extractTvContentRating(show),
+ tvdbId: show.external_ids?.tvdb_id ?? null,
+ lastFetchedAt: now,
+ });
upsertGenres(titleId, show.genres ?? []);
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
}
const updated = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (updated) {
- extractAndStoreColors(updated.id, updated.posterPath).catch((err) =>
- log.debug("Color extraction failed:", err),
- );
+ syncTitleArt(
+ updated.id,
+ updated.posterPath,
+ updated.backdropPath,
+ updated.type as "movie" | "tv",
+ ).catch((err) => log.debug("Cache/thumbhash failed:", err));
refreshTrailer(updated.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
refreshCredits(updated.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
- if (imageCacheEnabled()) {
- cacheImagesForTitle(updated.id).catch((err) =>
- log.debug("Image caching failed:", err),
- );
- if (updated.type === "tv") {
- cacheEpisodeStills(updated.id).catch((err) =>
- log.debug("Episode stills caching failed:", err),
- );
- }
- }
}
return updated;
}
@@ -381,6 +395,14 @@ export async function refreshTvChildren(
try {
const seasonData = await getTvSeasonDetails(tmdbId, sn);
+ const existingSeason = db
+ .select({
+ id: seasons.id,
+ posterPath: seasons.posterPath,
+ })
+ .from(seasons)
+ .where(and(eq(seasons.titleId, titleId), eq(seasons.seasonNumber, sn)))
+ .get();
const seasonRow = db
.insert(seasons)
@@ -406,6 +428,19 @@ export async function refreshTvChildren(
.returning()
.get();
+ // Snapshot existing episode still paths before upsert so we can detect changes
+ const oldEpStills = new Map(
+ db
+ .select({
+ episodeNumber: episodes.episodeNumber,
+ stillPath: episodes.stillPath,
+ })
+ .from(episodes)
+ .where(eq(episodes.seasonId, existingSeason?.id ?? seasonRow.id))
+ .all()
+ .map((e) => [e.episodeNumber, e.stillPath] as const),
+ );
+
// Batch all episode upserts in a single transaction per season
const eps = seasonData.episodes ?? [];
if (eps.length > 0) {
@@ -435,6 +470,38 @@ export async function refreshTvChildren(
}
});
}
+
+ // Clear stale hashes when image paths change during the upsert.
+ // Full hash (re)generation is handled by syncTitleArt() after
+ // cache warming, so we only need to null out stale values here.
+ if (
+ (existingSeason?.posterPath ?? null) !==
+ (seasonData.poster_path ?? null)
+ ) {
+ db.update(seasons)
+ .set({ posterThumbHash: null })
+ .where(eq(seasons.id, seasonRow.id))
+ .run();
+ }
+ const seasonEps = db
+ .select({
+ id: episodes.id,
+ episodeNumber: episodes.episodeNumber,
+ stillPath: episodes.stillPath,
+ stillThumbHash: episodes.stillThumbHash,
+ })
+ .from(episodes)
+ .where(eq(episodes.seasonId, seasonRow.id))
+ .all();
+ for (const ep of seasonEps) {
+ const oldStill = oldEpStills.get(ep.episodeNumber);
+ if (oldStill !== ep.stillPath && ep.stillThumbHash) {
+ db.update(episodes)
+ .set({ stillThumbHash: null })
+ .where(eq(episodes.id, ep.id))
+ .run();
+ }
+ }
} catch (err) {
// Skip this season and continue with the rest — partial data is
// better than aborting entirely. The next refresh cycle will retry.
@@ -492,47 +559,123 @@ export async function refreshRecommendations(titleId: string) {
if (allItems.length === 0) return;
+ const uniqueTitles = new Map<
+ number,
+ {
+ tmdbId: number;
+ type: "movie" | "tv";
+ title: string;
+ originalTitle: string | null;
+ overview: string | null;
+ releaseDate: string | null;
+ firstAirDate: string | null;
+ posterPath: string | null;
+ backdropPath: string | null;
+ popularity: number | null;
+ voteAverage: number | null;
+ voteCount: number | null;
+ }
+ >();
+ for (const item of allItems) {
+ if (uniqueTitles.has(item.result.id)) continue;
+ uniqueTitles.set(item.result.id, {
+ tmdbId: item.result.id,
+ type: item.type,
+ title: item.result.title ?? item.result.name ?? "Unknown",
+ originalTitle:
+ item.result.original_title ?? item.result.original_name ?? null,
+ overview: item.result.overview ?? null,
+ releaseDate: item.result.release_date ?? null,
+ firstAirDate: item.result.first_air_date ?? null,
+ posterPath: item.result.poster_path ?? null,
+ backdropPath: item.result.backdrop_path ?? null,
+ popularity: item.result.popularity ?? null,
+ voteAverage: item.result.vote_average ?? null,
+ voteCount: item.result.vote_count ?? null,
+ });
+ }
+
// Batch prefetch existing titles (1 query)
- const tmdbIds = [...new Set(allItems.map((item) => item.result.id))];
+ const tmdbIds = [...uniqueTitles.keys()];
const existingTitles = db
- .select({ id: titles.id, tmdbId: titles.tmdbId })
+ .select({
+ id: titles.id,
+ tmdbId: titles.tmdbId,
+ posterPath: titles.posterPath,
+ backdropPath: titles.backdropPath,
+ posterThumbHash: titles.posterThumbHash,
+ backdropThumbHash: titles.backdropThumbHash,
+ })
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.all();
+ const existingTitleMap = new Map(existingTitles.map((t) => [t.tmdbId, t]));
const titleIdMap = new Map(
existingTitles.map((t) => [t.tmdbId, t.id]),
);
// Insert missing titles + upsert recommendations in a single transaction
db.transaction((tx) => {
- // Insert only new titles
- const newItems = allItems.filter((item) => !titleIdMap.has(item.result.id));
const insertedTmdbIds = new Set();
- for (const item of newItems) {
- if (insertedTmdbIds.has(item.result.id)) continue;
- insertedTmdbIds.add(item.result.id);
- const r = item.result;
+ for (const item of uniqueTitles.values()) {
+ const existingTitle = existingTitleMap.get(item.tmdbId);
+ if (existingTitle) {
+ const posterPathChanged = existingTitle.posterPath !== item.posterPath;
+ const backdropPathChanged =
+ existingTitle.backdropPath !== item.backdropPath;
+
+ tx.update(titles)
+ .set({
+ type: item.type,
+ title: item.title,
+ originalTitle: item.originalTitle,
+ overview: item.overview,
+ releaseDate: item.releaseDate,
+ firstAirDate: item.firstAirDate,
+ posterPath: item.posterPath,
+ backdropPath: item.backdropPath,
+ popularity: item.popularity,
+ voteAverage: item.voteAverage,
+ voteCount: item.voteCount,
+ ...(posterPathChanged
+ ? {
+ posterThumbHash: null,
+ colorPalette: null,
+ }
+ : {}),
+ ...(backdropPathChanged
+ ? {
+ backdropThumbHash: null,
+ }
+ : {}),
+ })
+ .where(eq(titles.id, existingTitle.id))
+ .run();
+ continue;
+ }
+
+ insertedTmdbIds.add(item.tmdbId);
const row = tx
.insert(titles)
.values({
- tmdbId: r.id,
+ tmdbId: item.tmdbId,
type: item.type,
- title: r.title ?? r.name ?? "Unknown",
- originalTitle: r.original_title ?? r.original_name,
- overview: r.overview,
- releaseDate: r.release_date,
- firstAirDate: r.first_air_date,
- posterPath: r.poster_path,
- backdropPath: r.backdrop_path,
- popularity: r.popularity,
- voteAverage: r.vote_average,
- voteCount: r.vote_count,
+ title: item.title,
+ originalTitle: item.originalTitle,
+ overview: item.overview,
+ releaseDate: item.releaseDate,
+ firstAirDate: item.firstAirDate,
+ posterPath: item.posterPath,
+ backdropPath: item.backdropPath,
+ popularity: item.popularity,
+ voteAverage: item.voteAverage,
+ voteCount: item.voteCount,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning()
.get();
- if (row) titleIdMap.set(r.id, row.id);
+ if (row) titleIdMap.set(item.tmdbId, row.id);
}
// One fallback query for any that conflicted
@@ -580,6 +723,31 @@ export async function refreshRecommendations(titleId: string) {
.run();
}
});
+
+ // Fire-and-forget thumbhash generation for recommendation titles missing one
+ const recTitleIds = [
+ ...new Set(
+ allItems.map((i) => titleIdMap.get(i.result.id)).filter(Boolean),
+ ),
+ ] as string[];
+ if (recTitleIds.length > 0) {
+ const needingHash = db
+ .select({ id: titles.id, posterPath: titles.posterPath })
+ .from(titles)
+ .where(
+ and(
+ inArray(titles.id, recTitleIds),
+ isNotNull(titles.posterPath),
+ sql`${titles.posterThumbHash} IS NULL`,
+ ),
+ )
+ .all();
+ for (const t of needingHash) {
+ generateTitlePosterThumbHash(t.id, t.posterPath).catch((err) =>
+ log.debug("Recommendation poster thumbhash failed:", err),
+ );
+ }
+ }
}
/** Fetch seasons from the DB, building the Season[] structure. */
@@ -610,6 +778,7 @@ function fetchSeasonsFromDb(titleId: string): Season[] {
name: ep.name,
overview: ep.overview,
stillPath: tmdbImageUrl(ep.stillPath, "stills"),
+ stillThumbHash: ep.stillThumbHash,
airDate: ep.airDate,
runtimeMinutes: ep.runtimeMinutes,
});
@@ -639,17 +808,14 @@ export async function ensureTvHydrated(
if (!title.lastFetchedAt) {
try {
const show = await getTvDetails(tmdbId);
- db.update(titles)
- .set({
- overview: show.overview,
- posterPath: show.poster_path,
- backdropPath: show.backdrop_path,
- status: show.status,
- contentRating: extractTvContentRating(show),
- lastFetchedAt: new Date(),
- })
- .where(eq(titles.id, titleId))
- .run();
+ updateTitleWithArtInvalidation(title, {
+ overview: show.overview,
+ posterPath: show.poster_path,
+ backdropPath: show.backdrop_path,
+ status: show.status,
+ contentRating: extractTvContentRating(show),
+ lastFetchedAt: new Date(),
+ });
upsertGenres(titleId, show.genres ?? []);
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
} catch (err) {
@@ -686,7 +852,7 @@ async function ensureEnriched(
title: typeof titles.$inferSelect,
existing: { hasCast: boolean; hasAvailability: boolean },
): Promise {
- const tasks: Promise[] = [];
+ const tasks: Promise[] = [];
if (!existing.hasCast) {
tasks.push(
@@ -720,12 +886,20 @@ async function ensureEnriched(
);
}
- if (!title.colorPalette && title.posterPath) {
+ const needsTitleArtSync =
+ (!title.posterPath && (!!title.posterThumbHash || !!title.colorPalette)) ||
+ (!!title.posterPath && (!title.posterThumbHash || !title.colorPalette)) ||
+ (!title.backdropPath && !!title.backdropThumbHash) ||
+ (!!title.backdropPath && !title.backdropThumbHash);
+
+ if (needsTitleArtSync) {
tasks.push(
- extractAndStoreColors(titleId, title.posterPath).then(
- () => {},
- (err) => log.debug("Color enrichment failed:", err),
- ),
+ syncTitleArt(
+ titleId,
+ title.posterPath,
+ title.backdropPath,
+ title.type as "movie" | "tv",
+ ).catch((err) => log.debug("Title art enrichment failed:", err)),
);
}
@@ -786,23 +960,20 @@ export async function getOrFetchTitle(id: string): Promise<{
if (title.type === "movie" && !title.lastFetchedAt) {
try {
const movie = await getMovieDetails(title.tmdbId);
- db.update(titles)
- .set({
- title: movie.title,
- originalTitle: movie.original_title,
- overview: movie.overview,
- releaseDate: movie.release_date || null,
- posterPath: movie.poster_path,
- backdropPath: movie.backdrop_path,
- popularity: movie.popularity,
- voteAverage: movie.vote_average,
- voteCount: movie.vote_count,
- status: movie.status,
- contentRating: extractMovieContentRating(movie),
- lastFetchedAt: new Date(),
- })
- .where(eq(titles.id, id))
- .run();
+ updateTitleWithArtInvalidation(title, {
+ title: movie.title,
+ originalTitle: movie.original_title,
+ overview: movie.overview,
+ releaseDate: movie.release_date || null,
+ posterPath: movie.poster_path,
+ backdropPath: movie.backdrop_path,
+ popularity: movie.popularity,
+ voteAverage: movie.vote_average,
+ voteCount: movie.vote_count,
+ status: movie.status,
+ contentRating: extractMovieContentRating(movie),
+ lastFetchedAt: new Date(),
+ });
upsertGenres(id, movie.genres ?? []);
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
} catch (err) {
@@ -849,7 +1020,9 @@ export async function getOrFetchTitle(id: string): Promise<{
releaseDate: title.releaseDate,
firstAirDate: title.firstAirDate,
posterPath: tmdbImageUrl(title.posterPath, "posters"),
+ posterThumbHash: title.posterThumbHash,
backdropPath: tmdbImageUrl(title.backdropPath, "backdrops"),
+ backdropThumbHash: title.backdropThumbHash,
popularity: title.popularity,
voteAverage: title.voteAverage,
voteCount: title.voteCount,
@@ -916,6 +1089,92 @@ export async function refreshTrailer(titleId: string) {
}
}
+async function generateMissingTvChildThumbHashes(titleId: string) {
+ const titleSeasons = db
+ .select()
+ .from(seasons)
+ .where(eq(seasons.titleId, titleId))
+ .all();
+
+ const hashTasks: Promise[] = [];
+
+ for (const s of titleSeasons) {
+ if (s.posterPath && !s.posterThumbHash) {
+ hashTasks.push(generateSeasonThumbHash(s.id, s.posterPath));
+ }
+ }
+
+ const seasonIds = titleSeasons.map((s) => s.id);
+ if (seasonIds.length > 0) {
+ const epsNeedingHash = db
+ .select({
+ id: episodes.id,
+ stillPath: episodes.stillPath,
+ })
+ .from(episodes)
+ .where(
+ and(
+ inArray(episodes.seasonId, seasonIds),
+ isNotNull(episodes.stillPath),
+ sql`${episodes.stillThumbHash} IS NULL`,
+ ),
+ )
+ .all();
+ for (const ep of epsNeedingHash) {
+ hashTasks.push(generateEpisodeThumbHash(ep.id, ep.stillPath));
+ }
+ }
+
+ await Promise.all(hashTasks);
+}
+
+export async function syncTvChildArt(
+ titleId: string,
+ options?: { warmCache?: boolean },
+) {
+ if (options?.warmCache !== false && imageCacheEnabled()) {
+ await Promise.all([
+ cacheImagesForTitle(titleId),
+ cacheEpisodeStills(titleId),
+ ]);
+ }
+
+ await generateMissingTvChildThumbHashes(titleId);
+}
+
+/**
+ * Warm the image cache for a title, then derive poster colors and thumbhashes.
+ * Sequencing avoids duplicate TMDB downloads on cold caches.
+ */
+async function syncTitleArt(
+ titleId: string,
+ posterPath: string | null | undefined,
+ backdropPath: string | null | undefined,
+ type: "movie" | "tv",
+) {
+ if (imageCacheEnabled()) {
+ await cacheImagesForTitle(titleId);
+ if (type === "tv") {
+ await cacheEpisodeStills(titleId);
+ }
+ }
+
+ const posterBuffer = posterPath
+ ? await loadImageBuffer(posterPath, "posters")
+ : undefined;
+
+ // Title poster colors + thumbhash, then backdrop thumbhash
+ await Promise.all([
+ extractAndStoreColors(titleId, posterPath ?? null, posterBuffer),
+ generateTitlePosterThumbHash(titleId, posterPath ?? null, posterBuffer),
+ generateTitleBackdropThumbHash(titleId, backdropPath ?? null),
+ ]);
+
+ if (type === "tv") {
+ await syncTvChildArt(titleId, { warmCache: false });
+ }
+}
+
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
diff --git a/packages/core/src/person.ts b/packages/core/src/person.ts
index 3687f8f..069fc63 100644
--- a/packages/core/src/person.ts
+++ b/packages/core/src/person.ts
@@ -5,9 +5,30 @@ import { persons, titleCast, titles } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
import { tmdbImageUrl } from "@sofa/tmdb/image";
+import { generatePersonThumbHash } from "./thumbhash";
const log = createLogger("person");
+async function ensureProfileThumbHash(
+ person: Pick<
+ typeof persons.$inferSelect,
+ "id" | "profilePath" | "profileThumbHash"
+ >,
+) {
+ if (!person.profilePath) {
+ if (person.profileThumbHash) {
+ await generatePersonThumbHash(person.id, null);
+ }
+ return null;
+ }
+
+ if (person.profileThumbHash) {
+ return person.profileThumbHash;
+ }
+
+ return (await generatePersonThumbHash(person.id, person.profilePath)) ?? null;
+}
+
export async function getOrFetchPerson(
personId: string,
): Promise {
@@ -38,6 +59,20 @@ export async function getOrFetchPerson(
.where(eq(persons.id, personId))
.run();
+ const newProfilePath = details.profile_path ?? null;
+ const pathChanged = newProfilePath !== person.profilePath;
+ const profileThumbHash = pathChanged
+ ? await ensureProfileThumbHash({
+ id: personId,
+ profilePath: newProfilePath,
+ profileThumbHash: null,
+ })
+ : await ensureProfileThumbHash({
+ id: personId,
+ profilePath: newProfilePath,
+ profileThumbHash: person.profileThumbHash,
+ });
+
return {
id: person.id,
tmdbId: person.tmdbId,
@@ -47,6 +82,7 @@ export async function getOrFetchPerson(
deathday: details.deathday ?? null,
placeOfBirth: details.place_of_birth ?? null,
profilePath: tmdbImageUrl(details.profile_path ?? null, "profiles"),
+ profileThumbHash,
knownForDepartment: details.known_for_department ?? null,
imdbId: details.imdb_id ?? null,
};
@@ -55,6 +91,8 @@ export async function getOrFetchPerson(
}
}
+ const profileThumbHash = await ensureProfileThumbHash(person);
+
return {
id: person.id,
tmdbId: person.tmdbId,
@@ -64,6 +102,7 @@ export async function getOrFetchPerson(
deathday: person.deathday,
placeOfBirth: person.placeOfBirth,
profilePath: tmdbImageUrl(person.profilePath, "profiles"),
+ profileThumbHash,
knownForDepartment: person.knownForDepartment,
imdbId: person.imdbId,
};
@@ -108,6 +147,8 @@ export async function getOrFetchPersonByTmdbId(
row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
if (!person) return null;
+ const profileThumbHash = await ensureProfileThumbHash(person);
+
return {
id: person.id,
tmdbId: person.tmdbId,
@@ -117,6 +158,7 @@ export async function getOrFetchPersonByTmdbId(
deathday: person.deathday,
placeOfBirth: person.placeOfBirth,
profilePath: tmdbImageUrl(person.profilePath, "profiles"),
+ profileThumbHash,
knownForDepartment: person.knownForDepartment,
imdbId: person.imdbId,
};
@@ -134,6 +176,7 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
type: titles.type,
title: titles.title,
posterPath: titles.posterPath,
+ posterThumbHash: titles.posterThumbHash,
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
@@ -152,6 +195,7 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
type: r.type as "movie" | "tv",
title: r.title,
posterPath: tmdbImageUrl(r.posterPath, "posters"),
+ posterThumbHash: r.posterThumbHash,
releaseDate: r.releaseDate,
firstAirDate: r.firstAirDate,
voteAverage: r.voteAverage,
@@ -251,6 +295,7 @@ export async function fetchFullFilmography(
type: c.media_type as "movie" | "tv",
title: c.title ?? c.name ?? "Unknown",
posterPath: tmdbImageUrl(c.poster_path ?? null, "posters"),
+ posterThumbHash: null,
releaseDate: c.release_date ?? null,
firstAirDate: c.first_air_date ?? null,
voteAverage: c.vote_average,
diff --git a/packages/core/src/thumbhash.ts b/packages/core/src/thumbhash.ts
new file mode 100644
index 0000000..e78e443
--- /dev/null
+++ b/packages/core/src/thumbhash.ts
@@ -0,0 +1,111 @@
+import path from "node:path";
+import { db } from "@sofa/db/client";
+import { eq } from "@sofa/db/helpers";
+import { episodes, persons, seasons, titles } from "@sofa/db/schema";
+import { createLogger } from "@sofa/logger";
+import type { ImageCategory } from "@sofa/tmdb/image";
+import sharp from "sharp";
+import { rgbaToThumbHash } from "thumbhash";
+import { loadImageBuffer } from "./image-cache";
+
+const log = createLogger("thumbhash");
+
+/**
+ * Generate a ThumbHash string from an image path.
+ * Returns a base64-encoded ThumbHash, or null on failure.
+ */
+export async function generateThumbHash(
+ imagePath: string,
+ category: ImageCategory,
+ sourceBuffer?: Buffer | null,
+): Promise {
+ const filename = path.basename(imagePath);
+ const source =
+ sourceBuffer === undefined
+ ? await loadImageBuffer(imagePath, category)
+ : sourceBuffer;
+ if (!source) return null;
+
+ try {
+ const { data, info } = await sharp(source)
+ .resize(100, 100, { fit: "inside" })
+ .ensureAlpha()
+ .raw()
+ .toBuffer({ resolveWithObject: true });
+
+ const hash = rgbaToThumbHash(info.width, info.height, data);
+ return Buffer.from(hash).toString("base64");
+ } catch (err) {
+ log.error(`Failed to generate thumbhash for ${category}/${filename}:`, err);
+ return null;
+ }
+}
+
+export async function generateTitlePosterThumbHash(
+ titleId: string,
+ posterPath: string | null,
+ sourceBuffer?: Buffer | null,
+): Promise {
+ const hash = posterPath
+ ? await generateThumbHash(posterPath, "posters", sourceBuffer)
+ : null;
+ db.update(titles)
+ .set({ posterThumbHash: hash })
+ .where(eq(titles.id, titleId))
+ .run();
+ return hash;
+}
+
+export async function generateTitleBackdropThumbHash(
+ titleId: string,
+ backdropPath: string | null,
+): Promise {
+ const hash = backdropPath
+ ? await generateThumbHash(backdropPath, "backdrops")
+ : null;
+ db.update(titles)
+ .set({ backdropThumbHash: hash })
+ .where(eq(titles.id, titleId))
+ .run();
+ return hash;
+}
+
+export async function generateSeasonThumbHash(
+ seasonId: string,
+ posterPath: string | null,
+): Promise {
+ const hash = posterPath
+ ? await generateThumbHash(posterPath, "posters")
+ : null;
+ db.update(seasons)
+ .set({ posterThumbHash: hash })
+ .where(eq(seasons.id, seasonId))
+ .run();
+ return hash;
+}
+
+export async function generateEpisodeThumbHash(
+ episodeId: string,
+ stillPath: string | null,
+): Promise {
+ const hash = stillPath ? await generateThumbHash(stillPath, "stills") : null;
+ db.update(episodes)
+ .set({ stillThumbHash: hash })
+ .where(eq(episodes.id, episodeId))
+ .run();
+ return hash;
+}
+
+export async function generatePersonThumbHash(
+ personId: string,
+ profilePath: string | null,
+): Promise {
+ const hash = profilePath
+ ? await generateThumbHash(profilePath, "profiles")
+ : null;
+ db.update(persons)
+ .set({ profileThumbHash: hash })
+ .where(eq(persons.id, personId))
+ .run();
+ return hash;
+}
diff --git a/packages/core/test/image-cache.test.ts b/packages/core/test/image-cache.test.ts
new file mode 100644
index 0000000..0f7e97d
--- /dev/null
+++ b/packages/core/test/image-cache.test.ts
@@ -0,0 +1,34 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+import { loadImageBuffer } from "../src/image-cache";
+
+const originalFetch = globalThis.fetch;
+
+beforeEach(() => {
+ process.env.IMAGE_CACHE_ENABLED = "false";
+});
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ delete process.env.IMAGE_CACHE_ENABLED;
+});
+
+describe("loadImageBuffer", () => {
+ test("uses category-specific TMDB sizes when cache is disabled", async () => {
+ const urls: string[] = [];
+ globalThis.fetch = mock(async (input: string | URL | Request) => {
+ urls.push(String(input));
+ return new Response(new Uint8Array([1, 2, 3]), {
+ status: 200,
+ headers: { "content-type": "image/jpeg" },
+ });
+ }) as unknown as typeof fetch;
+
+ await loadImageBuffer("/poster.jpg", "posters");
+ await loadImageBuffer("/profile.jpg", "profiles");
+
+ expect(urls).toEqual([
+ "https://image.tmdb.org/t/p/w500/poster.jpg",
+ "https://image.tmdb.org/t/p/w185/profile.jpg",
+ ]);
+ });
+});
diff --git a/packages/core/test/metadata-refresh.test.ts b/packages/core/test/metadata-refresh.test.ts
new file mode 100644
index 0000000..d0079c0
--- /dev/null
+++ b/packages/core/test/metadata-refresh.test.ts
@@ -0,0 +1,220 @@
+import { beforeEach, describe, expect, mock, test } from "bun:test";
+import { eq } from "@sofa/db/helpers";
+import { episodes, seasons, titles } from "@sofa/db/schema";
+import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
+
+interface MockSeasonEpisode {
+ episode_number: number;
+ name: string | null;
+ overview: string | null;
+ still_path: string | null;
+ air_date: string | null;
+ runtime: number | null;
+}
+
+interface MockSeasonDetails {
+ season_number: number;
+ name: string | null;
+ overview: string | null;
+ poster_path: string | null;
+ air_date: string | null;
+ episodes: MockSeasonEpisode[];
+}
+
+let seasonDetails: MockSeasonDetails = {
+ season_number: 1,
+ name: "Season 1",
+ overview: null,
+ poster_path: "/new-season.png",
+ air_date: null,
+ episodes: [
+ {
+ episode_number: 1,
+ name: "Episode 1",
+ overview: null,
+ still_path: "/new-still.png",
+ air_date: null,
+ runtime: null,
+ },
+ ],
+};
+
+mock.module("@sofa/tmdb/client", () => ({
+ getMovieDetails: async () => {
+ throw new Error("not used");
+ },
+ getRecommendations: async () => ({ results: [] }),
+ getSimilar: async () => ({ results: [] }),
+ getTvDetails: async () => {
+ throw new Error("not used");
+ },
+ getTvSeasonDetails: async () => seasonDetails,
+ getVideos: async () => ({ results: [] }),
+}));
+
+import {
+ refreshTvChildren,
+ updateTitleWithArtInvalidation,
+} from "../src/metadata";
+
+beforeEach(() => {
+ clearAllTables();
+ seasonDetails = {
+ season_number: 1,
+ name: "Season 1",
+ overview: null,
+ poster_path: "/new-season.png",
+ air_date: null,
+ episodes: [
+ {
+ episode_number: 1,
+ name: "Episode 1",
+ overview: null,
+ still_path: "/new-still.png",
+ air_date: null,
+ runtime: null,
+ },
+ ],
+ };
+});
+
+describe("refreshTvChildren", () => {
+ test("clears stale season and episode thumbhashes when artwork paths change", async () => {
+ insertTitle({ id: "tv-1", tmdbId: 10, type: "tv", title: "Show" });
+ testDb
+ .insert(seasons)
+ .values({
+ id: "season-1",
+ titleId: "tv-1",
+ seasonNumber: 1,
+ posterPath: "/old-season.png",
+ posterThumbHash: "stale-season-hash",
+ })
+ .run();
+ testDb
+ .insert(episodes)
+ .values({
+ id: "episode-1",
+ seasonId: "season-1",
+ episodeNumber: 1,
+ stillPath: "/old-still.png",
+ stillThumbHash: "stale-episode-hash",
+ })
+ .run();
+
+ await refreshTvChildren("tv-1", 10, 1);
+
+ const season = testDb
+ .select()
+ .from(seasons)
+ .where(eq(seasons.id, "season-1"))
+ .get();
+ const episode = testDb
+ .select()
+ .from(episodes)
+ .where(eq(episodes.id, "episode-1"))
+ .get();
+
+ expect(season?.posterPath).toBe("/new-season.png");
+ expect(season?.posterThumbHash).toBeNull();
+ expect(episode?.stillPath).toBe("/new-still.png");
+ expect(episode?.stillThumbHash).toBeNull();
+ });
+
+ test("clears an episode thumbhash when the still disappears", async () => {
+ insertTitle({ id: "tv-1", tmdbId: 10, type: "tv", title: "Show" });
+ testDb
+ .insert(seasons)
+ .values({
+ id: "season-1",
+ titleId: "tv-1",
+ seasonNumber: 1,
+ posterPath: "/season.png",
+ })
+ .run();
+ testDb
+ .insert(episodes)
+ .values({
+ id: "episode-1",
+ seasonId: "season-1",
+ episodeNumber: 1,
+ stillPath: "/old-still.png",
+ stillThumbHash: "stale-episode-hash",
+ })
+ .run();
+
+ seasonDetails = {
+ ...seasonDetails,
+ poster_path: "/season.png",
+ episodes: [
+ {
+ episode_number: 1,
+ name: "Episode 1",
+ overview: null,
+ still_path: null,
+ air_date: null,
+ runtime: null,
+ },
+ ],
+ };
+
+ await refreshTvChildren("tv-1", 10, 1);
+
+ const episode = testDb
+ .select()
+ .from(episodes)
+ .where(eq(episodes.id, "episode-1"))
+ .get();
+
+ expect(episode?.stillPath).toBeNull();
+ expect(episode?.stillThumbHash).toBeNull();
+ });
+});
+
+describe("updateTitleWithArtInvalidation", () => {
+ test("clears stale title hashes and palette when artwork changes", () => {
+ testDb
+ .insert(titles)
+ .values({
+ id: "movie-1",
+ tmdbId: 20,
+ type: "movie",
+ title: "Movie",
+ posterPath: "/old-poster.png",
+ posterThumbHash: "stale-poster-hash",
+ backdropPath: "/old-backdrop.png",
+ backdropThumbHash: "stale-backdrop-hash",
+ colorPalette: JSON.stringify({ vibrant: "#fff" }),
+ lastFetchedAt: new Date(),
+ })
+ .run();
+
+ const existing = testDb
+ .select()
+ .from(titles)
+ .where(eq(titles.id, "movie-1"))
+ .get();
+
+ expect(existing).not.toBeUndefined();
+ if (!existing) {
+ throw new Error("Expected seeded title row");
+ }
+
+ updateTitleWithArtInvalidation(existing, {
+ posterPath: "/new-poster.png",
+ backdropPath: "/new-backdrop.png",
+ });
+
+ const title = testDb
+ .select()
+ .from(titles)
+ .where(eq(titles.id, "movie-1"))
+ .get();
+
+ expect(title?.posterPath).toBe("/new-poster.png");
+ expect(title?.backdropPath).toBe("/new-backdrop.png");
+ expect(title?.posterThumbHash).toBeNull();
+ expect(title?.backdropThumbHash).toBeNull();
+ expect(title?.colorPalette).toBeNull();
+ });
+});
diff --git a/packages/core/test/person-detail.test.ts b/packages/core/test/person-detail.test.ts
new file mode 100644
index 0000000..6233196
--- /dev/null
+++ b/packages/core/test/person-detail.test.ts
@@ -0,0 +1,103 @@
+import { beforeEach, describe, expect, mock, test } from "bun:test";
+import { eq } from "@sofa/db/helpers";
+import { persons } from "@sofa/db/schema";
+import { clearAllTables, testDb } from "@sofa/db/test-utils";
+
+const TINY_PNG = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==",
+ "base64",
+);
+
+let nextBuffer: Buffer | null = TINY_PNG;
+let nextPersonDetails = {
+ id: 100,
+ name: "Updated Person",
+ biography: "Bio",
+ birthday: null,
+ deathday: null,
+ place_of_birth: null,
+ profile_path: "/new-profile.png",
+ known_for_department: "Acting",
+ popularity: 10,
+ imdb_id: null,
+};
+
+mock.module("../src/image-cache", () => ({
+ loadImageBuffer: async () => nextBuffer,
+}));
+
+mock.module("@sofa/tmdb/client", () => ({
+ getPersonDetails: async () => nextPersonDetails,
+ getPersonCombinedCredits: async () => ({ cast: [] }),
+}));
+
+import { getOrFetchPerson } from "../src/person";
+
+beforeEach(() => {
+ clearAllTables();
+ nextBuffer = TINY_PNG;
+ nextPersonDetails = {
+ id: 100,
+ name: "Updated Person",
+ biography: "Bio",
+ birthday: null,
+ deathday: null,
+ place_of_birth: null,
+ profile_path: "/new-profile.png",
+ known_for_department: "Acting",
+ popularity: 10,
+ imdb_id: null,
+ };
+});
+
+describe("getOrFetchPerson", () => {
+ test("backfills a missing profile thumbhash for an already-fetched person", async () => {
+ testDb
+ .insert(persons)
+ .values({
+ id: "p1",
+ tmdbId: 100,
+ name: "Existing Person",
+ profilePath: "/profile.png",
+ profileThumbHash: null,
+ lastFetchedAt: new Date(),
+ })
+ .run();
+
+ const person = await getOrFetchPerson("p1");
+ const stored = testDb
+ .select()
+ .from(persons)
+ .where(eq(persons.id, "p1"))
+ .get();
+
+ expect(person?.profileThumbHash).toBeString();
+ expect(stored?.profileThumbHash).toBe(person?.profileThumbHash);
+ });
+
+ test("replaces a stale profile thumbhash when shell hydration changes the image path", async () => {
+ testDb
+ .insert(persons)
+ .values({
+ id: "p1",
+ tmdbId: 100,
+ name: "Shell Person",
+ profilePath: "/old-profile.png",
+ profileThumbHash: "stale-hash",
+ lastFetchedAt: null,
+ })
+ .run();
+
+ const person = await getOrFetchPerson("p1");
+ const stored = testDb
+ .select()
+ .from(persons)
+ .where(eq(persons.id, "p1"))
+ .get();
+
+ expect(stored?.profilePath).toBe("/new-profile.png");
+ expect(person?.profileThumbHash).toBeString();
+ expect(person?.profileThumbHash).not.toBe("stale-hash");
+ expect(stored?.profileThumbHash).toBe(person?.profileThumbHash);
+ });
+});
diff --git a/packages/core/test/thumbhash.test.ts b/packages/core/test/thumbhash.test.ts
new file mode 100644
index 0000000..1d3e6e1
--- /dev/null
+++ b/packages/core/test/thumbhash.test.ts
@@ -0,0 +1,63 @@
+import { beforeEach, describe, expect, mock, test } from "bun:test";
+import { eq } from "@sofa/db/helpers";
+import { episodes, seasons, titles } from "@sofa/db/schema";
+import { clearAllTables, testDb } from "@sofa/db/test-utils";
+
+const TINY_PNG = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==",
+ "base64",
+);
+
+let nextBuffer: Buffer | null = TINY_PNG;
+
+mock.module("../src/image-cache", () => ({
+ loadImageBuffer: async () => nextBuffer,
+}));
+
+import { generateEpisodeThumbHash, generateThumbHash } from "../src/thumbhash";
+
+beforeEach(() => {
+ clearAllTables();
+ nextBuffer = TINY_PNG;
+});
+
+describe("thumbhash generation", () => {
+ test("generates a thumbhash from a loaded image buffer", async () => {
+ const hash = await generateThumbHash("/poster.png", "posters");
+ expect(hash).toBeString();
+ expect(hash).not.toBe("");
+ });
+
+ test("clears a stale episode thumbhash when regeneration fails", async () => {
+ testDb
+ .insert(titles)
+ .values({ id: "tv-1", tmdbId: 100, type: "tv", title: "Show" })
+ .run();
+ testDb
+ .insert(seasons)
+ .values({ id: "season-1", titleId: "tv-1", seasonNumber: 1 })
+ .run();
+ testDb
+ .insert(episodes)
+ .values({
+ id: "ep-1",
+ seasonId: "season-1",
+ episodeNumber: 1,
+ stillPath: "/old-still.png",
+ stillThumbHash: "stale-hash",
+ })
+ .run();
+
+ nextBuffer = null;
+ const hash = await generateEpisodeThumbHash("ep-1", "/new-still.png");
+
+ const episode = testDb
+ .select()
+ .from(episodes)
+ .where(eq(episodes.id, "ep-1"))
+ .get();
+
+ expect(hash).toBeNull();
+ expect(episode?.stillThumbHash).toBeNull();
+ });
+});
diff --git a/packages/db/drizzle/20260314183844_lethal_odin/migration.sql b/packages/db/drizzle/20260314183844_lethal_odin/migration.sql
new file mode 100644
index 0000000..2c55f8d
--- /dev/null
+++ b/packages/db/drizzle/20260314183844_lethal_odin/migration.sql
@@ -0,0 +1,5 @@
+ALTER TABLE `episodes` ADD `stillThumbHash` text;--> statement-breakpoint
+ALTER TABLE `persons` ADD `profileThumbHash` text;--> statement-breakpoint
+ALTER TABLE `seasons` ADD `posterThumbHash` text;--> statement-breakpoint
+ALTER TABLE `titles` ADD `posterThumbHash` text;--> statement-breakpoint
+ALTER TABLE `titles` ADD `backdropThumbHash` text;
\ No newline at end of file
diff --git a/packages/db/drizzle/20260314183844_lethal_odin/snapshot.json b/packages/db/drizzle/20260314183844_lethal_odin/snapshot.json
new file mode 100644
index 0000000..6cb5aa8
--- /dev/null
+++ b/packages/db/drizzle/20260314183844_lethal_odin/snapshot.json
@@ -0,0 +1,2588 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "765b5a66-9bf8-4e55-8561-df3e25813307",
+ "prevIds": ["a55f858c-6739-4c19-98f2-2c685741ff65"],
+ "ddl": [
+ {
+ "name": "account",
+ "entityType": "tables"
+ },
+ {
+ "name": "appSettings",
+ "entityType": "tables"
+ },
+ {
+ "name": "availabilityOffers",
+ "entityType": "tables"
+ },
+ {
+ "name": "cronRuns",
+ "entityType": "tables"
+ },
+ {
+ "name": "episodes",
+ "entityType": "tables"
+ },
+ {
+ "name": "genres",
+ "entityType": "tables"
+ },
+ {
+ "name": "integrationEvents",
+ "entityType": "tables"
+ },
+ {
+ "name": "integrations",
+ "entityType": "tables"
+ },
+ {
+ "name": "persons",
+ "entityType": "tables"
+ },
+ {
+ "name": "seasons",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "titleCast",
+ "entityType": "tables"
+ },
+ {
+ "name": "titleGenres",
+ "entityType": "tables"
+ },
+ {
+ "name": "titleRecommendations",
+ "entityType": "tables"
+ },
+ {
+ "name": "titles",
+ "entityType": "tables"
+ },
+ {
+ "name": "user",
+ "entityType": "tables"
+ },
+ {
+ "name": "userEpisodeWatches",
+ "entityType": "tables"
+ },
+ {
+ "name": "userMovieWatches",
+ "entityType": "tables"
+ },
+ {
+ "name": "userRatings",
+ "entityType": "tables"
+ },
+ {
+ "name": "userTitleStatus",
+ "entityType": "tables"
+ },
+ {
+ "name": "verification",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "accountId",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "providerId",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "accessToken",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "refreshToken",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "idToken",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "accessTokenExpiresAt",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "refreshTokenExpiresAt",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "scope",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "password",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "key",
+ "entityType": "columns",
+ "table": "appSettings"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "value",
+ "entityType": "columns",
+ "table": "appSettings"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'US'",
+ "generated": null,
+ "name": "region",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "providerId",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "providerName",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "logoPath",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "offerType",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "link",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lastFetchedAt",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "jobName",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "startedAt",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "finishedAt",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "durationMs",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "errorMessage",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "seasonId",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "episodeNumber",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "overview",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "stillPath",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "stillThumbHash",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "airDate",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "runtimeMinutes",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "genres"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "genres"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "integrationEvents"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "integrationId",
+ "entityType": "columns",
+ "table": "integrationEvents"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "eventType",
+ "entityType": "columns",
+ "table": "integrationEvents"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "mediaType",
+ "entityType": "columns",
+ "table": "integrationEvents"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "mediaTitle",
+ "entityType": "columns",
+ "table": "integrationEvents"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "integrationEvents"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "errorMessage",
+ "entityType": "columns",
+ "table": "integrationEvents"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "receivedAt",
+ "entityType": "columns",
+ "table": "integrationEvents"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "integrations"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "integrations"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "integrations"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "type",
+ "entityType": "columns",
+ "table": "integrations"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "token",
+ "entityType": "columns",
+ "table": "integrations"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "true",
+ "generated": null,
+ "name": "enabled",
+ "entityType": "columns",
+ "table": "integrations"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "table": "integrations"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lastEventAt",
+ "entityType": "columns",
+ "table": "integrations"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "tmdbId",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "biography",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "birthday",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "deathday",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "placeOfBirth",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "profilePath",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "profileThumbHash",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "knownForDepartment",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "real",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "popularity",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "imdbId",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lastFetchedAt",
+ "entityType": "columns",
+ "table": "persons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "seasonNumber",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "overview",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "posterPath",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "posterThumbHash",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "airDate",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lastFetchedAt",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "token",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "expiresAt",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "ipAddress",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userAgent",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "impersonatedBy",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "titleCast"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "titleCast"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "personId",
+ "entityType": "columns",
+ "table": "titleCast"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "character",
+ "entityType": "columns",
+ "table": "titleCast"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Acting'",
+ "generated": null,
+ "name": "department",
+ "entityType": "columns",
+ "table": "titleCast"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "job",
+ "entityType": "columns",
+ "table": "titleCast"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "0",
+ "generated": null,
+ "name": "displayOrder",
+ "entityType": "columns",
+ "table": "titleCast"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "episodeCount",
+ "entityType": "columns",
+ "table": "titleCast"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lastFetchedAt",
+ "entityType": "columns",
+ "table": "titleCast"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "titleGenres"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "genreId",
+ "entityType": "columns",
+ "table": "titleGenres"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "titleRecommendations"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "recommendedTitleId",
+ "entityType": "columns",
+ "table": "titleRecommendations"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "source",
+ "entityType": "columns",
+ "table": "titleRecommendations"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "rank",
+ "entityType": "columns",
+ "table": "titleRecommendations"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lastFetchedAt",
+ "entityType": "columns",
+ "table": "titleRecommendations"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "tmdbId",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "tvdbId",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "type",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "originalTitle",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "overview",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "releaseDate",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "firstAirDate",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "posterPath",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "posterThumbHash",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "backdropPath",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "backdropThumbHash",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "real",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "popularity",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "real",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "voteAverage",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "voteCount",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "contentRating",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "colorPalette",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "trailerVideoKey",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lastFetchedAt",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "false",
+ "generated": null,
+ "name": "emailVerified",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "image",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'user'",
+ "generated": null,
+ "name": "role",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": "false",
+ "generated": null,
+ "name": "banned",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "banReason",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "banExpires",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "episodeId",
+ "entityType": "columns",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "watchedAt",
+ "entityType": "columns",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manual'",
+ "generated": null,
+ "name": "source",
+ "entityType": "columns",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "userMovieWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "userMovieWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "userMovieWatches"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "watchedAt",
+ "entityType": "columns",
+ "table": "userMovieWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manual'",
+ "generated": null,
+ "name": "source",
+ "entityType": "columns",
+ "table": "userMovieWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "userRatings"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "userRatings"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "ratingStars",
+ "entityType": "columns",
+ "table": "userRatings"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "ratedAt",
+ "entityType": "columns",
+ "table": "userRatings"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "userTitleStatus"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "userTitleStatus"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "userTitleStatus"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "addedAt",
+ "entityType": "columns",
+ "table": "userTitleStatus"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "table": "userTitleStatus"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "verification"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "identifier",
+ "entityType": "columns",
+ "table": "verification"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "value",
+ "entityType": "columns",
+ "table": "verification"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "expiresAt",
+ "entityType": "columns",
+ "table": "verification"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "table": "verification"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "table": "verification"
+ },
+ {
+ "columns": ["userId"],
+ "tableTo": "user",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_account_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "account"
+ },
+ {
+ "columns": ["titleId"],
+ "tableTo": "titles",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_availabilityOffers_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "availabilityOffers"
+ },
+ {
+ "columns": ["seasonId"],
+ "tableTo": "seasons",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_episodes_seasonId_seasons_id_fk",
+ "entityType": "fks",
+ "table": "episodes"
+ },
+ {
+ "columns": ["integrationId"],
+ "tableTo": "integrations",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_integrationEvents_integrationId_integrations_id_fk",
+ "entityType": "fks",
+ "table": "integrationEvents"
+ },
+ {
+ "columns": ["userId"],
+ "tableTo": "user",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_integrations_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "integrations"
+ },
+ {
+ "columns": ["titleId"],
+ "tableTo": "titles",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_seasons_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "seasons"
+ },
+ {
+ "columns": ["userId"],
+ "tableTo": "user",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "session"
+ },
+ {
+ "columns": ["titleId"],
+ "tableTo": "titles",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_titleCast_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "titleCast"
+ },
+ {
+ "columns": ["personId"],
+ "tableTo": "persons",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_titleCast_personId_persons_id_fk",
+ "entityType": "fks",
+ "table": "titleCast"
+ },
+ {
+ "columns": ["titleId"],
+ "tableTo": "titles",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_titleGenres_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "titleGenres"
+ },
+ {
+ "columns": ["genreId"],
+ "tableTo": "genres",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_titleGenres_genreId_genres_id_fk",
+ "entityType": "fks",
+ "table": "titleGenres"
+ },
+ {
+ "columns": ["titleId"],
+ "tableTo": "titles",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_titleRecommendations_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "titleRecommendations"
+ },
+ {
+ "columns": ["recommendedTitleId"],
+ "tableTo": "titles",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_titleRecommendations_recommendedTitleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "titleRecommendations"
+ },
+ {
+ "columns": ["userId"],
+ "tableTo": "user",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userEpisodeWatches_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "columns": ["episodeId"],
+ "tableTo": "episodes",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userEpisodeWatches_episodeId_episodes_id_fk",
+ "entityType": "fks",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "columns": ["userId"],
+ "tableTo": "user",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userMovieWatches_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "userMovieWatches"
+ },
+ {
+ "columns": ["titleId"],
+ "tableTo": "titles",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userMovieWatches_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "userMovieWatches"
+ },
+ {
+ "columns": ["userId"],
+ "tableTo": "user",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userRatings_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "userRatings"
+ },
+ {
+ "columns": ["titleId"],
+ "tableTo": "titles",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userRatings_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "userRatings"
+ },
+ {
+ "columns": ["userId"],
+ "tableTo": "user",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userTitleStatus_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "userTitleStatus"
+ },
+ {
+ "columns": ["titleId"],
+ "tableTo": "titles",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userTitleStatus_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "userTitleStatus"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "account_pk",
+ "table": "account",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["key"],
+ "nameExplicit": false,
+ "name": "appSettings_pk",
+ "table": "appSettings",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "cronRuns_pk",
+ "table": "cronRuns",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "episodes_pk",
+ "table": "episodes",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "genres_pk",
+ "table": "genres",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "integrationEvents_pk",
+ "table": "integrationEvents",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "integrations_pk",
+ "table": "integrations",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "persons_pk",
+ "table": "persons",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "seasons_pk",
+ "table": "seasons",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "session_pk",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "titleCast_pk",
+ "table": "titleCast",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "titles_pk",
+ "table": "titles",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "user_pk",
+ "table": "user",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "userEpisodeWatches_pk",
+ "table": "userEpisodeWatches",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "userMovieWatches_pk",
+ "table": "userMovieWatches",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "verification_pk",
+ "table": "verification",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "titleId",
+ "isExpression": false
+ },
+ {
+ "value": "region",
+ "isExpression": false
+ },
+ {
+ "value": "providerId",
+ "isExpression": false
+ },
+ {
+ "value": "offerType",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "availabilityOffers_unique",
+ "entityType": "indexes",
+ "table": "availabilityOffers"
+ },
+ {
+ "columns": [
+ {
+ "value": "jobName",
+ "isExpression": false
+ },
+ {
+ "value": "startedAt",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "cronRuns_jobName_startedAt",
+ "entityType": "indexes",
+ "table": "cronRuns"
+ },
+ {
+ "columns": [
+ {
+ "value": "seasonId",
+ "isExpression": false
+ },
+ {
+ "value": "episodeNumber",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "episodes_seasonId_episodeNumber",
+ "entityType": "indexes",
+ "table": "episodes"
+ },
+ {
+ "columns": [
+ {
+ "value": "integrationId",
+ "isExpression": false
+ },
+ {
+ "value": "receivedAt",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "integrationEvents_integrationId_receivedAt",
+ "entityType": "indexes",
+ "table": "integrationEvents"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "provider",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "integrations_userId_provider",
+ "entityType": "indexes",
+ "table": "integrations"
+ },
+ {
+ "columns": [
+ {
+ "value": "token",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "integrations_token",
+ "entityType": "indexes",
+ "table": "integrations"
+ },
+ {
+ "columns": [
+ {
+ "value": "tmdbId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "persons_tmdbId_unique",
+ "entityType": "indexes",
+ "table": "persons"
+ },
+ {
+ "columns": [
+ {
+ "value": "name",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "persons_name",
+ "entityType": "indexes",
+ "table": "persons"
+ },
+ {
+ "columns": [
+ {
+ "value": "titleId",
+ "isExpression": false
+ },
+ {
+ "value": "seasonNumber",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "seasons_titleId_seasonNumber",
+ "entityType": "indexes",
+ "table": "seasons"
+ },
+ {
+ "columns": [
+ {
+ "value": "titleId",
+ "isExpression": false
+ },
+ {
+ "value": "personId",
+ "isExpression": false
+ },
+ {
+ "value": "department",
+ "isExpression": false
+ },
+ {
+ "value": "character",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "titleCast_unique",
+ "entityType": "indexes",
+ "table": "titleCast"
+ },
+ {
+ "columns": [
+ {
+ "value": "titleId",
+ "isExpression": false
+ },
+ {
+ "value": "displayOrder",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "titleCast_titleId_displayOrder",
+ "entityType": "indexes",
+ "table": "titleCast"
+ },
+ {
+ "columns": [
+ {
+ "value": "personId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "titleCast_personId",
+ "entityType": "indexes",
+ "table": "titleCast"
+ },
+ {
+ "columns": [
+ {
+ "value": "titleId",
+ "isExpression": false
+ },
+ {
+ "value": "genreId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "titleGenres_titleId_genreId",
+ "entityType": "indexes",
+ "table": "titleGenres"
+ },
+ {
+ "columns": [
+ {
+ "value": "genreId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "titleGenres_genreId",
+ "entityType": "indexes",
+ "table": "titleGenres"
+ },
+ {
+ "columns": [
+ {
+ "value": "titleId",
+ "isExpression": false
+ },
+ {
+ "value": "recommendedTitleId",
+ "isExpression": false
+ },
+ {
+ "value": "source",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "titleRecommendations_unique",
+ "entityType": "indexes",
+ "table": "titleRecommendations"
+ },
+ {
+ "columns": [
+ {
+ "value": "titleId",
+ "isExpression": false
+ },
+ {
+ "value": "rank",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "titleRecommendations_titleId_rank",
+ "entityType": "indexes",
+ "table": "titleRecommendations"
+ },
+ {
+ "columns": [
+ {
+ "value": "tmdbId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "titles_tmdbId_unique",
+ "entityType": "indexes",
+ "table": "titles"
+ },
+ {
+ "columns": [
+ {
+ "value": "type",
+ "isExpression": false
+ },
+ {
+ "value": "releaseDate",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "titles_type_releaseDate",
+ "entityType": "indexes",
+ "table": "titles"
+ },
+ {
+ "columns": [
+ {
+ "value": "type",
+ "isExpression": false
+ },
+ {
+ "value": "firstAirDate",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "titles_type_firstAirDate",
+ "entityType": "indexes",
+ "table": "titles"
+ },
+ {
+ "columns": [
+ {
+ "value": "lastFetchedAt",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "titles_lastFetchedAt",
+ "entityType": "indexes",
+ "table": "titles"
+ },
+ {
+ "columns": [
+ {
+ "value": "type",
+ "isExpression": false
+ },
+ {
+ "value": "status",
+ "isExpression": false
+ },
+ {
+ "value": "lastFetchedAt",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "titles_type_status_lastFetchedAt",
+ "entityType": "indexes",
+ "table": "titles"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "watchedAt",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "userEpisodeWatches_userId_watchedAt",
+ "entityType": "indexes",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "columns": [
+ {
+ "value": "episodeId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "userEpisodeWatches_episodeId",
+ "entityType": "indexes",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "episodeId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "userEpisodeWatches_userId_episodeId",
+ "entityType": "indexes",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "watchedAt",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "userMovieWatches_userId_watchedAt",
+ "entityType": "indexes",
+ "table": "userMovieWatches"
+ },
+ {
+ "columns": [
+ {
+ "value": "titleId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "userMovieWatches_titleId",
+ "entityType": "indexes",
+ "table": "userMovieWatches"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "titleId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "userMovieWatches_userId_titleId",
+ "entityType": "indexes",
+ "table": "userMovieWatches"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "titleId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "userRatings_userId_titleId",
+ "entityType": "indexes",
+ "table": "userRatings"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "titleId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "userTitleStatus_userId_titleId",
+ "entityType": "indexes",
+ "table": "userTitleStatus"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "status",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "userTitleStatus_userId_status",
+ "entityType": "indexes",
+ "table": "userTitleStatus"
+ },
+ {
+ "columns": ["token"],
+ "nameExplicit": false,
+ "name": "session_token_unique",
+ "entityType": "uniques",
+ "table": "session"
+ },
+ {
+ "columns": ["email"],
+ "nameExplicit": false,
+ "name": "user_email_unique",
+ "entityType": "uniques",
+ "table": "user"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 9d965dd..98dd12a 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -110,7 +110,9 @@ export const titles = sqliteTable(
releaseDate: text("releaseDate"),
firstAirDate: text("firstAirDate"),
posterPath: text("posterPath"),
+ posterThumbHash: text("posterThumbHash"),
backdropPath: text("backdropPath"),
+ backdropThumbHash: text("backdropThumbHash"),
popularity: real("popularity"),
voteAverage: real("voteAverage"),
voteCount: int("voteCount"),
@@ -144,6 +146,7 @@ export const seasons = sqliteTable(
name: text("name"),
overview: text("overview"),
posterPath: text("posterPath"),
+ posterThumbHash: text("posterThumbHash"),
airDate: text("airDate"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
@@ -166,6 +169,7 @@ export const episodes = sqliteTable(
name: text("name"),
overview: text("overview"),
stillPath: text("stillPath"),
+ stillThumbHash: text("stillThumbHash"),
airDate: text("airDate"),
runtimeMinutes: int("runtimeMinutes"),
},
@@ -339,6 +343,7 @@ export const persons = sqliteTable(
deathday: text("deathday"),
placeOfBirth: text("placeOfBirth"),
profilePath: text("profilePath"),
+ profileThumbHash: text("profileThumbHash"),
knownForDepartment: text("knownForDepartment"),
popularity: real("popularity"),
imdbId: text("imdbId"),
diff --git a/packages/tmdb/src/image.ts b/packages/tmdb/src/image.ts
index 9eef610..d5f0426 100644
--- a/packages/tmdb/src/image.ts
+++ b/packages/tmdb/src/image.ts
@@ -8,7 +8,7 @@ export type ImageCategory =
const IMAGE_BASE_URL =
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
-const CATEGORY_SIZES: Record = {
+export const IMAGE_CATEGORY_SIZES: Record = {
posters: "w500",
backdrops: "w1280",
stills: "w1280",
@@ -16,6 +16,16 @@ const CATEGORY_SIZES: Record = {
profiles: "w185",
};
+export function tmdbCdnImageUrl(
+ path: string | null,
+ category: ImageCategory,
+ sizeOverride?: string,
+) {
+ if (!path) return null;
+ const size = sizeOverride ?? IMAGE_CATEGORY_SIZES[category];
+ return `${IMAGE_BASE_URL}/${size}${path}`;
+}
+
export function tmdbImageUrl(
path: string | null,
category: ImageCategory,
@@ -23,10 +33,8 @@ export function tmdbImageUrl(
) {
if (!path) return null;
- const size = sizeOverride ?? CATEGORY_SIZES[category];
-
if (process.env.IMAGE_CACHE_ENABLED === "false") {
- return `${IMAGE_BASE_URL}/${size}${path}`;
+ return tmdbCdnImageUrl(path, category, sizeOverride);
}
const filename = path.startsWith("/") ? path.slice(1) : path;