A cinematic social movie & TV platform with a multi-agent AI assistant.
Track, discover, and discuss what you watch β powered by LangGraph, Flask, and TMDb.
FrameIQ is a Letterboxd-inspired social platform for film and TV enthusiasts, elevated with a production-grade multi-agent AI assistant (CineBot) that understands natural language, recommends by vibe, and streams its reasoning in real-time.
Built for people who care about what they watch β and want an intelligent companion to help them discover more.
| Track everything you watch | Discover by mood, genre, era | Discuss with friends & community |
| Watch inline with resume | Analyze your taste DNA | Chat with an AI film expert |
- Watchlist with priority tiers (High / Medium / Low)
- Diary β chronological log with dates, ratings, notes
- Viewed library with rich filtering
- Star ratings (Β½β5β ) + written reviews with markdown
- Custom lists β public, private, collaborative
- Tags with autocomplete & trending suggestions
- Episode-by-episode progress with timestamps
- Batch season operations β mark entire seasons watched
- Smart completion β only marks Completed when TMDb confirms series ended
- Upcoming Episodes calendar β 60-day view, auto-synced daily
- Continue Watching row with resume points
- Follow / unfollow with activity feeds (Following / Global / Personal)
- Friends' activity on every title page
- Popular With Friends algorithmic ranking
- Review likes, comments, helpful votes
- Suggested follows based on taste overlap
- Stream movies & episodes directly β no redirects
- Resume playback with per-title progress persistence
- Auto-logs diary entry at 85% completion
- Full watch history with timestamps
- TMDb-powered filters: genre, year, language, rating, provider
- Trending movies & shows (daily/weekly)
- Entertainment news feed (NewsAPI)
- Semantic similarity β "movies that feel like a rainy Sunday"
Architecture: LangGraph pipeline
User message
β
βΌ
βββββββββββββββ
β Supervisor β β zero-LLM heuristic router (saves 2β3 API calls)
ββββββββ¬βββββββ
β
βββββ΄ββββ
βΌ βΌ
βββββββββ βββββββ
βRetrieverβ β Chatβ β Retriever: 7 TMDb/vector tools
β β β β Chat: open-ended film knowledge
βββββ¬ββββ ββββ¬βββ
ββββββ¬βββββ
βΌ
ββββββββββββ
β Enricher β β concurrent TMDb poster & metadata fetch
ββββββ¬ββββββ
βΌ
SSE stream β browser (markdown + poster cards)
| Agent | Role | Model |
|---|---|---|
| Supervisor | Route + extract entities (titles, people, genres, years) | gpt-4.1-mini (structured) |
| Retriever | ReAct agent with 7 cached tools | gpt-4.1-mini |
| Chat | General film knowledge, recommendations | gpt-5-mini |
| Enricher | Extract titles from reply β fetch posters/meta | gpt-4.1-mini |
- Streaming responses via SSE β see each tool call as it happens
- Poster & metadata cards injected inline in replies
- Conversation memory persisted (SQLite checkpointing, survives restarts)
- User personalization β ratings, favorite genres, TV progress, watchlist injected per request
- Command palette (βK) + Slide-over panel (βJ) for keyboard-first UX
- Production-hardened streaming: single process event loop owns the
AsyncSqliteSavercheckpointer (no cross-loop use); graph + checkpoint verified before headers commit or the API returns503 JSON(mid-stream failures arrive as SSEerrorevents, never a status flip); per-stream checkpoint threads so concurrent chats can't collide - Limits: 5 questions per user per UTC day, 20/min + 100/hour per IP β
the UI shows remaining quota live via
X-Chat-Remainingheaders
- Watch time, genre breakdown, top directors/actors
- Year-in-review with interactive Chart.js visualizations
- Taste DNA β genre affinity bars with gradient fills
- Taste Match badges on every title page
| Layer | Technology |
|---|---|
| Backend | Flask 3.1, SQLAlchemy 2.0, PostgreSQL 16 |
| AI / Agents | LangGraph, LangChain, OpenAI (gpt-4.1-mini / gpt-5-mini) |
| Auth | Google OAuth 2.0 (Authlib) + Flask-Login + Flask-WTF CSRF |
| Frontend | Jinja2, Tailwind CSS, Vanilla JS (ESM), Chart.js, Lucide icons |
| Media APIs | TMDb, Cloudinary (avatars), NewsAPI |
| Streaming | Rive, VidKing, Vidy, 1Embed providers |
| Infra | Docker Compose, systemd-nginx (reverse proxy), GitHub Actions |
| Observability | Langfuse-ready, structured logging |
Monochrome Marquee β true black (#000), white type, single amber accent (#F6B73C)
- Display: Archivo Expanded (variable width)
- Body: Inter
- Metadata: JetBrains Mono
- Motion: cubic-bezier(0.16, 1, 0.3, 1) β 180ms/320ms
- Radii: 4 / 6 / 10px β sharp, broadcast-grade
- Film grain overlay + projector beam/dust atmosphere
All 47 templates unified on a single design token system. Zero Poppins, zero indigo/violet, zero unused dependencies.
- Python 3.12+
- PostgreSQL 16 (local or Docker)
- API keys (see Environment Variables)
# 1. Clone & enter
git clone https://github.com/RobinMillford/FrameIQ.git
cd FrameIQ
# 2. Virtual environment
python -m venv .venv && source .venv/bin/activate
# 3. Dependencies (uv recommended)
uv sync
# or: pip install -r requirements.txt
# 4. Environment
cp .env.example .env
# Edit .env with your keys
# 5. Run
python app.py # http://localhost:5000pytest tests/ -v # offline suite (~7 min), no TMDb key needed
uv run flake8 . # lint (max-line 127, complexity 10)cp .env.example .env # production values
make deploy # pull + clean build + start (run from /opt/frameiq on the VPS)Stack: web (Gunicorn 2 workers Γ 4 threads, Python 3.11-slim) + db (Postgres 16-alpine). TLS terminates at the host nginx (ports 80/443, certbot); Postgres is bound to loopback only β never exposed to the internet.
make status # container status + /agent_health check
make logs # tail web logs (logs-db for Postgres)
make restart # restart web container
make backup # pg_dump snapshot via scripts/backup.sh
make db-shell # psql into the production DB
make migrate # status of the versioned migration ledger (READ-ONLY)The startup schema guard REQUIRES schema parity with the declared models and will fail Gunicorn workers if the database is behind β that guard is intentional and must not be weakened.
Application startup never creates schema. There is no db.create_all() at
runtime: not for new tables, not for new columns. Every schema change β new
table or new column β is applied by the versioned migration runner, before
the new web image starts:
git pull origin main
docker compose build web
docker compose run --rm --no-deps web \
python scripts/migrate.py init-ledger
docker compose run --rm --no-deps web \
python migrates/migrate_schema_convergence.py
docker compose run --rm --no-deps web \
python scripts/migrate.py upgrade --allow-production
docker compose up -d
docker compose exec web python -m utils.schema_guardRun the migrations in that order β they are the same steps the deploy workflow
runs. Do not run individual migrates/migrate_*.py scripts: they are
historical, unregistered, and several drop tables. scripts/migrate.py is the
only supported path, and it records what it applied in the schema_migrations
ledger so it can be verified afterwards.
docker compose run --rm --no-deps executes the migration against the
shared database in a one-off container (removed on exit, no port bindings,
no dependency restarts) β the migration scripts set SKIP_SCHEMA_GUARD=1
themselves, so the guard (enforced at application startup via
ensure_schema_compatible()) stays active for normal application runs
and never runs migrations for you. Never start web before its required
schema exists; the guard failing fast is the system protecting you from a
half-migrated deploy.
Chat memory (instance/chat_memory.db, WAL mode) persists on the chat_memory
volume across restarts and rebuilds.
Copy
.env.exampleβ.envand fill in.
| Variable | Required | Description |
|---|---|---|
SECRET_KEY |
β | Flask session secret (32+ chars) |
DATABASE_URL |
β (local dev) | postgresql://user:pass@host:5432/db β auto-built from POSTGRES_* in Docker |
TMDB_API_KEY |
β | TMDb API |
OPENAI_API_KEY |
β | OpenAI API key (chat + embeddings) |
GOOGLE_CLIENT_ID |
β | Google OAuth 2.0 |
GOOGLE_CLIENT_SECRET |
β | Google OAuth 2.0 |
CLOUDINARY_CLOUD_NAME |
β | Avatar uploads |
CLOUDINARY_API_KEY |
β | Cloudinary |
CLOUDINARY_API_SECRET |
β | Cloudinary |
CLOUDINARY_URL |
β | Cloudinary (alternative single-var config) |
STREAM_PROVIDER |
β | Default embed source: rive | vidking | vidy | oneembed |
RATELIMIT_STORAGE_URI |
β | Redis URI for multi-worker rate limits (defaults to memory) |
POSTGRES_DB / POSTGRES_USER / POSTGRES_PASSWORD |
β (Docker) | Self-hosted DB credentials |
POSTGRES_EXPOSED_PORT |
β | Loopback-bound host port for the episode-sync workflow |
WEB_WORKERS / WEB_THREADS |
β | Gunicorn sizing (defaults 2 / 4 β do not raise blindly) |
FLASK_DEBUG |
β | Never true in production |
MAIL_SERVER / MAIL_* |
β | Password reset emails |
RATELIMIT_STORAGE_URI |
β | Redis for rate limiting (defaults to memory) |
FrameIQ/
βββ app.py # Application factory (create_app)
βββ models/ # SQLAlchemy models package (user, media, chat, β¦)
βββ extensions.py # Flask extensions (limiter, mail, db)
βββ requirements.txt
β
βββ routes/ # 28 Flask blueprints (one per domain)
β βββ auth.py # Login, register, password reset
β βββ main.py # Home, search, news, tonights-pick
β βββ details.py # Movie / TV detail pages
β βββ reviews.py # Reviews & ratings
β βββ diary.py # Watch diary
β βββ lists.py # Custom lists (basic + advanced)
β βββ tv_tracking.py # Episode & season tracking
β βββ chat.py # SSE streaming chat
β βββ social.py # Follow, activity feed
β βββ stats.py # Dashboard, year-in-review
β βββ ...
β
βββ src/
β βββ agents/ # LangGraph multi-agent system
β β βββ graph.py # StateGraph definition
β β βββ nodes.py # Supervisor, Retriever, Chat, Enricher
β β βββ tools.py # 7 LangChain tools (cached TMDb + history)
β β βββ state.py # GraphState schema
β βββ api/
β βββ agent_service.py # User context building, SSE streaming
β βββ flask_integration.py
β
βββ api/ # Shared utilities
β βββ tmdb_client.py # TMDb wrapper (compat shim)
β βββ tmdb/ # TMDb package: cache, movies, tv, people, search
β βββ stream_providers.py # Embed providers (Rive, VidKing, Vidy, 1Embed)
β βββ chatbot.py # LLM helpers
β
βββ templates/ # 47 Jinja2 templates (unified on base.html)
βββ static/
β βββ css/ # tokens.css, chrome.css, detail.css, projector.css
β βββ js/ # chat-page.js, chrome.js, projector-dust.js
β βββ images/
β
βββ scripts/ # Ops (excluded from Docker)
β βββ sync_upcoming_episodes.py
β βββ collect_media.py
β
βββ migrates/ # Manual schema migrations
βββ .github/workflows/ # ci-cd.yml, deploy.yml, sync-upcoming-episodes.yml
βββ nginx/nginx.conf # Reverse proxy + SSL
βββ docker-compose.yml
βββ Dockerfile
βββ Makefile
| Workflow | Trigger | Actions |
|---|---|---|
| ci-cd.yml | Push to main/develop |
offline pytest + flake8 |
| deploy.yml | Push to main (after CI pass) |
SSH β VPS β make deploy |
| sync-upcoming-episodes.yml | Daily 02:00 UTC | Sync TMDb β PostgreSQL |
VPS: Self-hosted PostgreSQL 16, Docker Compose, system nginx, nightly pg_dump backups, certbot SSL.
Health: /health (app) and /agent_health (chat graph loaded?) β make status hits the latter.
The ordinary test suite is offline: no TMDB_API_KEY, no DNS, no internet.
# Full offline suite (the default, and what CI runs)
pytest tests/
# Smoke only
pytest tests/test_basic.py
# Models
pytest tests/test_models.py
# Single test
pytest -k "test_name"Every TMDb payload is served from deterministic fixtures, so tests exercise the real cache, fetchers, parsers and resolvers against fake payloads rather than against a live service:
| Concern | Where |
|---|---|
| Fixture payloads + fake transport | tests/tmdb_offline.py |
| Network guard + per-test isolation | tests/conftest.py |
| Shared deterministic TV fixtures | tests/tv_fixtures.py |
| The contract itself (pinned) | tests/test_tmdb_offline.py |
requests is intercepted at requests.sessions.Session.send, so an
unregistered TMDb endpoint returns the real "invalid API key" payload and any
other host raises immediately. Raw sockets, socket.getaddrinfo,
httpx and urllib are blocked too β an accidental request fails loudly
with Unexpected network access during offline test: <url> rather than
silently reaching api.themoviedb.org.
Genuine external verification lives behind a marker and is deselected by default:
pytest -m tmdb # real TMDb; requires TMDB_API_KEY in the env
pytest -m "not tmdb" # the offline suite (same as plain `pytest tests/`)Browser/data verification scripts under scripts/ (verify_tv_*_browser.py)
are a separate, manual layer: they use a configured TMDb key and are never
part of pytest tests/.
Quality gates: offline suite green with no TMDb secret β’ flake8 clean β’ vulture clean β’ 0 secrets β’ 0 dead code
We welcome contributions! Please read CLA.md before submitting a PR.
Good first issues:
- π Add more languages/regions to TMDb discover tools
- π§ͺ Write missing unit tests in
tests/ - βΏ Improve accessibility (ARIA, keyboard nav)
- π Add chart types to stats dashboard
- π¨ Extend design token system
# 1. Fork & clone
git clone https://github.com/YOUR-USERNAME/FrameIQ.git
# 2. Branch
git checkout -b feat/your-feature
# 3. Develop
# ... make changes ...
# 4. Verify
pytest tests/ && uv run flake8 .
# 5. Push & PR
git push origin feat/your-feature
# Open PR against mainLicensed under GNU Affero General Public License v3.0 β see LICENSE.
Commercial use, SaaS hosting, or white-labelling requires a separate licence.
Contact:robinmill4d@gmail.com
- TMDb β The movie database powering our data
- LangGraph β Multi-agent orchestration
- Letterboxd β Product inspiration
- Archivo β’ Inter β’ JetBrains Mono β Typefaces
- Tailwind CSS β’ Chart.js β’ Lucide β Frontend toolkit
Built with β€οΈ by Robin Millford and contributors.
Star β the repo if you find it useful β it helps more people discover FrameIQ.