What's on tonight, and where to watch it.
mox is a personal film and TV guide, on the web and on the iPhone. It answers the questions you actually ask, in the order you ask them: is there a new episode of something I follow, what just landed on the services I pay for, what is worth buying, which of these would I actually like — and what else has that actor made that I can watch tonight.
It is built for one household in Egypt, which shapes almost every decision in here — the calendar runs on Cairo time, the service catalogue is TMDB's Egyptian list, and prices come back in EGP — though the currency is whatever the store reports, not a constant.
The website and the iPhone app are one product: the same screens, the same order, the same data, from the same server.
Home — the ring, a question, and Ask MOX. Search for a film, a series, an
actor or a director; tap + for a mood ("Something funny", "Action") drawn from
what streams on your services. Scroll, and the board comes up underneath: new
episodes of your shows that you haven't watched, work from people you follow,
the airing calendar, what is trending, and what just reached the store.
People — every actor and director has a page: what of their work you can watch tonight on your services, what is coming, then all of it, newest first. Follow them and their new work turns up on Home. Titles show their cast and crew as faces, each one a way into that person's page.
A title — where it streams, then what you think of it: Follow (for a series), Watchlist, Seen and a rating, each one plain about its state — a tick and "In Watchlist", "Watched" — so nothing rests on colour alone. Share sends a mox link to someone as a recommendation: the phone's share sheet in the app and on a phone browser, a copied link on a desktop. The link opens the title on the website, and chat apps preview it with its name and backdrop.
A title says when it comes out — a film's full date, or "Coming 16 Dec 2026"; "Premieres 14 Oct" for a series not started — and its age rating. A series lists its episodes: pick a season from the menu and see each episode by name and date, tick the ones you have watched, and read where that season stands ("2 of 6 out · you've watched 0 · next Wed 30 Sep") and where it streams — which is not always where the show does: MobLand's first season is on Netflix here, its second on TOD.
Today — a timeline of what reached each service, newest first, grouped by day and then by service. Filter it to one service, or to films or TV. In the app, connect your calendar and your day sits on top of it.
My List — the shows and people you follow, your watchlist, and the people who keep turning up in what you rated well. The watchlist is in release order: what is already out, newest first, then what is coming, soonest first, each tagged with its date.
Universes — franchise progress, rebuilt from TMDB every night, so a film announced next month appears on its own. On the website only, for now.
News — what happened today that you would want to know. First your own updates, made from mox's data: a season of a show you follow about to start, something on your watchlist that just reached one of your services, new work from people you follow. Then stories from the film and TV press — Variety, Deadline, The Hollywood Reporter, IndieWire and Collider in English, Youm7 and CNN Arabic in Arabic — with the ones about what you follow, want or loved first, each saying why ("You follow Silo"). No model decides this: a story ranks because it names something you told mox about. Stories open at the newsroom; mox keeps only the headline, the feed's picture and the link.
F1 — the next race weekend with every session in Cairo time, a countdown, and a button to watch it on TOD. Results sit behind a spoiler shield: a race is often watched later, recorded, so its result — and the standings it moved — stay covered until you say you have watched it. Then the standings and the whole season, round by round. Notifications go off before qualifying, sprints and races, and say only when a session starts. The first of the interests: football, anime or games can follow as tabs of their own.
Your tabs — the ring is always in the middle of the glass bar; which tabs sit either side of it, and in what order, is yours to choose in Settings — up to four from Today, News, My List and F1, plus Calendar and Tasks in the app. The choice belongs to the account, so the website's bar follows the app's.
Age ratings — tick the levels you want to see: All ages, 7+, PG, 13+, 18+ (folded from the American certificates, the British ones when there are none). Anything outside them leaves search, Home, Today and the rest — except a show you follow or something you saved, which stays with a red badge carrying its rating. Unrated titles, and much Arabic and Asian work has no certificate at all, show unless you choose to hide them.
Notifications — new episodes of the shows you follow, a watchlist title reaching one of your services, and F1 sessions a few minutes before they start. An episode is announced when it actually lands, when that is known — Lanterns at four in the morning, because people wait up for it — and at an hour you choose otherwise; shows landing at the same minute share one notification, so ten at ten o'clock are one. The app schedules its own on the phone; the website's are sent by the server, in any browser that turned them on (on an iPhone, once the site is on the home screen).
About — in Settings: the wordmark, what mox is in two lines, and who made it (mosama.me).
Most "what to watch" tools are catalogues you search. mox is the opposite: it decides what to put in front of you and gets out of the way.
It maintains itself. Nothing in it is a list somebody keeps up to date. Every feed, every airing, every franchise and every price is rebuilt from TMDB, TVmaze and JustWatch by a job that runs nightly. There is no admin screen for adding a film, because adding films is not something a person should be doing.
It knows what you have. A title you cannot watch is noise. Pick your subscriptions once and everything filters to them — the board, the calendar, the timeline, the franchise pages.
It has an opinion. Six verdicts (love, like, dislike, watchlist,
seen, hidden) feed a taste model that ranks everything else. Rating
something removes it from the queue, which is the point: the board is a queue,
not a library. The same model knows the people you keep rating well, so a card
can say why it is there — "Because you like Denis Villeneuve".
It knows what you have watched. Tick an episode and it leaves "New for you". No streaming service tells anyone what you watched, so this is the only way to know which of today's episodes you have not seen yet.
npm run refresh is the whole of it. The deploy schedules it nightly and it is
safe to run by hand at any time.
| step | what it rebuilds |
|---|---|
| services | the streaming-service catalogue people pick from, in TMDB's own priority order |
| feeds | /new — what reached a tracked service in the last 60 days, what is due in the next 90, what is trending this week |
| calendar | upcoming episodes for every series the site knows, 60 days ahead, timed to when they actually arrive here — to the minute where TVmaze knows — and which service each current season is on |
| watchlist | where every title on anybody's watchlist streams now, so an arrival is noticed the night it happens |
| ages | age ratings for titles no other step looked at, a few hundred a night until every one is checked |
| universes | franchise membership, from each universe's TMDB keyword, company or collection |
| store | what has appeared on the digital shelves since the last sweep |
| prices | what those arrivals cost to rent or buy |
| cache | drops stale TMDB responses, last so nothing else loses a warm cache |
Every one of these was once a fixed list, written by a one-off import and never
touched again. /new froze on the day of the import; the calendar held five
weeks of airings and would then have emptied and stayed empty; the MCU ended at
whatever had been released that week.
A step that fails does not stop the others. A calendar TMDB could not answer
for is no reason to leave the release timeline stale as well. The run reports
each step and exits non-zero if any failed, so a bad night is visible in
data/refresh.err.log rather than silent.
An empty answer is never believed. No feed, franchise or catalogue is ever replaced with nothing. A bad night shows yesterday's site, never a blank one.
It does not rename what is already named. TMDB answers in en-US, so an early
version relabelled البرنس to "The Prince" and "The Office (US)" to "The Office"
overnight — a library curated over years, renamed by a job meant to keep scores
fresh. Scores, posters and dates are refreshed; names are left alone, and only
new titles take TMDB's.
Two steps feed the taste model (scripts/refresh/taste.mts). features
collects cast, crew, keywords and studios for the titles on a service somebody
here subscribes to — up to 400 a night — and drops them when a title leaves
every service unrated, so the table stays the size of the watchable catalogue.
picks then builds each person's model once and stores their best thirty in
picks, which the app's Picks tab reads as it is. Nothing is scored when the
app opens.
TMDB publishes an air date and no air time, and that date belongs to the network, not to you. HBO's Sunday 9pm ET is 4am Monday in Cairo. Stored raw, every HBO episode sat on the page a day early and dropped out of "today" on the morning it first became watchable.
TVmaze publishes a real instant per episode, which needs no guessing at all. Measured against 188 upcoming episodes it agreed with a hand-kept network list 167 times, and every disagreement was the list being wrong — Citytv, CBC, BET and Global TV all broadcast at 20:30 or later and were simply missing from it. The list survives as the fallback for shows TVmaze does not carry. Matching is by IMDb or TVDB id and never by name: "The Office" is four shows, and a calendar that guesses between them is worse than one that admits it cannot tell.
A film reaches a digital store three or four months after cinemas. Apple's Egyptian store held 6,265 films with 168 released inside the year and none inside the month — a release-dated feed of it renders empty forever while the shelf behind it changes every week.
So the shelf is recorded and compared. Each sweep is ids only (six thousand full titles a night to answer a set difference would be absurd) and the few that turn out to be new are fetched properly afterwards. Prices come from the base64 payload JustWatch puts on every offer link — its own data rather than its current markup, and far steadier to read than the page.
No AI. Weighted counting with three corrections, because a straight tally of 229 loves against 10 dislikes concludes "you like everything":
- Deviation, not volume. A feature scores by how far it pulls a verdict above your own average, so a feature rated exactly at that average contributes nothing.
- Rarity. "action" sits on most of the catalogue and says almost nothing; "time loop" sits on a handful. An idf term lets the specific outweigh the broad.
- Confidence. Rarity makes a feature loud, so two ratings must not shout —
n/(n+2)damps thin evidence.
Plus a sparse prior, so a title we know almost nothing about scores near zero rather than near the top. Reality shows carry no keywords, no recurring cast and no collection, and once outranked every drama.
src/lib/taste.test.ts checks the TypeScript against the original Python
implementation's own output, title for title and score for score.
todayISO() formats in Africa/Cairo and everything dates through it. Using
toISOString() meant that between midnight and 2am local, a film released today
still rendered as unreleased.
Actors and directors come from TMDB's person records. A person's work is one entry per title, with every role they had on it joined ("Writer, Producer, Director"), so a film someone wrote and directed is not listed twice. Talk shows, news and "Himself" appearances are dropped — they are appearances, not work — and so are "Thanks" credits, which otherwise put films a director never touched under his name. "Director" means directed; producing lives under the same crew heading at TMDB and is filtered out of it here.
Where each title streams in Egypt is a request per title, so it is asked only for the forty most popular, and anything already in the catalog is answered from the local availability table. Everything is disk-cached, so the second visit to a page is instant.
The ring on Home is the identity board's artwork, drawn as supplied, with a glow
that behaves like something charged rather than something spinning: one
energy value — a slow breath, an occasional surge, a faint waver, all from sines
at unrelated frequencies so it never visibly repeats — drives a bloom in the
ring's own shape, a light from inside it, a corona, and waves that leave the rim
at their own strength. The screen-wide light rises and falls with it.
It is the same curve on both platforms. In the app it is SwiftUI with
plusLighter and a Metal shader for the wide light. On the web every frame is
drawn into a small canvas with the canvas's own lighter operation, because the
CSS equivalents — mix-blend-mode, filter: blur — are composited differently
by Safari and came out there as a dark box, tiles and a washed-out ring. The
wide light is painted once per layout, a pixel at a time, with a one-step dither:
at that strength a plain gradient spans a dozen 8-bit steps and shows each as a
ring.
It only moves while it can be seen. Scrolled past, on another tab or in the background, the ring stops — in the app its 30 fps timeline and the per-pixel shader behind it had kept the processor busy on every tab, all the time — and its energy goes only to the layers that read it, not to the whole page.
TMDB lists where a show streams, one answer for every season, which is how MobLand came to read "Netflix" for a second season that streams on TOD. The calendar also reads each current season's own listing, in the same request as its episodes, and an episode takes its season's service when the season names one. An empty season listing is taken as "not known yet", never as "on nothing": TMDB's per-season data lags its show-level data.
TOD's Egyptian listing has gaps of its own, so a title TOD carries in its other
Arab markets counts as on TOD here too (FALLBACK_REGIONS in providers.ts) — a
stated assumption, not something TMDB says.
- Next.js 16 (App Router, React 19) — every page is server-rendered on demand
- SQLite through Drizzle ORM, in WAL mode, one file
- Tailwind CSS 4, with every colour, radius and shadow as a token in
globals.css - Vitest — no network, no fixture of the real database, nothing that needs a server running
- TMDB for titles and people, TVmaze for air times, JustWatch for prices
- SwiftUI for the iPhone app, iOS 26 and later, talking to the same routes
No hosted database, no queue, no container. It runs as one Node process next to one file.
src/
app/ pages and route handlers; icons live here too
components/ the UI, one concern per file
db/ schema.ts is the single definition of every table
lib/ everything with a decision in it — and everything tested
taste.ts the ranking model
feeds.ts which titles belong in which feed
airing.ts when an episode actually reaches a viewer here
providers.ts which of your services a title is included on
people.ts actors and directors: their work, follows, the people you love
avatars.ts profile photos, kept beside the database
prefs.ts the account's tabs, news languages and F1 spoiler shield
news-rules.ts reading feeds and deciding which stories are about you
news.ts the News tab: your updates, then the press
f1.ts the F1 calendar, results and standings, and the shield
ratings.ts age ratings: certificates folded into five levels
age-filter.ts the one filter every list passes through
progress.ts a series' episodes, season by season, and how far you are
push-plan.ts when each notification is due, and what it says
push.ts web notifications: keys, subscriptions, the sender
queries.ts every read the pages do, in one place
app/api/app/ what the iPhone app reads: home, today, library, discover, rate, alerts
instrumentation.ts starts the notification sender with the server
ios/ the iPhone app (SwiftUI), MOX.xcodeproj
scripts/
refresh.mts the nightly job
refresh/ one module per step
brand-assets.py cuts every icon out of the identity board
brand/ the identity board, and what is cut from it
drizzle/ migrations, applied with `npm run db:migrate`
deploy/ deploy script and LaunchAgent templates
Two rules hold this together. Pages never assemble SQL — they call
src/lib/queries.ts, so the shape a component receives is declared once.
Anything with a decision in it lives in src/lib and has a test, which is
why the feed windows, the air-time rules and the provider matching can all be
checked without a network or a database.
Node 24 or newer (npm 11 writes the lockfile) and SQLite.
npm ci
npm run db:migrate
npm run devOpen http://localhost:3000. Every page works signed out. Sign in at
/admin/login to rate titles, follow shows and choose your subscriptions.
For a brand-new database, create the first account and then:
npx tsx scripts/ensure-owner.mtsThat account becomes the install owner — the only one who can change roles or delete accounts, and the only one who cannot be demoted.
Everyone else signs up from the site or the app — with Google, or a username, email and password — and manages their own email, password and account deletion from settings.
Then fill it with something:
npm run refreshios/MOX.xcodeproj builds with Xcode 26 or later. A Debug build talks to
http://localhost:3000; a Release build to https://mox.mosama.me; either can
be pointed elsewhere from Settings in the app.
cd ios
xcodebuild -project MOX.xcodeproj -scheme MOX -configuration Release \
-destination 'id=<your iPhone UDID>' -derivedDataPath build \
DEVELOPMENT_TEAM=<team id> -allowProvisioningUpdates build
xcrun devicectl device install app --device <UDID> build/Build/Products/Release-iphoneos/MOX.appThe phone needs Developer Mode on, and the first launch needs the developer trusted under Settings → General → VPN & Device Management. With a free Apple account the install lasts seven days.
The app reads and writes the calendars and reminders already on the phone (iCloud, Google, Outlook) through EventKit, so what you add in MOX shows up there too, and the other way round. Calendar and Tasks are tabs you can add in Settings; they ask for access first.
The app's notifications are made on the phone, not pushed: episodes of shows
you follow are scheduled for when they land (or the hour chosen in Settings,
10:00 Cairo by default, when only the day is known), F1 sessions a few minutes
before they start, and a watchlist title reaching one of your services is
spotted by comparing where it streams with what the phone saw last time. They
refresh when the app opens and when iOS gives it a moment in the background,
from /api/app/alerts. Push from the server needs a paid Apple developer
account; the website's sender (src/lib/push.ts) is the half of that already
built.
.env.local in the project root, ignored by Git:
TMDB_API_KEY=your_tmdb_key
TMDB_REGION=EG
MOX_DB=./data/mox.db
MOX_TMDB_CACHE=./data/tmdb-cacheOptional:
MOX_SECURE_COOKIES=trueforces session cookies to HTTPS-only. Normally mox detects HTTPS from the request orX-Forwarded-Proto; leave it off if you reach the server directly over plain HTTP.MOX_PUBLIC_URLis the site's public address, used for the absolute links in shared-title previews. Defaults tohttps://mox.mosama.me; the app's Share button uses the same address (AppSettings.publicSite).GOOGLE_CLIENT_IDSturns on "Continue with Google": the web client ID first, then the iOS one, comma-separated. These are public IDs. There is no client secret — the server checks Google's ID token and its audience — so none is ever configured.MOX_OWNERnames the owner account forscripts/check-owner.mtsand the legacy importer.
Deploy settings live in deploy/target.env, not here.
- Migrations are in
drizzle/, applied withnpm run db:migrate. They only ever add. data/,backup/,legacy/,.env*anddeploy/target.envare ignored: they hold accounts, sessions, ratings, API keys and one particular server's details. Profile photos live indata/avatars/, so a deploy never touches them.- SQLite runs in WAL mode. Use
VACUUM INTOfor a consistent live backup — copyingmox.dbalone silently omits recent writes sitting inmox.db-wal. - Never copy a local database over a running install. The server owns its own.
Passwords are scrypt with a per-user salt. Changing any password revokes every session that account had, including the owner's own.
npm run check # lint, typecheck, tests
npm run buildCI runs npm run check on every push. npm run build:webpack is a diagnostic
fallback if Turbopack itself fails.
The core tests are self-contained. When a private local dataset is present, an extra differential suite verifies the TypeScript taste ranking against the historical Python output — it skips cleanly when that data is absent, which is why CI is green without it.
deploy/deploy.sh pushes this repository to a Mac reachable over SSH and runs it
there under two LaunchAgents: one serving the app, one refreshing its data
nightly.
Configure the target once — the file is ignored by Git, so your server details stay yours:
cp deploy/target.env.example deploy/target.envThen, from the repository root:
./deploy/deploy.shIt backs the server's database up first and refuses to continue if that backup
failed; syncs code while excluding every database, backup, build output and
.env.local and the iPhone app in ios/; installs, migrates and builds on the server; then renders
deploy/launchd/*.template into LaunchAgents and reloads both. Because the
refresh agent is RunAtLoad, a deploy also brings the data current rather than
waiting for the next night.
The server needs its own .env.local with a TMDB key. The deploy deliberately
never copies yours.
The full board is committed at brand/identity.webp, and
scripts/brand-assets.py cuts every icon the app serves out of it — the tile at
16 through 512, the maskable variant, the home-screen icon and the wordmark.
| Page | #0B0F0E |
| Card | #1F2422 |
| Green | #00D084 |
| Mint | #A7F3D0 |
| Ink | #F8FAF8 |
| Type | Sora, with IBM Plex Sans Arabic for Arabic |
The mark is measured off the board rather than redrawn: an earlier pass drew it
as SVG geometry and got the ring's counter wrong by a third. It is not a flat
donut but a lit ribbon — mint where the light lands, deeper green opposite, its
end tucking behind itself at the lower left. The wordmark is extracted as
premultiplied alpha, which is what keeps the glow around the o intact where a
threshold cut-out would leave a hard edge.
Run the script after any change to the board; never redraw by hand.
python3 scripts/brand-assets.pyNo licence is granted: the code is public to read, not to reuse. The identity — the board, the mark and everything cut from it — is not for reuse at all.










