A rewrite of MoonTV (Next.js 14, upstream version 4.2.5,
https://github.com/Stardm0/MoonTV) as two separate packages. MoonTVPlus is its
own release line: 5.0.0, stamped from the root VERSION.txt
and read by backend/moontv/version.py and frontend/src/lib/version.ts.
backend/ FastAPI (Python) — the whole /api/* surface plus the auth gate
frontend/ Vite + React — the UI, rebuilt rather than kept as a Next.js app
The split is only about code organization and runtime. The wire protocol does
not change: same paths, same query parameters, same response shapes, same
SSE/NDJSON framing, same auth cookie format, same environment variable names.
Browser client libs, TVBox/OrionTV style external clients and existing
docker run -e ... / docker-compose files keep working against this server.
All backend milestones (B0–B7) and the frontend port are in. See
PLAN.mdfor the full port plan and the list of deliberate behavior changes.
| Component | Need |
|---|---|
| Backend | CPython ≥ 3.11, fastapi, uvicorn, httpx, cryptography, redis, python-multipart (installed into backend/.venv) |
| Frontend | Node.js ≥ 20 and pnpm (only for building the UI; the backend just serves frontend/dist) |
| Storage | SQLite (stdlib, default) — Redis or Kvrocks optional (REDIS_URL / KVROCKS_URL) |
./install.sh # backend/.venv + dependencies, then pnpm install
./install.sh --build # also build frontend/dist, which the backend servesinstall.sh reads the dependency list from backend/pyproject.toml, so there is
one source of truth for what the backend needs. It is idempotent: an existing
venv is reused, and --recreate is the only path that removes one. Use
--dry-run to see the commands first.
Then start it:
PASSWORD=secret USERNAME=admin ./server.sh start
./server.sh status
./server.sh logWithout server.sh, the plain invocation is:
backend/.venv/bin/python -m uvicorn moontv.main:app --app-dir backend --host 0.0.0.0 --port 3000| Command | What it does |
|---|---|
start |
background; pid in .run/server.pid, log in .run/server.log; confirms startup before returning |
stop |
SIGTERM, waits MOONTVPLUS_STOP_TIMEOUT (10s), then SIGKILL |
restart |
stop + start |
status |
pid, elapsed time, RSS, cmdline, url/db/log paths, last log lines (exit 3 when stopped) |
run |
foreground with exec — for containers and for debugging |
log |
tail -f the log |
build |
rebuild frontend/dist, then start |
cron |
the old start.js poll-then-hourly /api/cron loop, in the foreground; each call is signed with an X-Cron-Key header carrying PASSWORD |
test |
pytest under backend/ |
Options (before or after the command): -h / --help, --env FILE, --host HOST,
--port PORT, --log FILE, and -- to forward extra uvicorn arguments.
Precedence is --host/--port > env file > exported HOSTNAME/PORT > 0.0.0.0:3000.
HOSTNAME and PORT are the names the old MoonTV Docker image exported, so
existing deployments keep their meaning; only an exported HOSTNAME counts,
because bash sets it to the machine name otherwise.
Every variable keeps the name MoonTV used. Note that PASSWORD and USERNAME
are not NEXT_PUBLIC_-prefixed — that is upstream's inconsistency, preserved
on purpose.
| Variable | Default | Meaning |
|---|---|---|
PASSWORD |
— | required; server.sh start/run/cron refuse to start without it |
USERNAME |
— | the owner/admin user |
NEXT_PUBLIC_STORAGE_TYPE |
sqlite |
sqlite (also covers the old localstorage/d1 values); redis/kvrocks pending |
MOONTV_DB_PATH |
backend/moontv.db |
SQLite file; server.sh sets it absolutely so it does not follow the caller's cwd |
NEXT_PUBLIC_SITE_NAME |
MoonTV |
site name, substituted into the served document |
ANNOUNCEMENT |
built-in default | banner text |
NEXT_PUBLIC_SEARCH_MAX_PAGE |
5 |
how many downstream pages search walks |
NEXT_PUBLIC_ENABLE_REGISTER |
false |
allow self-registered users |
NEXT_PUBLIC_DISABLE_YELLOW_FILTER |
false |
disable the content filter |
NEXT_PUBLIC_DANMU_API_BASE_URL |
"" |
danmaku API base |
NEXT_PUBLIC_PLAYBACK_SAVE_INTERVAL |
computed | play-record save interval |
NEXT_PUBLIC_DOUBAN_PROXY_TYPE / NEXT_PUBLIC_DOUBAN_PROXY |
direct / "" |
Douban text proxy |
NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE / NEXT_PUBLIC_DOUBAN_IMAGE_PROXY |
direct / "" |
Douban image proxy |
TVBOX_ENABLED, M3U8_PROXY_ALLOW_HOSTS |
— | TVBox and m3u8 proxy policy (routes pending) |
REDIS_URL, KVROCKS_URL |
— | alternative storage backends (pending) |
MOONTV_CONFIG_PATH |
backend/config.json |
the only file-config source |
server.sh also loads ./.env when it exists (dotenv-style: KEY=value,
# comments, optional quotes, no shell expansion). The copy-paste-ready
example at the repo root documents the parser's edge cases — no inline
comments, no indented assignments, values not trimmed — so read it before
editing.
PASSWORD is enforced at startup: server.sh start|run|cron and
python -m moontv.main refuse to boot when it is unset. A passwordless server
is half-open — every page would redirect to /warning, yet /api/login still
accepts existing database users — so the startup guard closes that half.
status, log and test still work without a password.
backend/
moontv/main.py app assembly + the auth-gate middleware
moontv/routes/ one module per src/app/api/**/route.ts
moontv/storage/ db.py (capability checks) + sqlite.py (from d1.db.ts)
moontv/{auth,config,crypto,fetch,downstream,utils,...}.py
config.json, pyproject.toml, .venv/
frontend/
index.html the Vite entry — the document the backend serves
src/main.tsx port of src/app/layout.tsx
src/shims/ navigation, link, image, next
src/app, src/components, src/lib
src/vendor/muxjs the github:Momo707577045/mux.js fork, vendored
dist/ build output, served under /assets
The backend serves the built document at every non-API path and the API at
/api/*, so there is one origin: no CORS, cookies work, and StreamSaver's
same-origin /mitm.html + /sw.js keep working.
Every upstream route is in, plus the addition called out below:
POST /api/login POST /api/register POST /api/logout
POST /api/change-password GET /api/server-config GET /api/search
GET /api/config/sources GET /api/config/custom_category
POST /api/admin/user POST /api/admin/group POST /api/admin/source
POST /api/admin/source/test POST /api/admin/category POST /api/admin/site
POST /api/admin/config_file GET /api/admin/config GET /api/admin/reset
POST /api/admin/subscription GET /api/admin/subscription
POST /api/admin/tvbox GET /api/admin/tvbox
POST /api/admin/data_migration/export
POST /api/admin/data_migration/import
GET /api/detail GET /api/search/{one,resources,suggestions,ws}
POST /api/m3u8 GET /api/m3u8
GET /api/douban GET /api/douban/{categories,recommends}
GET /api/image-proxy GET /api/tvbox/{config,categories}
GET /api/cron POST /api/followings/refresh POST /api/user/online
GET|POST /api/{favorites,followings,playrecords,searchhistory,skipconfigs,today-updated}
/api/admin/source/test is a MoonTVPlus addition, not an upstream route: it
probes every configured API site with the same request a search makes and
streams the verdicts back as SSE, so the admin panel can show which sources
went dead.
/api/cron stays outside the auth gate (upstream's matcher), but MoonTVPlus
requires a key on it: carry the env PASSWORD as an X-Cron-Key header, or the
route answers 401. A ?key= query param is rejected: uvicorn's access log
records the full URL, so a query credential would land in the log file even when
the route rejects it — access logging is therefore off (--no-access-log).
server.sh cron signs its own calls with the header.
Everything upstream had is ported. Still missing: the browser side of the old
.dat backup migration, and the Redis/Kvrocks storage backends (create_storage
raises until they exist). Until a route exists, an unauthenticated /api/...
path still gets the gate's 401, while a path the gate deliberately does not
cover falls through to the app document at status 404.
State lives in the SQLite database (favorites, followings, playrecords,
searchhistory, users, …) using the schema from upstream's d1-init.sql, with
SEARCH_HISTORY_LIMIT = 20 and BATCH_LIMIT = 100 unchanged. Old MoonTV
migration backups (.dat) are pako.deflate followed by CryptoJS
AES.encrypt(data, passphrase) — an OpenSSL Salted__ header, EVP_BytesToKey
with MD5, AES-256-CBC — and moontv/crypto.py reproduces that byte-for-byte in
both directions. The /api/admin/data_migration/{export,import} pair round-trips
that format — the export streams the ciphertext as SSE chunk events, and
reassembling them and posting the result back through the import route restores
the same state. The browser-side StreamSaver migration flow (/mitm.html +
/sw.js) is milestone B7.
cd frontend
pnpm build # generate manifest -> vite build -> Tailwind CLI -> postbuild
pnpm dev # vite build --watch
pnpm typecheck # tsc --noEmitThe app document is frontend/index.html, and the backend serves the built
frontend/dist/index.html with __SITE_NAME__, __RUNTIME_CONFIG__ and
__RUNTIME_LAYOUT__ substituted at serve time — one built document serves every
site configuration. Dependency pins (react 19.0.0, artplayer 5.3.0, vendored
mux.js) are load-bearing; see PLAN.md before changing them.
backend/.venv/bin/python -m pytest is wired up through backend/pyproject.toml
(asyncio_mode = auto); there are no test files yet. Cross-checks against the
original TypeScript are done with node harnesses and fixture comparisons
described in PLAN.md. The reusable ones are committed in
.opencode/skills/moontvplus-verification/scripts/ — adfilter_cases.cjs and
adfilter_probe.cjs (the ad filter, measured offline), cdp_eval.cjs and
cdp_net.cjs (the browser probes), servecheck.py (stale-dist check),
source_delete_check.py and source_health_check.py (the admin panel's source
routes) — with the traps that make a harness lie in references/harness-rules.md.
Apache-2.0 — see LICENSE.
This tree is a rewrite of MoonTV, which is MIT (© 2025 LunaTechLab). The files
copied from those sources (frontend/src/app, src/components, src/lib,
src/vendor/muxjs) keep that upstream attribution, recorded in
NOTICE; everything else here is Apache-2.0.