A pure REST API wrapper around moviebox.ph with an indexed catalog, provider failover, FTS5 search, and a self-hosted frontend.
No scraping. No HTML parsing. No headless browser. It talks the exact same private JSON API the moviebox web player talks — h5-api.aoneroom.com/wefeed-h5api-bff — and re-exposes it as a clean, self-hosted REST surface plus a browser UI.
Built for people who want to wire moviebox into their own apps, scripts, bots, dashboards, or media servers without reverse-engineering the network tab every time the site ships an update.
Version 2.7.0 — module split, provider failover, FTS5 search, trailer playback, continue-watching row.
git clone https://github.com/faisaljs/Moviebox-API
cd Moviebox-API
pip install -r requirements.txt
python main.py
Server boots on http://localhost:8000. Two entry points:
http://localhost:8000/ — HTML API dashboard, one-click tests for every endpointhttp://localhost:8000/app — the actual frontend (hero carousel, catalog, player, bookmarks, history)Dev with autoreload:
RELOAD=1 python main.py
Custom port:
PORT=9000 python main.py
Docker:
docker compose up -d
The first boot kicks off the catalog walk in the background. It takes 2–3 minutes to populate ~1700 rows and enrich the type fields. /catalog/stats shows progress.
Single instance only. SQLite is not multi-writer safe across processes. Two python main.py against one catalog.db corrupts it. If you’re testing, Ctrl+C the running server before starting a new one.
| Method | Path | Purpose |
|---|---|---|
GET |
/home |
Featured sections and banners |
GET |
/home/all |
Flattened deduped feed |
GET |
/sections |
List every home section |
GET |
/browse/{section} |
Direct access to any home section |
| Method | Path | Purpose |
|---|---|---|
GET |
/catalog |
Indexed catalog with sort/filter/search |
GET |
/catalog/stats |
Counts, walk progress, keyword telemetry |
POST |
/catalog/refresh |
Re-run the walk (?mode=enrich for enrichment only) |
POST |
/catalog/add |
Manually add a subject by slug |
| Method | Path | Purpose |
|---|---|---|
GET |
/movies |
Movies from home sections |
GET |
/tv-series |
TV series from home sections |
GET |
/animation |
Animation from home sections |
| Method | Path | Purpose |
|---|---|---|
GET |
/search |
Full-text search against upstream |
GET |
/search/suggest |
Typeahead (?enrich=true for slugs) |
GET |
/detail/{slug} |
Full metadata for a subject |
| Method | Path | Purpose |
|---|---|---|
GET |
/api/stream/{subject_id} |
Direct video sources |
GET |
/api/stream/{subject_id}/proxy |
Range-aware MP4 passthrough |
GET |
/api/stream/{subject_id}/captions |
Subtitle list |
GET |
/api/stream/{subject_id}/subtitles/{lang} |
SRT → VTT inline |
| Method | Path | Purpose |
|---|---|---|
GET |
/providers |
Live provider health with circuit breaker state |
POST |
/providers/probe |
Force-probe all providers now |
POST |
/providers/reset |
Clear all circuit breakers |
GET |
/api/hosts |
Backwards-compat host status |
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness, cache state, catalog counts |
GET |
/api/tabs |
Probe tab IDs (diagnostic) |
GET |
/api/tabs/variants |
Try every known filter payload shape |
GET |
/api/debug/upstream |
Raw upstream passthrough |
GET /homeReturns the operating list from the moviebox home feed, grouped into sections. Banner sections and SUBJECTS_* rows are normalized into the same item shape.
curl http://localhost:8000/home | jq
{
"status": "success",
"host": "moviebox.ph",
"sections": [
{
"section": "Banner",
"count": 11,
"items": [
{
"name": "VisionQuest",
"poster_url": "https://.../cover.webp",
"slug": "visionquest-hindi-0qVaGP1YHE4",
"subject_id": "3908280950886646112",
"badge": "Hindi"
}
]
}
]
}
GET /catalogThe real catalog. Reads from the SQLite index populated by the background walk.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
type |
string | all |
all, movie, tv, anime |
sort |
string | recent |
recent, title, rating |
page |
int | 1 |
1-indexed |
per_page |
int | 24 |
Max 100 |
q |
string | — | FTS5 title search (with LIKE fallback) |
host |
string | — | Filter by source host |
curl "http://localhost:8000/catalog?type=movie&sort=rating&per_page=5" | jq
curl "http://localhost:8000/catalog?q=dragon" | jq
curl "http://localhost:8000/catalog?q=love%20you" | jq # multi-word AND
Response includes total, total_pages, and items[] with name, poster_url, slug, subject_id, rating, year, badge, subject_type, genre, types[].
FTS5 search behavior:
love you finds titles with both)matri matches matrix)q= silently degrades to LIKE %query% — same results, slowerGET /catalog/statsEverything about the index:
{
"total": 1737,
"movies": 163,
"tv": 140,
"anime": 25,
"untyped": 1409,
"untyped_pending_enrichment": 1497,
"subject_types": { "1": 119, "2": 89, "6": 23 },
"by_host": { "moviebox.ph": 1737 },
"walk_state": { "running": false, "enriching": false },
"keyword_stats": {
"m": { "pages": 11, "items": 211, "hit_cap": false, "upstream_total": 211 }
}
}
POST /catalog/refreshcurl -X POST http://localhost:8000/catalog/refresh # full walk
curl -X POST "http://localhost:8000/catalog/refresh?mode=enrich" # enrichment only
curl -X POST "http://localhost:8000/catalog/refresh?force=true" # walk all hosts, no dedupe
Returns immediately; runs in background. Watch /catalog/stats.
POST /catalog/add?slug=Manually add a subject the walk missed. Useful for anything upstream’s search ranking doesn’t surface (Matrix was the case study — search for m returns 211 results but Matrix isn’t among them).
curl -X POST "http://localhost:8000/catalog/add?slug=matrix-W1E5680Y2S5" | jq
Fetches the detail response, upserts, and enriches immediately.
GET /api/stream/{subject_id}curl "http://localhost:8000/api/stream/6207982430134357800?detail_path=breaking-bad-ej6Bp0MCAo7&se=1&ep=1" | jq
Returns has_resource, sources[] (resolution, format, codec, url, size, duration), hls[], dash[], free_episodes, limited.
GET /api/stream/{subject_id}/proxyRange-aware MP4 passthrough. Drop into media servers (Jellyfin, Plex, Infuse, VLC) that can’t set custom headers.
# full file
curl -O "http://localhost:8000/api/stream/6207982430134357800/proxy?detail_path=breaking-bad-ej6Bp0MCAo7&res=1080p"
# ranged seek
curl -H "Range: bytes=0-1048575" "http://localhost:8000/api/stream/.../proxy?..." -o chunk.bin
Forwards Range upstream, returns 206 with Content-Range on partial. Full-file requests stream through a 64KB chunked iterator.
GET /api/stream/{subject_id}/subtitles/{lang}Fetches the SRT, strips index lines, converts timestamps, prepends WEBVTT. Drop-in for a <track> element.
curl "http://localhost:8000/api/stream/6207982430134357800/subtitles/en?detail_path=breaking-bad-ej6Bp0MCAo7" | head -20
WEBVTT
00:00:17.833 --> 00:00:18.916
[Vision] <i>Who am I?</i>
SRT formatting tags (<i>, <b>, {\an8}) survive the conversion.
GET /providersLive provider state:
{
"default_host": "moviebox.ph",
"provider_count": 7,
"healthy": 7,
"degraded": 0,
"providers": [
{
"host": "moviebox.ph",
"alive": true,
"circuit_open": false,
"circuit_open_for": 0,
"consecutive_failures": 0,
"total_success": 25,
"total_failure": 0,
"avg_latency_ms": 374,
"last_success_ago": 0,
"last_failure_ago": null,
"last_error": null
}
],
"circuit_breaker": { "threshold": 3, "window_seconds": 60 }
}
GET /health{
"status": "ok",
"version": "2.7.0",
"default_host": "moviebox.ph",
"providers": { "moviebox.ph": { "alive": true, "latency_ms": 374, "fails": 0, "circuit_open": false } },
"tokens_cached": 1,
"domain_cached": "https://mzfi.me",
"domain_expires_in": 287,
"catalog_indexed": 1737,
"walk_running": false,
"enriching": false
}
/appSingle-file vanilla JS, no build step, no dependencies. Served by the same FastAPI process.
Views:
Persistence (localStorage, per-browser):
mb_bookmarks — saved titlesmb_history — watch progress per (slug, se, ep), capped at 200 entriesTrailer playback — when subject.trailer.videoAddress.url exists, a ▶ Trailer button appears next to Bookmark on the detail page. Opens a minimal overlay with the promo MP4. No history tracking.
Player keyboard shortcuts:
| Key | Action |
|---|---|
Space / k |
Play / pause |
← / → |
Seek ±5s |
↑ / ↓ |
Volume ±10% |
f |
Fullscreen |
m |
Mute |
Esc |
Close player / exit fullscreen |

More at screenshots/.
config.py env vars + headers + constants
state.py mutable globals (client, db, tokens, caches, walk_state)
providers.py provider health, circuit breaker, probe loop
upstream.py token cache, bff_request (failover), player, stream resolution
storage.py sqlite schema, FTS5, read, upsert, classify
helpers.py normalize, srt_to_vtt, pick_mp4_stream
catalog_worker.py walk, enrichment, maintenance loop
dashboard.py DASHBOARD_HTML string
routes.py all API endpoints
main.py FastAPI app + lifespan + middleware + / and /app
static/app.html frontend
No circular imports. Import order: config → state → providers → upstream → catalog_worker → routes → main. storage and helpers are leaf modules.
The rule throughout: import state / import providers — never from state import client. Mutable references stay live via module attribute access.
Each mirror issues its own guest JWT via the x-user response header. Tokens are cached per host. On 401, the token is cleared, re-acquired, and the request retried once on the same host.
Stream URLs live on a different host than the BFF. Fetched from /media-player/get-domain, cached for 300s. On stream failure, the cache is invalidated and the domain re-discovered once before surfacing an error.
Every BFF request tries the primary first, then falls over to the next healthy provider on network failure, 5xx, 429 (honoring Retry-After), or non-JSON. Per-host circuit breaker opens after 3 consecutive failures for 60s. A background probe loop cycles one host per PROVIDER_PROBE_INTERVAL seconds, keeping latency and health fresh.
All 7 known mirrors share the same aoneroom backend — the failover is pure resilience, not multiple catalogs.
Two-tier source for the index:
/home under a category section gets tagged with is_movie / is_tv / is_anime based on substring matching against the section namehasMore: false or CATALOG_MAX_PAGES_PER_KEYWORD, upsert every subjectAfter walking, a background enricher hits /detail/{slug} for every subject whose type is unknown and reads subjectType (1=movie, 2=series, 6=music video) plus genre (contains “anime” → is_anime). This is what gets movies from 44 to 400+ and TV from 60 to 300+.
The walk re-runs every 6 hours. Enrichment runs once after each walk. POST /catalog/refresh forces both manually.
Home feed is geo-targeted. Mumbai sees Cinema, Bollywood, Top Anime. Singapore sees Popular Movie, K-Drama, Anime[English Dubbed]. The classifier uses substring matching (movie, series, anime, etc.) so both work. Genre-named sections (Comedy, Epic Fantasy, BL Story) fall through to enrichment.
subjects_fts is an FTS5 virtual table using external content mode — it mirrors the subjects table via triggers (subjects_fts_ai/ad/au). On first boot, if the row counts drift, init_schema rebuilds the FTS index. From then on, triggers keep it in sync on every insert/update/delete.
Query path: /catalog?q= calls storage.fts_search() which returns matching subject_ids or None. None (FTS unavailable or short query) → fall back to title LIKE ?. Empty list (FTS ran, no matches) → WHERE 1 = 0. Non-empty list → subject_id IN (...).
On boot, right after init_schema, the lifespan runs PRAGMA integrity_check. If the DB is corrupt, the process prints the fix command and exits(1) before uvicorn binds the port. This is the safety net for the case where you accidentally run two python main.py against the same catalog.db — better to fail loudly than silently on every write while reads succeed.
/subtitles/{lang} fetches the SRT, strips numeric index lines, converts , timestamp separators to ., prepends WEBVTT, collapses whitespace. Italics and other SRT tags survive.
.
├── main.py
├── config.py
├── state.py
├── providers.py
├── upstream.py
├── storage.py
├── helpers.py
├── catalog_worker.py
├── dashboard.py
├── routes.py
├── static/
│ └── app.html
├── requirements.txt
├── verify.py
├── Dockerfile
├── docker-compose.yml
├── README.md
├── CONTRIBUTING.md
├── .env.example
└── .gitignore
verify.py runs a live integration pass against a running server. 27 checks, chains real requests — pulls a slug from /home, feeds /detail, /api/stream, /captions, /proxy, /subtitles. Also exercises /providers, /api/hosts, catalog sorts, filters, FTS search.
# server must be running
python verify.py
# or against a remote host
python verify.py https://your-host
Exits non-zero on any failure. Drops straight into CI or a pre-commit hook.
See .env.example for the full list with defaults. Key variables:
| Variable | Default | Notes |
|---|---|---|
PORT |
8000 |
Listen port |
RELOAD |
0 |
1 enables autoreload (dev) |
REQUEST_TIMEOUT |
25 |
Upstream timeout, seconds |
CATALOG_DB |
catalog.db |
SQLite path |
CATALOG_REFRESH_INTERVAL |
21600 |
Walk re-run interval (6h) |
CATALOG_MAX_PAGES_PER_KEYWORD |
50 |
Bump to 200 for deeper keyword coverage |
ENRICH_RATE_LIMIT |
0.2 |
Seconds between /detail calls during enrichment |
MOVIEBOX_HOST |
moviebox.ph |
Primary provider |
MOVIEBOX_HOSTS |
7 mirrors | Comma-separated |
PROVIDER_PROBE_INTERVAL |
60 |
Seconds between probe ticks |
CIRCUIT_BREAKER_THRESHOLD |
3 |
Failures before host opens |
CIRCUIT_BREAKER_SECONDS |
60 |
How long host stays open |
No config file. set -a && source .env && set +a && python main.py or docker compose up.
/ as root./health.catalog.db — mount it as a volume or the walk restarts on every container rebuild. The compose file already bind-mounts it.auth_basic in the nginx config from day one. See CONTRIBUTING.md for the threat model.database disk image is malformed in logsTwo processes running against one catalog.db. Stop the server, lsof catalog.db to find any orphan PIDs, kill them, then rm -f catalog.db catalog.db-wal catalog.db-shm and restart clean. The integrity check on boot will now catch this before the port is bound.
FATAL: catalog.db is corrupt on bootSame as above — the integrity check fired. Run the fix command it printed and restart.
total: 0 or stays at 44The walk hasn’t finished or enrichment is still running. POST /catalog/refresh and watch /catalog/stats. walk_state.running should flip to true within a second.
?type=movie returns fewer results than expectedEnrichment hasn’t finished. Only home-ingested subjects have type tags until enrichment runs. Watch untyped_pending_enrichment in /catalog/stats — when it hits 0, everything has a type.
/movies and /catalog?type=movie show different countsThey’re different tools. /movies is a home-section fallback (44 items from Cinema/Bollywood/etc.). /catalog?type=movie is the indexed DB (400+ items). Use /catalog for real browsing.
Upstream CDN might be blocking the referer. Check /api/stream/{sid} first — if sources are present, the URLs are live and the proxy should work. If empty, the episode may be gated or mid-upload.
Upstream /subject/search is relevance-ranked, not exhaustive. Matrix isn’t in the top 200 for m, so no walk keyword catches it. Fix: POST /catalog/add?slug=<known-slug> — the detail endpoint always works.
/app 404static/app.html missing. Confirm the file exists in the repo. Dockerfile copies static/ explicitly.
v2.7.0 — module split (10 files), provider failover with per-host circuit breakers, multi-host detection, /providers + /providers/probe + /providers/reset, FTS5 catalog search, trailer playback on detail pages, continue-watching row on home, SQLite integrity check on boot.
v2.6.0 — multi-host probe, ?host= on every BFF endpoint, source_host column in catalog.
v2.4.1 — type enrichment from /detail, keyword_stats telemetry, POST /catalog/add, ?mode=enrich refresh.
v2.4.0 — indexed catalog (/catalog, /catalog/stats, /catalog/refresh), SQLite storage, background walk + enrichment.
v2.3.3 — re-added / dashboard route; /api/tabs/variants confirms /subject/filter requires auth as guest.
v2.3.2 — catalog home-fallback, /sections, /browse/{section}, fuzzy section matching.
v2.3.1 — /search/suggest honesty (text-only upstream), total_sentinel flag, ?tab= override, /api/tabs.
v2.3.0 — shared httpx.AsyncClient, async-locked token init, retry with backoff, /home/all, /api/stream/{sid}/proxy, /api/stream/{sid}/subtitles/{lang}, Docker setup.
Faisal (@faisaljs)
If you use this project, a star on GitHub is appreciated.
MIT — see LICENSE for the full text.
Not affiliated with moviebox.ph. Provided as-is. You are responsible for how you use it.