kryten-webqueue 0.25.0__tar.gz → 0.27.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/CHANGELOG.md +24 -0
  2. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/app.py +7 -1
  4. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/db.py +168 -0
  5. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/images.py +103 -0
  6. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/race_poller.py +31 -2
  7. kryten_webqueue-0.27.0/kryten_webqueue/routes/admin_feedback.py +111 -0
  8. kryten_webqueue-0.27.0/kryten_webqueue/routes/feedback.py +197 -0
  9. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/pages.py +9 -0
  10. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/static/css/main.css +124 -0
  11. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/index.html +227 -20
  12. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/base.html +1 -0
  13. kryten_webqueue-0.27.0/kryten_webqueue/templates/feedback/index.html +236 -0
  14. kryten_webqueue-0.27.0/kryten_webqueue/templates/race.html +397 -0
  15. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/pyproject.toml +1 -1
  16. kryten_webqueue-0.27.0/tests/test_feedback.py +416 -0
  17. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_race_poller.py +61 -0
  18. kryten_webqueue-0.25.0/kryten_webqueue/templates/race.html +0 -223
  19. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/.github/workflows/python-publish.yml +0 -0
  20. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/.github/workflows/release.yml +0 -0
  21. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/.gitignore +0 -0
  22. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/README.md +0 -0
  23. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/config.example.json +0 -0
  24. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/deploy/kryten-webqueue.service +0 -0
  25. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/deploy/nginx-queue.conf +0 -0
  26. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  27. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/IMPL_API_GATE.md +0 -0
  28. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/IMPL_ECONOMY.md +0 -0
  29. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  30. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/IMPL_ROBOT.md +0 -0
  31. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  32. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/PRE_PLAN_GAPS.md +0 -0
  33. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/PRODUCT_PLAN.md +0 -0
  34. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  35. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/UX_POLISH_PLAN.md +0 -0
  36. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/__init__.py +0 -0
  37. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/__main__.py +0 -0
  38. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  39. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/api_gate/client.py +0 -0
  40. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/auth/__init__.py +0 -0
  41. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/auth/otp.py +0 -0
  42. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  43. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/auth/session.py +0 -0
  44. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/__init__.py +0 -0
  45. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  46. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/sync.py +0 -0
  47. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/config.py +0 -0
  48. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/__init__.py +0 -0
  49. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  50. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  51. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  52. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  53. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  54. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  55. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  56. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  57. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/jobs/__init__.py +0 -0
  58. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  59. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/jobs/manager.py +0 -0
  60. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/jobs/tasks.py +0 -0
  61. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/logging_config.py +0 -0
  62. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/__init__.py +0 -0
  63. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  64. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/fire.py +0 -0
  65. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/importer.py +0 -0
  66. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/ordering.py +0 -0
  67. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  68. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/promos/__init__.py +0 -0
  69. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/promos/director.py +0 -0
  70. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/__init__.py +0 -0
  71. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/ordering.py +0 -0
  72. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/poller.py +0 -0
  73. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/presence.py +0 -0
  74. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/shadow.py +0 -0
  75. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/__init__.py +0 -0
  76. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  77. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  78. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  79. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  80. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  81. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  82. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/auth.py +0 -0
  83. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/catalog.py +0 -0
  84. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/queue.py +0 -0
  85. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/user.py +0 -0
  86. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/static/js/main.js +0 -0
  87. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  88. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  89. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  90. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  91. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/auth/login.html +0 -0
  92. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  93. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  94. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  95. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/queue/index.html +0 -0
  96. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  97. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/ws/__init__.py +0 -0
  98. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/ws/handler.py +0 -0
  99. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/ws/manager.py +0 -0
  100. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/__init__.py +0 -0
  101. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_config_persistence.py +0 -0
  102. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_fetchurls_sharepoint.py +0 -0
  103. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_phase1.py +0 -0
  104. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_phase2_jobs.py +0 -0
  105. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_phase3_jobs.py +0 -0
  106. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_phase4_live_fixes.py +0 -0
  107. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_playlist_import.py +0 -0
  108. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_presence_refund.py +0 -0
  109. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_promo_director.py +0 -0
  110. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_promo_pool_exclusion.py +0 -0
  111. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_queue_announce.py +0 -0
  112. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_save_results_to_playlist.py +0 -0
  113. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_schedule_lock.py +0 -0
  114. {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_search_facets.py +0 -0
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.27.0] — 2026-06-23
9
+
10
+ ### Added
11
+
12
+ - **Viewer feedback + "Suggest a Title".** A new **Feedback** page (login required, linked in the nav) lets viewers contribute to Channel-Z in two tabs. *Feedback* is a simple text box — submit and get an instant thank-you. *Suggest a Title* resolves what you type against our movie databases (TMDB + OMDB) and shows the candidate matches so you can confirm the right one; pick a match and it's recorded for the team. A title we can't match is still accepted (stored as **unresolved**), and if we already have it you're told right away with a link to watch it now.
13
+ - **Admin triage queues for feedback & suggestions.** Both feeds land in the admin dashboard with the same lightweight workflow: mark entries **read/unread** and **delete** stale ones, with a per-tab unread badge and an "unread only" filter. Suggestion rows show the resolved match (title/year/source), whether we **already have it in the catalog** (with a direct link to the item), or that it's unresolved.
14
+
15
+ ### Changed
16
+
17
+ - **Admin dashboard is now tabbed**, mirroring the user profile page. The at-a-glance sections (Overview, Jobs, Sync Logs) plus the new Feedback and Suggestions queues are organized into tabs; the Playlists / Schedules / Queue Management / Promos pages remain linked as before.
18
+ - **Race odds column is now a fixed width.** The odds/bet-total column on the `/race` view no longer auto-sizes to each entry's bet total, so the lanes (and the track rails) line up cleanly instead of staggering as bets come in.
19
+ - **Race view now supports 8 cars.** Added Brown and White lane colours to match the economy's expanded 8-car grid.
20
+
21
+ [0.27.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.27.0
22
+
23
+ ## [0.26.0] — 2026-06-22
24
+
25
+ ### Changed
26
+
27
+ - **Race view is now smooth, with cars, drivers, and live commentary.** The economy now ships the whole race as a precomputed timeline (economy 0.13.0), so the `/race` view animates it client-side with `requestAnimationFrame` + interpolation — cars glide continuously and re-sync to the server clock instead of jumping between polls. Visual overhaul: each racer is a little CSS race **car** (cabin + wheels) that drives down a striped track from a colour start-cap to a checkered finish, with the **driver's name** (e.g. *Manuel Transmission*) under its colour, a live **position** column, and bet totals. A **commentary banner** above the field shows play-by-play (start, driver-named lead changes, close-finish, winner call) with the newest line replacing the last. Now shows all **6** cars.
28
+ - **Race WebSocket bandwidth cut.** The heavy position timeline is broadcast only once per race; subsequent frames carry just the server `elapsed` re-sync hint (the browser self-animates), so per-tick traffic stays tiny even with many spectators. Late-joiners still receive the full timeline on connect.
29
+
30
+ [0.26.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.26.0
31
+
8
32
  ## [0.25.0] — 2026-06-22
9
33
 
10
34
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.25.0
3
+ Version: 0.27.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -33,6 +33,8 @@ from .routes.admin_queue import router as admin_queue_router
33
33
  from .routes.admin_jobs import router as admin_jobs_router
34
34
  from .routes.admin_catalog import router as admin_catalog_router
35
35
  from .routes.admin_promos import router as admin_promos_router
36
+ from .routes.feedback import router as feedback_router
37
+ from .routes.admin_feedback import router as admin_feedback_router
36
38
  from .routes.pages import router as pages_router
37
39
  from .ws.handler import router as ws_router
38
40
 
@@ -147,8 +149,10 @@ async def lifespan(app: FastAPI):
147
149
  await poller.start()
148
150
  app.state.poller = poller
149
151
 
150
- # Rate limiter
152
+ # Rate limiter (OTP requests) plus a more lenient limiter shared by the
153
+ # feedback/suggestion endpoints (per-user, namespaced keys).
151
154
  app.state.rate_limiter = RateLimiter()
155
+ app.state.feedback_rate_limiter = RateLimiter(max_requests=12, window_seconds=300)
152
156
 
153
157
  # Playlist scheduler
154
158
  scheduler = PlaylistScheduler(
@@ -256,6 +260,8 @@ def create_app(config: Config) -> FastAPI:
256
260
  app.include_router(admin_jobs_router)
257
261
  app.include_router(admin_catalog_router)
258
262
  app.include_router(admin_promos_router)
263
+ app.include_router(feedback_router)
264
+ app.include_router(admin_feedback_router)
259
265
  app.include_router(ws_router)
260
266
 
261
267
  # Health check
@@ -12,6 +12,20 @@ def _slugify(text: str) -> str:
12
12
  return s.strip("-") or "untitled"
13
13
 
14
14
 
15
+ def _normalize_title(title: str) -> str:
16
+ """Normalize a media title for equality comparison.
17
+
18
+ Lowercases, drops parenthetical/bracketed chunks and any 4-digit year, and
19
+ collapses remaining non-alphanumerics to single spaces. Lets a clean
20
+ database title ("The Matrix") match a catalog title ("The Matrix (1999)").
21
+ """
22
+ s = (title or "").lower()
23
+ s = re.sub(r"[\(\[][^\)\]]*[\)\]]", " ", s)
24
+ s = re.sub(r"\b(?:19|20)\d{2}\b", " ", s)
25
+ s = re.sub(r"[^a-z0-9]+", " ", s)
26
+ return " ".join(s.split())
27
+
28
+
15
29
  # Categories and tags whose items are hidden from the public catalog (dropdowns
16
30
  # and search results). Admins can opt to reveal them. Matched by exact name.
17
31
  HIDDEN_CATEGORY_NAMES = [
@@ -337,6 +351,36 @@ MIGRATIONS = [
337
351
  ALTER TABLE queue_shadow ADD COLUMN promo_type TEXT;
338
352
  ALTER TABLE queue_shadow ADD COLUMN lead_in_for_uid INTEGER;
339
353
  """,
354
+ # v12: Viewer feedback + movie-title suggestions, each with a simple
355
+ # admin-triage queue (status new|read). Title suggestions record the
356
+ # resolved match against TMDB/OMDB (NULL when unresolved) and whether we
357
+ # already have the title in the catalog (resolution + catalog_token).
358
+ """
359
+ CREATE TABLE IF NOT EXISTS feedback (
360
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
361
+ username TEXT NOT NULL,
362
+ body TEXT NOT NULL,
363
+ status TEXT NOT NULL DEFAULT 'new',
364
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
365
+ );
366
+ CREATE INDEX IF NOT EXISTS idx_feedback_status ON feedback(status, created_at);
367
+
368
+ CREATE TABLE IF NOT EXISTS title_suggestions (
369
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
370
+ username TEXT NOT NULL,
371
+ query TEXT NOT NULL,
372
+ resolved_title TEXT,
373
+ resolved_year TEXT,
374
+ resolved_source TEXT,
375
+ resolved_id TEXT,
376
+ poster_url TEXT,
377
+ resolution TEXT NOT NULL DEFAULT 'unresolved',
378
+ catalog_token TEXT,
379
+ status TEXT NOT NULL DEFAULT 'new',
380
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
381
+ );
382
+ CREATE INDEX IF NOT EXISTS idx_title_suggestions_status ON title_suggestions(status, created_at);
383
+ """,
340
384
  ]
341
385
 
342
386
 
@@ -946,6 +990,130 @@ class Database:
946
990
  )
947
991
  return int(row["c"]) if row else 0
948
992
 
993
+ # --- Feedback ---
994
+
995
+ async def add_feedback(self, *, username: str, body: str) -> int:
996
+ cursor = await self._db.execute(
997
+ "INSERT INTO feedback (username, body) VALUES (?, ?)",
998
+ [username, body],
999
+ )
1000
+ await self._db.commit()
1001
+ return cursor.lastrowid
1002
+
1003
+ async def list_feedback(self, *, status: str | None = None,
1004
+ limit: int = 100, offset: int = 0) -> list[dict]:
1005
+ sql = "SELECT * FROM feedback"
1006
+ params: list = []
1007
+ if status:
1008
+ sql += " WHERE status = ?"
1009
+ params.append(status)
1010
+ sql += " ORDER BY id DESC LIMIT ? OFFSET ?"
1011
+ params.extend([limit, offset])
1012
+ return await self._fetch_all(sql, params)
1013
+
1014
+ async def count_feedback(self, *, status: str | None = None) -> int:
1015
+ sql = "SELECT COUNT(*) AS c FROM feedback"
1016
+ params: list = []
1017
+ if status:
1018
+ sql += " WHERE status = ?"
1019
+ params.append(status)
1020
+ row = await self._fetch_one(sql, params)
1021
+ return int(row["c"]) if row else 0
1022
+
1023
+ async def set_feedback_status(self, feedback_id: int, status: str) -> bool:
1024
+ cursor = await self._db.execute(
1025
+ "UPDATE feedback SET status = ? WHERE id = ?", [status, feedback_id]
1026
+ )
1027
+ await self._db.commit()
1028
+ return (cursor.rowcount or 0) > 0
1029
+
1030
+ async def delete_feedback(self, feedback_id: int) -> bool:
1031
+ cursor = await self._db.execute("DELETE FROM feedback WHERE id = ?", [feedback_id])
1032
+ await self._db.commit()
1033
+ return (cursor.rowcount or 0) > 0
1034
+
1035
+ # --- Title suggestions ---
1036
+
1037
+ async def add_title_suggestion(self, *, username: str, query: str,
1038
+ resolved_title: str | None = None,
1039
+ resolved_year: str | None = None,
1040
+ resolved_source: str | None = None,
1041
+ resolved_id: str | None = None,
1042
+ poster_url: str | None = None,
1043
+ resolution: str = "unresolved",
1044
+ catalog_token: str | None = None) -> int:
1045
+ cursor = await self._db.execute(
1046
+ "INSERT INTO title_suggestions "
1047
+ "(username, query, resolved_title, resolved_year, resolved_source, "
1048
+ " resolved_id, poster_url, resolution, catalog_token) "
1049
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1050
+ [username, query, resolved_title, resolved_year, resolved_source,
1051
+ resolved_id, poster_url, resolution, catalog_token],
1052
+ )
1053
+ await self._db.commit()
1054
+ return cursor.lastrowid
1055
+
1056
+ async def list_title_suggestions(self, *, status: str | None = None,
1057
+ limit: int = 100, offset: int = 0) -> list[dict]:
1058
+ sql = "SELECT * FROM title_suggestions"
1059
+ params: list = []
1060
+ if status:
1061
+ sql += " WHERE status = ?"
1062
+ params.append(status)
1063
+ sql += " ORDER BY id DESC LIMIT ? OFFSET ?"
1064
+ params.extend([limit, offset])
1065
+ return await self._fetch_all(sql, params)
1066
+
1067
+ async def count_title_suggestions(self, *, status: str | None = None) -> int:
1068
+ sql = "SELECT COUNT(*) AS c FROM title_suggestions"
1069
+ params: list = []
1070
+ if status:
1071
+ sql += " WHERE status = ?"
1072
+ params.append(status)
1073
+ row = await self._fetch_one(sql, params)
1074
+ return int(row["c"]) if row else 0
1075
+
1076
+ async def set_title_suggestion_status(self, suggestion_id: int, status: str) -> bool:
1077
+ cursor = await self._db.execute(
1078
+ "UPDATE title_suggestions SET status = ? WHERE id = ?", [status, suggestion_id]
1079
+ )
1080
+ await self._db.commit()
1081
+ return (cursor.rowcount or 0) > 0
1082
+
1083
+ async def delete_title_suggestion(self, suggestion_id: int) -> bool:
1084
+ cursor = await self._db.execute(
1085
+ "DELETE FROM title_suggestions WHERE id = ?", [suggestion_id]
1086
+ )
1087
+ await self._db.commit()
1088
+ return (cursor.rowcount or 0) > 0
1089
+
1090
+ async def find_catalog_by_title(self, title: str) -> dict | None:
1091
+ """Best-effort lookup of a catalog item whose title matches ``title``.
1092
+
1093
+ Used to tell a user we already have a suggested movie. Runs an FTS
1094
+ phrase match to gather candidates, then confirms with a normalized
1095
+ (year/punctuation-stripped, case-insensitive) equality check so loose
1096
+ token overlaps don't produce false "already have" hits. Returns
1097
+ ``{friendly_token, title}`` of the best match, or None.
1098
+ """
1099
+ norm_target = _normalize_title(title)
1100
+ if not norm_target:
1101
+ return None
1102
+ phrase = '"' + (title or "").replace('"', '""') + '"'
1103
+ try:
1104
+ rows = await self._fetch_all(
1105
+ "SELECT c.friendly_token, c.title FROM catalog_fts fts "
1106
+ "JOIN catalog c ON c.rowid = fts.rowid "
1107
+ "WHERE catalog_fts MATCH ? LIMIT 25",
1108
+ [phrase],
1109
+ )
1110
+ except aiosqlite.Error:
1111
+ rows = []
1112
+ for row in rows:
1113
+ if _normalize_title(row["title"]) == norm_target:
1114
+ return {"friendly_token": row["friendly_token"], "title": row["title"]}
1115
+ return None
1116
+
949
1117
  # --- Saved playlists ---
950
1118
 
951
1119
  async def get_saved_playlists(self) -> list[dict]:
@@ -201,6 +201,109 @@ class CoverArtResolver:
201
201
  logger.warning(f"OMDB search error for {t!r}: {e}")
202
202
  return None
203
203
 
204
+ # --- Title suggestion resolution ---------------------------------------
205
+ # These return *candidate matches* (title/year/poster/id) rather than a
206
+ # single poster URL, so the "Suggest a Title" UI can let the user confirm
207
+ # which match they meant. An empty list = unresolved (no keys, or no hits).
208
+
209
+ async def search_titles(self, query: str, *, limit: int = 8) -> list[dict]:
210
+ """Search TMDB + OMDB for title candidates matching ``query``.
211
+
212
+ Returns up to ``limit`` candidate dicts, most-relevant first:
213
+ ``{source, id, title, year, media_type, poster_url}``. TMDB (movie + TV)
214
+ is primary and ranked by popularity; OMDB supplements when configured.
215
+ An empty list means "unresolved" — no API keys, or no matches.
216
+ """
217
+ query = (query or "").strip()
218
+ if not query:
219
+ return []
220
+
221
+ candidates: list[dict] = []
222
+ if self._tmdb_key:
223
+ candidates.extend(await self._tmdb_candidates(query))
224
+ if self._omdb_key:
225
+ candidates.extend(await self._omdb_candidates(query))
226
+
227
+ # Dedupe by (lowercased title, year), keeping the most popular instance.
228
+ seen: dict[tuple, dict] = {}
229
+ for c in candidates:
230
+ key = (c["title"].strip().lower(), c["year"] or "")
231
+ prev = seen.get(key)
232
+ if prev is None or c["_popularity"] > prev["_popularity"]:
233
+ seen[key] = c
234
+ # Stable sort preserves provider order (TMDB before OMDB) within ties.
235
+ merged = sorted(seen.values(), key=lambda c: c["_popularity"], reverse=True)
236
+ for c in merged:
237
+ c.pop("_popularity", None)
238
+ return merged[:limit]
239
+
240
+ async def _tmdb_candidates(self, query: str) -> list[dict]:
241
+ movie_task = asyncio.create_task(self._tmdb_search_candidates("movie", query))
242
+ tv_task = asyncio.create_task(self._tmdb_search_candidates("tv", query))
243
+ movie_hits, tv_hits = await asyncio.gather(movie_task, tv_task)
244
+ return [*movie_hits, *tv_hits]
245
+
246
+ async def _tmdb_search_candidates(self, media_type: str, query: str) -> list[dict]:
247
+ params = {"api_key": self._tmdb_key, "query": query, "include_adult": "false"}
248
+ try:
249
+ resp = await self._client.get(
250
+ f"https://api.themoviedb.org/3/search/{media_type}", params=params
251
+ )
252
+ if resp.status_code != 200:
253
+ logger.warning(f"TMDB {media_type} suggest search returned {resp.status_code} for {query!r}")
254
+ return []
255
+ results = resp.json().get("results", [])
256
+ except (httpx.HTTPError, ValueError) as e:
257
+ logger.warning(f"TMDB {media_type} suggest search error for {query!r}: {e}")
258
+ return []
259
+ out: list[dict] = []
260
+ for r in results[:8]:
261
+ title = (r.get("title") or r.get("name") or "").strip()
262
+ if not title:
263
+ continue
264
+ date = r.get("release_date") or r.get("first_air_date") or ""
265
+ poster = r.get("poster_path")
266
+ out.append({
267
+ "source": "tmdb",
268
+ "id": str(r.get("id") or ""),
269
+ "title": title,
270
+ "year": (date[:4] or None),
271
+ "media_type": "tv" if media_type == "tv" else "movie",
272
+ "poster_url": (f"https://image.tmdb.org/t/p/w185{poster}" if poster else None),
273
+ "_popularity": float(r.get("popularity") or 0.0),
274
+ })
275
+ return out
276
+
277
+ async def _omdb_candidates(self, query: str) -> list[dict]:
278
+ cleaned, year = _clean_title(query)
279
+ params: dict = {"apikey": self._omdb_key, "s": query}
280
+ if year:
281
+ params["y"] = year
282
+ try:
283
+ resp = await self._client.get("https://www.omdbapi.com/", params=params)
284
+ if resp.status_code != 200:
285
+ return []
286
+ data = resp.json()
287
+ except (httpx.HTTPError, ValueError) as e:
288
+ logger.warning(f"OMDB suggest search error for {query!r}: {e}")
289
+ return []
290
+ out: list[dict] = []
291
+ for r in (data.get("Search") or [])[:8]:
292
+ title = (r.get("Title") or "").strip()
293
+ if not title:
294
+ continue
295
+ poster = r.get("Poster")
296
+ out.append({
297
+ "source": "omdb",
298
+ "id": str(r.get("imdbID") or ""),
299
+ "title": title,
300
+ "year": ((r.get("Year") or "")[:4] or None),
301
+ "media_type": (r.get("Type") or "movie"),
302
+ "poster_url": (poster if poster and poster != "N/A" else None),
303
+ "_popularity": 0.0,
304
+ })
305
+ return out
306
+
204
307
  async def _save_responsive(self, friendly_token: str, data: bytes, source: str, db) -> str:
205
308
  """Save image in multiple responsive widths."""
206
309
  token_hash = hashlib.md5(friendly_token.encode()).hexdigest()[:8]
@@ -31,6 +31,9 @@ class RacePoller:
31
31
  self._idle_interval = idle_interval
32
32
  self._task: asyncio.Task | None = None
33
33
  self._last_frame: dict | None = None
34
+ # race_id whose heavy timeline has already been broadcast; subsequent
35
+ # frames for the same race are sent "light" (timeline body stripped).
36
+ self._bcast_race_id: str | None = None
34
37
 
35
38
  @property
36
39
  def last_frame(self) -> dict | None:
@@ -67,18 +70,44 @@ class RacePoller:
67
70
 
68
71
  active = bool(result.get("active")) and result.get("frame") is not None
69
72
  if active:
70
- self._last_frame = result["frame"]
73
+ frame = result["frame"]
74
+ self._last_frame = frame
71
75
  await self._ws_manager.broadcast(
72
- {"type": "race_frame", "data": self._last_frame}
76
+ {"type": "race_frame", "data": self._strip_repeat_timeline(frame)}
73
77
  )
74
78
  return self._active_interval
75
79
 
76
80
  # Not active. If we were showing a race, tell spectators to clear once.
77
81
  if self._last_frame is not None:
78
82
  self._last_frame = None
83
+ self._bcast_race_id = None
79
84
  await self._ws_manager.broadcast({"type": "race_clear"})
80
85
  return self._idle_interval
81
86
 
87
+ def _strip_repeat_timeline(self, frame: dict) -> dict:
88
+ """Drop the heavy timeline body after its first broadcast per race.
89
+
90
+ The browser caches the timeline and self-animates, so resending ~100
91
+ position frames every poll is pure waste (especially with many
92
+ spectators). The first racing frame for a race carries the full
93
+ timeline; later frames keep only ``elapsed`` (a tiny re-sync hint).
94
+ Betting/finished frames and new-race frames always pass through whole.
95
+ """
96
+ rid = frame.get("race_id")
97
+ tl = frame.get("timeline")
98
+ if tl is None:
99
+ # No timeline (betting/finished) — pass through, reset on new race.
100
+ if frame.get("phase") != "racing":
101
+ self._bcast_race_id = None if frame.get("phase") == "finished" else self._bcast_race_id
102
+ return frame
103
+ if rid == self._bcast_race_id:
104
+ light = dict(frame)
105
+ light["timeline"] = {"elapsed": tl.get("elapsed", 0)}
106
+ return light
107
+ # First racing frame for this race — send full timeline, then mark sent.
108
+ self._bcast_race_id = rid
109
+ return frame
110
+
82
111
  async def _loop(self):
83
112
  while True:
84
113
  try:
@@ -0,0 +1,111 @@
1
+ """Admin triage endpoints for viewer feedback + movie-title suggestions.
2
+
3
+ These back the "Feedback" and "Suggestions" tabs on the admin dashboard. Both
4
+ queues support the same lightweight triage: list (optionally filtered by
5
+ status), mark read/unread, and delete stale entries. Suggestion rows also carry
6
+ the resolved match + a catalog token when we already own the title, which the UI
7
+ turns into a direct link to the catalog item.
8
+ """
9
+
10
+ from fastapi import APIRouter, Request, Depends, HTTPException
11
+ from pydantic import BaseModel
12
+
13
+ from ..auth.session import require_admin
14
+
15
+ router = APIRouter(prefix="/admin", tags=["admin"])
16
+
17
+ _VALID_STATUSES = {"new", "read"}
18
+
19
+
20
+ class StatusUpdate(BaseModel):
21
+ status: str
22
+
23
+
24
+ # --- Feedback ---------------------------------------------------------------
25
+
26
+ @router.get("/feedback")
27
+ async def list_feedback(
28
+ request: Request, status: str | None = None, user: dict = Depends(require_admin)
29
+ ):
30
+ """List feedback submissions, newest first, optionally filtered by status."""
31
+ db = request.app.state.db
32
+ if status not in _VALID_STATUSES:
33
+ status = None
34
+ items = await db.list_feedback(status=status)
35
+ return {
36
+ "items": items,
37
+ "counts": {
38
+ "new": await db.count_feedback(status="new"),
39
+ "total": await db.count_feedback(),
40
+ },
41
+ }
42
+
43
+
44
+ @router.post("/feedback/{feedback_id}/status")
45
+ async def set_feedback_status(
46
+ feedback_id: int, payload: StatusUpdate, request: Request,
47
+ user: dict = Depends(require_admin),
48
+ ):
49
+ """Mark a feedback entry read/unread."""
50
+ if payload.status not in _VALID_STATUSES:
51
+ raise HTTPException(status_code=400, detail="Invalid status")
52
+ db = request.app.state.db
53
+ if not await db.set_feedback_status(feedback_id, payload.status):
54
+ raise HTTPException(status_code=404, detail="Feedback not found")
55
+ return {"success": True}
56
+
57
+
58
+ @router.delete("/feedback/{feedback_id}")
59
+ async def delete_feedback(
60
+ feedback_id: int, request: Request, user: dict = Depends(require_admin)
61
+ ):
62
+ """Delete a stale feedback entry."""
63
+ db = request.app.state.db
64
+ if not await db.delete_feedback(feedback_id):
65
+ raise HTTPException(status_code=404, detail="Feedback not found")
66
+ return {"success": True}
67
+
68
+
69
+ # --- Title suggestions ------------------------------------------------------
70
+
71
+ @router.get("/suggestions")
72
+ async def list_suggestions(
73
+ request: Request, status: str | None = None, user: dict = Depends(require_admin)
74
+ ):
75
+ """List movie suggestions, newest first, optionally filtered by status."""
76
+ db = request.app.state.db
77
+ if status not in _VALID_STATUSES:
78
+ status = None
79
+ items = await db.list_title_suggestions(status=status)
80
+ return {
81
+ "items": items,
82
+ "counts": {
83
+ "new": await db.count_title_suggestions(status="new"),
84
+ "total": await db.count_title_suggestions(),
85
+ },
86
+ }
87
+
88
+
89
+ @router.post("/suggestions/{suggestion_id}/status")
90
+ async def set_suggestion_status(
91
+ suggestion_id: int, payload: StatusUpdate, request: Request,
92
+ user: dict = Depends(require_admin),
93
+ ):
94
+ """Mark a suggestion read/unread."""
95
+ if payload.status not in _VALID_STATUSES:
96
+ raise HTTPException(status_code=400, detail="Invalid status")
97
+ db = request.app.state.db
98
+ if not await db.set_title_suggestion_status(suggestion_id, payload.status):
99
+ raise HTTPException(status_code=404, detail="Suggestion not found")
100
+ return {"success": True}
101
+
102
+
103
+ @router.delete("/suggestions/{suggestion_id}")
104
+ async def delete_suggestion(
105
+ suggestion_id: int, request: Request, user: dict = Depends(require_admin)
106
+ ):
107
+ """Delete a stale suggestion."""
108
+ db = request.app.state.db
109
+ if not await db.delete_title_suggestion(suggestion_id):
110
+ raise HTTPException(status_code=404, detail="Suggestion not found")
111
+ return {"success": True}