Terminal-first media player with a fast TUI, a CLI mode, local progress tracking, and optional ArvanCloud S3 sync.
- TUI with keyboard-first navigation and search
- CLI mode for quick play and management
- Auto resume per series/episode or movie
- Series directory scraping with episode ordering
- Manual URL lists (paste multiple episode URLs)
- Local caching and background prefetch of next episode
- Download resume with exponential-backoff retry on partial transfers
- Cache progress shown in mpv OSD (desktop)
- Delete local cache files after watching
- Hardsub export helper (if stoh +
ffprobeare installed) - Import/export to the series-project JSON format
- Deduplicate series-project JSON files
- Optional ArvanCloud S3 sync across devices
- Termux/VLC support on Android (GrapheneOS compatible)
- Node.js 18+ (tested on Node 22)
mpvcurl- Optional: stoh +
ffprobefor hardsub export - Optional: Kitty terminal for detached TUI window
- Termux (F-Droid build recommended)
- Termux:API (F-Droid) — needed for
termux-amintent launching - Node.js via Termux:
pkg install nodejs curlvia Termux:pkg install curl- VLC for Android (F-Droid or Play Store)
# Clone or copy the project, then:
npm install
# tsx is required to run .ts files directly:
npm install -g tsxnpx tsx tui.tsDetach into a new Kitty window:
npx tsx tui.ts --detachnpx tsx player.tsList, export, import from the command line:
npx tsx player.ts list
npx tsx player.ts export
npx tsx player.ts import /path/to/series.json| Key | Action |
|---|---|
j / k or arrows |
Move up/down |
Enter |
Play selected |
/ |
Search |
n |
New entry |
e |
Edit entry |
f |
Toggle finished |
r |
Rename |
d |
Delete |
D |
Multi-delete |
i |
Import |
x |
Export |
u |
Dedupe series JSON file |
s |
Force sync to ArvanCloud |
q |
Quit |
# Allow Termux to access shared storage (required for VLC to open files)
termux-setup-storage
# Install dependencies
pkg install nodejs curl
# Install tsx globally
npm install -g tsxcd ~/player
npx tsx player.tsThe TUI (tui.ts) is desktop-only. On Termux, use the CLI (player.ts).
When you play an episode:
- VLC is launched via Android Intent pointing at the remote HTTP URL directly — this means seeking works freely at any position without buffering issues.
curldownloads the episode to/storage/emulated/0/player-cache/<series>/in the background as a cache for next time.- The terminal shows:
Press Enter when you have closed VLC… - Close VLC, switch back to Termux, press Enter.
- The player asks:
Did you finish the episode? [Y/n] - If you answer no, it asks where you stopped (e.g.
32:10) so your position is saved.
file://URIs are blocked across apps by GrapheneOS — the player always passes the HTTP URL to VLC instead of a local file path, which avoids this restriction entirely.termux-am(from Termux:API) is used to fire the Intent. If Termux:API is not installed, it falls back to baream start.dumpsysis not available withoutDUMPpermission, so the player does not use it.
On Termux, videos are cached to /storage/emulated/0/player-cache/ by default (so VLC can access them via shared storage). Override with:
export PLAYER_TERMUX_VIDEO_DIR=/storage/emulated/0/MyFolder| Path | Purpose |
|---|---|
<project>/.mpv-web-player/progress.json |
Watch progress store |
<project>/.mpv-web-player/cache/ |
Episode list cache (1 h TTL) |
<project>/video-cache/ |
Downloaded video files (desktop) |
/storage/emulated/0/player-cache/ |
Downloaded video files (Termux) |
Override the config root:
export PLAYER_CONFIG_DIR=/path/to/dirProgress can be synced across devices (e.g. desktop ↔ phone) via an ArvanCloud S3 bucket. The sync file is stored as mpv-progress.json in the bucket.
Create a bucket in the ArvanCloud console, then set these environment variables:
export PLAYER_ARVAN_ACCESS_KEY="your-access-key"
export PLAYER_ARVAN_SECRET_KEY="your-secret-key"
export PLAYER_ARVAN_BUCKET="your-bucket-name"
export PLAYER_ARVAN_REGION="ir-thr-at1" # default, change if neededPut these in your shell RC file (~/.bashrc, ~/.zshrc, or Termux's ~/.bashrc) so they are set on every session.
- On startup: the player pulls the remote progress file and merges it with the local store. Remote entries only win if they are further ahead (higher season/episode/timestamp, or marked finished).
- During playback: progress is saved locally after every episode and pushed to ArvanCloud with a 30-second debounce.
- On exit: any pending push is flushed before the process ends.
- Deletions are tracked in a deletion log so removed entries are not re-imported from the remote.
- Force sync from the TUI: press
s.
The merge is one-way-wins-if-ahead: whichever side (local or remote) is further along in the series keeps its value. Progress is never rolled back by a sync.
If you watch on two devices without syncing in between, the one that is further ahead wins on the next pull. No manual conflict resolution needed.
| Variable | Default | Description |
|---|---|---|
PLAYER_ARVAN_ACCESS_KEY |
— | ArvanCloud access key (enables sync) |
PLAYER_ARVAN_SECRET_KEY |
— | ArvanCloud secret key |
PLAYER_ARVAN_BUCKET |
— | S3 bucket name |
PLAYER_ARVAN_REGION |
ir-thr-at1 |
ArvanCloud region |
PLAYER_CONFIG_DIR |
<project>/.mpv-web-player |
Override config/progress directory |
PLAYER_TERMUX_VIDEO_DIR |
/storage/emulated/0/player-cache |
Override video cache dir on Termux |
PLAYER_CURL_CONNECT_TIMEOUT |
20 |
curl connect timeout in seconds |
PLAYER_CURL_RETRY |
5 |
curl built-in retry count |
PLAYER_CURL_RETRY_DELAY |
3 |
Seconds between curl retries |
PLAYER_CURL_DISABLE_RANGE |
0 |
Set to 1 to disable Range requests (some CDNs) |
PLAYER_MIN_BUFFER_KB |
256 |
Minimum KB on disk before mpv starts (desktop) |
PLAYER_MPV_NO_TERMINAL |
1 |
Set to 0 to show mpv's own terminal output |
PLAYER_STREAM_FALLBACK |
1 |
Set to 0 to disable HTTP fallback if cache is empty |
PLAYER_DISABLE_SEEK_AHEAD |
0 |
Set to 1 to disable curl seek-ahead on resume |
The series-project JSON format is an array of objects, each with:
id— e.g.player_my_showtitle,year,ratingisMovieplayerData— the fullSeriesProgressobject (url,season,episode,timestamp,finished,manualUrls, etc.)
Use TUI i / x to import and export, or CLI:
npx tsx player.ts import /path/to/file.json
npx tsx player.ts exportUse TUI u to deduplicate a series JSON file (removes entries with duplicate IDs, keeping the one further ahead).
Each entry in the progress store contains:
url— series directory URL or direct episode/movie URLseason,episode— 0-based episode index within the seasontimestamp— playback position in secondsfinished— true when the series or movie is fully watchedmanualUrls— optional ordered list of episode URLs for manual sources
Progress is saved after every key event: episode start, episode end, position poll during playback, and on exit.
When you play a series the player resolves the episode list in this order:
- If
manualUrlsare saved, use them directly — no network request. - Otherwise fetch the directory listing at the season URL, scrape video links, and sort by episode number.
If no episodes are found, you are prompted to paste URLs manually. These are saved as manualUrls for future runs.
On desktop, playback always runs from a local file in video-cache/:
curlstarts downloading immediately.- mpv starts only after at least 256 KB (configurable) is on disk so the container header is valid.
- mpv reads from the growing local file while curl keeps writing.
- If playback is about to outrun the download, mpv is paused and resumed once enough data is buffered.
- Cache state (MB downloaded, percent, state) is shown as an OSD message in mpv.
- If a partial file exists from a previous run, the download resumes from the current file size.
On Termux, VLC is given the remote HTTP URL directly rather than a local file. VLC handles its own streaming and seeking natively. curl still downloads the file in the background as a cache for the next time the same episode is played, but playback does not depend on it.
- When you stop, the current timestamp is saved.
- On the next run, playback resumes from that timestamp (
--start=<seconds>for mpv, position Intent extra for VLC). - When an episode ends within 60 seconds of the end (or past 95% of duration), the player automatically advances to the next episode and resets the timestamp to 0.
curl retries automatically on partial transfers (exit code 18 — server closed early) and connection errors (exit codes 6, 7, 28), up to 20 attempts with exponential backoff. The player keeps playing from the local file uninterrupted while retries happen in the background.
While you watch episode N, the player prefetches episode N+1 in the background so the next episode is ready (or partially downloaded) when you continue.
After playback ends you are offered the option to delete the local cache file. This removes only the video file — your watch progress is unaffected.
- TLS certificate verification is disabled (
-kin curl,NODE_TLS_REJECT_UNAUTHORIZED=0) to handle self-signed or misconfigured CDN hosts common in the target use case. - If a URL is unreachable, playback will fail with a curl or mpv error. Check the link or the server.
- The episode list cache has a 1-hour TTL. If a server updates its episode list, use TUI
e→ change URL to force a refresh, or wait for the TTL to expire.
Thanks to Narixius for having this idea and help me through that.