This commit is contained in:
2026-05-12 23:10:10 +02:00
commit edbe3f9bf2
6 changed files with 1334 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules
+218
View File
@@ -0,0 +1,218 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "samsung-tv-server",
"dependencies": {
"puppeteer": "latest",
},
"devDependencies": {
"@types/bun": "latest",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@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=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="],
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="],
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
"bare-fs": ["bare-fs@4.7.1", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw=="],
"bare-os": ["bare-os@3.9.1", "", {}, "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ=="],
"bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="],
"bare-stream": ["bare-stream@2.13.1", "", { "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow=="],
"bare-url": ["bare-url@2.4.3", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ=="],
"basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
"devtools-protocol": ["devtools-protocol@0.0.1608973", "", {}, "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"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=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"puppeteer": ["puppeteer@24.43.1", "", { "dependencies": { "@puppeteer/browsers": "2.13.2", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1608973", "puppeteer-core": "24.43.1", "typed-query-selector": "^2.12.2" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw=="],
"puppeteer-core": ["puppeteer-core@24.43.1", "", { "dependencies": { "@puppeteer/browsers": "2.13.2", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1608973", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.20.0" } }, "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="],
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="],
"tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="],
"teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="],
"text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
"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=="],
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="],
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "samsung-tv-server",
"version": "1.0.0",
"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"
},
"dependencies": {
"puppeteer": "^24.43.1"
},
"devDependencies": {
"@types/bun": "^1.3.13"
}
}
+513
View File
@@ -0,0 +1,513 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Samsung TV Proxy</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f0f1a;
--surface: #1a1a2e;
--surface2: #25253e;
--border: #2a2a4a;
--text: #e0e0f0;
--muted: #8888aa;
--accent: #7c5ce7;
--accent2: #a78bfa;
--danger: #e94560;
--green: #2ecc71;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
}
/* ── Layout ── */
.app { max-width: 900px; margin: 0 auto; padding: 32px 16px; }
header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 32px; flex-wrap: wrap; gap: 12px;
}
header h1 { font-size: 24px; font-weight: 700; }
header h1 span { color: var(--accent2); }
/* ── Buttons ── */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 16px; border: none; border-radius: var(--radius);
font-size: 14px; font-weight: 600; cursor: pointer;
transition: all 0.15s; text-decoration: none; font-family: var(--font);
}
.btn:active { transform: scale(0.97); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: #6a4fd4; }
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--surface2); color: var(--text); }
.btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); }
.btn-danger:hover { background: var(--danger); color: #fff; }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.btn-icon { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; }
/* ── Cards / List ── */
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; margin-bottom: 12px;
transition: border-color 0.15s;
}
.card:hover { border-color: var(--accent); }
.card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.card-title { font-weight: 600; font-size: 16px; word-break: break-all; }
.card-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.badge {
font-size: 11px; font-weight: 700; text-transform: uppercase;
padding: 3px 8px; border-radius: 6px; letter-spacing: 0.5px;
}
.badge-mjpeg { background: rgba(124,92,231,0.15); color: var(--accent2); }
.badge-ssr { background: rgba(46,204,113,0.12); color: var(--green); }
.badge-refresh { background: rgba(255,193,7,0.12); color: #ffc107; }
.card-url-preview { font-size: 13px; color: var(--muted); margin-top: 8px; }
.card-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.url-copy-row {
display: flex; align-items: center; gap: 8px; margin-top: 10px;
background: var(--surface2); border-radius: 8px; padding: 8px 12px;
}
.url-copy-row code {
font-size: 12px; color: var(--accent2); word-break: break-all; flex: 1;
user-select: all;
}
/* ── Modal ── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
z-index: 100; padding: 16px;
}
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: 14px; padding: 28px; max-width: 620px; width: 100%;
max-height: 90vh; overflow-y: auto;
}
.modal h2 { font-size: 20px; margin-bottom: 24px; }
/* ── Form ── */
.field { margin-bottom: 20px; }
.field label {
display: block; font-size: 13px; font-weight: 600; color: var(--muted);
margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px;
}
.field input, .field select {
width: 100%; padding: 10px 12px; border-radius: 8px;
border: 1px solid var(--border); background: var(--surface2);
color: var(--text); font-size: 14px; font-family: var(--font);
outline: none; transition: border-color 0.15s;
}
.field input:focus, .field select:focus { border-color: var(--accent); }
.field select { cursor: pointer; }
.url-row {
display: flex; gap: 8px; align-items: center; margin-bottom: 10px;
flex-wrap: wrap;
}
.url-row input[type="url"] { flex: 3; min-width: 180px; }
.url-row input[type="number"] { flex: 1; min-width: 80px; }
.checkbox-row {
display: flex; align-items: center; gap: 10px; margin-bottom: 20px;
}
.checkbox-row input[type="checkbox"] {
width: 18px; height: 18px; cursor: pointer; accent-color: var(--accent);
}
.checkbox-row label { font-size: 14px; cursor: pointer; margin: 0; text-transform: none; letter-spacing: 0; color: var(--text); }
.hint { font-size: 12px; color: var(--muted); margin-top: 4px; }
.modal-footer { display: flex; gap: 10px; margin-top: 28px; justify-content: flex-end; }
/* ── Empty state ── */
.empty { text-align: center; padding: 60px 20px; color: var(--muted); }
.empty-icon { font-size: 48px; margin-bottom: 16px; }
.empty p { margin-bottom: 20px; }
/* ── Toast ── */
.toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 200; display: flex; flex-direction: column; gap: 8px; }
.toast {
padding: 12px 18px; border-radius: 10px; font-size: 13px; font-weight: 600;
animation: slideIn 0.25s ease; box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
.toast-success { background: var(--green); color: #000; }
.toast-error { background: var(--danger); color: #fff; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: none; opacity: 1; } }
/* ── Confirm dialog ── */
.confirm-text { font-size: 15px; margin-bottom: 24px; color: var(--muted); line-height: 1.6; }
.confirm-text strong { color: var(--text); }
</style>
</head>
<body>
<div class="app">
<header>
<h1>Samsung <span>TV Proxy</span></h1>
<button class="btn btn-primary" onclick="openCreateModal()">+ New View</button>
</header>
<div id="views-list"></div>
</div>
<!-- ── MODAL ── -->
<div id="modal-container"></div>
<!-- ── TOASTS ── -->
<div class="toast-container" id="toast-container"></div>
<script>
// ═══════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════
let views = [];
const { origin } = location;
// ═══════════════════════════════════════════════
// TOAST
// ═══════════════════════════════════════════════
function toast(msg, type="success") {
const el = document.getElementById("toast-container");
const t = document.createElement("div");
t.className = "toast toast-" + type;
t.textContent = msg;
el.appendChild(t);
setTimeout(() => { t.style.opacity = "0"; t.style.transition = "opacity 0.3s"; setTimeout(() => t.remove(), 300); }, 2500);
}
// ═══════════════════════════════════════════════
// API
// ═══════════════════════════════════════════════
async function api(method, path, body) {
const opts = { method, headers: { "Content-Type": "application/json" } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(origin + path, opts);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Request failed");
return data;
}
async function loadViews() {
try {
views = await api("GET", "/api/views");
renderViews();
} catch (err) {
toast(err.message, "error");
}
}
// ═══════════════════════════════════════════════
// RENDER VIEWS LIST
// ═══════════════════════════════════════════════
function renderViews() {
const container = document.getElementById("views-list");
if (!views.length) {
container.innerHTML = `
<div class="empty">
<div class="empty-icon">📺</div>
<p>No views yet. Create one to start serving<br>dynamic content to your Samsung TV.</p>
<button class="btn btn-primary" onclick="openCreateModal()">+ Create First View</button>
</div>`;
return;
}
container.innerHTML = views.map(v => {
const viewUrl = origin + "/v/" + v.id;
const urlCount = v.urls.length;
const urlPreview = v.urls.length
? (v.urls.length === 1
? v.urls[0].url
: v.urls.map((u,i) => `${u.url} (${u.durationSec}s)`).join(" → "))
: "(no URLs)";
const badges = [
`<span class="badge badge-${v.method}">${v.method.toUpperCase()}</span>`,
v.method === "ssr" && v.metaRefreshEnabled
? '<span class="badge badge-refresh">META-REFRESH</span>'
: "",
].filter(Boolean).join(" ");
return `
<div class="card">
<div class="card-header">
<span class="card-title">${esc(v.name)}</span>
<div style="display:flex;gap:6px;">
<button class="btn btn-icon btn-ghost btn-sm" onclick="openEditModal('${v.id}')" title="Edit">✏️</button>
<button class="btn btn-icon btn-danger btn-sm" onclick="confirmDelete('${v.id}','${esc(v.name)}')" title="Delete">🗑</button>
</div>
</div>
<div class="card-badges">${badges}</div>
<div class="card-url-preview">${esc(urlPreview)}</div>
<div class="url-copy-row">
<code>${viewUrl}</code>
<button class="btn btn-sm btn-primary" onclick="copyUrl('${viewUrl}'); toast('Copied!')">Copy</button>
</div>
</div>`;
}).join("");
}
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
function copyUrl(url) {
navigator.clipboard.writeText(url).catch(() => {});
}
// ═══════════════════════════════════════════════
// MODAL SYSTEM
// ═══════════════════════════════════════════════
function openModal(html) {
document.getElementById("modal-container").innerHTML = `
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal">${html}</div>
</div>`;
document.body.style.overflow = "hidden";
}
function closeModal() {
document.getElementById("modal-container").innerHTML = "";
document.body.style.overflow = "";
}
// ── Create Modal ──
function openCreateModal() {
openModal(buildEditorHTML(null));
}
// ── Edit Modal ──
function openEditModal(id) {
const v = views.find(v => v.id === id);
if (!v) return;
openModal(buildEditorHTML(v));
}
// ── Delete Confirm ──
function confirmDelete(id, name) {
openModal(`
<h2>Delete View</h2>
<div class="confirm-text">
Remove <strong>${name}</strong>?<br>
This action cannot be undone. The URL <code style="background:var(--surface2);padding:2px 6px;border-radius:4px;">/v/${id}</code> will stop working.
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-danger" onclick="doDelete('${id}')">Delete</button>
</div>`);
}
async function doDelete(id) {
try {
await api("DELETE", "/api/views/" + id);
closeModal();
toast("View deleted");
loadViews();
} catch (err) {
toast(err.message, "error");
}
}
// ═══════════════════════════════════════════════
// EDITOR HTML BUILDER
// ═══════════════════════════════════════════════
function buildEditorHTML(existing) {
const isEdit = !!existing;
const title = isEdit ? "Edit View" : "Create New View";
const idAttr = isEdit ? ` data-id="${existing.id}"` : "";
// Build URL rows HTML
let urlRowsHTML = "";
const urls = existing ? existing.urls : [{ url: "", durationSec: 30 }];
urls.forEach((u, i) => {
urlRowsHTML += buildUrlRowHTML(u.url, u.durationSec, i, urls.length);
});
const method = existing ? existing.method : "ssr";
const metaChecked = existing ? existing.metaRefreshEnabled : false;
const metaSectionStyle = method === "ssr" ? "" : "display:none;";
return `
<h2>${title}</h2>
<form id="view-form" onsubmit="handleSave(event)"${idAttr}>
<div class="field">
<label>View Name</label>
<input type="text" id="view-name" placeholder="e.g. Office Dashboard" value="${existing ? esc(existing.name) : ""}" required autofocus>
</div>
<div class="field">
<label>URLs & Durations</label>
<div id="url-rows">${urlRowsHTML}</div>
<button type="button" class="btn btn-ghost btn-sm" onclick="addUrlRow()" style="margin-top:8px;">+ Add URL</button>
<div class="hint">Each URL plays for its configured duration. Single URL = static display.</div>
</div>
<div class="field">
<label>Serving Method</label>
<select id="view-method" onchange="onMethodChange()">
<option value="ssr" ${method === "ssr" ? "selected" : ""}>SSR (Server-Side Render)</option>
<option value="mjpeg" ${method === "mjpeg" ? "selected" : ""}>MJPEG Stream (screenshot frames)</option>
</select>
</div>
<div id="meta-section" style="${metaSectionStyle}">
<div class="checkbox-row">
<input type="checkbox" id="meta-refresh" ${metaChecked ? "checked" : ""}>
<label for="meta-refresh">Inject &lt;meta http-equiv="refresh"&gt; tag</label>
</div>
<div class="hint" style="margin-top:-8px;margin-bottom:16px;">TV auto-reloads page when current URL's duration expires. Essential for multi-URL rotation on non-JS browsers.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">${isEdit ? "Save Changes" : "Create View"}</button>
</div>
</form>`;
}
function buildUrlRowHTML(url, durationSec, index, total) {
const isSingle = total <= 1;
const disabledAttr = isSingle ? "disabled" : "";
const grayClass = isSingle ? 'style="opacity:0.4;"' : "";
return `
<div class="url-row" data-index="${index}">
<input type="url" placeholder="https://example.com/slideshow" value="${esc(url || "")}" class="url-input" required>
<input type="number" placeholder="Seconds" value="${durationSec || 30}" min="1" max="86400" class="duration-input" ${disabledAttr} ${grayClass} required>
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeUrlRow(this)" ${total <= 1 ? "disabled" : ""}>✕</button>
</div>`;
}
// ═══════════════════════════════════════════════
// EDITOR INTERACTIONS
// ═══════════════════════════════════════════════
function addUrlRow() {
const container = document.getElementById("url-rows");
const rows = container.querySelectorAll(".url-row");
const newIdx = rows.length;
const div = document.createElement("div");
div.innerHTML = buildUrlRowHTML("", 30, newIdx, rows.length + 1);
container.appendChild(div.firstElementChild);
updateUrlRowStates();
}
function removeUrlRow(btn) {
const row = btn.closest(".url-row");
row.remove();
updateUrlRowStates();
}
function updateUrlRowStates() {
const container = document.getElementById("url-rows");
const rows = container.querySelectorAll(".url-row");
const total = rows.length;
rows.forEach((row, i) => {
row.setAttribute("data-index", i);
const durInput = row.querySelector(".duration-input");
const delBtn = row.querySelector(".btn-danger");
if (total <= 1) {
durInput.disabled = true;
durInput.style.opacity = "0.4";
delBtn.disabled = true;
} else {
durInput.disabled = false;
durInput.style.opacity = "";
delBtn.disabled = false;
}
});
}
function onMethodChange() {
const method = document.getElementById("view-method").value;
const metaSection = document.getElementById("meta-section");
metaSection.style.display = method === "ssr" ? "" : "none";
}
// ═══════════════════════════════════════════════
// SAVE
// ═══════════════════════════════════════════════
async function handleSave(e) {
e.preventDefault();
const form = document.getElementById("view-form");
const isEdit = form.hasAttribute("data-id");
const viewId = form.getAttribute("data-id");
const name = document.getElementById("view-name").value.trim();
const method = document.getElementById("view-method").value;
const metaRefreshEnabled = document.getElementById("meta-refresh").checked;
// Collect URLs
const rows = document.querySelectorAll("#url-rows .url-row");
const urls = [];
for (const row of rows) {
const urlInput = row.querySelector(".url-input");
const durInput = row.querySelector(".duration-input");
const urlVal = urlInput.value.trim();
if (!urlVal) continue;
urls.push({
url: urlVal,
durationSec: parseInt(durInput.value) || 30,
});
}
if (!name) { toast("Name is required", "error"); return; }
if (urls.length === 0) { toast("At least one URL is required", "error"); return; }
const body = { name, urls, method };
if (method === "ssr") body.metaRefreshEnabled = metaRefreshEnabled;
try {
if (isEdit) {
await api("PUT", "/api/views/" + viewId, body);
toast("View updated!");
} else {
const created = await api("POST", "/api/views", body);
toast("View created!");
}
closeModal();
loadViews();
} catch (err) {
toast(err.message, "error");
}
}
// ═══════════════════════════════════════════════
// KEYBOARD
// ═══════════════════════════════════════════════
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeModal();
});
// ═══════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════
loadViews();
</script>
</body>
</html>
+587
View File
@@ -0,0 +1,587 @@
import { Database } from "bun:sqlite";
import type { Browser, Page } from "puppeteer";
import puppeteer from "puppeteer";
// ═══════════════════════════════════════════════════════════════
// TYPES
// ═══════════════════════════════════════════════════════════════
interface URLItem {
url: string;
durationSec: number;
}
interface View {
id: string;
name: string;
urls: URLItem[];
method: "mjpeg" | "ssr";
metaRefreshEnabled: boolean;
createdAt: number;
}
interface ViewRow {
id: string;
name: string;
urls: string; // JSON text
method: string;
meta_refresh_enabled: number;
created_at: number;
}
interface CreateViewBody {
name: string;
urls: URLItem[];
method: "mjpeg" | "ssr";
metaRefreshEnabled?: boolean;
}
interface UpdateViewBody {
name?: string;
urls?: URLItem[];
method?: "mjpeg" | "ssr";
metaRefreshEnabled?: boolean;
}
// ═══════════════════════════════════════════════════════════════
// 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()) )`,
);
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,
};
}
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 loadAllViews(): View[] {
const rows = db
.query("SELECT * FROM views ORDER BY created_at DESC")
.all() as ViewRow[];
return rows.map(rowToView);
}
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 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 deleteViewById(id: string): boolean {
const result = db.run("DELETE FROM views WHERE id = ?", [id]);
return result.changes > 0;
}
// ═══════════════════════════════════════════════════════════════
// BROWSER MANAGER
// ═══════════════════════════════════════════════════════════════
let browser: Browser | null = null;
async function getBrowser(): Promise<Browser> {
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;
}
async function createPage(): Promise<Page> {
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;
}
// ═══════════════════════════════════════════════════════════════
// 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;
}
/**
* 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;
}
// ═══════════════════════════════════════════════════════════════
// SSR RENDERING
// ═══════════════════════════════════════════════════════════════
function injectMetaRefresh(html: string, intervalSec: number): string {
// Use integer seconds for meta refresh
const sec = Math.max(1, Math.ceil(intervalSec));
const tag = `<meta http-equiv="refresh" content="${sec}">`;
if (html.includes("</head>")) {
return html.replace("</head>", `${tag}\n</head>`);
}
if (/<html[^>]*>/i.test(html)) {
return html.replace(/(<html[^>]*>)/i, `$1\n<head>${tag}</head>`);
}
return `<html><head>${tag}</head><body>${html}</body></html>`;
}
function stripScriptTags(html: string): string {
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "")
.replace(/\s+on\w+\s*=\s*'[^']*'/gi, "");
}
async function renderSSR(url: string): Promise<string> {
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
`;
}
// ═══════════════════════════════════════════════════════════════
// MJPEG STREAMING
// ═══════════════════════════════════════════════════════════════
interface ActiveStream {
aborted: boolean;
page: Page | null;
}
const activeStreams = new Map<string, ActiveStream>();
async function serveMJPEG(view: View): Promise<Response> {
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
// ═══════════════════════════════════════════════════════════════
async function serveView(view: View): Promise<Response> {
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",
},
});
}
// ═══════════════════════════════════════════════════════════════
// HTTP SERVER
// ═══════════════════════════════════════════════════════════════
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<string> {
return req.text();
}
async function handleAPI(req: Request, path: string): Promise<Response> {
const method = req.method;
// GET /api/views — list all
if (path === "/api/views" && method === "GET") {
return json(loadAllViews());
}
// POST /api/views — create
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);
}
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);
}
const view: View = {
id: crypto.randomUUID(),
name: body.name.trim(),
urls: body.urls.map((u) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
})),
method: body.method,
metaRefreshEnabled: body.metaRefreshEnabled ?? false,
createdAt: Math.floor(Date.now() / 1000),
};
insertView(view);
console.log(`[api] Created view "${view.name}" (${view.id}) method=${view.method}`);
return json(view, 201);
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// Match /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);
if (!view) return json({ error: "View not found" }, 404);
return json(view);
}
// PUT /api/views/:id
if (method === "PUT") {
const view = loadView(viewId);
if (!view) return json({ error: "View not found" }, 404);
try {
const body: UpdateViewBody = JSON.parse(await readBody(req));
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);
}
view.urls = body.urls.map((u) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
}));
}
if (body.method !== undefined) {
if (!["mjpeg", "ssr"].includes(body.method)) {
return json({ error: "method must be 'mjpeg' or 'ssr'" }, 400);
}
view.method = body.method;
}
if (body.metaRefreshEnabled !== undefined) {
view.metaRefreshEnabled = body.metaRefreshEnabled;
}
updateView(view);
console.log(`[api] Updated view "${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}`);
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);
}
// 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);
}
// 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/<view-id>\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(() => { });
}
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);
BIN
View File
Binary file not shown.