kryten-webqueue 0.33.1__tar.gz → 0.33.2__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 (130) hide show
  1. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/CHANGELOG.md +6 -0
  2. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/app.py +6 -1
  4. kryten_webqueue-0.33.2/kryten_webqueue/auth/rate_limit.py +62 -0
  5. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/feedback.py +22 -0
  6. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/pyproject.toml +1 -1
  7. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_feedback.py +73 -1
  8. kryten_webqueue-0.33.1/kryten_webqueue/auth/rate_limit.py +0 -29
  9. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/.github/workflows/python-publish.yml +0 -0
  10. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/.github/workflows/release.yml +0 -0
  11. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/.gitignore +0 -0
  12. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/README.md +0 -0
  13. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/config.example.json +0 -0
  14. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/deploy/kryten-webqueue.service +0 -0
  15. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/deploy/nginx-queue.conf +0 -0
  16. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/IMPLEMENTATION_SPEC.md +0 -0
  17. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/IMPL_API_GATE.md +0 -0
  18. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/IMPL_ECONOMY.md +0 -0
  19. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/IMPL_KRYTEN_PY.md +0 -0
  20. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/IMPL_ROBOT.md +0 -0
  21. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  22. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  23. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/Log for incorrect promo insertion problem.txt +0 -0
  24. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  25. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/PRE_PLAN_GAPS.md +0 -0
  26. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/PRODUCT_PLAN.md +0 -0
  27. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  28. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/UX_POLISH_PLAN.md +0 -0
  29. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/__init__.py +0 -0
  30. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/__main__.py +0 -0
  31. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/api_gate/__init__.py +0 -0
  32. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/api_gate/client.py +0 -0
  33. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/auth/__init__.py +0 -0
  34. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/auth/otp.py +0 -0
  35. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/auth/session.py +0 -0
  36. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/__init__.py +0 -0
  37. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/__init__.py +0 -0
  38. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  39. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_connection.py +0 -0
  40. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  41. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  42. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_queue.py +0 -0
  43. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/images.py +0 -0
  44. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/mediacms.py +0 -0
  45. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/sync.py +0 -0
  46. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/config.py +0 -0
  47. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/__init__.py +0 -0
  48. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  49. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  50. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  51. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  52. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  53. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  54. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  55. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  56. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/jobs/__init__.py +0 -0
  57. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  58. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/jobs/manager.py +0 -0
  59. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/jobs/tasks.py +0 -0
  60. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/logging_config.py +0 -0
  61. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/__init__.py +0 -0
  62. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/bulk_add.py +0 -0
  63. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/fire.py +0 -0
  64. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/importer.py +0 -0
  65. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/ordering.py +0 -0
  66. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/scheduler.py +0 -0
  67. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/promos/__init__.py +0 -0
  68. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/promos/director.py +0 -0
  69. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/__init__.py +0 -0
  70. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/completion.py +0 -0
  71. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/ordering.py +0 -0
  72. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/poller.py +0 -0
  73. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/presence.py +0 -0
  74. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/race_poller.py +0 -0
  75. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/shadow.py +0 -0
  76. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/__init__.py +0 -0
  77. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_catalog.py +0 -0
  78. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_feedback.py +0 -0
  79. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_jobs.py +0 -0
  80. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_moderation.py +0 -0
  81. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_playlists.py +0 -0
  82. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_promos.py +0 -0
  83. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_queue.py +0 -0
  84. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_schedules.py +0 -0
  85. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/auth.py +0 -0
  86. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/catalog.py +0 -0
  87. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/pages.py +0 -0
  88. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/queue.py +0 -0
  89. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/user.py +0 -0
  90. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/css/main.css +0 -0
  91. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  92. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-promos.js +0 -0
  93. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  94. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  95. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin.js +0 -0
  96. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/main.js +0 -0
  97. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/index.html +0 -0
  98. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
  99. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/promos.html +0 -0
  100. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  101. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
  102. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/auth/login.html +0 -0
  103. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/base.html +0 -0
  104. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/catalog/browse.html +0 -0
  105. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  106. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  107. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/feedback/index.html +0 -0
  108. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/queue/index.html +0 -0
  109. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/race.html +0 -0
  110. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
  111. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/ws/__init__.py +0 -0
  112. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/ws/handler.py +0 -0
  113. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/ws/manager.py +0 -0
  114. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/__init__.py +0 -0
  115. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_config_persistence.py +0 -0
  116. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_fetchurls_sharepoint.py +0 -0
  117. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_phase1.py +0 -0
  118. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_phase2_jobs.py +0 -0
  119. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_phase3_jobs.py +0 -0
  120. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_phase4_live_fixes.py +0 -0
  121. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_playlist_import.py +0 -0
  122. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_presence_refund.py +0 -0
  123. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_promo_director.py +0 -0
  124. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_promo_pool_exclusion.py +0 -0
  125. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_queue_announce.py +0 -0
  126. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_race_poller.py +0 -0
  127. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_recently_played_hide.py +0 -0
  128. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_save_results_to_playlist.py +0 -0
  129. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_schedule_lock.py +0 -0
  130. {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_search_facets.py +0 -0
@@ -5,6 +5,12 @@ 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.33.2] — 2026-07-29
9
+
10
+ ### Added
11
+
12
+ - **Per-user submission quota on feedback and title suggestions.** Each user may now submit at most **2 per day and 6 per week** for feedback *and* (independently) for suggestions. Exceeding either tier returns `429` with a message pointing at the day/week limit. Implemented as a new `QuotaLimiter` (multi-tier sliding window) wired into the two submit endpoints, in addition to the existing short-burst throttle. Search/resolve requests are unaffected.
13
+
8
14
  ## [0.33.1] — 2026-07-29
9
15
 
10
16
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.33.1
3
+ Version: 0.33.2
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
@@ -22,7 +22,7 @@ from .queue.presence import PresenceRefundMonitor
22
22
  from .promos.director import PromoDirector
23
23
  from .ws.manager import WebSocketManager
24
24
  from .playlists.scheduler import PlaylistScheduler
25
- from .auth.rate_limit import RateLimiter
25
+ from .auth.rate_limit import QuotaLimiter, RateLimiter
26
26
 
27
27
  from .routes.auth import router as auth_router
28
28
  from .routes.catalog import router as catalog_router
@@ -196,6 +196,11 @@ async def lifespan(app: FastAPI):
196
196
  # feedback/suggestion endpoints (per-user, namespaced keys).
197
197
  app.state.rate_limiter = RateLimiter()
198
198
  app.state.feedback_rate_limiter = RateLimiter(max_requests=12, window_seconds=300)
199
+ # Hard per-user submission quota for feedback and suggestions: 2/day, 6/week
200
+ # (namespaced keys, checked in addition to the short-burst limiter above).
201
+ app.state.feedback_quota_limiter = QuotaLimiter(
202
+ [("day", 2, 86_400), ("week", 6, 604_800)]
203
+ )
199
204
 
200
205
  # Playlist scheduler
201
206
  scheduler = PlaylistScheduler(
@@ -0,0 +1,62 @@
1
+ import time
2
+ from collections import defaultdict, deque
3
+
4
+
5
+ class RateLimiter:
6
+ """Sliding-window rate limiter for OTP requests."""
7
+
8
+ def __init__(self, max_requests: int = 3, window_seconds: int = 300):
9
+ self._max = max_requests
10
+ self._window = window_seconds
11
+ self._requests: dict[str, deque] = defaultdict(deque)
12
+
13
+ def is_allowed(self, key: str) -> bool:
14
+ now = time.time()
15
+ window = self._requests[key]
16
+ # Remove expired entries
17
+ while window and window[0] < now - self._window:
18
+ window.popleft()
19
+ if len(window) >= self._max:
20
+ return False
21
+ window.append(now)
22
+ return True
23
+
24
+ def remaining(self, key: str) -> int:
25
+ now = time.time()
26
+ window = self._requests[key]
27
+ while window and window[0] < now - self._window:
28
+ window.popleft()
29
+ return max(0, self._max - len(window))
30
+
31
+
32
+ class QuotaLimiter:
33
+ """Sliding-window limiter that enforces several named tiers at once.
34
+
35
+ Each tier is ``(label, max_requests, window_seconds)``. An action is only
36
+ permitted when *every* tier still has room; a permitted action is recorded
37
+ against all tiers atomically. Used for hard per-user submission quotas
38
+ (e.g. 2/day and 6/week) rather than short-burst throttling.
39
+ """
40
+
41
+ def __init__(self, tiers: list[tuple[str, int, int]]):
42
+ if not tiers:
43
+ raise ValueError("QuotaLimiter requires at least one tier")
44
+ self._tiers = list(tiers)
45
+ self._max_window = max(window for _, _, window in self._tiers)
46
+ self._events: dict[str, deque] = defaultdict(deque)
47
+
48
+ def check(self, key: str) -> str | None:
49
+ """Return the label of the first exhausted tier, or ``None`` if allowed.
50
+
51
+ When allowed the action is recorded; when blocked nothing is recorded.
52
+ """
53
+ now = time.time()
54
+ events = self._events[key]
55
+ while events and events[0] < now - self._max_window:
56
+ events.popleft()
57
+ for label, max_requests, window in self._tiers:
58
+ count = sum(1 for ts in events if ts >= now - window)
59
+ if count >= max_requests:
60
+ return label
61
+ events.append(now)
62
+ return None
@@ -54,6 +54,26 @@ def _rate_limit(request: Request, key: str, message: str) -> None:
54
54
  raise HTTPException(status_code=429, detail=message)
55
55
 
56
56
 
57
+ def _submission_quota(request: Request, key: str, noun: str) -> None:
58
+ """Enforce the hard per-user daily/weekly submission quota (2/day, 6/week).
59
+
60
+ Raises 429 with a tier-appropriate message when the day or week cap for
61
+ ``key`` is reached. A permitted submission is recorded against both tiers.
62
+ """
63
+ limiter = request.app.state.feedback_quota_limiter
64
+ exceeded = limiter.check(key)
65
+ if exceeded == "day":
66
+ raise HTTPException(
67
+ status_code=429,
68
+ detail=f"You've reached today's limit of 2 {noun}. Please try again tomorrow.",
69
+ )
70
+ if exceeded == "week":
71
+ raise HTTPException(
72
+ status_code=429,
73
+ detail=f"You've reached this week's limit of 6 {noun}. Please try again next week.",
74
+ )
75
+
76
+
57
77
  @router.post("/feedback/submit")
58
78
  async def submit_feedback(
59
79
  payload: FeedbackSubmit, request: Request, user: dict = Depends(get_current_user)
@@ -74,6 +94,7 @@ async def submit_feedback(
74
94
  f"feedback:{user['username']}",
75
95
  "You're sending feedback very quickly. Please wait a moment and try again.",
76
96
  )
97
+ _submission_quota(request, f"feedback:{user['username']}", "feedback submissions")
77
98
  db = request.app.state.db
78
99
  await db.add_feedback(username=user["username"], body=body)
79
100
  return {
@@ -144,6 +165,7 @@ async def submit_suggestion(
144
165
  f"suggest:{user['username']}",
145
166
  "You're sending suggestions very quickly. Please wait a moment and try again.",
146
167
  )
168
+ _submission_quota(request, f"suggest:{user['username']}", "suggestions")
147
169
  db = request.app.state.db
148
170
  username = user["username"]
149
171
  choice = payload.choice
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.33.1"
3
+ version = "0.33.2"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -13,7 +13,7 @@ from fastapi import HTTPException
13
13
 
14
14
  from kryten_webqueue.catalog.db import Database
15
15
  from kryten_webqueue.catalog.images import CoverArtResolver
16
- from kryten_webqueue.auth.rate_limit import RateLimiter
16
+ from kryten_webqueue.auth.rate_limit import QuotaLimiter, RateLimiter
17
17
  from kryten_webqueue.routes.feedback import (
18
18
  FeedbackSubmit,
19
19
  SuggestResolve,
@@ -63,6 +63,7 @@ async def _add_catalog(db, token, title):
63
63
 
64
64
  def _request(**state):
65
65
  """Minimal stand-in for a FastAPI Request exposing app.state.<attr>."""
66
+ state.setdefault("feedback_quota_limiter", _lenient_quota())
66
67
  return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(**state)))
67
68
 
68
69
 
@@ -70,6 +71,10 @@ def _lenient_limiter():
70
71
  return RateLimiter(max_requests=100, window_seconds=300)
71
72
 
72
73
 
74
+ def _lenient_quota():
75
+ return QuotaLimiter([("day", 1000, 86_400), ("week", 1000, 604_800)])
76
+
77
+
73
78
  class _FakeCover:
74
79
  """Stub cover-art resolver returning preset suggestion candidates."""
75
80
 
@@ -317,6 +322,55 @@ async def test_submit_feedback_rate_limited(db):
317
322
  assert ei.value.status_code == 429
318
323
 
319
324
 
325
+ async def test_submit_feedback_daily_quota(db):
326
+ # 2 feedback submissions per day; the 3rd is rejected with a 429.
327
+ req = _request(
328
+ db=db,
329
+ feedback_rate_limiter=_lenient_limiter(),
330
+ feedback_quota_limiter=QuotaLimiter([("day", 2, 86_400), ("week", 6, 604_800)]),
331
+ )
332
+ await submit_feedback(FeedbackSubmit(body="one"), req, user=USER)
333
+ await submit_feedback(FeedbackSubmit(body="two"), req, user=USER)
334
+ with pytest.raises(HTTPException) as ei:
335
+ await submit_feedback(FeedbackSubmit(body="three"), req, user=USER)
336
+ assert ei.value.status_code == 429
337
+ assert "today" in ei.value.detail
338
+ assert await db.count_feedback() == 2
339
+
340
+
341
+ async def test_submit_feedback_weekly_quota(db):
342
+ # 6 per week even when the daily cap is not the binding constraint.
343
+ req = _request(
344
+ db=db,
345
+ feedback_rate_limiter=_lenient_limiter(),
346
+ # High daily cap so only the weekly tier (6) can bind.
347
+ feedback_quota_limiter=QuotaLimiter(
348
+ [("day", 100, 86_400), ("week", 6, 604_800)]
349
+ ),
350
+ )
351
+ for i in range(6):
352
+ await submit_feedback(FeedbackSubmit(body=f"item {i}"), req, user=USER)
353
+ with pytest.raises(HTTPException) as ei:
354
+ await submit_feedback(FeedbackSubmit(body="seventh"), req, user=USER)
355
+ assert ei.value.status_code == 429
356
+ assert "week" in ei.value.detail
357
+ assert await db.count_feedback() == 6
358
+
359
+
360
+ async def test_submit_feedback_quota_is_per_user(db):
361
+ # Each user gets an independent quota (namespaced keys).
362
+ req = _request(
363
+ db=db,
364
+ feedback_rate_limiter=_lenient_limiter(),
365
+ feedback_quota_limiter=QuotaLimiter([("day", 2, 86_400), ("week", 6, 604_800)]),
366
+ )
367
+ await submit_feedback(FeedbackSubmit(body="a"), req, user={"username": "Alice"})
368
+ await submit_feedback(FeedbackSubmit(body="b"), req, user={"username": "Alice"})
369
+ # Bob is unaffected by Alice reaching her cap.
370
+ res = await submit_feedback(FeedbackSubmit(body="c"), req, user={"username": "Bob"})
371
+ assert res["success"] is True
372
+
373
+
320
374
  # ── User routes: suggestions ────────────────────────────────────────────────
321
375
 
322
376
 
@@ -430,6 +484,24 @@ async def test_submit_suggestion_sanitizes_bad_source(db):
430
484
  assert row["resolution"] == "resolved"
431
485
 
432
486
 
487
+ async def test_submit_suggestion_daily_quota(db):
488
+ # Suggestions share the same 2/day, 6/week cap (independent of feedback).
489
+ req = _request(
490
+ db=db,
491
+ feedback_rate_limiter=_lenient_limiter(),
492
+ feedback_quota_limiter=QuotaLimiter([("day", 2, 86_400), ("week", 6, 604_800)]),
493
+ )
494
+ await submit_suggestion(SuggestSubmit(query="one", unresolved=True), req, user=USER)
495
+ await submit_suggestion(SuggestSubmit(query="two", unresolved=True), req, user=USER)
496
+ with pytest.raises(HTTPException) as ei:
497
+ await submit_suggestion(
498
+ SuggestSubmit(query="three", unresolved=True), req, user=USER
499
+ )
500
+ assert ei.value.status_code == 429
501
+ assert "today" in ei.value.detail
502
+ assert await db.count_title_suggestions() == 2
503
+
504
+
433
505
  # ── Admin routes ────────────────────────────────────────────────────────────
434
506
 
435
507
 
@@ -1,29 +0,0 @@
1
- import time
2
- from collections import defaultdict, deque
3
-
4
-
5
- class RateLimiter:
6
- """Sliding-window rate limiter for OTP requests."""
7
-
8
- def __init__(self, max_requests: int = 3, window_seconds: int = 300):
9
- self._max = max_requests
10
- self._window = window_seconds
11
- self._requests: dict[str, deque] = defaultdict(deque)
12
-
13
- def is_allowed(self, key: str) -> bool:
14
- now = time.time()
15
- window = self._requests[key]
16
- # Remove expired entries
17
- while window and window[0] < now - self._window:
18
- window.popleft()
19
- if len(window) >= self._max:
20
- return False
21
- window.append(now)
22
- return True
23
-
24
- def remaining(self, key: str) -> int:
25
- now = time.time()
26
- window = self._requests[key]
27
- while window and window[0] < now - self._window:
28
- window.popleft()
29
- return max(0, self._max - len(window))