Capture Bristol Rovers video pass streams for personal time-shifting
Verified end to end against a live match: 1080p H.264/AAC MPEG-TS at ~5.2 Mbit/s, recorded while the browser played the same stream. Components: - tampermonkey sniffer hooks fetch/XHR at document-start, captures the manifest URL verbatim and POSTs it to the local catcher. v0.4 also traces the API chain to find which call mints the token. - catcher receives the URL and launches yt-dlp. Dedupes on the Kaltura asset id and checks downloader liveness, so a mid-match page reload is both safe and the recovery path for a dead downloader. - creds stores secrets in the OS vault rather than a gitignored file, and refuses to run on a backend that does not encrypt. Protocol notes are in the README: the stream uses a two-stage token chain (a ~25s hdnts token for the master playlist, a ~24h hdntl token for media), concurrency is unrestricted, and the live window is ~30s with no DVR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kyt7QHQvLJtQRuiY4wAqKB
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Credential storage for unattended recording.
|
||||
|
||||
Secrets live in the Windows Credential Manager (DPAPI, keyed to your Windows
|
||||
account), not in a file. Nothing here can be committed by accident because
|
||||
nothing here is written to the repo.
|
||||
|
||||
python creds.py --set-login store email + password (prompts)
|
||||
python creds.py --show show what is stored (never the password)
|
||||
python creds.py --clear remove everything
|
||||
|
||||
What this does and does not protect against:
|
||||
|
||||
DOES -- plaintext at rest, and the classic "gitignored file gets committed
|
||||
after a git add -A" accident.
|
||||
DOES -- readable by another Windows user on this machine.
|
||||
NOT -- a process already running as you. Credential Manager unlocks with
|
||||
your login session, so anything running as you can read it. That is
|
||||
inherent to unattended operation: a job that logs in without you
|
||||
present must be able to reach the secret without you present.
|
||||
|
||||
Caveat for Task Scheduler: "Run whether user is logged on or not" runs in a
|
||||
session where DPAPI may not be able to decrypt these entries. Prefer "Run only
|
||||
when user is logged on" for any task that needs credentials.
|
||||
|
||||
PLATFORM NOTE -- this module is only safe where a real vault backend exists:
|
||||
|
||||
Windows WinVaultKeyring (Credential Manager, DPAPI). Good.
|
||||
Linux+GUI SecretService (GNOME Keyring / KWallet). Good.
|
||||
Headless No D-Bus session, no unlocked keyring. `keyring` NOT SAFE.
|
||||
Linux falls back to a fail or plaintext backend.
|
||||
|
||||
On a headless Pi or server, do NOT use this module. Use systemd-creds, which
|
||||
encrypts to the host/TPM key and is decrypted only for the service:
|
||||
|
||||
systemd-creds encrypt --name=brfc-pass - /etc/credstore.encrypted/brfc-pass
|
||||
# then in the unit: LoadCredential=brfc-pass
|
||||
# and read it from: $CREDENTIALS_DIRECTORY/brfc-pass
|
||||
|
||||
Run `python creds.py --check` on each host before trusting it with a secret.
|
||||
"""
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
try:
|
||||
import keyring
|
||||
except ImportError:
|
||||
sys.exit("keyring not installed. Run: pip install keyring")
|
||||
|
||||
SERVICE = "brfc-stream-pirate"
|
||||
|
||||
# Keys held in the vault.
|
||||
K_USER = "username"
|
||||
K_PASS = "password"
|
||||
K_COOKIE = "session-cookie" # cached login session, refreshed periodically
|
||||
|
||||
|
||||
def get(key):
|
||||
return keyring.get_password(SERVICE, key)
|
||||
|
||||
|
||||
def put(key, value):
|
||||
keyring.set_password(SERVICE, key, value)
|
||||
|
||||
|
||||
def drop(key):
|
||||
try:
|
||||
keyring.delete_password(SERVICE, key)
|
||||
except keyring.errors.PasswordDeleteError:
|
||||
pass # already absent
|
||||
|
||||
|
||||
def load_login():
|
||||
"""Return (username, password), or None if not configured yet."""
|
||||
user, pw = get(K_USER), get(K_PASS)
|
||||
return (user, pw) if user and pw else None
|
||||
|
||||
|
||||
def load_cookie():
|
||||
"""Cached session cookie, or None. Callers must handle it being stale."""
|
||||
return get(K_COOKIE)
|
||||
|
||||
|
||||
def save_cookie(value):
|
||||
put(K_COOKIE, value)
|
||||
|
||||
|
||||
# Backends that do not actually protect anything. `keyring` selects these
|
||||
# silently on a headless box, so a secret stored there is effectively plaintext.
|
||||
UNSAFE_BACKENDS = {"PlaintextKeyring", "Keyring", "fail.Keyring", "EnvironCredential"}
|
||||
|
||||
|
||||
def backend():
|
||||
return keyring.get_keyring().__class__.__name__
|
||||
|
||||
|
||||
def backend_is_safe():
|
||||
name = backend()
|
||||
return name not in UNSAFE_BACKENDS and "fail" not in name.lower()
|
||||
|
||||
|
||||
def _mask(s):
|
||||
if not s:
|
||||
return "(not set)"
|
||||
return s[:2] + "*" * max(0, len(s) - 4) + s[-2:] if len(s) > 4 else "*" * len(s)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
g = ap.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--set-login", action="store_true", help="store email + password")
|
||||
g.add_argument("--show", action="store_true", help="show stored username")
|
||||
g.add_argument("--clear", action="store_true", help="remove all stored secrets")
|
||||
g.add_argument("--check", action="store_true",
|
||||
help="report whether this host has a real vault backend")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.check:
|
||||
name = backend()
|
||||
safe = backend_is_safe()
|
||||
print(f"backend : {name}")
|
||||
print(f"verdict : {'SAFE' if safe else 'NOT SAFE -- do not store secrets here'}")
|
||||
if not safe:
|
||||
print(" use systemd-creds on this host (see module docstring)")
|
||||
sys.exit(0 if safe else 1)
|
||||
|
||||
if not backend_is_safe():
|
||||
sys.exit(f"refusing to use backend {backend()!r}: it does not encrypt. "
|
||||
"Run 'python creds.py --check' for guidance.")
|
||||
|
||||
if args.set_login:
|
||||
user = input("email: ").strip()
|
||||
if not user:
|
||||
sys.exit("aborted: empty email")
|
||||
pw = getpass.getpass("password: ")
|
||||
if not pw:
|
||||
sys.exit("aborted: empty password")
|
||||
put(K_USER, user)
|
||||
put(K_PASS, pw)
|
||||
print(f"stored in Windows Credential Manager under '{SERVICE}'")
|
||||
|
||||
elif args.show:
|
||||
print(f"service : {SERVICE}")
|
||||
print(f"username: {get(K_USER) or '(not set)'}")
|
||||
print(f"password: {'(set)' if get(K_PASS) else '(not set)'}")
|
||||
print(f"cookie : {_mask(get(K_COOKIE))}")
|
||||
|
||||
elif args.clear:
|
||||
for k in (K_USER, K_PASS, K_COOKIE):
|
||||
drop(k)
|
||||
print("cleared")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user