Top kép community voting (Drive-free) — design spec

Replaces the Google-Drive top-pics workflow with a self-hosted, tailnet-only community voting system on telep-mainframe: people vote their favourite camera pics for the day, then the week, then the month. Daily votes roll up. Picked images are stored on the mainframe until further instruction. Hungarian UI + reminders. Supersedes the Drive half of 2026-08-10-top-kepek-drive-curation-print-pipeline.

For Agents

Extends the existing Flask picker ~/top4-web.py (top4-web.service, 127.0.0.1:8090https://telep-mainframe.taild4189d.ts.net:8443). Frigate API at http://127.0.0.1:5000. No secrets in this note — the Telegram bot token is referenced by file path only.

Goal / success criteria

  • Community browses and votes favourite pics per Nap / Hét / Hónap in Hungarian, tailnet-only.
  • Reminders fire: daily if new candidates since last reminder, Sun 19:30 weekly, month-end 21:00 monthly — all to the existing Telegram security group.
  • Voted pics persist on the mainframe (survive Frigate’s ~14-day event purge).
  • Google Drive is out of the new flow entirely.

Decisions (locked)

ForkDecision
Voting channelExtend the existing tailnet Flask picker (not Telegram-native, not public)
IdentityTailscale-User-Login request header (one vote per tailnet identity; shared login = shared vote)
AggregationDaily votes roll up — week/month re-rank pics already voted in that window
Reminder targetExisting group -1004475187307 (『Telephely biztonsági riasztások』), existing bot token
Candidate setFrigate person events for the day, deduped to one per second per camera (mirrors /explore?labels=person)
Save triggerOn first vote — fetch clean full-res snapshot, persist locally. Unvoted pics never stored.
Daily reminder triggerOnly if new candidates since last reminder (watermark), evaluated at 20:00
DriveNew flow writes nothing to Drive; weekly top4-export.timer disabled; full rip-out deferred

Architecture

Single new module added to the existing top4-web.py Flask app — no new service, reuses the :8443 tailnet exposure. SQLite for state (stdlib sqlite3, zero deps). Reminders are standalone systemd timers calling a small sender script.

Frigate API (person events) ──dedup/sec/cam──> candidate feed ──render──> /nap /het /honap (grid + ❤)
                                                                              │ vote (Tailscale-User-Login)
                                                                              ▼
                                          first vote ──fetch full-res──> /srv/top-kep/img/YYYY-MM-DD/<id>.jpg
                                                                              │
                                                              SQLite /srv/top-kep/votes.db
                                                                              │
        systemd timers (daily/weekly/monthly) ──> top-kep-remind.py ──Bot API──> Telegram group -1004475187307

Units

  • vote blueprint in top4-web.py — routes, candidate feed, dedup, vote toggle, tier ranking. Depends on: Frigate API, the store.
  • Store (top-kep-store helpers) — thin SQLite wrapper: record_vote, remove_vote, votes_for(window), persist_pic. Depends on: sqlite3, Frigate snapshot fetch.
  • top-kep-remind.py — CLI: --daily | --weekly | --monthly; builds the Hungarian message, applies the trigger guard, sends via Bot API. Depends on: the store (for candidate/watermark counts), bot token file.

Data model — SQLite /srv/top-kep/votes.db

CREATE TABLE pics (
  event_id     TEXT PRIMARY KEY,   -- Frigate event id (unique key; NOT the _score in filenames)
  camera       TEXT NOT NULL,
  captured_ts  INTEGER NOT NULL,   -- unix seconds (Frigate start_time)
  saved_path   TEXT,               -- set when persisted on first vote
  first_voted_ts INTEGER
);
CREATE TABLE votes (
  event_id  TEXT NOT NULL,
  voter     TEXT NOT NULL,         -- Tailscale-User-Login
  voted_ts  INTEGER NOT NULL,
  PRIMARY KEY (event_id, voter)
);
CREATE TABLE reminder_state (
  kind          TEXT PRIMARY KEY,  -- 'daily'
  last_event_ts INTEGER            -- watermark: newest candidate captured_ts at last daily reminder
);

Images: /srv/top-kep/img/YYYY-MM-DD/<event_id>.jpg (date = capture date), native-res clean snapshot (bbox=0&quality=100).

Candidate feed & dedup

  • Query GET http://127.0.0.1:5000/api/events?label=person&after=<day_start>&before=<day_end>&has_snapshot=1.
  • Dedup to one event per (camera, whole-second): bucket by (camera, floor(start_time)), keep the highest top_score in each bucket. This mirrors what the user sees on /explore?labels=person de-noised to a browseable set.
  • Thumbnails for unvoted candidates are fetched live from Frigate (/api/events/<id>/snapshot.jpg?bbox=0&quality=100), consistent with the existing picker’s on-demand thumbnail loading. Reuse the existing IntersectionObserver batched scroll to avoid hammering Frigate.

Routes (Hungarian)

RouteShowsOrder
GET /naptoday’s deduped candidateschronological
GET /hetpics with ≥1 vote in the current ISO weekvote count desc
GET /honappics with ≥1 vote in the current calendar monthvote count desc
POST /vote{event_id} → toggle ❤ for the caller

/vote behaviour:

  1. Resolve voter from Tailscale-User-Login header. If absent → 403 (tailnet identity required). ponytail: header-only per the locked decision; add name-pick fallback only if shared-device voting becomes a real complaint.
  2. If a vote row (event_id, voter) exists → delete it (un-vote). Else insert it.
  3. On the first-ever vote for event_id (pics row has no saved_path): fetch the full-res snapshot from Frigate, write to /srv/top-kep/img/…, set saved_path + first_voted_ts. Idempotent — never re-fetch if already saved.
  4. Return the new vote count.

Week = ISO week (Mon–Sun) containing “now”, Europe/Budapest. Month = calendar month, Europe/Budapest.

Reminders — systemd timers → Telegram

Bot token read from /home/levander/nvr/frigate-notify/config.yml (mode 600, already used by other services); target chat -1004475187307. Sent via Bot API sendMessage. Link base: https://telep-mainframe.taild4189d.ts.net:8443.

TimerScheduleGuardMessage
top-kep-remind-daily.timerdaily 20:00send only if newest candidate captured_ts > reminder_state.daily.last_event_ts; then update watermark📸 Vannak új képek a mai napról — szavazz a kedvenceidre: …/nap
top-kep-remind-weekly.timerOnCalendar=Sun *-*-* 19:30none🗓️ Heti szavazás — válaszd ki a hét legjobb képeit: …/het
top-kep-remind-monthly.timerdaily 21:00send only if tomorrow is the 1st (covers 28/30/31)📅 Havi szavazás — a hónap legjobb képei: …/honap

Timers use Persistent=true so a reminder missed during a power outage fires on the next boot. All units User=levander.

Drive removal

  • New voting flow: no Drive writes.
  • Disable top4-export.timer (weekly Mon 08:00 Drive export) — superseded.
  • Leave the old picker’s /save-full-to-drive / /save-crop-to-drive endpoints, the bulk helpers (~/top-kep-drive.py, ~/top-kep-id.py), and the 4-slot camwall flow (/srv/frigate/top4/{1..4}.jpg) in place and working — not part of this change. Full Drive rip-out is a separate, later task on the user’s word.

Home portal

Repoint the existing “Top képek” tile in /home/levander/home-portal/index.html (served :8093, node home.taild4189d.ts.net) from the old picker to …:8443/nap. Leave an index.html.bak-* backup (existing convention).

Verification (one runnable self-check)

test_top_kep_vote.py (stdlib unittest / plain asserts, no framework), asserting:

  1. Dedup: a synthetic burst of N person events within the same second on one camera collapses to exactly 1 candidate; different seconds or different cameras stay separate.
  2. Vote toggle: two /vote calls for the same (event_id, voter) net to zero; count is per-identity, not per-request.
  3. Persist-once: saved_path is set on first vote and unchanged (no re-fetch) on subsequent votes.
  4. Month guard: the monthly-reminder guard returns true iff tomorrow is day 1, for a spread of month-lengths (Feb 28, Apr 30, Jul 31).

Open / deferred

  • Daily reminder time 20:00 is a chosen default — trivially adjustable in the timer.
  • Full Drive rip-out (endpoints, helpers, config) — deferred until the user confirms nothing there is still wanted.
  • Print/export path off the local store — deferred (“stored on mainframe until further instruction”).
  • Larger-than-720p pics for printing would require pulling from the record/main stream (2304×1296), same limitation as the old pipeline — out of scope here.