From 5f85d611c92ca70a28cd3c79050432ce1b397064 Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 12 Sep 2026 12:04:28 +0100 Subject: [PATCH] owncast: relay Twitch/YouTube for synced watch parties Co-Authored-By: Claude Opus 5 --- common.nix | 1 + services/nginx.nix | 16 +++++++++++ services/owncast.nix | 65 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 services/owncast.nix diff --git a/common.nix b/common.nix index fd21958..2e814e6 100644 --- a/common.nix +++ b/common.nix @@ -126,6 +126,7 @@ in ./services/hardware-health.nix ./services/wan-watchdog.nix ./services/valheim-autoupdate.nix + ./services/owncast.nix ./services/prebuild.nix ]; diff --git a/services/nginx.nix b/services/nginx.nix index 554913f..866356f 100644 --- a/services/nginx.nix +++ b/services/nginx.nix @@ -106,6 +106,22 @@ in "jellyfin.nordhammer.it" = proxy 8096; # streaming to external clients "seerr.nordhammer.it" = proxy 5055; # own auth via Jellyfin sign-in + # Watch-party relay (services/owncast.nix). Shared basic-auth password + # rather than Authelia: the guests have no accounts here, and Owncast + # has no viewer auth of its own. The chat websocket is exempt because + # browsers don't replay cached basic-auth credentials on a WS + # handshake — /ws would 401 forever and chat would silently never + # connect. Video stays gated; only the chat socket is reachable by + # someone who already knows the hostname. + "watch.nordhammer.it" = lib.recursiveUpdate (proxy 8088) { + basicAuth = { blizzcon = "Blizzcon"; }; + locations."/ws" = { + proxyPass = "http://127.0.0.1:8088"; + proxyWebsockets = true; + extraConfig = "auth_basic off;"; + }; + }; + # wings daemon (see services/pelican.nix). Deliberately NOT behind # Authelia: the panel container calls this server-to-server and has no # session cookie to present. wings authenticates every request with its diff --git a/services/owncast.nix b/services/owncast.nix new file mode 100644 index 0000000..fad5b90 --- /dev/null +++ b/services/owncast.nix @@ -0,0 +1,65 @@ +# services/owncast.nix — Owncast relay for synced watch parties. +# +# Why relay instead of everyone just opening Twitch: embed-sync tools only sync +# play/pause/seek commands, not buffers. Each viewer's Twitch player picks its +# own live-edge offset, so a group drifts 5-15s apart and no amount of "seek to +# now" fixes it. One shared HLS playlist removes the drift by construction — +# everybody pulls the same segment numbers — at the cost of ~15-25s of extra +# absolute latency. That tradeoff is the whole point; without it this is just +# watching on the website. +# +# Private by design: Owncast has no viewer auth of its own (upstream #489/#4344 +# are still open), so nginx does it with a shared password in services/nginx.nix. +{ config, lib, pkgs, ... }: +let + port = 8088; + + # Party password, also the Owncast admin password and RTMP stream key. Not a + # secret in the /var/secrets sense — it gets read out loud in Discord and it + # only guards a living-room stream — so plain text in the store is fine here. + # Anything that actually matters still goes through Authelia or runtime + # injection. + password = "Blizzcon"; +in +{ + config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { + + services.owncast = { + enable = true; + inherit port; + listen = "127.0.0.1"; # nginx fronts it; RTMP is localhost-only too + }; + + # The module hardcodes its ExecStart with no escape hatch, so restate it. + # Without -adminpassword the admin panel sits on the documented default + # abc123, behind nothing but the same password the guests were given. + systemd.services.owncast.serviceConfig.ExecStart = lib.mkForce ( + lib.concatStringsSep " " [ + "${pkgs.owncast}/bin/owncast" + "-webserverport ${toString port}" + "-rtmpport 1935" + "-webserverip 127.0.0.1" + "-adminpassword ${password}" + "-streamkey ${password}" + ] + ); + + # relay [quality] — pull a live stream and push it into Owncast. + # Run it when the stream starts, Ctrl-C when it ends. Deliberately not a + # unit: there's nothing to supervise, the channel changes every time, and + # a systemd service would need an EnvironmentFile edit + restart per event. + environment.systemPackages = [ + (pkgs.writeShellScriptBin "relay" '' + set -euo pipefail + url=''${1:?usage: relay [quality]} + # streamlink drops Twitch pre/mid-roll ads on its own (mandatory since + # 7.5.0), but that leaves a timestamp discontinuity where the ad was. + # -fflags +genpts rebuilds PTS across it so the copy doesn't stall on + # non-monotonic DTS. + ${pkgs.streamlink}/bin/streamlink --stdout "$url" "''${2:-best}" \ + | ${pkgs.ffmpeg}/bin/ffmpeg -hide_banner -fflags +genpts -i pipe:0 \ + -c copy -f flv rtmp://127.0.0.1:1935/live/${password} + '') + ]; + }; +}