Personal watch time logger for Twitch, YouTube, X (Twitter), Facebook, Instagram and Plex. A browser extension sends heartbeats to a self-hosted API, which stores them in SQLite and serves a multi-platform dashboard. Plex is tracked server-side by polling a Plex Media Server, so it captures playback on every device, not just the browser.
Live at stats.jwsoat.co.nz.
The extension runs a heartbeat timer on twitch.tv, youtube.com, x.com, facebook.com and instagram.com pages. Every tick (~10s), if a video is playing, it records:
- channel name
- timestamp (Unix seconds UTC)
- category / stream title
- video ID and playlist ID (YouTube)
- tab visibility
- user activity state (active, passive, audio-only)
Heartbeats buffer in chrome.storage.local and flush to the API every minute.
If the API is unreachable, they accumulate locally (up to 5000) and flush on
next success.
Watch time = count(heartbeats) * interval. No session-stitching needed.
Four views behind an API key gate:
| Path | Description |
|---|---|
/ |
Merged — combined Twitch + YouTube rankings, daily chart, quick stats |
/twitch |
Twitch-only — channels, categories, daily chart |
/youtube |
YouTube-only — channels, videos, daily chart |
/x /facebook /instagram /plex |
Per-source — top accounts, top videos, daily chart |
/tv |
Ambient scoreboard with rotating panels (point a spare display here) |
/settings |
Accounts, channel links, avatars, data management, Google Drive backup |
Merged view ranks creators across all platforms in two columns (odd ranks left, even right, up to 40 total). Platform badges (TW/YT/X/FB/IG/PLEX) indicate source. Channels linked as the same creator (see Creator links in Settings) roll up into a single combined row — e.g. watching the same creator on YouTube and Plex counts as one.
Account picker lets you filter by linked Twitch + YouTube account pairs. Configure these in Settings.
cp .env.example .env
echo "API_KEY=$(openssl rand -hex 32)" > .env
docker compose up -d --buildTest:
curl http://localhost:8765/health
# {"ok":true,"interval":10}Put behind a reverse proxy for HTTPS:
stats.jwsoat.co.nz {
reverse_proxy localhost:8765
}- Open
chrome://extensions, enable Developer Mode - Click "Load unpacked", select the
extension/directory - Click Details > Extension options
- Set API URL and API key, click Save
Open twitch.tv or youtube.com, watch something, verify:
curl -H "X-API-Key: $API_KEY" https://stats.jwsoat.co.nz/stats/todayAll /stats/*, /heartbeat*, and /settings/* require X-API-Key header.
| Method | Path | Notes |
|---|---|---|
| POST | /heartbeat |
Single Twitch heartbeat |
| POST | /heartbeats |
Batched Twitch heartbeats |
| POST | /youtube/heartbeats |
Batched YouTube heartbeats |
| POST | /media/heartbeats |
Batched heartbeats for x / facebook / instagram / plex (each carries its own platform) |
| Method | Path | Notes |
|---|---|---|
| GET | /stats/today |
Per-channel seconds since midnight |
| GET | /stats/week |
Last 7 days |
| GET | /stats/month |
Last 30 days |
| GET | /stats/all |
All time |
| GET | /stats/daily?days=30 |
Total seconds per day |
| GET | /stats/now |
Currently watching |
| GET | /stats/top_channel?window=today |
Top channel + seconds |
| GET | /stats/total?window=today |
Total seconds |
| GET | /stats/channel?channel=xqc |
Single channel breakdown |
| GET | /stats/categories |
Category rankings |
| GET | /stats/recent |
Recently watched channels |
| GET | /stats/users |
Known Twitch users |
| GET | /stats/channels |
All tracked channels (both platforms) |
| Method | Path | Notes |
|---|---|---|
| GET | /stats/youtube/today |
Per-channel seconds since midnight |
| GET | /stats/youtube/week |
Last 7 days |
| GET | /stats/youtube/month |
Last 30 days |
| GET | /stats/youtube/all |
All time |
| GET | /stats/youtube/daily?days=30 |
Seconds per day |
| GET | /stats/youtube/now |
Currently watching |
| GET | /stats/youtube/videos |
Video rankings |
| GET | /stats/youtube/playlists |
Playlist stats |
| GET | /stats/youtube/users |
Known YouTube users |
{platform} is one of x, facebook, instagram, plex.
| Method | Path | Notes |
|---|---|---|
| GET | /stats/media/{platform}/today |
Per-account seconds since midnight |
| GET | /stats/media/{platform}/week |
Last 7 days |
| GET | /stats/media/{platform}/month |
Last 30 days |
| GET | /stats/media/{platform}/all |
All time |
| GET | /stats/media/{platform}/daily?days=30 |
Seconds per day |
| GET | /stats/media/{platform}/videos |
Video/post rankings |
| GET | /stats/media/{platform}/now |
Currently watching |
| GET | /stats/media/{platform}/users |
Known accounts |
Add ?include_passive=false to exclude idle time. Add ?user=<handle> to
filter by account. Add ?platform=twitch|youtube on shared endpoints.
Note on attribution: X, Facebook and Instagram are tracked by scraping the page DOM, which these sites obfuscate and change frequently. Playback time is reliable; the detected account/title is best-effort and may occasionally be missing. Expect to refresh the content-script selectors over time.
| Method | Path | Notes |
|---|---|---|
| GET | /stats/merged/channels?window=today |
Per-creator watch time rolled up across every platform; linked creators collapse into one row |
| GET | /stats/merged/daily?days=30 |
Combined watch time per day across every platform |
?user=<label> filters Twitch + YouTube by a user-account label (media
platforms have no viewer-account linking and are always included).
| Method | Path | Notes |
|---|---|---|
| GET | /settings/creator-links |
List creator groups + their per-platform channels |
| POST | /settings/creator-links |
Add a channel to a creator group (by label) |
| DELETE | /settings/creator-links/alias/{id} |
Remove one channel from a creator |
| DELETE | /settings/creator-links/group/{id} |
Remove a whole creator group |
| GET | /settings/channel-links |
(Legacy) Twitch-YouTube channel pairs; migrated into creator links |
| POST | /settings/channel-links |
(Legacy) Add Twitch-YouTube pair |
| DELETE | /settings/channel-links/{id} |
(Legacy) Remove pair |
| GET | /settings/user-accounts |
List account pairs |
| POST | /settings/user-accounts |
Add account |
| DELETE | /settings/user-accounts/{id} |
Remove account |
| POST | /settings/user-accounts/auto-link |
Auto-detect pairs from extension |
| Method | Path | Notes |
|---|---|---|
| GET | /avatars/{platform}/{channel} |
Fetch avatar (custom > cache > unavatar.io) |
| POST | /avatars/{platform}/{channel} |
Upload custom avatar |
| DELETE | /avatars/{platform}/{channel} |
Delete custom avatar |
| GET | /avatars/custom |
List custom avatars |
| Method | Path | Notes |
|---|---|---|
| GET | /settings/export |
Full JSON export of all tables |
| GET | /settings/backup |
Download raw SQLite file |
| POST | /settings/import?mode=merge |
Import JSON (merge or replace) |
| Method | Path | Notes |
|---|---|---|
| GET | /settings/gdrive/status |
Connection status + backup list |
| GET | /settings/gdrive/connect |
Start OAuth flow |
| POST | /settings/gdrive/backup |
Upload backup + rotate (keeps 3) |
| DELETE | /settings/gdrive/disconnect |
Remove stored token |
Requires GDRIVE_CLIENT_ID and GDRIVE_CLIENT_SECRET env vars. Add
https://stats.jwsoat.co.nz/settings/gdrive/callback as an authorized
redirect URI in Google Cloud Console.
Three options:
- Local download — Settings > Data Management > Download DB backup
- JSON export — Settings > Data Management > Export JSON (importable)
- Google Drive — Settings > Google Drive backup > connects via OAuth, keeps 3 rotating backups in a "Watchtime Backups" folder
The raw database is at api/data/watchtime.db. For cron-based backups:
docker exec twitch-watch-api sh -c \
"sqlite3 /data/watchtime.db .dump" > backup-$(date +%F).sql| Variable | Required | Default | Description |
|---|---|---|---|
API_KEY |
Yes | — | Auth key for all protected endpoints |
DB_PATH |
No | /data/watchtime.db |
SQLite database path |
HEARTBEAT_INTERVAL_SECONDS |
No | 10 |
Heartbeat-to-seconds multiplier |
GDRIVE_CLIENT_ID |
No | — | Google OAuth client ID (for Drive backup) |
GDRIVE_CLIENT_SECRET |
No | — | Google OAuth client secret |
PLEX_BASE_URL |
No | — | Plex Media Server URL, e.g. http://192.168.1.10:32400 (enables Plex poller) |
PLEX_TOKEN |
No | — | Plex auth token (how to find it) |
PLEX_CHANNEL_FROM_STUDIO |
No | false |
Use the Plex item's studio field as the channel/author (falls back to show title when empty) |
TZ |
No | UTC |
Timezone for "today" calculations |
Set PLEX_BASE_URL and PLEX_TOKEN to enable the server-side Plex poller. On
startup the API polls {PLEX_BASE_URL}/status/sessions every
HEARTBEAT_INTERVAL_SECONDS and records one heartbeat per actively-playing
video session (movies, episodes, clips — music is skipped). This captures
playback from any Plex client (TV, phone, native apps), not just the browser.
By default the channel/author is the Plex series/show title (or the movie
title for movies). If you archive a creator's videos in Plex and want them to
line up with that creator's Twitch/YouTube channel, either name the Plex show
with their handle, or set PLEX_CHANNEL_FROM_STUDIO=true and put the handle in
each item's Studio field. Either way, matching across platforms is explicit:
link the channels under one creator in Settings → Creator links.