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.
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/CHANGELOG.md +6 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/PKG-INFO +1 -1
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/app.py +6 -1
- kryten_webqueue-0.33.2/kryten_webqueue/auth/rate_limit.py +62 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/feedback.py +22 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/pyproject.toml +1 -1
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_feedback.py +73 -1
- kryten_webqueue-0.33.1/kryten_webqueue/auth/rate_limit.py +0 -29
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/.gitignore +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/README.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/config.example.json +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_catalog.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_connection.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_playlists.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/completion.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_moderation.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-playlists.js +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin.js +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/__init__.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_recently_played_hide.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/tests/test_schedule_lock.py +0 -0
- {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
|
|
@@ -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
|
|
@@ -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))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/docs/Log for incorrect promo insertion problem.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/_common.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/enrichtv.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/fetchurls.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/ytpipe/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/ytpipe/downloader.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_moderation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-playlists.js
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-queue-mgmt.js
RENAMED
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-schedules.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/index.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/promos.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/feedback/index.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/queue/index.html
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.33.1 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|