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.
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.shOverride anything via env: STREAMSNIPE_DB, STREAMSNIPE_DATA, STREAMSNIPE_POLL_INTERVAL,
STREAMSNIPE_PROXY, STREAMSNIPE_RETENTION_DAYS, STREAMSNIPE_PIXELDRAIN_KEY,
STREAMSNIPE_TOKEN, STREAMSNIPE_ALLOWED_HOSTS.
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.
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.
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.
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.0Then visit /login?token=… once; the cookie carries it from then on. serve warns if
you bind beyond loopback without one.
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 policyOr ./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.
- 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.
backfilltherefore 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/room→webcast/room/info(status2 = 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_trgmwith 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
previewThumbnailsshape — a sprite sheet plus a WebVTT index, both emitted bypostprocess.py. Hover previews are a 12-frame JPEG strip animated with CSSsteps(), 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.seedfrom a handle list is the dependable way to grow it.
.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/pytestPersonal archiving. TikTok's ToS prohibits automated downloading, and recorded streams remain the creator's copyrighted work — fine for personal viewing, not redistribution.