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.
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/CHANGELOG.md +24 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/app.py +7 -1
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/db.py +168 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/images.py +103 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/race_poller.py +31 -2
- kryten_webqueue-0.27.0/kryten_webqueue/routes/admin_feedback.py +111 -0
- kryten_webqueue-0.27.0/kryten_webqueue/routes/feedback.py +197 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/pages.py +9 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/static/css/main.css +124 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/index.html +227 -20
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/base.html +1 -0
- kryten_webqueue-0.27.0/kryten_webqueue/templates/feedback/index.html +236 -0
- kryten_webqueue-0.27.0/kryten_webqueue/templates/race.html +397 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/pyproject.toml +1 -1
- kryten_webqueue-0.27.0/tests/test_feedback.py +416 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_race_poller.py +61 -0
- kryten_webqueue-0.25.0/kryten_webqueue/templates/race.html +0 -223
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/.gitignore +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/README.md +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/config.example.json +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.25.0 → kryten_webqueue-0.27.0}/tests/test_schedule_lock.py +0 -0
- {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
|
|
@@ -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
|
-
|
|
73
|
+
frame = result["frame"]
|
|
74
|
+
self._last_frame = frame
|
|
71
75
|
await self._ws_manager.broadcast(
|
|
72
|
-
{"type": "race_frame", "data": self.
|
|
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}
|