A modern, mobile-first web application for managing household TV shows and movies to watch. Built with React, Express, and Vite.
- π Multiple Lists: Separate watchlists for different household members (Dad, Mom, Both)
- π¬ Movies Tab: Every movie across all lists in one place, with an owner chip
- π¬ Content Tracking: Track TV shows and movies across streaming services
- π Theaters: Mark a film as in-cinemas; it moves to a service when it lands on one
- π TMDB Lookup: Search as you type, with artwork, synopsis and true-owner info
- βοΈ Editable Titles: Tap any card to change service, venue, list or category
- βοΈ Editable Services: Add your own streaming services, hide the built-ins you never use
- π Shared Passphrase: Optional household passphrase, asked once per device
- π Two-Device Safe: Concurrent edits from two phones merge instead of overwriting
- π Drag & Drop: Reorder your watchlist with intuitive drag-and-drop
- π Watch History: Keep track of completed titles with dates
- π¨ Beautiful UI: Light theme with brand-coloured service marks and small posters
- π± Mobile-First: Optimized for phones and tablets with touch gestures
- π Touch Gestures: Swipe and long-press for quick actions on mobile
- π² PWA Ready: Install as an app on your phone or desktop
- π Offline Support: Works without internet with cached data
- βΏ Accessible: ARIA labels and keyboard navigation support
- β Confirmation Dialogs: Prevent accidental deletions
- π Toast Notifications: Clear feedback for all actions
- β¨ Error Handling: Graceful error recovery with helpful messages
- Frontend: React 19, Vite
- Backend: Express.js
- State Management: React Context + useReducer
- Drag & Drop: @dnd-kit
- Validation: Zod
- Styling: Pure CSS with CSS variables
- Node.js 16+ and npm
-
Clone or download the project
-
Install dependencies:
npm install
-
Optionally configure TMDB and friends:
cp .env.example .env
Everything in it is optional β the app runs without any of it.
-
Start the development server:
npm run dev
This runs both the Express backend (port 3002) and Vite dev server (port 5173) concurrently.
To develop against a scratch copy of the data instead of whatever data.json
happens to be sitting in the project root:
DATA_DIR=$PWD/.devdata npm run dev- Open http://localhost:5173 in your browser
MediaList is a Progressive Web App and can be installed on your device:
Android/Desktop Chrome:
- Click the install button in the app banner
- Or use browser menu β "Install app"
iOS Safari:
- Tap Share button β "Add to Home Screen"
Benefits:
- π± Appears as standalone app (no browser UI)
- π Works offline with cached data
- β‘ Faster loading with cached assets
- π Quick access from home screen
See PWA.md for detailed installation instructions and PWA features.
MediaList-fixed/
βββ server.js # Express API server with validation
βββ data.example.json # Sample/testing data; the real list is not in the repo
βββ public/
β βββ manifest.json # PWA manifest (app metadata)
β βββ sw.js # Service worker (offline support)
β βββ icon.svg # App icon
βββ src/
β βββ App.jsx # Main app component
β βββ main.jsx # React entry point
β βββ index.css # Global styles
β βββ components/ # React components
β β βββ TitleCard.jsx # Card: poster thumb, chips, touch gestures
β β βββ TitleDetail.jsx # Tap a card: full artwork + all editing
β β βββ TitleList.jsx # List view with drag-and-drop
β β βββ AddTitleForm.jsx # Add modal with TMDB search
β β βββ SettingsView.jsx # Services, backups
β β βββ HistoryView.jsx # Watch history with grouping
β β βββ TabBar.jsx # Bottom navigation
β β βββ ServiceMark.jsx # Brand-coloured service tile
β β βββ StreamingBadge.jsx # Service mark + label
β β βββ PassphraseGate.jsx # Optional household passphrase
β β βββ Toast.jsx # Toast notification system
β β βββ ConfirmDialog.jsx # Confirmation dialog
β β βββ ErrorBoundary.jsx # Error boundary component
β β βββ InstallPrompt.jsx # PWA install banner
β βββ context/
β β βββ MediaContext.jsx # Titles: state, autosave, conflict merge
β β βββ ServicesContext.jsx # Streaming service catalogue
β βββ utils/
β βββ storage.js # API communication, auth, versioning
β βββ merge.js # Three-way merge for concurrent edits
β βββ tmdb.js # TMDB client + poster sizes
β βββ services.js # Service catalogue helpers
β βββ streamingApps.js # Built-in services
β βββ constants.js # Shared constants
β βββ validation.js # Data validation schemas
β βββ serviceWorker.js # Service worker registration
βββ .env.example # Copy to .env for TMDB / passphrase / backup
βββ package.json
npm run dev- Start development servers (frontend + backend)npm run build- Build for productionnpm run preview- Preview production buildnpm run lint- Run ESLint
The real watchlist is not in this repo.
- Production:
/data/data.jsonon the Fly volume. This is the live list. - Local: point
DATA_DIRat a scratch directory (.devdata/, gitignored) so experiments never touch anything real. - In this repo:
data.example.json, two obviously fake records, enough for a fresh clone to run and to document the record shape.
Any watchlist JSON you find in this repo or its history is sample/testing data β an old development snapshot, not the maintained list and not kept in sync with anything.
data.json and data copy.json are gitignored so a local working copy never
gets committed by accident.
Because the only live copy is the Fly volume, configure the off-box backup β
see Backups. Local backups/ sit on that same volume and do not protect
against losing it.
All optional β the app runs with none of these set.
| Variable | Default | Purpose |
|---|---|---|
PORT |
3002 |
Server port |
DATA_DIR |
/data in production, repo root otherwise |
Where data.json, services.json and backups/ live. Point it at a scratch copy to develop without touching the real list. |
MEDIALIST_PASSPHRASE |
unset | Shared passphrase for /api. Unset means no auth. |
ALLOWED_ORIGINS |
unset | Comma-separated origins allowed to call the API cross-origin. Unset sends no CORS headers, which is right when the app is served from this same server. |
BACKUP_MIN_INTERVAL_MS |
3600000 |
Minimum gap between local backup snapshots. |
TMDB_ACCESS_TOKEN / TMDB_API_KEY |
unset | TMDB credential. Unset disables title search and artwork; everything else still works. |
TMDB_REGION |
IL |
Region used for "where does this stream now" lookups. |
BACKUP_GITHUB_REPO |
unset | owner/repo for the daily off-box backup. |
BACKUP_GITHUB_TOKEN |
unset | Fine-grained PAT with Contents: read and write on that repo. |
BACKUP_GITHUB_PATH |
medialist/data.json |
Path within the backup repo. |
Values can go in a .env file (gitignored) β see .env.example.
Set the passphrase on Fly with:
fly secrets set MEDIALIST_PASSPHRASE='your-passphrase'Both devices hold the whole list and POST it wholesale, so a stale client could silently overwrite the other person's changes. To prevent that:
GET /api/titlesreturns anX-List-Versionheader (a hash of the content).POST /api/titlessends it back asIf-Match. A mismatch returns409along with the server's current copy.- The client three-way merges its own changes with the server's and retries. Changes this device made win; changes it did not make are taken from the server; deletions are honoured only when this device made them.
- The list is re-read whenever the tab regains focus.
Local. DATA_DIR/backups/ holds timestamped snapshots, taken at most once
an hour. Retention keeps everything from the last 24 hours plus one snapshot
per day for 14 days. GET /api/backup downloads the newest; Settings has a
Download Backup button.
Off-box. Local backups sit on the same Fly volume as the data, so they are
no help if the volume goes. Set BACKUP_GITHUB_REPO and BACKUP_GITHUB_TOKEN
and the server commits data.json to that private repo once a day.
It runs after a save rather than on a timer: Fly stops the machine when idle
(auto_stop_machines), so a nightly setInterval would often never fire.
Piggybacking on activity means it runs on any day the app is used. Settings
shows when it last ran and has a Back up now button.
With a credential set, typing a title in the add form searches TMDB and fills in the poster, year and category. List rows show deliberately small thumbnails so plenty of titles fit on a phone; tapping a card opens the detail view with the large artwork, synopsis, runtime and where it currently streams in your region.
Requests are proxied through this server, so the credential never reaches the
browser. Posters load directly from image.tmdb.org, which needs no key.
TMDB lists a title under resellers as well as its real owner: "Paramount+ Amazon Channel" means you can buy a Paramount+ subscription through Prime, not that Prime carries it. It also splits owners into tiers ("Paramount Plus Premium", "Netflix Standard with Ads").
Both are filtered out server-side, so "Streaming now in βΉregionβΊ" shows only the true owner. When that disagrees with the service recorded on a title, the detail view offers a one-tap correction. Providers are matched on TMDB's provider id rather than name, so rebrands (Apple TV+ to "Apple TV", Max back to HBO Max) do not break the mapping.
Tap any card to open its detail view: change the streaming service, flip between Streaming and In theaters, move it to another list, change category or watch style, mark it watched, or delete it.
Venue and service are separate fields on purpose. A film in cinemas has no service yet; when it lands on one, pick the service in the detail view and it moves to Streaming automatically. The detail view also shows what TMDB says is streaming it in your region, which is usually how you find out.
Each title has:
id: Unique identifier (UUID)title: Show/movie namestreamingApp: Platform (netflix, disney, hbo, etc.)category: "tv" or "movie"type: "binge" or "on-going"list: "dad", "mom", or "both"position: Sort order in liststatus: "watching" or "watched"addedAt: Timestamp when addedwatchedAt: Timestamp when marked watched (null if watching)
Built in, in picker order: Netflix, HBO Max, Apple TV+, Prime Video, Disney+,
Paramount+, Peacock, Starz, AMC+, Israel, Other. Add your own from Settings β
they are stored in services.json and appear in the picker before Other.
Hulu is absent: it folded into Disney+, and existing Hulu titles are migrated
across automatically. Theaters is absent too β that is a venue, not a
service.
Each service shows as a brand-coloured tile with a short mark. These are brand-coloured abbreviations, not reproductions of the official logos.
Escape- Close modals and dialogsEnter- Confirm actions in dialogsTab- Navigate between interactive elements
All destructive actions (delete, permanent delete) now require confirmation to prevent accidents. Dialogs can be dismissed with Escape or confirmed with Enter.
Success and error messages appear as toast notifications at the bottom of the screen:
- β Green for success (e.g., "Added Breaking Bad!")
- β Red for errors (e.g., "Failed to save")
β οΈ Orange for warnings- βΉοΈ Purple for info
- Server-side validation prevents corrupt data
- Automatic retry on save failures
- Graceful degradation if backend is unavailable
- Error boundary catches React errors
- ARIA labels for screen readers
- Keyboard navigation support
- Focus management in dialogs
- Semantic HTML elements
- Sufficient color contrast
Modern browsers with ES6+ support:
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Mobile browsers (iOS Safari, Chrome Mobile)
- The app saves automatically; every change is persisted immediately
- Writes take an in-process lock and are version-checked, so two devices cannot silently overwrite each other (see Concurrency)
- All data is validated with Zod on both client and server
- Records are normalised on read and write, so schema changes migrate themselves rather than needing a migration step
- Responsive design adapts to tablet and desktop screens
Private household project.
This is a personal household project, but feel free to fork and adapt for your own use!