kryten-webqueue 0.26.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 (113) hide show
  1. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/CHANGELOG.md +15 -0
  2. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/app.py +7 -1
  4. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/db.py +168 -0
  5. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/images.py +103 -0
  6. kryten_webqueue-0.27.0/kryten_webqueue/routes/admin_feedback.py +111 -0
  7. kryten_webqueue-0.27.0/kryten_webqueue/routes/feedback.py +197 -0
  8. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/pages.py +9 -0
  9. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/static/css/main.css +124 -0
  10. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/index.html +227 -20
  11. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/base.html +1 -0
  12. kryten_webqueue-0.27.0/kryten_webqueue/templates/feedback/index.html +236 -0
  13. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/race.html +7 -5
  14. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/pyproject.toml +1 -1
  15. kryten_webqueue-0.27.0/tests/test_feedback.py +416 -0
  16. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/.github/workflows/python-publish.yml +0 -0
  17. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/.github/workflows/release.yml +0 -0
  18. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/.gitignore +0 -0
  19. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/README.md +0 -0
  20. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/config.example.json +0 -0
  21. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/deploy/kryten-webqueue.service +0 -0
  22. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/deploy/nginx-queue.conf +0 -0
  23. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  24. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/docs/IMPL_API_GATE.md +0 -0
  25. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/docs/IMPL_ECONOMY.md +0 -0
  26. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  27. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/docs/IMPL_ROBOT.md +0 -0
  28. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  29. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/docs/PRE_PLAN_GAPS.md +0 -0
  30. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/docs/PRODUCT_PLAN.md +0 -0
  31. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  32. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/docs/UX_POLISH_PLAN.md +0 -0
  33. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/__init__.py +0 -0
  34. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/__main__.py +0 -0
  35. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  36. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/api_gate/client.py +0 -0
  37. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/auth/__init__.py +0 -0
  38. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/auth/otp.py +0 -0
  39. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  40. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/auth/session.py +0 -0
  41. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/__init__.py +0 -0
  42. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  43. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/sync.py +0 -0
  44. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/config.py +0 -0
  45. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/__init__.py +0 -0
  46. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  47. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  48. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  49. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  50. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  51. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  52. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  53. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  54. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/jobs/__init__.py +0 -0
  55. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  56. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/jobs/manager.py +0 -0
  57. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/jobs/tasks.py +0 -0
  58. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/logging_config.py +0 -0
  59. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/__init__.py +0 -0
  60. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  61. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/fire.py +0 -0
  62. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/importer.py +0 -0
  63. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/ordering.py +0 -0
  64. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  65. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/promos/__init__.py +0 -0
  66. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/promos/director.py +0 -0
  67. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/__init__.py +0 -0
  68. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/ordering.py +0 -0
  69. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/poller.py +0 -0
  70. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/presence.py +0 -0
  71. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/race_poller.py +0 -0
  72. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/shadow.py +0 -0
  73. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/__init__.py +0 -0
  74. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  75. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  76. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  77. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  78. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  79. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  80. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/auth.py +0 -0
  81. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/catalog.py +0 -0
  82. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/queue.py +0 -0
  83. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/user.py +0 -0
  84. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/static/js/main.js +0 -0
  85. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  86. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  87. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  88. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  89. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/auth/login.html +0 -0
  90. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  91. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  92. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  93. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/queue/index.html +0 -0
  94. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  95. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/ws/__init__.py +0 -0
  96. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/ws/handler.py +0 -0
  97. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/kryten_webqueue/ws/manager.py +0 -0
  98. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/__init__.py +0 -0
  99. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_config_persistence.py +0 -0
  100. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_fetchurls_sharepoint.py +0 -0
  101. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_phase1.py +0 -0
  102. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_phase2_jobs.py +0 -0
  103. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_phase3_jobs.py +0 -0
  104. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_phase4_live_fixes.py +0 -0
  105. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_playlist_import.py +0 -0
  106. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_presence_refund.py +0 -0
  107. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_promo_director.py +0 -0
  108. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_promo_pool_exclusion.py +0 -0
  109. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_queue_announce.py +0 -0
  110. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_race_poller.py +0 -0
  111. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_save_results_to_playlist.py +0 -0
  112. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_schedule_lock.py +0 -0
  113. {kryten_webqueue-0.26.0 → kryten_webqueue-0.27.0}/tests/test_search_facets.py +0 -0
@@ -5,6 +5,21 @@ 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
+
8
23
  ## [0.26.0] — 2026-06-22
9
24
 
10
25
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.26.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]
@@ -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}
@@ -0,0 +1,197 @@
1
+ """User-facing feedback + movie-title suggestion endpoints.
2
+
3
+ Backs the public "Feedback" page (templates/feedback/index.html). Both features
4
+ require a logged-in user so submissions carry a canonical username for
5
+ attribution and light spam control.
6
+
7
+ Movie suggestions are resolved against TMDB/OMDB (reusing the cover-art
8
+ resolver). The flow is two-step: ``/suggest/resolve`` returns candidate matches
9
+ the user confirms, then ``/suggest/submit`` records the chosen match. A title we
10
+ can't match is still accepted and stored as ``unresolved``; a title we already
11
+ have is stored as ``already_have`` and the user is told it's available.
12
+ """
13
+
14
+ from fastapi import APIRouter, Request, Depends, HTTPException
15
+ from pydantic import BaseModel
16
+
17
+ from ..auth.session import get_current_user
18
+
19
+ router = APIRouter(tags=["feedback"])
20
+
21
+ FEEDBACK_MAX_LEN = 2000
22
+ SUGGEST_QUERY_MAX_LEN = 200
23
+ _THANKS = "Thanks for helping make Channel-Z better!"
24
+ _VALID_SOURCES = {"tmdb", "omdb"}
25
+
26
+
27
+ class FeedbackSubmit(BaseModel):
28
+ body: str
29
+
30
+
31
+ class SuggestResolve(BaseModel):
32
+ query: str
33
+
34
+
35
+ class SuggestChoice(BaseModel):
36
+ source: str | None = None
37
+ id: str | None = None
38
+ title: str | None = None
39
+ year: str | None = None
40
+ media_type: str | None = None
41
+ poster_url: str | None = None
42
+
43
+
44
+ class SuggestSubmit(BaseModel):
45
+ query: str
46
+ choice: SuggestChoice | None = None
47
+ unresolved: bool = False
48
+
49
+
50
+ def _rate_limit(request: Request, key: str, message: str) -> None:
51
+ """Raise 429 when the per-user sliding window for ``key`` is exhausted."""
52
+ limiter = request.app.state.feedback_rate_limiter
53
+ if not limiter.is_allowed(key):
54
+ raise HTTPException(status_code=429, detail=message)
55
+
56
+
57
+ @router.post("/feedback/submit")
58
+ async def submit_feedback(
59
+ payload: FeedbackSubmit, request: Request, user: dict = Depends(get_current_user)
60
+ ):
61
+ """Record a free-text feedback submission and thank the user."""
62
+ body = (payload.body or "").strip()
63
+ if not body:
64
+ raise HTTPException(status_code=400, detail="Please enter some feedback before submitting.")
65
+ if len(body) > FEEDBACK_MAX_LEN:
66
+ raise HTTPException(
67
+ status_code=400,
68
+ detail=f"Feedback is too long (max {FEEDBACK_MAX_LEN} characters).",
69
+ )
70
+ _rate_limit(
71
+ request, f"feedback:{user['username']}",
72
+ "You're sending feedback very quickly. Please wait a moment and try again.",
73
+ )
74
+ db = request.app.state.db
75
+ await db.add_feedback(username=user["username"], body=body)
76
+ return {
77
+ "success": True,
78
+ "message": (
79
+ f"Thanks, {user['username']}! {_THANKS} Your feedback has been "
80
+ "sent to the Channel-Z team."
81
+ ),
82
+ }
83
+
84
+
85
+ @router.post("/feedback/suggest/resolve")
86
+ async def resolve_suggestion(
87
+ payload: SuggestResolve, request: Request, user: dict = Depends(get_current_user)
88
+ ):
89
+ """Search the movie databases for candidate matches to a title query.
90
+
91
+ Each candidate is annotated with ``catalog_token``/``catalog_title`` when we
92
+ already have that title, so the UI can flag it before the user submits.
93
+ """
94
+ query = (payload.query or "").strip()
95
+ if not query:
96
+ raise HTTPException(status_code=400, detail="Enter a movie or show title to search.")
97
+ if len(query) > SUGGEST_QUERY_MAX_LEN:
98
+ raise HTTPException(
99
+ status_code=400,
100
+ detail=f"Title is too long (max {SUGGEST_QUERY_MAX_LEN} characters).",
101
+ )
102
+ _rate_limit(
103
+ request, f"suggest-resolve:{user['username']}",
104
+ "Too many searches in a short time. Please wait a moment and try again.",
105
+ )
106
+ cover_art = request.app.state.cover_art
107
+ db = request.app.state.db
108
+ candidates = await cover_art.search_titles(query)
109
+ for c in candidates:
110
+ match = await db.find_catalog_by_title(c.get("title") or "")
111
+ if match:
112
+ c["catalog_token"] = match["friendly_token"]
113
+ c["catalog_title"] = match["title"]
114
+ return {"query": query, "candidates": candidates}
115
+
116
+
117
+ @router.post("/feedback/suggest/submit")
118
+ async def submit_suggestion(
119
+ payload: SuggestSubmit, request: Request, user: dict = Depends(get_current_user)
120
+ ):
121
+ """Record a movie suggestion, resolving 'already have' server-side.
122
+
123
+ A missing/empty choice (or ``unresolved=true``) is stored as an unresolved
124
+ suggestion — still surfaced to admins. The chosen candidate fields are
125
+ user-supplied and so are sanitized and length-capped; the catalog match is
126
+ always re-derived from the DB rather than trusted from the client.
127
+ """
128
+ query = (payload.query or "").strip()
129
+ if not query:
130
+ raise HTTPException(status_code=400, detail="Enter a movie or show title.")
131
+ if len(query) > SUGGEST_QUERY_MAX_LEN:
132
+ raise HTTPException(
133
+ status_code=400,
134
+ detail=f"Title is too long (max {SUGGEST_QUERY_MAX_LEN} characters).",
135
+ )
136
+ _rate_limit(
137
+ request, f"suggest:{user['username']}",
138
+ "You're sending suggestions very quickly. Please wait a moment and try again.",
139
+ )
140
+ db = request.app.state.db
141
+ username = user["username"]
142
+ choice = payload.choice
143
+
144
+ # Unresolved path: no match chosen (or explicitly flagged). Still recorded.
145
+ if payload.unresolved or not choice or not (choice.title or "").strip():
146
+ await db.add_title_suggestion(username=username, query=query, resolution="unresolved")
147
+ return {
148
+ "success": True,
149
+ "resolution": "unresolved",
150
+ "message": (
151
+ f"Thanks, {username}! {_THANKS} We couldn't match that to a "
152
+ "database entry, but we've passed your suggestion to the "
153
+ "Channel-Z team."
154
+ ),
155
+ }
156
+
157
+ # Sanitize the client-provided candidate before storing.
158
+ title = (choice.title or "").strip()[:300]
159
+ year = ((choice.year or "").strip()[:8]) or None
160
+ source = ((choice.source or "").strip().lower()[:16]) or None
161
+ if source not in _VALID_SOURCES:
162
+ source = None
163
+ resolved_id = ((choice.id or "").strip()[:64]) or None
164
+ poster = ((choice.poster_url or "").strip()[:500]) or None
165
+
166
+ # Authoritatively re-check whether we already have this title.
167
+ match = await db.find_catalog_by_title(title)
168
+ if match:
169
+ await db.add_title_suggestion(
170
+ username=username, query=query, resolved_title=title, resolved_year=year,
171
+ resolved_source=source, resolved_id=resolved_id, poster_url=poster,
172
+ resolution="already_have", catalog_token=match["friendly_token"],
173
+ )
174
+ return {
175
+ "success": True,
176
+ "resolution": "already_have",
177
+ "catalog_token": match["friendly_token"],
178
+ "catalog_title": match["title"],
179
+ "message": (
180
+ f"Good news, {username} — we already have \u201c{match['title']}\u201d "
181
+ "in the catalog! Thanks for the suggestion."
182
+ ),
183
+ }
184
+
185
+ await db.add_title_suggestion(
186
+ username=username, query=query, resolved_title=title, resolved_year=year,
187
+ resolved_source=source, resolved_id=resolved_id, poster_url=poster,
188
+ resolution="resolved",
189
+ )
190
+ return {
191
+ "success": True,
192
+ "resolution": "resolved",
193
+ "message": (
194
+ f"Thanks, {username}! {_THANKS} We've added \u201c{title}\u201d to the "
195
+ "suggestions list for the Channel-Z team to review."
196
+ ),
197
+ }