Skip to content

Repository files navigation

StreamSnipe

A self-hosted live-stream recorder, inspired by the commercial recorder services but running entirely on your own machine: fuzzy creator search with cached avatars, automatic live detection, server-side capture, a player with scrub previews, and recordings kept on disk or pushed to pixeldrain.

Currently records TikTok LIVE. More platforms to come.

Nothing here is a hosted service. One Postgres database, one media directory, two processes, and the archive is yours.

Setup

brew install ffmpeg postgresql@16
brew services start postgresql@16
createuser -s streamsnipe && createdb -O streamsnipe streamsnipe

python3 -m venv .venv
.venv/bin/pip install -r requirements.txt

./fetch-assets.sh

Override anything via env: STREAMSNIPE_DB, STREAMSNIPE_DATA, STREAMSNIPE_POLL_INTERVAL, STREAMSNIPE_PROXY, STREAMSNIPE_RETENTION_DAYS, STREAMSNIPE_PIXELDRAIN_KEY, STREAMSNIPE_TOKEN, STREAMSNIPE_ALLOWED_HOSTS.

First run: ./fetch-assets.sh

The two UI shells are built against streamrecorder.io's design, and their stylesheets, scripts and icon fonts are theirs rather than mine — so they are not redistributed in this repository. The script downloads them from streamrecorder.io into streamsnipe/web/static/vendor/. Everything else vendored there (Bootstrap, jQuery, FontAwesome, slick, Lexend, simple-notify, dialog-polyfill) is openly licensed and already checked in.

Skip it and the app still runs — the pages just render unstyled.

Cookies (optional but recommended)

Drop a cookies.json in the project root:

{ "sessionid": "" }

A sessionid is what gets you into age-restricted and follower-only rooms and cuts down on captcha challenges. Region-locked rooms need STREAMSNIPE_PROXY. The file is gitignored.

pixeldrain (optional)

Recordings can be kept on disk, uploaded to pixeldrain, or both. Set the key and pick the default under Settings:

export STREAMSNIPE_PIXELDRAIN_KEY=…

The key can also be pasted on the Settings page instead, in which case it lives in the database; the environment variable wins when both are set. Uploads use pixeldrain's filesystem API, which is a paid-plan feature, and land in me/streamsnipe/<handle>/<recording id>.mp4.

"On pixeldrain" deletes the local file, but only once the size and sha256 pixeldrain reports back match what was sent. Playback and downloads then stream through /media/<id>/video with your API key, so nothing has to be shared publicly to be watchable, and thumbnails stay on disk either way. python main.py storage prints what is where.

Exposing it beyond localhost

The default bind is 127.0.0.1 and there is no login, which is the right shape for a single-user tool on your own machine. Two things guard it either way: the Host header has to be one you allowed (so a remote page cannot reach a loopback-bound server by pointing a name it controls at 127.0.0.1), and cross-origin requests to /api/ are refused — which matters because the vendored recordings UI issues its state-changing calls as GETs, so without that check any open tab could delete a creator with an <img> tag.

Binding anywhere else, set a token as well:

export STREAMSNIPE_TOKEN=$(openssl rand -hex 16)
export STREAMSNIPE_ALLOWED_HOSTS=nas.local
python main.py serve --host 0.0.0.0

Then visit /login?token=… once; the cookie carries it from then on. serve warns if you bind beyond loopback without one.

Usage

python main.py serve                    # web UI on http://127.0.0.1:8000
python main.py monitor                  # poll watched creators, record when live
python main.py add <handle>             # add a creator and watch them
python main.py check <handle>           # one-shot: are they live right now?
python main.py list                     # targets + recent recordings
python main.py seed handles.txt         # bulk-fill the search index
python main.py refresh                  # re-resolve names, replace expired avatars
python main.py backfill <handle>        # pull posted videos with yt-dlp
python main.py import <file> <handle>   # adopt a local file
python main.py prune --days 30          # apply a retention window
python main.py prune --orphans          # drop media directories with no row left
python main.py storage --sync           # disk vs pixeldrain; --sync applies the policy

Or ./run.sh, which starts both processes and stops both on Ctrl-C. Run serve and monitor separately if you prefer: the UI stays responsive while the monitor blocks on long-lived ffmpeg pulls.

Notes on the implementation

  • Your archive starts the day you start recording. TikTok publishes no archive of finished LIVEs — replays are visible only to the creator, in their own app, if enabled, for a limited window. backfill therefore pulls posted profile videos, where LIVE highlights usually end up, and imports local files; it cannot conjure a LIVE nobody recorded.
  • Detection polls api-live/user/roomwebcast/room/info (status 2 = live, 4 = ended), falling back to scraping __UNIVERSAL_DATA_FOR_REHYDRATION__ off the public live page when challenged. Default 60s interval, jittered, exponential backoff.
  • Capture prefers FLV over HLS. yt-dlp's TikTok HLS path 404s roughly 30s in (#16114, #9517); the FLV pull URL is a single long-lived connection.
  • Capture writes MPEG-TS, then remuxes to MP4. TS survives an abrupt kill; an MP4 killed mid-write loses its moov atom and will not play. recover_orphans() salvages rows left behind by a crash on the next start.
  • Search is trigram similarity, not a search engine. Postgres pg_trgm with GIN indexes on handle and display name, so a typo still finds the account and a nonsense query returns the nearest rows rather than nothing.
  • Avatars are cached and re-served locally. TikTok's avatar URLs are signed and expire, so they are downloaded and resized on add, then served behind /imgred/profile_image/{id}.
  • Scrub previews are Plyr's previewThumbnails shape — a sprite sheet plus a WebVTT index, both emitted by postprocess.py. Hover previews are a 12-frame JPEG strip animated with CSS steps(), not a video.
  • Search index coverage is the limitation. There is no public TikTok user directory to bulk-import, and the live-discovery endpoint expects request signing (X-Bogus/msToken) and rejects unsigned callers. seed from a handle list is the dependable way to grow it.

Tests

.venv/bin/pip install pytest pytest-asyncio ruff
.venv/bin/pytest
.venv/bin/ruff check .

Most of the suite needs neither a database nor the network — the parts worth pinning down are the TikTok payload parsers (a third-party shape that changes without notice, and whose failure mode is a monitor that silently stops recording) and the JSON the vendored frontend reads, where a missing key fails quietly inside their card builder.

A dozen tests do need Postgres. They read STREAMSNIPE_TEST_DB — deliberately not STREAMSNIPE_DB, because they truncate tables and that variable probably points at a real archive. They skip themselves when it is unset:

createdb -O streamsnipe streamsnipe_test
STREAMSNIPE_TEST_DB=postgresql://streamsnipe:streamsnipe@localhost/streamsnipe_test .venv/bin/pytest

Legal

Personal archiving. TikTok's ToS prohibits automated downloading, and recorded streams remain the creator's copyrighted work — fine for personal viewing, not redistribution.

About

Self-hosted TikTok LIVE recorder: automatic live detection, server-side capture, scrub previews, and optional pixeldrain storage. More platforms to come.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages