Pick what to watch, together.
A self-hosted, group movie & show picker for Jellyfin. Drop a link in a group chat and let everyone collectively decide what to watch through a two-round poll — nominate titles straight from your own library, then vote. Mobile-first, no accounts required, runs as a single small container alongside your existing Jellyfin stack.
![]() Start a poll |
![]() Round 1 — nominate |
![]() Round 2 — vote |
![]() Results |
- Create a poll. Pick the library scope (movies, shows, or both), a voting method, and optional nomination rules (min / max / required per person). You get a short share code and a link.
- Round 1 — Nominate. Participants open the link, join with a display name, browse or search your Jellyfin library, and nominate titles. Duplicate nominations are merged and show how many people picked them.
- Round 2 — Vote. The host starts voting and everyone casts a ballot using the poll's voting method. Ballots are editable until the round closes.
- Results. The host closes the poll and the winner is revealed. Results can optionally update live during voting.
Everything updates in real time via Server-Sent Events — no refreshing.
- Two-round flow — nominate, then vote, with host-defined controls.
- Four voting methods, chosen per poll (see below), built on a pluggable engine so new methods are a single registration — including a no-vote random pick.
- Guest-friendly — participants just enter a display name; no Jellyfin account needed to join and vote. Optionally require a Jellyfin login to create a poll or request downloads (see Access control), and/or set a per-poll passcode to keep random link-finders out.
- Browse your real library — poster-grid search, scoped to movies/shows and optionally restricted to chosen genres for the whole poll. While browsing, narrow the results on the fly with the type chips and a horizontally scrollable genre filter. Posters are proxied through the backend, so the browser never sees your Jellyfin URL or API key. Duplicate library entries for the same title are collapsed automatically.
- Flexible self-voting — allow, forbid, or cap how much voters may back their own picks; optionally reveal who nominated the winner.
- Write-ins via Seerr (optional) — nominate titles you don't have yet by searching Seerr/TMDB, and auto-request a winning write-in so it downloads.
- Short share codes — ambiguity-free 6-character codes (Crockford base32,
no
O/0/I/1/Lconfusion), embedded in a tappable link. - Mobile-first — designed for passing a phone around / dropping a link in a chat.
- Light & dark, theme-aware — the "Reef" design system adapts to system, light, or dark with no flash of the wrong theme.
- Live updates — counts, nominations, and results stream over SSE.
- Admin dashboard (optional) — an
/adminview of every poll's history (status, counts, winner), with force-advance and delete, plus an opt-in retention window. Gated by your Jellyfin account (a username allowlist or any Jellyfin admin) — see Access control. - Tiny footprint — a single ~13 MB distroless image; pure-Go SQLite, no external database.
- Modern Jellyfin auth — uses the
Authorization: MediaBrowserheader, not the legacy schemes Jellyfin removes in 10.13.
![]() Browse — filter by type (teal) & a scrollable genre bar (amber) |
| Method | Label in UI | Config keys (defaults) | How the winner is decided |
|---|---|---|---|
| Approval | Approval / N votes | votes_per_user (3), max_votes_per_option (1, 0 = unlimited), allow_self_vote (true) |
Highest total weight across all ballots. With max_votes_per_option: 1 this is plain approval voting. |
| Ranked | Ranked-choice (IRV) | allow_self_vote (true), max_ranked (0 = no limit) |
Instant-runoff: eliminate the lowest first choice and redistribute until a title has a majority. |
| Score | Star / score rating | max_score (5), aggregate (total; or average), allow_self_vote (true) |
Rate each title 0..max_score; ranked by total (or average) score. |
| Random | Random pick | none | No voting round — when the host closes round 1, one nomination is drawn uniformly at random. The draw is persisted so the winner is stable. |
Each method enforces its own ballot rules server-side (vote budgets, per-option
caps, and whether you may vote for a title you nominated). Voting for your own
picks can be open, disabled, or capped via max_self_votes (<0 unlimited,
0 none, N at most N — when set it overrides the legacy allow_self_vote).
Nomination rules (min / max / required) are enforced independently of the
voting method.
Each method renders its own ballot:
![]() Approval / N votes |
![]() Ranked-choice (IRV) |
![]() Star / score rating |
![]() Random pick (no ballot — winner drawn) |
Run it standalone, pointed at any reachable Jellyfin server:
export JELLYFIN_URL=http://your-jellyfin:8096
export JELLYFIN_API_KEY=... # Jellyfin Dashboard → API Keys
export SEEURCHIN_SESSION_SECRET=$(openssl rand -hex 32)
export SEEURCHIN_BASE_URL=http://localhost:5858
docker compose up --buildOpen http://localhost:5858.
Getting a Jellyfin API key: in Jellyfin, go to Dashboard → API Keys → +, name it
seeurchin, and copy the key. This key is used only for reading the library; it's never exposed to the browser.
Add a service to your stack's docker-compose.yml, on the same Docker network
as Jellyfin so it can reach it by service name, and route a hostname to it
through your reverse proxy / Cloudflare tunnel:
seeurchin:
image: seeurchin:latest # or: build: ./seeurchin
container_name: seeurchin
init: true
environment:
- TZ=America/Chicago
- JELLYFIN_URL=http://jellyfin:8096
- JELLYFIN_API_KEY=<dashboard API key>
- SEEURCHIN_BASE_URL=https://vote.example.com # your public tunnel hostname
- SEEURCHIN_SESSION_SECRET=<openssl rand -hex 32>
volumes:
- ./config/seeurchin:/config
ports:
- "5858:5858" # optional; the tunnel can reach it over the internal network
networks:
- media-network
depends_on:
- jellyfin
restart: unless-stoppedThen add a public-hostname route in your tunnel (e.g. vote.example.com →
http://seeurchin:5858). Because SEEURCHIN_BASE_URL is set to the public
hostname — and copied share links are derived from whatever origin you open the
app at — links shared from the browser just work.
docker-compose.stack.yml runs seeurchin attached to an already-running stack's
network (it references the external network created by your main compose
project), so it talks to Jellyfin at the same internal URL it will use in
production:
export JELLYFIN_API_KEY=...
export SEEURCHIN_SESSION_SECRET=$(openssl rand -hex 32)
docker compose -f docker-compose.stack.yml up --buildAdjust the name: under networks.media-network to match your stack's network
(docker network ls).
All configuration is via environment variables.
| Variable | Required | Default | Description |
|---|---|---|---|
JELLYFIN_URL |
yes | — | Jellyfin base URL, e.g. http://jellyfin:8096. |
JELLYFIN_API_KEY |
yes | — | API key (Dashboard → API Keys) for library reads. |
SEEURCHIN_BASE_URL |
recommended | http://localhost:5858 |
Public origin used to build shareable links. Set to your tunnel hostname in production. |
SEEURCHIN_SESSION_SECRET |
recommended | random | HMAC secret for session cookies. A hex string (≥16 bytes) is decoded; anything else is used verbatim. If unset, a random secret is generated and sessions won't survive a restart. |
SEEURCHIN_ADDR |
no | :5858 |
TCP listen address. |
SEEURCHIN_DB_PATH |
no | ./seeurchin.db |
SQLite file path. Use a mounted volume (the image defaults to /config). |
SEEURCHIN_CODE_STYLE |
no | base32 |
Share-code style. Only base32 is implemented today (words is planned). |
SEEURCHIN_ENABLE_USER_LOGIN |
no | false |
Require a Jellyfin login to create a poll and to request a winning write-in (the actions that can trigger NAS downloads). Joining/voting stay open via the share link. When off (default), those actions are unauthenticated. |
SEEURCHIN_ADMIN_USERS |
no | — | Comma-separated Jellyfin usernames (case-insensitive) allowed into the admin dashboard at /admin. Requires SEEURCHIN_ENABLE_USER_LOGIN=true. |
SEEURCHIN_ADMIN_JELLYFIN_ADMINS |
no | false |
Also grant admin access to any account flagged as a Jellyfin administrator. Requires SEEURCHIN_ENABLE_USER_LOGIN=true. |
SEEURCHIN_POLL_RETENTION_DAYS |
no | 0 |
Auto-delete polls this many days after they close (cascading to their participants/nominations/votes). 0 (default) keeps history forever. |
SEERR_URL |
no | — | Seerr/Overseerr/Jellyseerr base URL, e.g. http://seerr:5055. Set with SEERR_API_KEY to enable write-in nominations + winner auto-request. |
SEERR_API_KEY |
no | — | Seerr API key (Settings → General → API Key). Use a dedicated account whose default profile is what you want requested; grant it auto-approve to have winners download without manual approval. |
SEERR_USER_ID |
no | API key owner | Seerr user id to attribute requests to (e.g. a dedicated "movie night" account), using that user's defaults. |
SEERR_MOVIE_PROFILE_ID / SEERR_TV_PROFILE_ID |
no | account default | Quality profile id applied to movie / TV requests. |
SEERR_MOVIE_ROOT_FOLDER / SEERR_TV_ROOT_FOLDER |
no | account default | Root folder for movie / TV requests. |
SEERR_SERVER_ID |
no | account default | Radarr/Sonarr server id to request against. |
The Docker image additionally defaults SEEURCHIN_DB_PATH=/config/seeurchin.db
and declares /config as a volume.
When SEERR_URL + SEERR_API_KEY are set, participants can nominate titles that
aren't in your library yet (searched via Seerr/TMDB), and a winning write-in is
auto-requested through Seerr — each per-poll toggleable. The request is made by
the account that owns the API key, using its default (or the configured) quality
profile; it only downloads automatically if that account is set to auto-approve.
seeurchin is designed to be shared with people outside your network, so its gating is split by what each action can do:
- Joining, nominating, and voting are open to anyone with the share link — optionally narrowed by a per-poll passcode the host sets at creation (a soft gate against random link-finders; it never touches your NAS).
- Creating a poll and requesting a winning write-in are the only actions
that can reach Seerr and trigger a download. Set
SEEURCHIN_ENABLE_USER_LOGIN=trueto require a Jellyfin login for them, so only people with an account on your server can cause downloads. Guests can still suggest off-library titles; the logged-in host who enabled auto-request (or who clicks "request") owns the consequence. - Admin (
/admin) is authorized from that same Jellyfin identity: list the usernames you trust inSEEURCHIN_ADMIN_USERS, and/or setSEEURCHIN_ADMIN_JELLYFIN_ADMINS=trueto admit any Jellyfin administrator.
For a public deployment, also consider rate-limiting /api/user/login (e.g. a
Cloudflare rule) on top of the small built-in per-IP throttle.
The frontend is a single-page app served from the same origin as the API (so no
CORS). All poll endpoints are scoped by share code; mutations require a
session cookie obtained from create or join.
| Method | Path | Description |
|---|---|---|
GET |
/api/health |
Liveness check. |
GET |
/api/features |
Optional capabilities, e.g. {"seerr": true, "admin": true, "user_login": true}. |
GET |
/api/methods |
Available voting methods and their default configs. |
GET |
/api/genres?scope= |
Library genres for a scope (movie/series/both), for the genre picker. |
GET |
/api/user/session |
Jellyfin login state ({login_enabled, authenticated, display_name, is_admin}). |
POST |
/api/user/login |
Log in with Jellyfin ({username, password}); sets the identity cookie. |
POST |
/api/user/logout |
Clear the Jellyfin identity cookie. |
POST |
/api/polls |
Create a poll (creator becomes host). Requires Jellyfin login when enabled. Sets cookie. |
GET |
/api/polls/{code} |
Poll state, including your participation. |
POST |
/api/polls/{code}/join |
Join as a guest ({display_name}, plus {passcode} when the poll requires one); sets cookie. |
GET |
/api/polls/{code}/library?q=&type=&genre= |
Search/browse the library, optionally narrowed by type and genre (proxied). |
GET |
/api/polls/{code}/search-external?q= |
Search Seerr/TMDB for write-in titles (when enabled). |
POST |
/api/polls/{code}/nominations |
Nominate a library title ({item_id}) or a write-in ({tmdb_id, media_type}). |
DELETE |
/api/polls/{code}/nominations/{id} |
Withdraw your nomination. |
POST |
/api/polls/{code}/advance |
Host: advance the poll (round1 → round2 → closed). |
POST |
/api/polls/{code}/votes |
Cast/replace your ballot ({selections}). |
POST |
/api/polls/{code}/request/{id} |
Host: request a winning write-in via Seerr. Requires Jellyfin login when enabled. |
GET |
/api/polls/{code}/results |
Tally (when closed, or live if enabled). |
GET |
/api/polls/{code}/events |
SSE stream of poll updates. |
GET |
/api/items/{id}/image |
Poster image proxy. |
Admin endpoints (only when the dashboard is enabled — see Access control;
otherwise every path below is 404). Sign-in is the shared /api/user/login;
the data endpoints require a Jellyfin identity authorized for admin.
| Method | Path | Description |
|---|---|---|
GET |
/api/admin/session |
Whether the caller is signed in and authorized ({authenticated, authorized}). |
GET |
/api/admin/polls |
All polls (newest first) with counts + winner. |
GET |
/api/admin/polls/{code} |
Full state of one poll (drill-down). |
POST |
/api/admin/polls/{code}/advance |
Force the poll's state machine forward. |
DELETE |
/api/admin/polls/{code} |
Delete a poll and all of its data. |
Create-poll body:
{
"title": "Movie Night",
"host_name": "Alex",
"library_scope": "movie", // "movie" | "series" | "both"
"submission_rules": { "min": 0, "max": 0, "required": 0 },
"voting_method": "approval", // "approval" | "ranked" | "score" | "random"
"voting_config": null, // method defaults used when null
"allow_guests": true,
"results_live": false,
"reveal_nominators": false, // show who nominated, on the results screen
"reveal_scope": "winner", // "winner" | "all" (when reveal_nominators)
"genres": [], // restrict nominations to these genres (empty = any)
"allow_writeins": false, // allow nominating titles not in the library (needs Seerr)
"auto_request_winner": false // auto-request a winning write-in via Seerr on close
}seeurchin wears the "Reef" design system: a token-driven, theme-aware (system / light / dark) skin built on SvelteKit (Svelte 5) + Tailwind v4. The mark is a twelve-spike urchin in the poll palette — coral, teal, mango, ocean — radiating from an ink hub, reading as individual votes gathered around a pick. The wordmark is lowercase Baloo 2.
![]() Dark |
![]() Light |
- Palette: ocean
#0e5a7d, teal#11b3aa, coral#ff6f5e, mango#ffa23a, sun#ffce5c, ink#143a45, sand#fdf7ec. - Type: Baloo 2 (display/wordmark), Quicksand (titles), Nunito (body).
- Conventions, tokens, and component classes: see
docs/design-system.md. - Brand source masters (mark cuts, app-icon masters, the Baloo 2 font + OFL
license) live in
docs/brand/; the runtime favicon / app-icon kit + PWA manifest are inweb/static/brand/.
Screenshots are generated against a live instance by
tools/screenshots— re-run it after a UI change to refresh the gallery above.
Backend (run the API on :5859; the frontend dev server proxies /api to it):
go test ./... # backend tests
JELLYFIN_URL=http://localhost:8096 \
JELLYFIN_API_KEY=... \
SEEURCHIN_ADDR=:5859 \
go run ./cmd/seeurchinFrontend (hot-reloading dev server on :5173):
cd web
npm install
npm run devThe frontend compiles to static files that are embedded into the Go binary:
npm --prefix web ci
npm --prefix web run build # writes internal/httpapi/webdist/
CGO_ENABLED=0 go build -o seeurchin ./cmd/seeurchinOr just build the container (multi-stage: Node build → Go build → distroless):
docker build -t seeurchin:latest .- Backend: Go —
chirouter, pure-Go SQLite viamodernc.org/sqlite(no CGO), and the built frontend embedded withembed.FS. - Frontend: SvelteKit (Svelte 5, static adapter, SPA) + Tailwind CSS v4,
wearing the "Reef" design system — semantic tokens that re-map for light
and dark, so theming is automatic (no
dark:variants). Seedocs/design-system.md. - Live updates: Server-Sent Events via an in-memory hub keyed by poll.
- Sessions: HMAC-signed, HTTP-only cookie holding a per-poll token map.
- Identity: guests by default behind an
auth.Providerseam, plus optional Jellyfin login (AuthenticateByName) gating poll creation / write-in requests and authorizing admin;participants.jellyfin_user_idrecords the signed-in host.
cmd/seeurchin entrypoint + Jellyfin→domain adapter
internal/config env-var configuration
internal/jellyfin Jellyfin client (modern auth header, search, image proxy)
internal/store SQLite repository (modernc.org/sqlite)
internal/poll domain types + service (state machine, rules)
internal/voting pluggable voting engine (approval, ranked, score, random)
internal/codes Crockford base32 share codes
internal/auth session cookies + provider seam (guest now, Jellyfin later)
internal/seerr Seerr client (external search, winner auto-request)
internal/httpapi REST + SSE handlers, embedded SPA
web/ SvelteKit + Tailwind frontend ("Reef" design system)
docs/ design-system.md + brand source masters
tools/screenshots Playwright generator for the README screenshots
New to the backend (or to Go)? The backend walkthrough is a chapter-by-chapter tour of the Go code aimed at developers coming from other API stacks — how the layers fit together, the request lifecycle, the voting-engine plugin system, the SSE hub, and the testing/build setup, with side-by-side comparisons throughout.
- Quick Connect login — extend the Jellyfin login (already shipped via
AuthenticateByName) with Jellyfin's Quick Connect for passwordless sign-in, and per-user Seerr request attribution. - Sudden-death runoff and a genre pre-round.
- Word-style share codes (
SEEURCHIN_CODE_STYLE=words). - Deadlines / auto-advance between rounds.
v1.1.0 — adds a random pick voting method, genre-restricted
nominations, an option to reveal who nominated on the results screen,
capped self-voting (max_self_votes), and a clearer round-1 nomination
screen, on top of v1.0. The UI now wears the "Reef" design system with
system / light / dark theming.
v1.0.0 — the two-round flow, the core voting methods, guest identity, live updates, library browsing, and Docker packaging are complete and in use.








