diff --git a/.gitignore b/.gitignore index 3c3629e..5d98851 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +views.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5482fba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM oven/bun:latest + +LABEL org.opencontainers.image.source="https://gitea.bigoscloud.com/biggy/samsung-tv-server" \ + org.opencontainers.image.title="Samsung TV Server" \ + org.opencontainers.image.description="Samsung TV Server — Bun runtime" \ + org.opencontainers.image.licenses="MIT" + +WORKDIR /app + +RUN apt-get update && apt-get install -y chromium && rm -rf /var/lib/apt/lists/* + +COPY package.json bun.lock ./ +RUN bun install --production + +COPY . . + +EXPOSE 3099 + +CMD ["bun", "run", "start"] diff --git a/bun.lock b/bun.lock index 0fe8e53..100d541 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,13 @@ "": { "name": "samsung-tv-server", "dependencies": { - "puppeteer": "latest", + "puppeteer": "^24.43.1", }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.13", + "add": "^2.0.6", + "oxfmt": "^0.49.0", + "oxlint": "^1.64.0", }, }, }, @@ -17,6 +20,82 @@ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-HbifJ84prIh9+55CTPAU35JdRQrwg47y16cGerCC+iejSKOuHXYo2WDql6l7cQlzrYVtc3f4UWY+dBj2lRmOeA=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ef7SKJqAaH2d7E6eXZZa2OffIShbhFMxnGK0zd93p4qiyTJr75B0qf7lrPD+qQOwcf04BrjYJ0JUxq8d5+yZwg=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.49.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8x5DN9CsFfb432sHa9NyqX5XisGUdA53LPEGSdv/VniS+v4uEOR8Orv7A9QSB98Xxgp0t6r31DzQA/wpIobGqQ=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.49.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-e0+DSVzk4ewhMVKNYDaRTmP81jNMBWR1X9al0cVKWS+hDM/dElNqD5zjTOCuLOZc4oOdp2Gx2ldrVL+yYo9TZQ=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.49.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-W+mjtYtrQvFbXT/uNT+221OBhGRZ8UqNsLxjTWsjZ4GsQnRdvRC/N2NCK86BcamWr7lsTxwpwN3PULnr78sgcQ=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.49.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Rtv6UevV7czDlLqil+NZUe4d8gs8jQo/zScSpumwyf7I+fSdLc+hc8AF3MQC7ymxSMMD9+vfiqQlsIf7wOAzXA=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.49.0", "", { "os": "linux", "cpu": "arm" }, "sha512-sBi+8C/Q/MdKa5FL8ibAUCdhFBGFH7HFN/Qoyd5xQbZ/0ky3NMPpKfIBpaH0lhK2dXkGLczVQUoZ+xuNSerCdQ=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.49.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-JIfWenFhlzx+O8YygyZhoHFzTsdgDhxhbDRnE2iJLnnM5pWKScFvPECO2vOlA7JqJ/9S1g3uzEKuRCkHFwTjvA=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.49.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iNzkMPG18jPkwBOZ4/HEjwqfzAjq4RrUQ0CgId/fC1ENvYD5jLVAaU/gWgpiqP1ys07kxSsSggDd1fp3E7mQHw=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.49.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BPHA/NN3LvoIXiid+iz3BHt5V0Rzx0tXAqRUovwE1NsbDaLG9e8mtv7evDGRIkVQacqTDBv0XL25THHsxSJosQ=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-3Eroshe+s69htC9JIL0+zLGQczLtRKezkMhwqQC21VC5Z/fuLvzLfbAOLgJLUq601H8gDYjy7deYycfOBjCvWg=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-fnaERGgsxGm0lKAmO72EYR4BA3qBnzBTJBTi6EtUMq1D4R7EexRBMU4voXnx4TXla3SEDl9x4uNp/18SbkPjGg=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.49.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-rBwasMl1Uul1MCCeTGEFKnOTL7VUxHf+634jWStrQAbzpBJgd5Yz5m4F7exVCsoI8PHn57dNjssXagXLCLB5yA=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.49.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BoC/F9xHe2y/deuBGA5Aw7bes07OD2gcL2wlpzTrfImR92vPP7S/k3LBTyspQZCNIVNdagkELcqKELwMLGIfAg=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.49.0", "", { "os": "linux", "cpu": "x64" }, "sha512-umY6jFADAo/oztFKl8D/S6vSrG6oBpEskcentiRuz42kZVU2kfDXMWCYavxyZR2bwPjqkHpcHZ6EZFiH3Qj9ZA=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.49.0", "", { "os": "none", "cpu": "arm64" }, "sha512-J85zQMiw2pXiGPK+OusmDvSnJ/dgpgN7VgmB2zOBtgS8F+nsOUfSg9ZEBrwbQscjZ7tkPbm38CG4VF5f53MsiA=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.49.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-38K67XR++CoFFORDd4sMFwUVAnD6msYBdGTei+qvKGrRPO6S2PbrYPNL/eQQ1RgnnxOegNba0YQwg6uRkNcw6A=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.49.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-rXVe0HICwQF0dBgbQtBCoYf8x/SidPIdhyQl+iPuJlV7suV+qDv7yUEB3wQ4qC3nOeNxz287SwFXKzyr0kWgEg=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.49.0", "", { "os": "win32", "cpu": "x64" }, "sha512-gwWLwSEmBBfIK/Wh7GGd658161o4RKAvHWRaRQbJm571iQXGKfyr7UKsI1vsWvDlNLc30CxJDc8mMmCvJ/kczQ=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-2r6Nq3XXGLHEXKkSj8JtmJ6N4gDw431DPFOg0ZoJHlNjnG6HVMm/ksQ10m0HJ8WBvwgMe1L50UHPaYZutCRPCw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.64.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ePJMpePgg7fBv+L/hVx1xXRU5/5gd5m0obLA6hPEfLXF3GjpR8idIDbY1dhQYhyz1ms2wdTccSboo6KEd2Oxtg=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.64.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-U4DMLQd10gJLuoSTLSGbfv3bGjTlUNsScm9Dgb8wwBqmCzidf1pE1pXV4doGNxqwH3KtVng1AGTINA0NvkGLvQ=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.64.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-GoRIL48QWm4/TAvjN8pB1nAG+1/uqc9EdnWT9zqHeb6wsmjZtywj8VRe5aGW47Fdb64YtLOsdLqVxOvQuz98Wg=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.64.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5dFkv4tkg7PxJJGS9/OjrJwjhuHczrd3OQOkRE0wHcLM+ncUnULtzEPWjqGOxTXxZnLWcB91bGiIznx89TVXyQ=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jsBqMLl/uOL5+Kq/+BtK9FrmiNGUbx8SiyZXv+WlUxA45KuwcLu9BfiSIL3I3DBDgWM3yZizDITnTK9BcqNBQg=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-1lrj8At/Uuc9GhjrVFBQo0NEjfBrTkzpmtHIGAhNnIXqn1CAyGL+qrztUsXb2GIluJrpl9Q7qRLJOb/NqydacQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HpSQbubwh03mMhAdy2BYtad/fsY8vDFHDAb6bUwuCYg2VD3xCQgn6ArKcO0oZyLCheacKTv4PrF3Mfu5hgoE2g=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-00QQ0h0Y7u0G69BgiH3+ky2aaq/QvkDL6DYok8htIuJHxybiux5aQ8jwmg8qIk9wha6UagUP2BAwAzbemcJbpg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.64.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2GaimTV6EMW+s5HS0An3oGbQme3BgHswvfVdGk3EB57Xe9+/gyT+Qd7lNVzb3rtir52vbIPzXfaYArzs5b5zcw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-H46AtFb9wypjoVwGdlxrm0DsD809NGmtiK9HiyPKTxkSte2YjhC4S+00rOIrwCaxcyPiGid3Y3OMXp5KMAkGZw=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-HEgsidjjvvyzdg82icYkuFCf7REDV7B9JFwbIMbVwrKLBY0MrXX+bku3POn/hduZ2yW91IyVDUMq0Bf02KwXQw=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.64.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Axvm8qryotmKN00P5w4JapaSjvP2LOSbdbBJiX+2SuHd3QzhW7TUc8skqgw+ahQZ5DmzEYeHCqauvW8f32Ns6Q=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cR60vSd7+m+KRZ3GQGfDxWwahW5RMXg0qlGvAluZr0fTUYvw0H9N9AXAF/M/PMqgytyqvVNmBAkJG9l7U30Y1g=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2u/aPZ9pEg7HnvZPDsHxUGNnrpr4qaHi+mCgLgpt+LYRzPrS4Px4wPfkIdRdr2GvKnaYyt+XSlto0Vm5sbStTg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.64.0", "", { "os": "none", "cpu": "arm64" }, "sha512-kfhkGfCdoXLSxEkrhDlJrvBYajGmq+ma4EMc53dsOWTq+rIBOlI0vTBmpZNnM5oH2LY/K/w1HAK+UQEgjgpVUg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.64.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-r/cNKBFieONoVu2bb1KkVouq9W+edDUgHumXJGphCRRj+U0xaD4nanrw8ZOqo0IsutPkEM4vCcGBpak6x5aXMg=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.64.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-tUw0xUUwEFVZbpJoeCblkv8SJA4Xz3CdXCJbAnBsiNLyxDrk2tLcxEAS6M73Q7hHHDg3OtwI8vZVK3t5RJt4Gw=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-9CBR+LO0JVST87fNTzzNxS5I29jIUO5gxT9i9+M3SDHHALElj9sY1Prf12tad3vIRC6OD7Ehtvvh+sn13vSwHw=="], + "@puppeteer/browsers": ["@puppeteer/browsers@2.13.2", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw=="], "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], @@ -27,6 +106,8 @@ "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "add": ["add@2.0.6", "", {}, "sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -139,6 +220,10 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "oxfmt": ["oxfmt@0.49.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.49.0", "@oxfmt/binding-android-arm64": "0.49.0", "@oxfmt/binding-darwin-arm64": "0.49.0", "@oxfmt/binding-darwin-x64": "0.49.0", "@oxfmt/binding-freebsd-x64": "0.49.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.49.0", "@oxfmt/binding-linux-arm-musleabihf": "0.49.0", "@oxfmt/binding-linux-arm64-gnu": "0.49.0", "@oxfmt/binding-linux-arm64-musl": "0.49.0", "@oxfmt/binding-linux-ppc64-gnu": "0.49.0", "@oxfmt/binding-linux-riscv64-gnu": "0.49.0", "@oxfmt/binding-linux-riscv64-musl": "0.49.0", "@oxfmt/binding-linux-s390x-gnu": "0.49.0", "@oxfmt/binding-linux-x64-gnu": "0.49.0", "@oxfmt/binding-linux-x64-musl": "0.49.0", "@oxfmt/binding-openharmony-arm64": "0.49.0", "@oxfmt/binding-win32-arm64-msvc": "0.49.0", "@oxfmt/binding-win32-ia32-msvc": "0.49.0", "@oxfmt/binding-win32-x64-msvc": "0.49.0" }, "peerDependencies": { "svelte": "^5.0.0" }, "optionalPeers": ["svelte"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-IAHFMdlJSWe+oAr65dx22UvjCtV9DBMisAuLnKpDqMQrctzCkGnj3QRwNHm0d+uwSWPalsDF8ZYLz9rh6nH2IQ=="], + + "oxlint": ["oxlint@1.64.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.64.0", "@oxlint/binding-android-arm64": "1.64.0", "@oxlint/binding-darwin-arm64": "1.64.0", "@oxlint/binding-darwin-x64": "1.64.0", "@oxlint/binding-freebsd-x64": "1.64.0", "@oxlint/binding-linux-arm-gnueabihf": "1.64.0", "@oxlint/binding-linux-arm-musleabihf": "1.64.0", "@oxlint/binding-linux-arm64-gnu": "1.64.0", "@oxlint/binding-linux-arm64-musl": "1.64.0", "@oxlint/binding-linux-ppc64-gnu": "1.64.0", "@oxlint/binding-linux-riscv64-gnu": "1.64.0", "@oxlint/binding-linux-riscv64-musl": "1.64.0", "@oxlint/binding-linux-s390x-gnu": "1.64.0", "@oxlint/binding-linux-x64-gnu": "1.64.0", "@oxlint/binding-linux-x64-musl": "1.64.0", "@oxlint/binding-openharmony-arm64": "1.64.0", "@oxlint/binding-win32-arm64-msvc": "1.64.0", "@oxlint/binding-win32-ia32-msvc": "1.64.0", "@oxlint/binding-win32-x64-msvc": "1.64.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Star3SNpWPeWFPw7kRXIhXUSn6fdiAl25q15CQzH/9WaOtG6e9CWTc25vNZOCr4PE1yEP1GtKJKIKglhj3OmEQ=="], + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], @@ -191,6 +276,8 @@ "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..fbc7ab5 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,12 @@ +services: + samsung-tv-server: + image: gitea.bigoscloud.com/biggy/samsung-tv-server:latest + build: . + ports: + - "3099:3099" + volumes: + - views_db:/app/data + restart: unless-stopped + +volumes: + views_db: \ No newline at end of file diff --git a/package.json b/package.json index 58b56c8..c7ca70a 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,19 @@ "description": "Bun web server for serving dynamic content to Samsung TVs via SSR and MJPEG streaming", "scripts": { "start": "bun run src/server.ts", - "dev": "bun --watch run src/server.ts" + "dev": "bun --watch run src/server.ts", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check" }, "dependencies": { "puppeteer": "^24.43.1" }, "devDependencies": { - "@types/bun": "^1.3.13" + "@types/bun": "^1.3.13", + "add": "^2.0.6", + "oxfmt": "^0.49.0", + "oxlint": "^1.64.0" } } diff --git a/public/index.html b/public/index.html index 818a436..ca71948 100644 --- a/public/index.html +++ b/public/index.html @@ -1,252 +1,752 @@ - + - - - -Samsung TV Proxy - - - + @media (max-width: 640px) { + .app { + padding: 16px 10px; + } + .card { + padding: 14px; + } + .debug-grid { + grid-template-columns: repeat(2, 1fr); + } + .debug-table { + font-size: 11px; + } + .debug-table td, + .debug-table th { + padding: 6px 6px; + } + } + + + +
+
+

Samsung TV Proxy

+
+ + +
+
-
-
-

Samsung TV Proxy

- -
+
-
-
+ +
+
+

🔍 Live Debug

+
+
+ every + + +
+
- - +
+
+
+
Uptime (s)
+
+
+
+
Views
+
+
+
+
Active Renders
+
+
+
+
Queue Depth
+
+
+
+
Cache Entries
+
+
+
+
Devices
+
+
- -
+
+

📺 Views & Rotation

+
+ + + + + + + + + + + + + + +
ViewMethodViewportCurrent URLIdxRemainingCycleCache TTL
+
+
- + debugSocket.onclose = () => { + debugSocket = null; + if (debugVisible) { + debugReconnectTimer = setTimeout(connectDebugSocket, 2000); + } + }; - + debugSocket.onerror = () => { + debugSocket?.close(); + }; + } + + function disconnectDebugSocket() { + if (debugReconnectTimer) { clearTimeout(debugReconnectTimer); debugReconnectTimer = null; } + if (debugSocket) { + debugSocket.onclose = null; // prevent auto-reconnect + debugSocket.close(); + debugSocket = null; + } + if (debugTickInterval) { clearInterval(debugTickInterval); debugTickInterval = null; } + } + + function setDebugInterval(ms) { + debugIntervalMs = ms; + if (debugSocket && debugSocket.readyState === WebSocket.OPEN) { + debugSocket.send(JSON.stringify({ type: "debug-interval", interval: ms })); + } + } + + function toggleDebug() { + debugVisible = !debugVisible; + const panel = document.getElementById("debug-panel"); + const btn = document.getElementById("debug-toggle"); + if (debugVisible) { + panel.classList.add("visible"); + btn.textContent = "🔍 Hide Debug"; + connectDebugSocket(); + } else { + panel.classList.remove("visible"); + btn.textContent = "🔍 Debug"; + disconnectDebugSocket(); + } + } + + let lastDebugData = null; + let debugTickInterval = null; + + function renderDebug(data) { + const s = data.server; + document.getElementById("stat-uptime").textContent = s.uptime; + document.getElementById("stat-views").textContent = data.views.length; + document.getElementById("stat-renders").textContent = s.activeRenders; + document.getElementById("stat-queue").textContent = s.queueLength; + document.getElementById("stat-cache").textContent = s.cacheEntries; + document.getElementById("stat-devices").textContent = data.devices.length; + + // ── Views table ── + document.getElementById("debug-views-tbody").innerHTML = data.views + .map((v) => { + const methodBadge = + v.method === "mjpeg" + ? 'MJPEG' + : 'SSR'; + return ` + + ${esc(v.name)} + ${methodBadge} + ${v.viewportWidth}×${v.viewportHeight} + ${esc(v.currentUrl)} + ${v.currentUrlIndex}/${v.urls.length} + ${v.remainingSec}s + ${v.totalCycleSec}s + ${v.cacheTtlSec}s + `; + }) + .join(""); + + // ── Cache table ── + document.getElementById("debug-cache-tbody").innerHTML = data.views + .flatMap((v) => + v.cacheEntries.map( + (c) => ` + + ${esc(v.name)} + ${c.urlIndex}/${v.urls.length} + ${esc(c.url)} + ${c.ttlRemainingSec}s + `, + ), + ) + .join(""); + + // ── Devices table ── + document.getElementById("debug-devices-tbody").innerHTML = data.devices + .map((d) => { + const ago = Math.round((Date.now() - d.lastSeen) / 1000); + return ` + + ${esc(d.ip)} + ${esc(d.viewName)} + ${d.requestCount} + ${ago}s ago + ${esc(d.userAgent.slice(0, 60))} + `; + }) + .join(""); + + if (!debugTickInterval) { + debugTickInterval = setInterval(tickDebugValues, 500); + } + } + + function tickDebugValues() { + document.querySelectorAll("[data-remaining]").forEach((el) => { + let val = parseFloat(el.getAttribute("data-remaining")); + val = Math.max(0, val - 0.5); + el.setAttribute("data-remaining", val); + el.textContent = val.toFixed(1) + "s"; + if (val <= 3) el.style.color = "var(--danger)"; + else if (val <= 10) el.style.color = "var(--orange)"; + else el.style.color = "var(--yellow)"; + }); + + document.querySelectorAll("[data-ttl]").forEach((el) => { + let val = parseFloat(el.getAttribute("data-ttl")); + val = Math.max(0, val - 0.5); + el.setAttribute("data-ttl", val); + el.textContent = val.toFixed(1) + "s"; + if (val <= 0) { + el.classList.remove("fresh"); + el.classList.add("expired"); + } + }); + } + + // ═══════════════════════════════════════════════ + // KEYBOARD + // ═══════════════════════════════════════════════ + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeModal(); + }); + + // ═══════════════════════════════════════════════ + // INIT + // ═══════════════════════════════════════════════ + + loadViews(); + + diff --git a/src/server.ts b/src/server.ts index 3007eed..8bce0db 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,420 +2,1180 @@ import { Database } from "bun:sqlite"; import type { Browser, Page } from "puppeteer"; import puppeteer from "puppeteer"; +// ═══════════════════════════════════════════════════════════════ +// CONFIG +// ═══════════════════════════════════════════════════════════════ + +const CONFIG = { + PORT: parseInt(process.env.PORT || "3099"), + BROWSER_COUNT: 2, + PAGES_PER_BROWSER: 8, + PAGE_POOL_WARM: 4, + PAGE_ACQUIRE_TIMEOUT_MS: 3000, + RENDER_TIMEOUT_MS: 25000, + MAX_CONCURRENT_RENDERS: 16, + QUEUE_TIMEOUT_MS: 30000, + CACHE_CLEANUP_INTERVAL_MS: 30000, +} as const; + // ═══════════════════════════════════════════════════════════════ // TYPES // ═══════════════════════════════════════════════════════════════ + interface URLItem { - url: string; - durationSec: number; + url: string; + durationSec: number; + forceDarkMode?: boolean; + bakeStyles?: boolean; } + interface View { - id: string; - name: string; - urls: URLItem[]; - method: "mjpeg" | "ssr"; - metaRefreshEnabled: boolean; - createdAt: number; + id: string; + name: string; + urls: URLItem[]; + method: "mjpeg" | "ssr"; + metaRefreshEnabled: boolean; + cacheTtlSec: number; + viewportWidth: number; + viewportHeight: number; + createdAt: number; } + interface ViewRow { - id: string; - name: string; - urls: string; // JSON text - method: string; - meta_refresh_enabled: number; - created_at: number; + id: string; + name: string; + urls: string; + method: string; + meta_refresh_enabled: number; + cache_ttl_sec: number; + viewport_width: number; + viewport_height: number; + created_at: number; } + interface CreateViewBody { - name: string; - urls: URLItem[]; - method: "mjpeg" | "ssr"; - metaRefreshEnabled?: boolean; + name: string; + urls: URLItem[]; + method: "mjpeg" | "ssr"; + metaRefreshEnabled?: boolean; + cacheTtlSec?: number; + viewportWidth?: number; + viewportHeight?: number; } -interface UpdateViewBody { - name?: string; - urls?: URLItem[]; - method?: "mjpeg" | "ssr"; - metaRefreshEnabled?: boolean; +type UpdateViewBody = Partial; + +interface CachedRender { + body: string; + urlIndex: number; + createdAt: number; + expiresAt: number; } + +interface ActiveRender { + promise: Promise; + urlIndex: number; +} + +interface QueuedRender { + resolve: (body: string) => void; + reject: (err: Error) => void; + key: string; + viewId: string; + urlIndex: number; + queuedAt: number; +} + +interface DeviceEntry { + ip: string; + viewId: string; + viewName: string; + userAgent: string; + lastSeen: number; + requestCount: number; +} + // ═══════════════════════════════════════════════════════════════ // DATABASE // ═══════════════════════════════════════════════════════════════ -const db = new Database("views.db", { create: true }); -db.run( - `CREATE TABLE IF NOT EXISTS views ( id TEXT PRIMARY KEY, name TEXT NOT NULL, urls TEXT NOT NULL DEFAULT '[]', method TEXT NOT NULL DEFAULT 'ssr', meta_refresh_enabled INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()) )`, -); + +const db = new Database("/app/data/views.db", { create: true }); + +db.run(` + CREATE TABLE IF NOT EXISTS views ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + urls TEXT NOT NULL DEFAULT '[]', + method TEXT NOT NULL DEFAULT 'ssr', + meta_refresh_enabled INTEGER NOT NULL DEFAULT 0, + cache_ttl_sec INTEGER NOT NULL DEFAULT 60, + viewport_width INTEGER NOT NULL DEFAULT 1920, + viewport_height INTEGER NOT NULL DEFAULT 1080, + bake_styles INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) +`); + +// Migrations for upgrading from older schema +try { + db.run("ALTER TABLE views ADD COLUMN cache_ttl_sec INTEGER NOT NULL DEFAULT 60"); +} catch {} +try { + db.run("ALTER TABLE views ADD COLUMN viewport_width INTEGER NOT NULL DEFAULT 1920"); +} catch {} +try { + db.run("ALTER TABLE views ADD COLUMN viewport_height INTEGER NOT NULL DEFAULT 1080"); +} catch {} +try { + db.run("ALTER TABLE views ADD COLUMN bake_styles INTEGER NOT NULL DEFAULT 0"); +} catch {} + function rowToView(row: ViewRow): View { - return { - id: row.id, - name: row.name, - urls: JSON.parse(row.urls), - method: row.method as "mjpeg" | "ssr", - metaRefreshEnabled: row.meta_refresh_enabled === 1, - createdAt: row.created_at, - }; + return { + id: row.id, + name: row.name, + urls: JSON.parse(row.urls), + method: row.method as View["method"], + metaRefreshEnabled: row.meta_refresh_enabled === 1, + cacheTtlSec: row.cache_ttl_sec ?? 60, + viewportWidth: row.viewport_width ?? 1920, + viewportHeight: row.viewport_height ?? 1080, + createdAt: row.created_at, + }; } -function loadView(id: string): View | null { - const row = db.query("SELECT * FROM views WHERE id = ?").get(id) as - | ViewRow - | undefined; - return row ? rowToView(row) : null; + +function dbLoadAll(): View[] { + return (db.query("SELECT * FROM views ORDER BY created_at DESC").all() as ViewRow[]).map( + rowToView, + ); } -function loadAllViews(): View[] { - const rows = db - .query("SELECT * FROM views ORDER BY created_at DESC") - .all() as ViewRow[]; - return rows.map(rowToView); + +function dbLoad(id: string): View | null { + const row = db.query("SELECT * FROM views WHERE id = ?").get(id) as ViewRow | undefined; + return row ? rowToView(row) : null; } -function insertView(v: View): void { - db.run( - "INSERT INTO views (id, name, urls, method, meta_refresh_enabled, created_at) VALUES (?, ?, ?, ?, ?, ?)", - [ - v.id, - v.name, - JSON.stringify(v.urls), - v.method, - v.metaRefreshEnabled ? 1 : 0, - v.createdAt, - ], - ); + +function dbInsert(v: View): void { + db.run( + `INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at) + VALUES (?,?,?,?,?,?,?,?,?)`, + [ + v.id, + v.name, + JSON.stringify(v.urls), + v.method, + v.metaRefreshEnabled ? 1 : 0, + v.cacheTtlSec, + v.viewportWidth, + v.viewportHeight, + v.createdAt, + ], + ); } -function updateView(v: View): void { - db.run( - "UPDATE views SET name=?, urls=?, method=?, meta_refresh_enabled=? WHERE id=?", - [ - v.name, - JSON.stringify(v.urls), - v.method, - v.metaRefreshEnabled ? 1 : 0, - v.id, - ], - ); + +function dbUpdate(v: View): void { + db.run( + "UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=? WHERE id=?", + [ + v.name, + JSON.stringify(v.urls), + v.method, + v.metaRefreshEnabled ? 1 : 0, + v.cacheTtlSec, + v.viewportWidth, + v.viewportHeight, + v.id, + ], + ); } -function deleteViewById(id: string): boolean { - const result = db.run("DELETE FROM views WHERE id = ?", [id]); - return result.changes > 0; + +function dbDelete(id: string): boolean { + return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0; } + // ═══════════════════════════════════════════════════════════════ -// BROWSER MANAGER +// IN-MEMORY VIEW CACHE // ═══════════════════════════════════════════════════════════════ -let browser: Browser | null = null; -async function getBrowser(): Promise { - if (browser && browser.isConnected()) return browser; - console.log("[browser] Launching headless Chromium…"); - browser = await puppeteer.launch({ - executablePath: "/usr/bin/chromium", - headless: true, - args: [ - "--no-sandbox", - "--disable-setuid-sandbox", - "--disable-dev-shm-usage", - "--disable-gpu", - "--disable-software-rasterizer", - ], - }); - // Handle browser crash/disconnect - browser.on("disconnected", () => { - console.log( - "[browser] Browser disconnected — will restart on next request", - ); - browser = null; - }); - console.log("[browser] Ready"); - return browser; + +const viewCache = new Map(); + +function viewCacheInit(): void { + for (const v of dbLoadAll()) viewCache.set(v.id, v); } -async function createPage(): Promise { - const b = await getBrowser(); - const page = await b.newPage(); - await page.setViewport({ width: 1920, height: 1080 }); - // Block unnecessary resource types for faster loads on TV-targeted renders - await page.setRequestInterception(true); - page.on("request", (req) => { - const type = req.resourceType(); - if (type === "media" || type === "websocket" || type === "manifest") { - req.abort(); - } else { - req.continue(); - } - }); - return page; + +function viewCacheGet(id: string): View | null { + return viewCache.get(id) ?? null; } + +function viewCacheAll(): View[] { + return [...viewCache.values()].sort((a, b) => b.createdAt - a.createdAt); +} + +// ═══════════════════════════════════════════════════════════════ +// DEVICE TRACKING +// ═══════════════════════════════════════════════════════════════ + +const devices = new Map(); +const DEVICE_TTL_MS = 120_000; + +function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): void { + const existing = devices.get(ip); + if (existing) { + existing.viewId = viewId; + existing.viewName = viewName; + existing.userAgent = userAgent; + existing.lastSeen = Date.now(); + existing.requestCount++; + } else { + devices.set(ip, { ip, viewId, viewName, userAgent, lastSeen: Date.now(), requestCount: 1 }); + } +} + +function devicesPrune(): void { + const cutoff = Date.now() - DEVICE_TTL_MS; + for (const [ip, d] of devices) { + if (d.lastSeen < cutoff) devices.delete(ip); + } +} + +function devicesSnapshot(): DeviceEntry[] { + devicesPrune(); + return [...devices.values()].sort((a, b) => b.lastSeen - a.lastSeen); +} + +// ═══════════════════════════════════════════════════════════════ +// DEBUG DATA HELPER (used by both REST and WebSocket) +// ═══════════════════════════════════════════════════════════════ + +function getDebugData() { + const viewsDebug = viewCacheAll().map(v => { + const idx = getCurrentURLIndex(v); + return { + id: v.id, + name: v.name, + urls: v.urls, + method: v.method, + metaRefreshEnabled: v.metaRefreshEnabled, + cacheTtlSec: v.cacheTtlSec, + viewportWidth: v.viewportWidth, + viewportHeight: v.viewportHeight, + currentUrlIndex: idx, + currentUrl: v.urls[idx]?.url ?? "", + remainingSec: getRemainingSec(v), + totalCycleSec: v.urls.reduce((s, u) => s + u.durationSec, 0), + cacheEntries: cacheStats().filter(c => c.viewId === v.id), + }; + }); + + return { + server: { + uptime: Math.round(process.uptime()), + activeRenders: pagePool?.activeRenders ?? 0, + queueLength: renderQueue.length, + cacheEntries: renderCache.size, + browsers: CONFIG.BROWSER_COUNT, + pagesPerBrowser: CONFIG.PAGES_PER_BROWSER, + maxConcurrentRenders: CONFIG.MAX_CONCURRENT_RENDERS, + }, + views: viewsDebug, + devices: devicesSnapshot(), + }; +} + +// ═══════════════════════════════════════════════════════════════ +// BROWSER + PAGE POOL +// ═══════════════════════════════════════════════════════════════ + +interface BrowserSlot { + browser: Browser; + available: Page[]; + inUse: number; + max: number; +} + +class PagePool { + private slots: BrowserSlot[] = []; + private pageToSlot = new WeakMap(); + private waiters: Array<{ + resolve: (p: Page) => void; + reject: (e: Error) => void; + timeout: ReturnType; + }> = []; + + async init(): Promise { + for (let i = 0; i < CONFIG.BROWSER_COUNT; i++) { + const browser = await puppeteer.launch({ + executablePath: "/usr/bin/chromium", + headless: true, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--disable-software-rasterizer", + "--disable-extensions", + "--mute-audio", + ], + }); + browser.on("disconnected", () => { + console.error(`[browser-pool] Browser ${i} disconnected`); + }); + + const slot: BrowserSlot = { browser, available: [], inUse: 0, max: CONFIG.PAGES_PER_BROWSER }; + for (let j = 0; j < CONFIG.PAGE_POOL_WARM; j++) { + const page = await this.createPage(slot); + slot.available.push(page); + } + this.slots.push(slot); + } + console.log( + `[browser-pool] ${CONFIG.BROWSER_COUNT} browsers × ${CONFIG.PAGES_PER_BROWSER} pages ready`, + ); + } + + private async createPage(slot: BrowserSlot): Promise { + const page = await slot.browser.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + await page.setRequestInterception(true); + page.on("request", (req) => { + const t = req.resourceType(); + if (t === "media" || t === "websocket" || t === "manifest" || t === "font") { + req.abort(); + } else { + req.continue(); + } + }); + this.pageToSlot.set(page, slot); + return page; + } + + async resetPage(page: Page): Promise { + try { + await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 }); + await page.emulateMediaFeatures([]); + + // Clear storage via CDP — avoids opaque-origin SecurityError + const client = await page.createCDPSession(); + await client.send("Storage.clearDataForOrigin", { + origin: "*", + storageTypes: "all", + }); + await client.send("Network.clearBrowserCookies"); + await client.detach(); + return true; + } catch (err) { + console.warn(`[page-pool] resetPage failed, discarding page: ${err}`); + try { await page.close(); } catch {} + return false; + } + } + + acquire(): Promise { + for (const slot of this.slots) { + if (slot.available.length > 0) { + slot.inUse++; + return Promise.resolve(slot.available.pop()!); + } + if (slot.inUse < slot.max) { + slot.inUse++; + return this.createPage(slot); + } + } + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const idx = this.waiters.findIndex((w) => w.timeout === timeout); + if (idx >= 0) this.waiters.splice(idx, 1); + reject(new Error("Page pool exhausted")); + }, CONFIG.PAGE_ACQUIRE_TIMEOUT_MS); + this.waiters.push({ resolve, reject, timeout }); + }); + } + + async release(page: Page): Promise { + const slot = this.pageToSlot.get(page); + if (!slot) { + await page.close().catch(() => {}); + return; + } + slot.inUse--; + + const ok = await this.resetPage(page); + if (!ok) { + // Page was closed during reset — don't recycle it + this.pageToSlot.delete(page); + return; + } + + if (this.waiters.length > 0) { + const w = this.waiters.shift()!; + clearTimeout(w.timeout); + slot.inUse++; + w.resolve(page); + return; + } + if (slot.available.length + slot.inUse < slot.max) { + slot.available.push(page); + } else { + await page.close().catch(() => {}); + this.pageToSlot.delete(page); + } + } + + get activeRenders(): number { + return this.slots.reduce((s, slot) => s + slot.inUse, 0); + } + + async shutdown(): Promise { + for (const slot of this.slots) { + for (const p of slot.available) await p.close().catch(() => {}); + await slot.browser.close().catch(() => {}); + } + this.slots = []; + } +} + +let pagePool: PagePool; + +// ═══════════════════════════════════════════════════════════════ +// WEBSOCKET DEBUG CLIENTS +// ═══════════════════════════════════════════════════════════════ + +const debugClients = new Map< + any, + { interval: number; timer: ReturnType } +>(); +const DEBUG_DEFAULT_INTERVAL_MS = 1000; +const DEBUG_MIN_INTERVAL_MS = 100; + +// ═══════════════════════════════════════════════════════════════ +// RENDER CACHE + QUEUE +// ═══════════════════════════════════════════════════════════════ + +const renderCache = new Map(); +const inFlightRenders = new Map(); +const renderQueue: QueuedRender[] = []; + +function renderCacheKey(viewId: string, urlIndex: number): string { + return `${viewId}:${urlIndex}`; +} + +function cachePurgeExpired(): void { + const now = Date.now(); + for (const [key, entry] of renderCache) { + if (now >= entry.expiresAt) renderCache.delete(key); + } +} + +setInterval(cachePurgeExpired, CONFIG.CACHE_CLEANUP_INTERVAL_MS); + +function cacheInvalidate(viewId: string): void { + const prefix = `${viewId}:`; + for (const key of renderCache.keys()) { + if (key.startsWith(prefix)) renderCache.delete(key); + } + for (let i = renderQueue.length - 1; i >= 0; i--) { + if (renderQueue[i].viewId === viewId) { + renderQueue[i].reject(new Error("View deleted")); + renderQueue.splice(i, 1); + } + } +} + +function drainRenderQueue(): void { + const now = Date.now(); + + for (let i = renderQueue.length - 1; i >= 0; i--) { + if (now - renderQueue[i].queuedAt > CONFIG.QUEUE_TIMEOUT_MS) { + const q = renderQueue[i]; + q.reject(new Error("Render queue timeout")); + console.log( + `[queue] Dropped waiter for ${q.key} (timeout ${Math.round((now - q.queuedAt) / 1000)}s)`, + ); + renderQueue.splice(i, 1); + } + } + + while (renderQueue.length > 0 && pagePool.activeRenders < CONFIG.MAX_CONCURRENT_RENDERS) { + const next = renderQueue.shift()!; + const view = viewCacheGet(next.viewId); + if (!view) { + next.reject(new Error("View deleted while queued")); + continue; + } + + const cached = renderCache.get(next.key); + if (cached && Date.now() < cached.expiresAt) { + cached.expiresAt = Date.now() + view.cacheTtlSec * 1000; + next.resolve(cached.body); + continue; + } + + console.log( + `[queue] Dequeued ${next.key} (waited ${Math.round((now - next.queuedAt) / 1000)}s)`, + ); + const renderPromise = renderViewToCache(view, next.urlIndex, next.key); + inFlightRenders.set(next.key, { promise: renderPromise, urlIndex: next.urlIndex }); + renderPromise.then( + (body) => { + inFlightRenders.delete(next.key); + next.resolve(body); + }, + (err) => { + inFlightRenders.delete(next.key); + next.reject(err); + }, + ); + } +} + +async function renderViewToCache(view: View, urlIndex: number, key: string): Promise { + const urlItem = view.urls[urlIndex]; + const forceDarkMode = urlItem.forceDarkMode ?? false; + const bakeStyles = urlItem.bakeStyles ?? false; + + let body: string; + if (view.method === "mjpeg") { + const jpeg = await renderScreenshotFrame( + urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode, + ); + body = jpeg.toString("base64"); + } else { + body = await renderSSR( + urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode, bakeStyles, + ); + } + + const now = Date.now(); + const entry: CachedRender = { + body, + urlIndex, + createdAt: now, + expiresAt: now + view.cacheTtlSec * 1000, + }; + renderCache.set(key, entry); + + drainRenderQueue(); + + return body; +} + +async function getOrRender(view: View, urlIndex: number): Promise { + const key = renderCacheKey(view.id, urlIndex); + const now = Date.now(); + + const cached = renderCache.get(key); + if (cached && now < cached.expiresAt) { + cached.expiresAt = now + view.cacheTtlSec * 1000; + return cached.body; + } + + const inFlight = inFlightRenders.get(key); + if (inFlight) { + return inFlight.promise; + } + + if (pagePool.activeRenders < CONFIG.MAX_CONCURRENT_RENDERS) { + const promise = renderViewToCache(view, urlIndex, key); + inFlightRenders.set(key, { promise, urlIndex }); + try { + return await promise; + } finally { + inFlightRenders.delete(key); + } + } + + console.log(`[queue] Enqueuing ${key} (position=${renderQueue.length})`); + return new Promise((resolve, reject) => { + renderQueue.push({ + resolve, + reject, + key, + viewId: view.id, + urlIndex, + queuedAt: Date.now(), + }); + }); +} + +function cacheStats(): Array<{ + key: string; + viewId: string; + urlIndex: number; + url: string; + createdAt: number; + expiresAt: number; + ttlRemainingSec: number; +}> { + const now = Date.now(); + const result: Array<{ + key: string; + viewId: string; + urlIndex: number; + url: string; + createdAt: number; + expiresAt: number; + ttlRemainingSec: number; + }> = []; + + for (const [key, entry] of renderCache) { + const [viewId] = key.split(":"); + const view = viewCacheGet(viewId); + result.push({ + key, + viewId, + urlIndex: entry.urlIndex, + url: view?.urls[entry.urlIndex]?.url ?? "?", + createdAt: entry.createdAt, + expiresAt: entry.expiresAt, + ttlRemainingSec: Math.max(0, Math.round((entry.expiresAt - now) / 1000)), + }); + } + + return result.sort((a, b) => a.ttlRemainingSec - b.ttlRemainingSec); +} + // ═══════════════════════════════════════════════════════════════ // ROTATION LOGIC // ═══════════════════════════════════════════════════════════════ -/** -* Returns which URL index is "current" based on wall-clock time - -* and the cumulative durations of all URLs in the view. - */ function getCurrentURLIndex(view: View): number { - if (view.urls.length <= 1) return 0; - const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 0); - if (totalCycle <= 0) return 0; - const elapsed = Math.floor(Date.now() / 1000) % totalCycle; - let accumulated = 0; - for (let i = 0; i < view.urls.length; i++) { - accumulated += view.urls[i].durationSec; - if (elapsed < accumulated) return i; - } - return view.urls.length - 1; + if (view.urls.length <= 1) return 0; + const totalCycle = view.urls.reduce((s, u) => s + u.durationSec, 0); + if (totalCycle <= 0) return 0; + const elapsed = Math.floor(Date.now() / 1000) % totalCycle; + let acc = 0; + for (let i = 0; i < view.urls.length; i++) { + acc += view.urls[i].durationSec; + if (elapsed < acc) return i; + } + return view.urls.length - 1; } +function getRemainingSec(view: View): number { + if (view.urls.length <= 1) { + return view.urls[0]?.durationSec || 30; + } + const totalCycle = view.urls.reduce((s, u) => s + u.durationSec, 0); + if (totalCycle <= 0) return 30; + const elapsed = Math.floor(Date.now() / 1000) % totalCycle; + let acc = 0; + for (let i = 0; i < view.urls.length; i++) { + acc += view.urls[i].durationSec; + if (elapsed < acc) return acc - elapsed; + } + return view.urls[0].durationSec; +} + +// ═══════════════════════════════════════════════════════════════ +// SINGLE-PASS INLINING (SSR mode) +// ═══════════════════════════════════════════════════════════════ + +function injectMetaRefresh(html: string, intervalSec: number): string { + const sec = Math.max(1, Math.ceil(intervalSec)); + const tag = ``; + if (html.includes("")) { + return html.replace("", ` ${tag}\n`); + } + if (/]*>/i.test(html)) { + return html.replace(/(]*>)/i, `$1\n${tag}`); + } + return `${tag}${html}`; +} + +function stripScriptTags(html: string): string { + return html + .replace(/)<[^<]*)*<\/script>/gi, "") + .replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "") + .replace(/\s+on\w+\s*=\s*'[^']*'/gi, ""); +} + +// ── Recursive @import resolver (max 3 levels deep) ── +async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): Promise { + if (depth > 3) return cssText; + const importRe = /@import\s+(?:url\s*\(\s*["']?([^)"']+)["']?\s*\)|["']([^"']+)["'])\s*([^;]*);/gi; + const replacements: Array<{ match: string; resolved: string }> = []; + + let m: RegExpExecArray | null; + while ((m = importRe.exec(cssText)) !== null) { + const importUrl = new URL(m[1] || m[2], baseUrl).href; + // Skip font services — font files are blocked by request interception + if (/fonts\.googleapis\.com|fonts\.gstatic\.com/.test(importUrl)) { + replacements.push({ match: m[0], resolved: "/* removed font import: " + importUrl + " */" }); + continue; + } + try { + const res = await fetch(importUrl); + if (!res.ok) throw new Error("failed"); + let imported = await res.text(); + imported = await resolveCSSImports(imported, importUrl, depth + 1); + replacements.push({ match: m[0], resolved: "/* begin " + importUrl + " */\n" + imported + "\n/* end " + importUrl + " */" }); + } catch { + replacements.push({ match: m[0], resolved: "/* unresolved import: " + importUrl + " */" }); + } + } + + for (const r of replacements) { + cssText = cssText.replace(r.match, r.resolved); + } + return cssText; +} + +// ═══════════════════════════════════════════════════════════════ +// COMPUTED STYLE BAKING +// ═══════════════════════════════════════════════════════════════ + /** + * Bake computed styles into inline style attributes. + * This ensures styles work on browsers with limited CSS support (e.g., Samsung TV). + * Compares each element against a same-tagname fresh element so tag-specific defaults + * (e.g. block display on div, inline on span, bold on h1) are accounted for. + */ +async function bakeComputedStyles(page: Page): Promise { + await page.evaluate((): void => { + const computedProperties = [ + // Layout + "display", "position", "top", "right", "bottom", "left", + "width", "height", "min-width", "min-height", "max-width", "max-height", + "margin", "margin-top", "margin-right", "margin-bottom", "margin-left", + "padding", "padding-top", "padding-right", "padding-bottom", "padding-left", + // Flex/Grid + "flex", "flex-direction", "flex-wrap", "flex-flow", "flex-grow", "flex-shrink", "flex-basis", + "justify-content", "align-items", "align-content", "align-self", "gap", "row-gap", "column-gap", + "grid", "grid-template", "grid-template-columns", "grid-template-rows", "grid-area", + // Box model + "box-sizing", "border-top-width", "border-right-width", "border-bottom-width", "border-left-width", + "border-top-style", "border-right-style", "border-bottom-style", "border-left-style", + "border-top-color", "border-right-color", "border-bottom-color", "border-left-color", + "border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius", + // Colors & Background + "color", "background-color", "background-image", "background-size", "background-position", + "background-repeat", "background-attachment", "background-clip", "background-origin", + // Typography + "font-family", "font-size", "font-weight", "font-style", "font-variant", + "line-height", "text-align", "text-decoration-line", "text-decoration-color", + "text-transform", "letter-spacing", "word-spacing", + "white-space", "overflow-wrap", "word-break", + // Visual + "opacity", "visibility", "z-index", "overflow-x", "overflow-y", + "box-shadow", "text-shadow", "transform", "transform-origin", + // List/Table + "list-style-type", "list-style-position", "border-collapse", "border-spacing", + // Cursor/pointer + "cursor", "pointer-events", + ]; -* Returns how many seconds remain before the _next_ URL switch. - -* Used for meta-refresh interval so the TV reloads exactly when - -* the rotation advances. - */ -function getRemainingSec(view: View): number { - if (view.urls.length <= 1) return view.urls[0]?.durationSec || 30; - const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 0); - if (totalCycle <= 0) return 30; - const elapsed = Math.floor(Date.now() / 1000) % totalCycle; - let accumulated = 0; - for (let i = 0; i < view.urls.length; i++) { - accumulated += view.urls[i].durationSec; - if (elapsed < accumulated) { - return accumulated - elapsed; - } - } - // Shouldn't reach here — wrap around - return view.urls[0].durationSec; + // Cache of default computed values keyed by tagName + const defaultCache = new Map>(); + const hiddenContainer = document.createElement("div"); + hiddenContainer.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;top:-9999px;"; + document.body.appendChild(hiddenContainer); + + function getTagDefaults(tag: string): Map { + let cache = defaultCache.get(tag); + if (cache) return cache; + cache = new Map(); + try { + const el = document.createElement(tag); + hiddenContainer.appendChild(el); + const cs = window.getComputedStyle(el); + for (const prop of computedProperties) { + cache.set(prop, cs.getPropertyValue(prop)); + } + el.remove(); + } catch { /* unknown tag, cache will be empty */ } + defaultCache.set(tag, cache); + return cache; + } + + function shouldBake(value: string, defaultValue: string): boolean { + if (!value) return false; + // Skip browser keywords that cannot be serialized as inline style values + if (value === "initial" || value === "inherit" || value === "unset" || value === "revert") return false; + return value !== defaultValue; + } + + // Walk all elements and bake computed styles + const elements = document.querySelectorAll("*"); + for (const el of elements) { + if (el === hiddenContainer) continue; + // Skip non-visual tags + const tag = el.tagName.toLowerCase(); + if (["script", "style", "meta", "link", "noscript", "template", "head", "html"].includes(tag)) continue; + + const computed = window.getComputedStyle(el); + const defaults = getTagDefaults(tag); + const stylesToBake: string[] = []; + + for (const prop of computedProperties) { + const value = computed.getPropertyValue(prop); + const defaultValue = defaults.get(prop) ?? ""; + if (shouldBake(value, defaultValue)) { + stylesToBake.push(`${prop}:${value}`); + } + } + + if (stylesToBake.length > 0) { + // Existing inline styles take precedence — put them after baked styles + const existingInline = el.getAttribute("style") || ""; + const bakedStyles = stylesToBake.join(";"); + el.setAttribute("style", existingInline ? `${bakedStyles};${existingInline}` : bakedStyles); + } + } + + hiddenContainer.remove(); + }); +} + +async function inlineAllResources(page: Page, html: string): Promise { + interface InlinePayload { + css: Array<{ url: string; text: string }>; + images: Array<{ src: string; dataUri: string }>; + favicons: Array<{ href: string; dataUri: string }>; + bgImages: Array<{ url: string; dataUri: string }>; + } + + let payload: InlinePayload; + + try { + payload = await page.evaluate((): Promise => { + function blobToDataUri(blob: Blob): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + } + async function collect(): Promise { + const result: InlinePayload = { css: [], images: [], favicons: [], bgImages: [] }; + // CSS + const cssLinks = [...document.querySelectorAll('link[rel="stylesheet"]')]; + const cssResults = await Promise.allSettled( + cssLinks.map(async (link) => { + const res = await fetch(link.href); + if (!res.ok) throw new Error("fetch failed"); + return { url: link.href, text: await res.text() }; + }), + ); + result.css = cssResults + .filter( + (r): r is PromiseFulfilledResult<{ url: string; text: string }> => + r.status === "fulfilled", + ) + .map((r) => r.value); + // Images + const imgs = [ + ...document.querySelectorAll('img[src]:not([src^="data:"])'), + ]; + const imgResults = await Promise.allSettled( + imgs.map(async (img) => { + const res = await fetch(img.src); + if (!res.ok) throw new Error("fetch failed"); + return { src: img.src, dataUri: await blobToDataUri(await res.blob()) }; + }), + ); + result.images = imgResults + .filter( + (r): r is PromiseFulfilledResult<{ src: string; dataUri: string }> => + r.status === "fulfilled", + ) + .map((r) => r.value); + // Background images + const styleText = [...document.querySelectorAll("style")] + .map((s) => s.textContent ?? "") + .join("\n"); + const elemStyle = [...document.querySelectorAll("[style]")] + .map((e) => e.getAttribute("style") ?? "") + .join("\n"); + const allStyle = styleText + "\n" + elemStyle; + const urlRegex = /url\s*\(\s*["']?([^)"'\s]+)["']?\s*\)/gi; + const bgUrls = new Set(); + let m: RegExpExecArray | null; + while ((m = urlRegex.exec(allStyle)) !== null) { + if (!m[1].startsWith("data:") && !m[1].startsWith("#")) bgUrls.add(m[1]); + } + const bgResults = await Promise.allSettled( + [...bgUrls].map(async (url) => { + if (!/\.(png|jpe?g|gif|svg|webp|ico)/i.test(url)) throw new Error("not image"); + const res = await fetch(url); + if (!res.ok) throw new Error("fetch failed"); + return { url, dataUri: await blobToDataUri(await res.blob()) }; + }), + ); + result.bgImages = bgResults + .filter( + (r): r is PromiseFulfilledResult<{ url: string; dataUri: string }> => + r.status === "fulfilled", + ) + .map((r) => r.value); + // Favicons + const icons = [...document.querySelectorAll('link[rel*="icon"]')]; + const iconResults = await Promise.allSettled( + icons.map(async (link) => { + if (link.href.startsWith("data:")) throw new Error("skip"); + const res = await fetch(link.href); + if (!res.ok) throw new Error("fetch failed"); + return { href: link.href, dataUri: await blobToDataUri(await res.blob()) }; + }), + ); + result.favicons = iconResults + .filter( + (r): r is PromiseFulfilledResult<{ href: string; dataUri: string }> => + r.status === "fulfilled", + ) + .map((r) => r.value); + return result; + } + return collect(); + }); + } catch { + return html; + } + + for (const css of payload.css) { + const escaped = css.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + // Match both double and single-quoted href + const linkRegex = new RegExp(`]*\\bhref\\s*=\\s*["']${escaped}["'][^>]*>`, "gi"); + const linkMatch = html.match(linkRegex); + + // Preserve media attribute if present + let mediaAttr = ""; + if (linkMatch) { + const m2 = linkMatch[0].match(/\bmedia\s*=\s*["']([^"']*)["']/i); + if (m2 && m2[1]) mediaAttr = ` media="${m2[1]}"`; + } + + // Resolve @import rules recursively + const resolvedCSS = await resolveCSSImports(css.text, css.url); + + html = html.replace( + linkRegex, + `/* inlined: ${css.url} */\n${resolvedCSS}\n` + ); + } + for (const img of payload.images) { + const escaped = img.src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + html = html.replace( + new RegExp(`(]*\\bsrc\\s*=\\s*)"${escaped}"`, "gi"), + `$1"${img.dataUri}"`, + ); + } + for (const bg of payload.bgImages) { + const escaped = bg.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + html = html.replace( + new RegExp(`url\\s*\\(\\s*["']?${escaped}["']?\\s*\\)`, "gi"), + `url(${bg.dataUri})`, + ); + } + for (const fav of payload.favicons) { + const escaped = fav.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + html = html.replace( + new RegExp(`(]*\\bhref\\s*=\\s*)"${escaped}"`, "gi"), + `$1"${fav.dataUri}"`, + ); + } + html = html.replace( + /]*\brel\s*=\s*"(?:preload|prefetch|preconnect|dns-prefetch|modulepreload)"[^>]*>/gi, + "", + ); + html = html.replace(/\s+crossorigin\s*=\s*"[^"]*"/gi, ""); + html = html.replace(/\s+integrity\s*=\s*"[^"]*"/gi, ""); + return html; +} + +async function renderSSR( + url: string, + viewportWidth = 1920, + viewportHeight = 1080, + forceDarkMode = false, + bakeStyles = false, +): Promise { + const page = await pagePool.acquire(); + try { + await page.setViewport({ width: viewportWidth, height: viewportHeight }); + if (forceDarkMode) { + await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "dark" }]); + } + + // Try networkidle0 first; fall back to whatever we have on timeout + try { + await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS }); + } catch (err: any) { + if (err.message?.includes("timeout")) { + console.warn(`[renderSSR] networkidle0 timeout for ${url}, using current content`); + } else { + throw err; + } + } + + let html = await page.content(); + html = await inlineAllResources(page, html); + + // Bake computed styles into inline attributes (for Samsung TV compatibility) + if (bakeStyles) { + await bakeComputedStyles(page); + html = await page.content(); + } + + html = stripScriptTags(html); + return html; + } finally { + await pagePool.release(page); + } } // ═══════════════════════════════════════════════════════════════ -// SSR RENDERING +// SCREENSHOT RENDERING // ═══════════════════════════════════════════════════════════════ -function injectMetaRefresh(html: string, intervalSec: number): string { - // Use integer seconds for meta refresh - const sec = Math.max(1, Math.ceil(intervalSec)); - const tag = ``; - if (html.includes("")) { - return html.replace("", `${tag}\n`); - } - if (/]*>/i.test(html)) { - return html.replace(/(]*>)/i, `$1\n${tag}`); - } - return `${tag}${html}`; + +async function renderScreenshotFrame( + url: string, + viewportWidth = 1920, + viewportHeight = 1080, + forceDarkMode = false, +): Promise { + const page = await pagePool.acquire(); + try { + await page.setViewport({ width: viewportWidth, height: viewportHeight }); + if (forceDarkMode) { + await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "dark" }]); + } + + try { + await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS }); + } catch (err: any) { + if (err.message?.includes("timeout")) { + console.warn(`[renderScreenshot] networkidle0 timeout for ${url}, using current content`); + } else { + throw err; + } + } + + return Buffer.from(await page.screenshot({ type: "jpeg", quality: 75, fullPage: false })); + } finally { + await pagePool.release(page); + } } -function stripScriptTags(html: string): string { - return html - .replace(/)<[^<]*)*<\/script>/gi, "") - .replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "") - .replace(/\s+on\w+\s*=\s*'[^']*'/gi, ""); -} -async function renderSSR(url: string): Promise { - const page = await createPage(); - try { - await page.goto(url, { waitUntil: "networkidle0", timeout: 25000 }); - const html = await page.content(); - return stripScriptTags(html); - } finally { - await page.close().catch(() => {}); - } -} -function errorPage(message: string): string { - return ` -⚠ -${message} -Samsung TV Proxy -`; + +function screenshotHTML(jpegBase64: string, refreshSec: number): string { + const sec = Math.max(2, Math.ceil(refreshSec)); + return ` + + + + + + + + + +`; } + // ═══════════════════════════════════════════════════════════════ -// MJPEG STREAMING +// ERROR PAGE // ═══════════════════════════════════════════════════════════════ -interface ActiveStream { - aborted: boolean; - page: Page | null; + +function errorPage(message: string, retrySec = 10): string { + return ` + +Error + + + + +
+

+

${message}

+

Samsung TV Proxy — retrying in ${retrySec}s

+
+ +`; } -const activeStreams = new Map(); -async function serveMJPEG(view: View): Promise { - const b = await getBrowser(); - const page = await b.newPage(); - await page.setViewport({ width: 1920, height: 1080 }); - // Block heavy resources during MJPEG capture - await page.setRequestInterception(true); - page.on("request", (req) => { - const type = req.resourceType(); - if ( - type === "media" || - type === "websocket" || - type === "manifest" || - type === "font" - ) { - req.abort(); - } else { - req.continue(); - } - }); - // Per-connection key so multiple viewers of the same view don't collide - const connKey = `${view.id}:${crypto.randomUUID()}`; - const boundary = "MJPEG_FRAME"; - let currentIdx = -1; - const streamRecord: ActiveStream = { aborted: false, page }; - activeStreams.set(connKey, streamRecord); - - // Navigate to the first URL before returning the response so the first - // frame is ready immediately and Chrome has something to display. - const firstIdx = getCurrentURLIndex(view); - try { - console.log(`[mjpeg:${connKey}] Initial navigation to URL ${firstIdx}: ${view.urls[firstIdx].url}`); - await page.goto(view.urls[firstIdx].url, { - waitUntil: "networkidle2", - timeout: 20000, - }); - currentIdx = firstIdx; - } catch (err: any) { - console.error(`[mjpeg:${connKey}] Initial navigation failed: ${err.message}`); - } - - const enc = new TextEncoder(); - const stream = new ReadableStream({ - async start(controller) { - const FRAME_MS = 200; // ~5 fps - - const enqueueFrame = async () => { - if (streamRecord.aborted) return; - - const idx = getCurrentURLIndex(view); - if (idx !== currentIdx) { - try { - console.log(`[mjpeg:${connKey}] Switching to URL ${idx}: ${view.urls[idx].url}`); - await page.goto(view.urls[idx].url, { - waitUntil: "networkidle2", - timeout: 20000, - }); - currentIdx = idx; - } catch (err: any) { - console.error(`[mjpeg:${connKey}] Navigation failed: ${err.message}`); - // Stay on current page, will retry next cycle - } - } - - try { - const screenshot = await page.screenshot({ - type: "jpeg", - quality: 70, - }); - const part = - `--${boundary}\r\n` + - `Content-Type: image/jpeg\r\n` + - `Content-Length: ${screenshot.length}\r\n` + - `\r\n`; - controller.enqueue(enc.encode(part)); - controller.enqueue(new Uint8Array(screenshot)); - controller.enqueue(enc.encode("\r\n")); - } catch (err: any) { - if (!streamRecord.aborted) { - console.error(`[mjpeg:${connKey}] Screenshot failed: ${err.message}`); - } - } - }; - - while (!streamRecord.aborted) { - const start = Date.now(); - await enqueueFrame(); - const elapsed = Date.now() - start; - const delay = Math.max(0, FRAME_MS - elapsed); - if (delay > 0) await new Promise((r) => setTimeout(r, delay)); - } - - controller.close(); - }, - cancel() { - streamRecord.aborted = true; - if (streamRecord.page) { - streamRecord.page.close().catch(() => {}); - streamRecord.page = null; - } - activeStreams.delete(connKey); - console.log(`[mjpeg:${connKey}] Stream closed`); - }, - }); - return new Response(stream, { - headers: { - "Content-Type": `multipart/x-mixed-replace; boundary=${boundary}`, - "Cache-Control": "no-cache, no-store, must-revalidate", - "Connection": "keep-alive", - "X-Content-Type-Options": "nosniff", - }, - }); -} -// ═══════════════════════════════════════════════════════════════ -// VIEW SERVING — /v/:id // ═══════════════════════════════════════════════════════════════ +// VIEW SERVING — /v/:id +// ═══════════════════════════════════════════════════════════════ + async function serveView(view: View): Promise { - if (view.method === "mjpeg") { - return serveMJPEG(view); - } - // SSR method - const idx = getCurrentURLIndex(view); - const urlItem = view.urls[idx]; - let html: string; - try { - html = await renderSSR(urlItem.url); - } catch (err: any) { - console.error(`[ssr:${view.id}] Render failed: ${err.message}`); - return new Response(errorPage(`Failed to load: ${urlItem.url}`), { - status: 502, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } - if (view.metaRefreshEnabled) { - const interval = getRemainingSec(view); - html = injectMetaRefresh(html, interval); - } - return new Response(html, { - headers: { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-cache, no-store, must-revalidate", - }, - }); + const idx = getCurrentURLIndex(view); + let body: string; + + try { + body = await getOrRender(view, idx); + } catch (err: any) { + console.error(`[serve:${view.id}] Render failed: ${err.message}`); + return new Response(errorPage(`Failed: ${view.urls[idx]?.url}`), { + status: 502, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + let html: string; + if (view.method === "mjpeg") { + html = screenshotHTML(body, getRemainingSec(view)); + } else { + html = view.metaRefreshEnabled ? injectMetaRefresh(body, getRemainingSec(view)) : body; + } + + return new Response(html, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + }); } + // ═══════════════════════════════════════════════════════════════ -// HTTP SERVER +// HTTP HELPERS // ═══════════════════════════════════════════════════════════════ -const PORT = parseInt(process.env.PORT || "3099"); -const STATIC_DIR = new URL("../public", import.meta.url).pathname; + function json(data: unknown, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - headers: { "Content-Type": "application/json" }, - }); -} -function readBody(req: Request): Promise { - return req.text(); + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); } + +// ═══════════════════════════════════════════════════════════════ +// API +// ═══════════════════════════════════════════════════════════════ + async function handleAPI(req: Request, path: string): Promise { const method = req.method; - // GET /api/views — list all + + // GET /api/views if (path === "/api/views" && method === "GET") { - return json(loadAllViews()); + return json(viewCacheAll()); } - // POST /api/views — create + + // GET /api/debug + if (path === "/api/debug" && method === "GET") { + return json(getDebugData()); + } + + // POST /api/views if (path === "/api/views" && method === "POST") { try { - const body: CreateViewBody = JSON.parse(await readBody(req)); - if (!body.name || !body.name.trim()) { - return json({ error: "Name is required" }, 400); - } - if (!body.urls || body.urls.length === 0) { - return json({ error: "At least one URL is required" }, 400); - } + const body: CreateViewBody = JSON.parse(await req.text()); + if (!body.name?.trim()) return json({ error: "Name is required" }, 400); + if (!body.urls?.length) return json({ error: "At least one URL is required" }, 400); for (const u of body.urls) { - if (!u.url || !u.url.trim()) { - return json({ error: "Each URL entry must have a url" }, 400); - } - if (typeof u.durationSec !== "number" || u.durationSec <= 0) { - return json({ error: "Each URL entry must have a positive durationSec" }, 400); - } - } - if (!["mjpeg", "ssr"].includes(body.method)) { - return json({ error: "method must be 'mjpeg' or 'ssr'" }, 400); + if (!u.url?.trim()) return json({ error: "Each URL must have a url" }, 400); + if (typeof u.durationSec !== "number" || u.durationSec <= 0) + return json({ error: "Each URL must have positive durationSec" }, 400); } + if (!["mjpeg", "ssr"].includes(body.method)) + return json({ error: 'method must be "mjpeg" or "ssr"' }, 400); const view: View = { id: crypto.randomUUID(), @@ -423,165 +1183,267 @@ async function handleAPI(req: Request, path: string): Promise { urls: body.urls.map((u) => ({ url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), + forceDarkMode: u.forceDarkMode ?? false, + bakeStyles: u.bakeStyles ?? false, })), method: body.method, metaRefreshEnabled: body.metaRefreshEnabled ?? false, + cacheTtlSec: Math.max(0, body.cacheTtlSec ?? 60), + viewportWidth: Math.max(320, Math.min(7680, body.viewportWidth ?? 1920)), + viewportHeight: Math.max(240, Math.min(4320, body.viewportHeight ?? 1080)), createdAt: Math.floor(Date.now() / 1000), }; - insertView(view); - console.log(`[api] Created view "${view.name}" (${view.id}) method=${view.method}`); + dbInsert(view); + viewCache.set(view.id, view); + console.log(`[api] Created "${view.name}" (${view.id}) method=${view.method}`); return json(view, 201); } catch { return json({ error: "Invalid JSON body" }, 400); } } - // Match /api/views/:id + + // /api/views/:id const viewMatch = path.match(/^\/api\/views\/([a-f0-9-]+)$/); if (viewMatch) { const viewId = viewMatch[1]; - // GET /api/views/:id if (method === "GET") { - const view = loadView(viewId); + const view = viewCacheGet(viewId); if (!view) return json({ error: "View not found" }, 404); return json(view); } - // PUT /api/views/:id if (method === "PUT") { - const view = loadView(viewId); + const view = viewCacheGet(viewId); if (!view) return json({ error: "View not found" }, 404); try { - const body: UpdateViewBody = JSON.parse(await readBody(req)); - + const body: UpdateViewBody = JSON.parse(await req.text()); if (body.name !== undefined) view.name = body.name.trim(); if (body.urls !== undefined) { - if (body.urls.length === 0) { - return json({ error: "At least one URL is required" }, 400); - } + if (body.urls.length === 0) return json({ error: "At least one URL is required" }, 400); view.urls = body.urls.map((u) => ({ url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), + forceDarkMode: u.forceDarkMode ?? false, + bakeStyles: u.bakeStyles ?? false, })); } if (body.method !== undefined) { - if (!["mjpeg", "ssr"].includes(body.method)) { - return json({ error: "method must be 'mjpeg' or 'ssr'" }, 400); - } + if (!["mjpeg", "ssr"].includes(body.method)) + return json({ error: 'method must be "mjpeg" or "ssr"' }, 400); view.method = body.method; } - if (body.metaRefreshEnabled !== undefined) { + if (body.metaRefreshEnabled !== undefined) view.metaRefreshEnabled = body.metaRefreshEnabled; - } + if (body.cacheTtlSec !== undefined) view.cacheTtlSec = Math.max(0, body.cacheTtlSec); + if (body.viewportWidth !== undefined) + view.viewportWidth = Math.max(320, Math.min(7680, body.viewportWidth)); + if (body.viewportHeight !== undefined) + view.viewportHeight = Math.max(240, Math.min(4320, body.viewportHeight)); - updateView(view); - console.log(`[api] Updated view "${view.name}" (${view.id})`); + dbUpdate(view); + cacheInvalidate(viewId); + viewCache.set(view.id, view); + console.log(`[api] Updated "${view.name}" (${view.id})`); return json(view); } catch { return json({ error: "Invalid JSON body" }, 400); } } - // DELETE /api/views/:id if (method === "DELETE") { - // Close all active MJPEG streams for this view (one per connected client) - for (const [key, active] of activeStreams) { - if (key.startsWith(viewId + ":")) { - active.aborted = true; - if (active.page) active.page.close().catch(() => {}); - activeStreams.delete(key); - } - } - - const deleted = deleteViewById(viewId); - if (!deleted) return json({ error: "View not found" }, 404); - console.log(`[api] Deleted view ${viewId}`); + if (!dbDelete(viewId)) return json({ error: "View not found" }, 404); + cacheInvalidate(viewId); + viewCache.delete(viewId); + console.log(`[api] Deleted ${viewId}`); return json({ ok: true }); } } + return json({ error: "Not found" }, 404); } -const server = Bun.serve({ - port: PORT, - async fetch(req) { - const url = new URL(req.url); - const path = url.pathname; - // API routes - if (path.startsWith("/api/")) { - return handleAPI(req, path); - } +// ═══════════════════════════════════════════════════════════════ +// STATIC +// ═══════════════════════════════════════════════════════════════ - // View serving — /v/:id - const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/); - if (viewMatch) { - const view = loadView(viewMatch[1]); - if (!view) { - return new Response(errorPage("View not found"), { - status: 404, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } - if (view.urls.length === 0) { - return new Response(errorPage("View has no URLs configured"), { - status: 500, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } - return serveView(view); - } +const STATIC_DIR = new URL("../public", import.meta.url).pathname; - // Health check - if (path === "/health") { - return json({ status: "ok", uptime: process.uptime() }); - } - - // Static files from public/ - const filePath = path === "/" ? "/index.html" : path; - const fullPath = STATIC_DIR + filePath; - - try { - const file = Bun.file(fullPath); - // Check if file exists (will throw if not) - await file.slice(0, 0).text(); // probe - return new Response(file); - } catch { - // SPA fallback: serve index.html for non-API routes - try { - return new Response(Bun.file(STATIC_DIR + "/index.html")); - } catch { - return new Response("Not found", { status: 404 }); - } - } - - }, - error(err) { - console.error("[server]", err); - return new Response("Internal Server Error", { status: 500 }); - }, - }); - console.log(`\n🚀 Samsung TV Proxy Server`); - console.log(`http://localhost:${PORT}`); - console.log(`Static: ${STATIC_DIR}`); - console.log(`Views: http://localhost:${PORT}/v/\n`); - // ── Graceful shutdown ── - async function shutdown() { - console.log("\n[shutdown] Closing…"); - // Close all active MJPEG streams - for (const [, stream] of activeStreams) { - stream.aborted = true; - if (stream.page) await stream.page.close().catch(() => { }); +async function serveStatic(path: string): Promise { + const filePath = path === "/" ? "/index.html" : path; + if (!filePath.startsWith("/") || filePath.includes("..")) return null; + try { + const file = Bun.file(STATIC_DIR + filePath); + await file.slice(0, 0).text(); + return new Response(file); + } catch { + try { + return new Response(Bun.file(STATIC_DIR + "/index.html")); + } catch { + return null; } - activeStreams.clear(); - // Close browser - if (browser) { - await browser.close().catch(() => { }); - browser = null; - } - server.stop(); - process.exit(0); } - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); +} + +// ═══════════════════════════════════════════════════════════════ +// SERVER +// ═══════════════════════════════════════════════════════════════ + +const server = Bun.serve<{ interval: number }>({ + port: CONFIG.PORT, + idleTimeout: (CONFIG.QUEUE_TIMEOUT_MS + 10_000) / 1000, // 40s — enough for worst-case queue wait + websocket: { + open(ws) { + const interval = ws.data?.interval ?? DEBUG_DEFAULT_INTERVAL_MS; + const timer = setInterval(() => { + try { + ws.send(JSON.stringify(getDebugData())); + } catch { + clearInterval(timer); + debugClients.delete(ws); + } + }, interval); + debugClients.set(ws, { interval, timer }); + // Send initial payload immediately + try { ws.send(JSON.stringify(getDebugData())); } catch {} + }, + message(ws, msg) { + try { + const data = JSON.parse(msg as string); + if (data.type === "debug-interval" && typeof data.interval === "number") { + const client = debugClients.get(ws); + if (!client) return; + const interval = Math.max(DEBUG_MIN_INTERVAL_MS, data.interval); + clearInterval(client.timer); + client.interval = interval; + client.timer = setInterval(() => { + try { ws.send(JSON.stringify(getDebugData())); } catch { + clearInterval(client.timer); + debugClients.delete(ws); + } + }, interval); + // Push immediately after rate change + try { ws.send(JSON.stringify(getDebugData())); } catch {} + } + } catch { /* ignore malformed messages */ } + }, + close(ws) { + const client = debugClients.get(ws); + if (client) { + clearInterval(client.timer); + debugClients.delete(ws); + } + }, + }, + + async fetch(req) { + const url = new URL(req.url); + const path = url.pathname; + + // ── WebSocket upgrade for debug panel ── + if (path === "/ws") { + const qInterval = parseInt(url.searchParams.get("interval") ?? ""); + const wsInterval = Math.max( + DEBUG_MIN_INTERVAL_MS, + Number.isFinite(qInterval) ? qInterval : DEBUG_DEFAULT_INTERVAL_MS, + ); + if (server.upgrade(req, { data: { interval: wsInterval } })) { + return; // upgraded — Bun will call websocket.open + } + return new Response("WebSocket upgrade failed", { status: 500 }); + } + + // View serving — /v/:id + const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/); + if (viewMatch) { + const view = viewCacheGet(viewMatch[1]); + const ip = server.requestIP(req)?.address ?? "unknown"; + if (!view) { + return new Response(errorPage("View not found"), { + status: 404, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + if (view.urls.length === 0) { + return new Response(errorPage("No URLs configured"), { + status: 500, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + deviceTrack( + ip, + view.id, + view.name, + req.headers.get("User-Agent") ?? "unknown", + ); + return serveView(view); + } + + // API + if (path.startsWith("/api/")) { + return handleAPI(req, path); + } + + // Health + if (path === "/health") { + return json({ + status: "ok", + uptime: process.uptime(), + views: viewCacheAll().length, + cacheEntries: renderCache.size, + activeRenders: pagePool?.activeRenders ?? 0, + queueLength: renderQueue.length, + }); + } + + // Static + const staticResponse = await serveStatic(path); + if (staticResponse) return staticResponse; + + return new Response("Not found", { status: 404 }); + }, + + error(err) { + console.error("[server]", err); + return new Response("Internal Server Error", { status: 500 }); + }, +}); + +// ═══════════════════════════════════════════════════════════════ +// INIT & SHUTDOWN +// ═══════════════════════════════════════════════════════════════ + +async function init() { + viewCacheInit(); + pagePool = new PagePool(); + await pagePool.init(); + + console.log(`\n🚀 Samsung TV Proxy Server`); + console.log(` http://localhost:${CONFIG.PORT}`); + console.log(` Views: http://localhost:${CONFIG.PORT}/v/`); + console.log(` Health: http://localhost:${CONFIG.PORT}/health`); + console.log(` Debug: http://localhost:${CONFIG.PORT}/api/debug`); + console.log(` Browsers: ${CONFIG.BROWSER_COUNT} × ${CONFIG.PAGES_PER_BROWSER} pages`); + console.log(` Cache TTL cleanup: ${CONFIG.CACHE_CLEANUP_INTERVAL_MS / 1000}s intervals\n`); +} + +async function shutdown() { + console.log("\n[shutdown] Closing…"); + + if (inFlightRenders.size > 0) { + console.log(`[shutdown] Waiting for ${inFlightRenders.size} in-flight render(s)…`); + await Promise.allSettled([...inFlightRenders.values()].map((r) => r.promise)); + } + + await pagePool.shutdown(); + server.stop(); + process.exit(0); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + +init(); diff --git a/views.db b/views.db deleted file mode 100644 index 7db43a8..0000000 Binary files a/views.db and /dev/null differ