owncast: relay Twitch/YouTube for synced watch parties

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-09-12 12:04:28 +01:00
parent 843d0d12c1
commit 5f85d611c9
3 changed files with 82 additions and 0 deletions

View file

@ -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

65
services/owncast.nix Normal file
View file

@ -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 <url> [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 <twitch or youtube url> [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}
'')
];
};
}