commit edbe3f9bf2d30de16ee6fe66e98b8e45b580dba1 Author: Igor Barcik Date: Tue May 12 23:10:10 2026 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..0fe8e53 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..58b56c8 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..818a436 --- /dev/null +++ b/public/index.html @@ -0,0 +1,513 @@ + + + + + +Samsung TV Proxy + + + + +
+
+

Samsung TV Proxy

+ +
+ +
+
+ + + + + +
+ + + + + diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..3007eed --- /dev/null +++ b/src/server.ts @@ -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 { + 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 { + 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 = ``; + 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, ""); +} +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 +`; +} +// ═══════════════════════════════════════════════════════════════ +// MJPEG STREAMING +// ═══════════════════════════════════════════════════════════════ +interface ActiveStream { + aborted: boolean; + page: Page | null; +} +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 +// ═══════════════════════════════════════════════════════════════ +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", + }, + }); +} +// ═══════════════════════════════════════════════════════════════ +// 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 { + return req.text(); +} +async function handleAPI(req: Request, path: string): Promise { + 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/\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); diff --git a/views.db b/views.db new file mode 100644 index 0000000..7db43a8 Binary files /dev/null and b/views.db differ