From ffab4949f334f5a7bc699bfba8592097e8fa5787 Mon Sep 17 00:00:00 2001 From: 4DBug <4DBug@github.com> Date: Wed, 26 Aug 2026 14:34:20 -0500 Subject: [PATCH] push --- .gitignore | 2 +- cloudflare/worker.mjs | 185 ---------------------------------- flake.lock | 27 +++++ flake.nix | 225 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 248 insertions(+), 191 deletions(-) delete mode 100644 cloudflare/worker.mjs create mode 100644 flake.lock diff --git a/.gitignore b/.gitignore index 3c3629e..c2658d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -node_modules +node_modules/ diff --git a/cloudflare/worker.mjs b/cloudflare/worker.mjs deleted file mode 100644 index 36cdb69..0000000 --- a/cloudflare/worker.mjs +++ /dev/null @@ -1,185 +0,0 @@ -const DISALLOWED = new Set([ - 'host', - 'connection', - 'content-length', - 'transfer-encoding', - 'upgrade', - 'keep-alive', - 'proxy-authorization', - 'proxy-authentication', - 'te', - 'trailer', - 'via', - 'warning', - 'expect', - 'date', - 'dnt', - 'accept-encoding', -]); - -function corsHeaders(extra = {}) { - return new Headers({ - 'access-control-allow-origin': '*', - 'cache-control': 'no-store', - ...extra, - }); -} - -function jsonResponse(status, obj) { - return new Response(JSON.stringify(obj), { - status, - headers: corsHeaders({ 'content-type': 'application/json; charset=utf-8' }), - }); -} - -function parseProxyHeaders(raw) { - if (!raw) return {}; - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - return {}; - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; - const out = {}; - for (const [name, value] of Object.entries(parsed)) { - if (typeof value !== 'string' || !value) continue; - if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) continue; - if (DISALLOWED.has(name.toLowerCase())) continue; - out[name] = value; - } - return out; -} - -function proxyTarget(target, headers, token) { - const query = new URLSearchParams({ - url: target, - h: JSON.stringify(headers || {}), - }); - if (token) query.set('token', token); - return `media?${query.toString()}`; -} - -function rewriteM3u8(text, baseUrl, headers, token) { - return text - .split(/\r?\n/) - .map((line) => { - const trimmed = line.trim(); - if (!trimmed) return line; - if (trimmed.startsWith('#')) { - return line.replace(/(URI\s*=\s*)(?:"([^"]*)"|'([^']*)')/g, (match, prefix, double, single) => { - const uri = double !== undefined ? double : single; - try { - return `${prefix}"${proxyTarget(new URL(uri, baseUrl).toString(), headers, token)}"`; - } catch { - return match; - } - }); - } - try { - return proxyTarget(new URL(trimmed, baseUrl).toString(), headers, token); - } catch { - return line; - } - }) - .join('\n'); -} - -async function proxy(request, url, env) { - const target = (url.searchParams.get('url') || '').trim(); - const headers = parseProxyHeaders(url.searchParams.get('h')); - const token = url.searchParams.get('token') || ''; - - let parsed; - try { - parsed = new URL(target); - } catch { - return jsonResponse(400, { error: 'missing or invalid ?url=' }); - } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return jsonResponse(400, { error: 'proxy only supports http/https' }); - } - - const upstreamHeaders = new Headers(); - for (const [name, value] of Object.entries(headers)) { - upstreamHeaders.set(name, value); - } - const range = request.headers.get('range'); - if (range) upstreamHeaders.set('range', range); - - let upstream; - try { - upstream = await fetch(parsed.toString(), { - headers: upstreamHeaders, - redirect: 'follow', - }); - } catch (e) { - return jsonResponse(502, { error: `proxy fetch failed: ${e.message}` }); - } - - const contentType = upstream.headers.get('content-type') || ''; - const looksLikeM3u8 = /mpegurl/i.test(contentType) || /\.m3u8(?:[?#].*)?$/i.test(parsed.pathname + parsed.search); - - if (looksLikeM3u8 && upstream.status < 400) { - try { - const text = await upstream.text(); - if (/mpegurl/i.test(contentType) || text.trimStart().startsWith('#EXTM3U')) { - const body = rewriteM3u8(text, parsed.toString(), headers, token); - return new Response(body, { - status: upstream.status, - headers: corsHeaders({ 'content-type': 'application/vnd.apple.mpegurl; charset=utf-8' }), - }); - } - return new Response(text, { - status: upstream.status, - headers: corsHeaders({ 'content-type': contentType || 'text/plain; charset=utf-8' }), - }); - } catch (e) { - return jsonResponse(502, { error: `proxy playlist failed: ${e.message}` }); - } - } - - const out = corsHeaders(); - for (const name of ['content-type', 'content-range', 'accept-ranges']) { - const value = upstream.headers.get(name); - if (value) out.set(name, value); - } - if (upstream.status === 206) { - const value = upstream.headers.get('content-length'); - if (value) out.set('content-length', value); - } - - return new Response(upstream.body || null, { - status: upstream.status, - headers: out, - }); -} - -export default { - async fetch(request, env) { - const url = new URL(request.url); - - if (request.method === 'OPTIONS') { - return new Response(null, { - status: 204, - headers: corsHeaders({ - 'access-control-allow-methods': 'GET, OPTIONS', - 'access-control-allow-headers': '*', - }), - }); - } - - if (request.method !== 'GET') { - return jsonResponse(405, { error: 'method not allowed, use GET' }); - } - - if (env.PROXY_TOKEN) { - const token = url.searchParams.get('token') || request.headers.get('x-proxy-token') || ''; - if (token !== env.PROXY_TOKEN) { - return jsonResponse(403, { error: 'unauthorized' }); - } - } - - return await proxy(request, url, env); - }, -}; \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3ffd6b9 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1787631388, + "narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "ac6b2166e7a9375683b8e98f860f273222337b16", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index 692246e..e63387f 100644 --- a/flake.nix +++ b/flake.nix @@ -1,9 +1,224 @@ { - inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; - }; + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; - outputs = { nixpkgs, ... }: { + outputs = { nixpkgs, ... }: + let + lib = nixpkgs.lib; - }; + systems = [ "x86_64-linux" "aarch64-linux" ]; + pkgsFor = system: nixpkgs.legacyPackages.${system}; + + appFor = pkgs: + let + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./rest + ./mod + ./providers + ./package.json + ./package-lock.json + ]; + }; + + npmDeps = pkgs.fetchNpmDeps { + name = "streamreverse-npm-deps"; + inherit src; + hash = ""; + }; + in + pkgs.stdenvNoCC.mkDerivation { + pname = "streamreverse"; + version = "0.1.0"; + + inherit src npmDeps; + + dontConfigure = true; + dontBuild = true; + + installPhase = '' + mkdir -p $out/lib/bug-tv $out/bin + cp -r rest mod providers package.json $out/lib/bug-tv/ + ln -s ${npmDeps} $out/lib/bug-tv/node_modules + printf '%s\n' \ + '#!${pkgs.bash}/bin/bash' \ + 'exec ${pkgs.nodejs}/bin/node "$out/lib/bug-tv/rest/server.js" "$@"' \ + > $out/bin/bug-tv + chmod 755 $out/bin/bug-tv + ''; + }; + in + { + packages = lib.genAttrs systems (system: { + default = appFor (pkgsFor system); + }); + + devShells = lib.genAttrs systems (system: { + default = + let pkgs = pkgsFor system; + in pkgs.mkShell { + packages = [ pkgs.nodejs pkgs.nodejs-npm ]; + }; + }); + + nixosModules.default = + { config, lib, pkgs, ... }: + let + cfg = config.services."bug-tv"; + + baseFilters = { + minQuality = cfg.filters.minQuality; + audioCodec = cfg.filters.audioCodec; + subtitles = cfg.filters.subtitles; + subtitleLang = cfg.filters.subtitleLang; + }; + + baseConfig = { + tmdbKey = cfg.tmdbApiKey; + enabledProviders = cfg.providers; + timeoutMs = cfg.timeoutMs; + apiHost = cfg.host; + apiPort = cfg.port; + filters = baseFilters; + }; + + mergedConfig = + (baseConfig // (lib.removeAttrs cfg.extraConfig [ "filters" ])) + // lib.optionalAttrs (cfg.extraConfig ? "filters") { filters = baseFilters // cfg.extraConfig.filters; }; + + configJson = pkgs.writeText "bug-tv-config.json" (builtins.toJSON mergedConfig); + + app = pkgs.runCommand "bug-tv" { preferLocalBuild = true; } '' + cp -a ${cfg.package}/lib/bug-tv $out + cp ${configJson} $out/config.json + mkdir -p $out/bin + printf '%s\n' \ + '#!${pkgs.bash}/bin/bash' \ + 'exec ${pkgs.nodejs}/bin/node "$out/rest/server.js" "$@"' \ + > $out/bin/bug-tv + chmod 755 $out/bin/bug-tv + ''; + in + { + options.services."bug-tv" = lib.mkOption { + default = { }; + type = lib.types.submodule { + options = { + enable = lib.mkEnableOption "the bug.tv multi-provider stream finder API"; + + package = lib.mkOption { + description = "The streamreverse package to run."; + type = lib.types.package; + }; + + port = lib.mkOption { + description = "TCP port to listen on."; + type = lib.types.port; + default = 8789; + }; + + host = lib.mkOption { + description = "Interface to bind."; + type = lib.types.str; + default = "0.0.0.0"; + }; + + tmdbApiKey = lib.mkOption { + description = "TMDB API key used for search and details lookups."; + type = lib.types.str; + default = ""; + }; + + providers = lib.mkOption { + description = "Providers enabled by default when none are requested."; + type = lib.types.listOf lib.types.str; + default = [ "cineby" ]; + }; + + timeoutMs = lib.mkOption { + description = "Per-provider scrape timeout in milliseconds."; + type = lib.types.int; + default = 30000; + }; + + filters = lib.mkOption { + description = "Default stream filters."; + default = { }; + type = lib.types.submodule { + options = { + minQuality = lib.mkOption { + description = "Minimum quality (e.g. 1080p, or any)."; + type = lib.types.str; + default = "any"; + }; + audioCodec = lib.mkOption { + description = "Required audio codec, or any."; + type = lib.types.str; + default = "any"; + }; + subtitles = lib.mkOption { + description = "Subtitle requirement (any/required/none)."; + type = lib.types.str; + default = "any"; + }; + subtitleLang = lib.mkOption { + description = "Required subtitle language, empty for any."; + type = lib.types.str; + default = ""; + }; + }; + }; + }; + + extraConfig = lib.mkOption { + description = "Extra keys merged into the generated config.json."; + type = lib.types.attrsOf lib.types.json; + default = { }; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.tmdbApiKey != ""; + message = "services.bug-tv.tmdbApiKey must be set"; + } + { + assertion = cfg.providers != [ ]; + message = "services.bug-tv.providers must not be empty"; + } + ]; + + users.users."bug-tv" = { + isSystemUser = true; + group = "bug-tv"; + }; + + users.groups."bug-tv" = { }; + + systemd.services."bug-tv" = { + description = "bug.tv stream finder API"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + serviceConfig = { + User = "bug-tv"; + Group = "bug-tv"; + ExecStart = "${app}/bin/bug-tv"; + Restart = "always"; + RestartSec = 5; + + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + LockPersonality = true; + }; + }; + }; + }; + }; }