Pin the shows you actually care about, and get a calendar of just those.
Sonarr already has a calendar. The problem is that it shows everything — every series you ever added, including the one you abandoned in 2019. Forty-seven items a week, of which four matter.
Pinnarr is the curation layer. You pin a handful of shows; you get a calendar of their upcoming episodes and a push notification the moment an episode actually lands in Plex and is watchable.
Status: in use, single-household. Running daily against a real Plex, Sonarr and Tautulli. See SPEC.md for the full design and the roadmap for what is and isn't built.
Most tools in this space are a nicer skin on Sonarr's calendar feed. Pinnarr adds three things they don't have:
A curated subset. Notifications are only useful if they're rare. Pinning means a push means something you care about is ready, not "47 things aired".
Honest availability state. Not just "did it air" but "is it actually here":
| State | Meaning |
|---|---|
upcoming |
Hasn't aired yet |
awaiting |
Aired in the last 48h, not grabbed yet |
missing |
Aired more than 48h ago and still isn't here — something's wrong |
available |
In Plex, watchable now |
That missing state is the one nothing else surfaces well. Sonarr buries it
under Wanted.
Season outlook. Sonarr's status: continuing means only "TVDB hasn't marked
this ended" — a quietly cancelled show keeps that status for years. Pinnarr
combines Sonarr's scheduling data with TMDB's production status to give an
honest verdict:
| Outlook | Meaning |
|---|---|
dated |
Next episode has a date |
announced |
Next season exists in metadata, no dates yet |
in_production |
Filming, nothing scheduled |
between_seasons |
Normal gap |
dormant |
Nominally continuing, but nothing for 18+ months and not in production |
cancelled / ended |
Two different things; TMDB distinguishes them |
dormant is pin-list hygiene: without it, your pins slowly fill with zombie
shows and nothing ever tells you.
Plex → what you own, external IDs, genres
Sonarr → air dates, grab state, season structure
Tautulli → watch history (makes the pinning pass fast)
TMDB → production status: cancelled vs on hiatus
↓
SQLite + FastAPI
↓
calendar · library browser · ntfy push
Series identity is the TVDB id, resolved from Plex GUIDs, falling back to TMDB → IMDb → title+year.
Notifications fire from Sonarr's On Import webhook rather than polling, so
they're instant — and they mean arrived, not aired. Getting pinged at 2am
because something aired in America is a tease.
- Plex Media Server
- Sonarr v3+ API
- A free TMDB API key (for season outlook)
- Tautulli (optional, improves library sorting)
- An ntfy topic (optional, for push)
git clone https://github.com/marctew/pinnarr.git
cd pinnarr
docker compose up -dThen open http://<host>:8737, which lands on Settings. Fill in Plex and
Sonarr, save, and hit Test on each — the Plex test lists your libraries so
you can tick the TV ones rather than hunting for section IDs.
There is no configuration file to edit. .env carries only DATABASE_PATH
and LOG_LEVEL; everything else lives in the database.
Your tokens are in the database. PLEX_TOKEN is long-lived and grants full
read access to your library, and it now sits in pinnarr.db rather than a
chmod 600 file. Give the data directory permissions to match, and don't hand
the database around.
Settings → Connect → + → Webhook:
- URL:
http://<pinnarr-host>:8737/hooks/sonarr?secret=<the webhook secret from Settings> - Method: POST
- Triggers: On Import, On Upgrade
Sonarr needs to be able to reach Pinnarr, not just the other way round — worth checking if your *arr stack and Pinnarr are on different VLANs.
If you're deploying to an LXC, Docker needs nesting=1 and keyctl=1 set on
the container. Unprivileged is fine — making it privileged is an outdated
workaround. 1 vCPU / 1GB RAM / 8GB disk is plenty.
pip install -r requirements-dev.txt
pytest -q
ruff check app tests
uvicorn app.main:app --reload --port 8737For anything that isn't a browser — a home-automation module, a Stream Deck plugin, a shell script. Create a key under Your account → API keys; it is shown once and stored hashed.
curl -H "X-Api-Key: pnr_..." http://pinnarr.lan:8737/api/summaryAuthorization: Bearer pnr_... works equally. A key acts as the account that
made it — same pins, same watch state, same role — so a standard user's key
cannot reach the admin routes.
| Endpoint | Answers |
|---|---|
GET /api/summary |
Everything a dashboard needs, in one call |
GET /api/schedule?days=7&back=1 |
What's on, for your pins |
GET /api/next |
The single next episode |
GET /api/watching |
Shows you're partway through |
GET /api/arrivals?hours=24 |
What landed recently — the automation trigger |
GET /api/downloads |
The queue, and what has stalled |
POST /api/series/{id}/refresh-watched |
Re-read one show's watch state from Plex now |
POST /api/series/{id}/watched |
Mark a show (or season=N) watched in Plex |
POST /api/episodes/{id}/watched |
Mark one episode watched in Plex |
POST /api/request/{tmdbId} |
Ask Overseerr for a show you don't own |
GET /api/widget |
A home-screen widget's worth, pre-worded |
GET /api/calendar |
The raw calendar feed |
GET /healthz |
Liveness, config state and last job runs (no key needed) |
/api/summary is deliberately not RESTful: a wall display redrawing every
minute shouldn't make six requests to fill one screen, and every number in it
comes from the same instant. It carries healthy and failing_jobs — not
"is Pinnarr up", since you just reached it, but whether what it's telling you
is still fresh.
Full schemas at /api/docs.
Pinnarr speaks MCP at /mcp, authenticated with the same API keys. Add it as
a custom connector in your Claude settings — it needs to be reachable from
the internet, so a Cloudflare Tunnel or similar — and you can then ask
questions of your own shelf:
what should I watch tonight? — "You've got crime, comedy and documentary available. Which do you fancy?" comedy, I've got about half an hour — "Some Sitcom — six unwatched, 25 minutes each..."
That two-step is deliberate. what_to_watch called without genres returns
what's available by genre and no suggestions, because what somebody owns
is a poor guide to what they're in the mood for on a given evening.
Ranking uses your own history: a pin is a deliberate statement, a watched episode a revealed one, and a pin is weighted at about a season's worth of watching so one long-running show can't drown out every choice you ever made. It's a transparent count, not a model — "you watch a lot of crime" is a useful thing to be told and "the algorithm says so" isn't.
| Tool | |
|---|---|
library_stats |
The shape of the library without listing it |
search_library |
Find a series; capped, says what it left out |
get_series |
One show, with your progress through it |
what_to_watch |
Ask the genre, then suggest — series or film |
continue_watching |
What you're partway through |
whats_on |
Broadcast schedule for your pins |
search_films |
Films, by genre, length or whether you've seen it |
find_person |
An actor, against what you own |
pin_series / unpin_series |
Follow or stop following |
mark_watched |
Marks it in Plex, needs your Plex token |
Films are in there too. They sync from Plex alongside the shows, and
what_to_watch will offer one when the time budget suits — forty minutes is
an episode, two hours is a film. They have no pins and no calendar: a pin
exists because television arrives weekly and you need telling, and a film
either is on the shelf or isn't.
Requesting isn't a tool. It spends disk and a misread "ooh get me that" could pull down twelve seasons. Do that in the app.
Nothing returns the whole library — every list is capped and says how many it left out. Two thousand series won't fit in a context window, and a tool that tries makes every answer after it worse.
Built:
- Schema, migrations, database-backed config with an admin panel
- Plex / Sonarr / Tautulli / TMDB / ntfy clients
- Season outlook engine
- Sync jobs, scheduler and series identity resolution
- Arrival notifications, reconcile pass and weekly digest
- Calendar — month grid, agenda, and live download progress
- Library browser with faceted filtering and bulk pin
- Sonarr webhook receiver (
/hooks/sonarr) - Poster proxy route (
/poster/{id}) - Accounts: admin-created users, per-user pins, notifications and watch state
- Two-way pin sync with Sonarr tags and the Plex Watchlist
- Per-episode watch state from Plex, with Tautulli filling in play times
- Ready to watch, gaps, retire and discover
- Backup and restore
- Cast cross-referencing against your own library
- API keys and a read API for integrations
- Overseerr: link out, request what you don't own, and see what's been asked
- iPhone home screen widget (Scriptable)
- MCP server, so Claude can be asked what to watch
- Films synced from Plex, searchable and suggestable
Not built:
- Radarr digital-release track (movies, GB region)
- iCal feed of pinned episodes
MIT