Skip to content

Repository files navigation

mox

What's on tonight, and where to watch it.

quality Next.js 16 SQLite


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.

What it looks like

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.

Home on the web

Home in the iPhone app    A director's page in the iPhone app

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.

A series' episodes in the iPhone app, with the season menu and progress

A title in the iPhone app, with Follow, Watchlist, Seen, rating and Share    Sharing a title from the iPhone app as a mox link

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.

Today

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.

Universes

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.

News in the iPhone app    The F1 tab, with the spoiler shield up

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).

The idea

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.

How it stays current

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.

Three rules the job keeps

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.

The parts worth explaining

When an episode actually arrives

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 shop is not a release feed

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.

The taste model

No AI. Weighted counting with three corrections, because a straight tally of 229 loves against 10 dislikes concludes "you like everything":

  1. 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.
  2. 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.
  3. 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.

Cairo, not UTC

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.

People and where their work streams

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

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.

Which service, season by season

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.

The stack

  • 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.

Layout

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.

Running it

Node 24 or newer (npm 11 writes the lockfile) and SQLite.

npm ci
npm run db:migrate
npm run dev

Open 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.mts

That 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 refresh

The iPhone app

ios/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.app

The 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.

Environment

.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-cache

Optional:

  • MOX_SECURE_COOKIES=true forces session cookies to HTTPS-only. Normally mox detects HTTPS from the request or X-Forwarded-Proto; leave it off if you reach the server directly over plain HTTP.
  • MOX_PUBLIC_URL is the site's public address, used for the absolute links in shared-title previews. Defaults to https://mox.mosama.me; the app's Share button uses the same address (AppSettings.publicSite).
  • GOOGLE_CLIENT_IDS turns 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_OWNER names the owner account for scripts/check-owner.mts and the legacy importer.

Deploy settings live in deploy/target.env, not here.

Data

  • Migrations are in drizzle/, applied with npm run db:migrate. They only ever add.
  • data/, backup/, legacy/, .env* and deploy/target.env are ignored: they hold accounts, sessions, ratings, API keys and one particular server's details. Profile photos live in data/avatars/, so a deploy never touches them.
  • SQLite runs in WAL mode. Use VACUUM INTO for a consistent live backup — copying mox.db alone silently omits recent writes sitting in mox.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.

Quality

npm run check     # lint, typecheck, tests
npm run build

CI 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.

Deploying

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.env

Then, from the repository root:

./deploy/deploy.sh

It 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 identity

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.py

Licence

No 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.

About

What's on tonight, and where to watch it. A personal film and TV board that rebuilds itself from TMDB, TVmaze and JustWatch every night.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages