kryten-webqueue 0.14.2__tar.gz → 0.15.1__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.14.2 → kryten_webqueue-0.15.1}/CHANGELOG.md +26 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/PKG-INFO +1 -1
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/config.example.json +3 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/__main__.py +3 -1
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/api_gate/client.py +15 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/db.py +10 -3
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/config.py +11 -0
- kryten_webqueue-0.15.1/kryten_webqueue/logging_config.py +81 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/promos/director.py +105 -4
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/queue.py +8 -4
- kryten_webqueue-0.15.1/kryten_webqueue/routes/user.py +89 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/static/css/main.css +164 -0
- kryten_webqueue-0.15.1/kryten_webqueue/templates/user/dashboard.html +449 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/pyproject.toml +1 -1
- kryten_webqueue-0.14.2/kryten_webqueue/routes/user.py +0 -35
- kryten_webqueue-0.14.2/kryten_webqueue/templates/user/dashboard.html +0 -126
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/.gitignore +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/README.md +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/__init__.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_save_results_to_playlist.py +0 -0
|
@@ -6,6 +6,32 @@ 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
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.15.1] — 2026-06-17
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Application logs were silently dropped.** The app never configured Python logging — `uvicorn.run(log_level="info")` only sets up uvicorn's own loggers, leaving the `kryten_webqueue` hierarchy with no handler, so Python's "last resort" path emitted only `WARNING`+. Every `logger.info(...)` (including all promo-insertion diagnostics) was discarded. A new `logging_config.build_log_config()` installs a `dictConfig` (passed to uvicorn via `log_config`) that attaches a console handler to the application loggers.
|
|
14
|
+
- **Silent failure in the promo poll loop.** `PromoDirector.on_poll()` swallowed every exception from the immutable-event-lock check with a bare `except: return` and no log line, hiding faults. It now logs at `WARNING` with a traceback before skipping the cycle.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Configurable log levels.** New `log_level` (default `INFO`) and `promo_log_level` (default falls back to `log_level`) config fields. Set `promo_log_level` to `DEBUG` for a full per-poll trace of promo decisions without flooding the rest of the app (the `kryten_webqueue.promos` logger is independently tunable).
|
|
19
|
+
- **Deep promo observability.** `PromoDirector` now emits detailed diagnostics across the whole insertion path: now-playing advance + cadence counter, clip selection (order, pool size, sequential index, random no-repeat avoidance), weighted type pick (candidates/weights/skipped), cadence-due reason, idempotency-guard skips, lead-in decisions, and the add/move/uid result. It warns on single-clip pools (which always repeat) and **errors** when an add succeeds but returns no uid (an untracked insertion that would otherwise repeat every poll).
|
|
20
|
+
|
|
21
|
+
[0.15.1]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.15.1
|
|
22
|
+
|
|
23
|
+
## [0.15.0] — 2026-06-14
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **Account progression panel on the Z-Coin dashboard.** The left column now surfaces the user's economy account in full: current rank/level, a progress bar toward the next milestone (remaining Z + percent), active perks (including spend discount), and their purchased vanity items. Powered by the new api-gate `GET /economy/account/{username}` endpoint (economy `account.summary`), proxied via `GET /user/account`.
|
|
28
|
+
- **Vanity item editing dialogs.** Inline **Edit** buttons open dialogs to purchase/update the custom greeting (textarea, 200-char limit) and custom chat color (native color picker synced with a 6-digit hex field). Backed by `POST /user/vanity/greeting` and `POST /user/vanity/color`, which proxy the economy `vanity.set_greeting` / `vanity.set_color` commands. The username is taken from the authenticated session, never the request body.
|
|
29
|
+
- **Transaction credit/debit filter.** The Recent Transactions column gains an All / Credits / Debits toggle and a **Load more** control, with friendlier titles derived from each transaction's `trigger_id` (e.g. `presence.base` → "Watching reward", `spend.vanity.chat_color` → "Custom color") instead of raw `earn` labels.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **Queue history pagination.** `GET /queue/history` now accepts `limit`/`offset` and returns a `total` count; the dashboard's middle column paginates through the full history with Prev/Next controls instead of showing only the most recent 20 entries.
|
|
34
|
+
|
|
9
35
|
## [0.14.2] — 2026-06-13
|
|
10
36
|
|
|
11
37
|
### Added
|
|
@@ -3,13 +3,15 @@ import uvicorn
|
|
|
3
3
|
|
|
4
4
|
from .config import Config
|
|
5
5
|
from .app import create_app
|
|
6
|
+
from .logging_config import build_log_config
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def main():
|
|
9
10
|
config_path = os.environ.get("WQ_CONFIG", "/etc/kryten-webqueue/config.json")
|
|
10
11
|
config = Config.from_file(config_path)
|
|
11
12
|
app = create_app(config)
|
|
12
|
-
|
|
13
|
+
log_config = build_log_config(config.log_level, config.promo_log_level)
|
|
14
|
+
uvicorn.run(app, host=config.host, port=config.port, log_config=log_config)
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
if __name__ == "__main__":
|
|
@@ -93,6 +93,21 @@ class ApiGateClient:
|
|
|
93
93
|
async def get_transactions(self, username: str, limit: int = 20, offset: int = 0) -> dict:
|
|
94
94
|
return await self.get(f"/economy/transactions/{username}", limit=limit, offset=offset)
|
|
95
95
|
|
|
96
|
+
async def get_account_summary(self, username: str) -> dict:
|
|
97
|
+
return await self.get(f"/economy/account/{username}")
|
|
98
|
+
|
|
99
|
+
async def set_vanity_greeting(self, username: str, value: str) -> dict:
|
|
100
|
+
return await self.post("/economy/vanity/greeting", json={
|
|
101
|
+
"username": username,
|
|
102
|
+
"value": value,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
async def set_vanity_color(self, username: str, value: str) -> dict:
|
|
106
|
+
return await self.post("/economy/vanity/color", json={
|
|
107
|
+
"username": username,
|
|
108
|
+
"value": value,
|
|
109
|
+
})
|
|
110
|
+
|
|
96
111
|
async def queue_preview(self, username: str, duration_sec: int, tier: str = "queue") -> dict:
|
|
97
112
|
return await self.post("/economy/queue-preview", json={
|
|
98
113
|
"username": username,
|
|
@@ -893,12 +893,19 @@ class Database:
|
|
|
893
893
|
[username, friendly_token, title, tier, z_cost],
|
|
894
894
|
)
|
|
895
895
|
|
|
896
|
-
async def get_user_queue_history(self, username: str, limit: int = 50) -> list[dict]:
|
|
896
|
+
async def get_user_queue_history(self, username: str, limit: int = 50, offset: int = 0) -> list[dict]:
|
|
897
897
|
return await self._fetch_all(
|
|
898
|
-
"SELECT * FROM queue_history WHERE username=? ORDER BY id DESC LIMIT ?",
|
|
899
|
-
[username, limit],
|
|
898
|
+
"SELECT * FROM queue_history WHERE username=? ORDER BY id DESC LIMIT ? OFFSET ?",
|
|
899
|
+
[username, limit, offset],
|
|
900
900
|
)
|
|
901
901
|
|
|
902
|
+
async def get_user_queue_history_count(self, username: str) -> int:
|
|
903
|
+
row = await self._fetch_one(
|
|
904
|
+
"SELECT COUNT(*) AS c FROM queue_history WHERE username=?",
|
|
905
|
+
[username],
|
|
906
|
+
)
|
|
907
|
+
return int(row["c"]) if row else 0
|
|
908
|
+
|
|
902
909
|
# --- Saved playlists ---
|
|
903
910
|
|
|
904
911
|
async def get_saved_playlists(self) -> list[dict]:
|
|
@@ -104,6 +104,17 @@ class Config(BaseModel):
|
|
|
104
104
|
secret_key: str
|
|
105
105
|
session_ttl_hours: int = 24
|
|
106
106
|
|
|
107
|
+
# Logging
|
|
108
|
+
# Root application log level for the ``kryten_webqueue`` logger hierarchy.
|
|
109
|
+
# Without explicit configuration Python only emits WARNING+ via its
|
|
110
|
+
# "last resort" handler, so INFO diagnostics (e.g. promo insertions) are
|
|
111
|
+
# silently dropped. ``__main__`` installs a dictConfig using these values.
|
|
112
|
+
log_level: str = "INFO"
|
|
113
|
+
# Independent level for the promo subsystem (``kryten_webqueue.promos``).
|
|
114
|
+
# Set to "DEBUG" for a full per-poll trace of promo decisions without
|
|
115
|
+
# flooding the rest of the app. Falls back to ``log_level`` when None.
|
|
116
|
+
promo_log_level: str | None = None
|
|
117
|
+
|
|
107
118
|
# API Gate
|
|
108
119
|
api_gate_url: str = "http://127.0.0.1:24444"
|
|
109
120
|
api_gate_token: str
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Centralised logging configuration.
|
|
2
|
+
|
|
3
|
+
The app previously relied on ``uvicorn.run(log_level="info")`` which only
|
|
4
|
+
configures uvicorn's *own* loggers (``uvicorn``/``uvicorn.access``/
|
|
5
|
+
``uvicorn.error``). Application loggers in the ``kryten_webqueue`` hierarchy had
|
|
6
|
+
no handler, so Python's "last resort" handler emitted only ``WARNING`` and
|
|
7
|
+
above — silently dropping every ``logger.info(...)`` call (e.g. all promo
|
|
8
|
+
insertion diagnostics). This module builds a single ``dictConfig`` that installs
|
|
9
|
+
a console handler for both uvicorn and the application loggers, with an
|
|
10
|
+
independently tunable level for the promo subsystem.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize_level(level: str | None, default: str) -> str:
|
|
17
|
+
if not level:
|
|
18
|
+
return default
|
|
19
|
+
candidate = str(level).strip().upper()
|
|
20
|
+
valid = {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET"}
|
|
21
|
+
return candidate if candidate in valid else default
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_log_config(log_level: str = "INFO", promo_log_level: str | None = None) -> dict:
|
|
25
|
+
"""Return a ``logging.config.dictConfig`` dict for uvicorn + the app.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
log_level: Level for the root and ``kryten_webqueue`` loggers.
|
|
29
|
+
promo_log_level: Level for ``kryten_webqueue.promos`` (the promo
|
|
30
|
+
director). Falls back to ``log_level`` when not provided. Set to
|
|
31
|
+
``DEBUG`` for a full per-poll trace of promo decisions.
|
|
32
|
+
"""
|
|
33
|
+
app_level = _normalize_level(log_level, "INFO")
|
|
34
|
+
promo_level = _normalize_level(promo_log_level, app_level)
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
"version": 1,
|
|
38
|
+
# Never tear down loggers created at import time (module-level
|
|
39
|
+
# ``getLogger`` calls); we only attach handlers/levels.
|
|
40
|
+
"disable_existing_loggers": False,
|
|
41
|
+
"formatters": {
|
|
42
|
+
"default": {
|
|
43
|
+
"format": "%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
|
44
|
+
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
45
|
+
},
|
|
46
|
+
"access": {
|
|
47
|
+
"format": "%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
|
48
|
+
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
"handlers": {
|
|
52
|
+
"console": {
|
|
53
|
+
"class": "logging.StreamHandler",
|
|
54
|
+
"formatter": "default",
|
|
55
|
+
"stream": "ext://sys.stderr",
|
|
56
|
+
},
|
|
57
|
+
"access": {
|
|
58
|
+
"class": "logging.StreamHandler",
|
|
59
|
+
"formatter": "access",
|
|
60
|
+
"stream": "ext://sys.stdout",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
"root": {"handlers": ["console"], "level": app_level},
|
|
64
|
+
"loggers": {
|
|
65
|
+
"kryten_webqueue": {
|
|
66
|
+
"level": app_level,
|
|
67
|
+
"handlers": ["console"],
|
|
68
|
+
"propagate": False,
|
|
69
|
+
},
|
|
70
|
+
# Promo subsystem: independently tunable so operators can crank it to
|
|
71
|
+
# DEBUG for a deep dive without flooding the rest of the app.
|
|
72
|
+
"kryten_webqueue.promos": {
|
|
73
|
+
"level": promo_level,
|
|
74
|
+
"handlers": ["console"],
|
|
75
|
+
"propagate": False,
|
|
76
|
+
},
|
|
77
|
+
"uvicorn": {"level": "INFO", "handlers": ["console"], "propagate": False},
|
|
78
|
+
"uvicorn.error": {"level": "INFO", "handlers": ["console"], "propagate": False},
|
|
79
|
+
"uvicorn.access": {"level": "INFO", "handlers": ["access"], "propagate": False},
|
|
80
|
+
},
|
|
81
|
+
}
|
|
@@ -186,10 +186,18 @@ class PromoDirector:
|
|
|
186
186
|
def _general_due(self, now: datetime) -> bool:
|
|
187
187
|
g = self._config.general
|
|
188
188
|
if self._content_since_last_general >= g.every_n_items:
|
|
189
|
+
logger.debug(
|
|
190
|
+
"General promo due: content_since_last=%d >= every_n_items=%d",
|
|
191
|
+
self._content_since_last_general, g.every_n_items,
|
|
192
|
+
)
|
|
189
193
|
return True
|
|
190
194
|
if self._last_general_at is not None:
|
|
191
195
|
elapsed_min = (now - self._last_general_at).total_seconds() / 60.0
|
|
192
196
|
if elapsed_min >= g.every_m_minutes:
|
|
197
|
+
logger.debug(
|
|
198
|
+
"General promo due: elapsed=%.1fmin >= every_m_minutes=%.1f",
|
|
199
|
+
elapsed_min, g.every_m_minutes,
|
|
200
|
+
)
|
|
193
201
|
return True
|
|
194
202
|
return False
|
|
195
203
|
|
|
@@ -197,37 +205,70 @@ class PromoDirector:
|
|
|
197
205
|
"""Weighted choice among enabled general types with a non-empty pool."""
|
|
198
206
|
candidates: list[str] = []
|
|
199
207
|
weights: list[int] = []
|
|
208
|
+
skipped: list[str] = []
|
|
200
209
|
for t in GENERAL_PROMO_TYPES:
|
|
201
210
|
tc = self._config.types.get(t)
|
|
202
211
|
if not tc or not tc.enabled or tc.weight <= 0:
|
|
212
|
+
skipped.append(f"{t}(disabled/weight)")
|
|
203
213
|
continue
|
|
204
214
|
pool = await self._db.get_promo_pool_items(t)
|
|
205
215
|
if not pool:
|
|
216
|
+
skipped.append(f"{t}(empty-pool)")
|
|
206
217
|
continue
|
|
207
218
|
candidates.append(t)
|
|
208
219
|
weights.append(tc.weight)
|
|
209
220
|
if not candidates:
|
|
221
|
+
logger.warning(
|
|
222
|
+
"Promo general-type pick found no eligible types (skipped=%s)", skipped
|
|
223
|
+
)
|
|
210
224
|
return None
|
|
211
|
-
|
|
225
|
+
chosen = self._rng.choices(candidates, weights=weights, k=1)[0]
|
|
226
|
+
logger.debug(
|
|
227
|
+
"Promo general-type pick: chosen=%s candidates=%s weights=%s skipped=%s",
|
|
228
|
+
chosen, candidates, weights, skipped,
|
|
229
|
+
)
|
|
230
|
+
return chosen
|
|
212
231
|
|
|
213
232
|
def _select_clip(self, promo_type: str, pool: list[dict]) -> dict:
|
|
214
233
|
tc = self._config.types.get(promo_type)
|
|
215
234
|
order = tc.order if tc else "random"
|
|
235
|
+
pool_size = len(pool)
|
|
216
236
|
if order == "sequential":
|
|
217
|
-
|
|
237
|
+
raw_index = self._seq_index.get(promo_type, 0)
|
|
238
|
+
idx = raw_index % pool_size
|
|
218
239
|
self._seq_index[promo_type] = idx + 1
|
|
219
240
|
clip = pool[idx]
|
|
241
|
+
logger.debug(
|
|
242
|
+
"Promo clip select [%s] order=sequential pool_size=%d raw_index=%d "
|
|
243
|
+
"-> idx=%d media_id=%s title=%r next_index=%d",
|
|
244
|
+
promo_type, pool_size, raw_index, idx,
|
|
245
|
+
clip.get("media_id"), clip.get("title"), idx + 1,
|
|
246
|
+
)
|
|
220
247
|
else:
|
|
221
248
|
clip = self._rng.choice(pool)
|
|
222
|
-
|
|
249
|
+
repeated = False
|
|
250
|
+
if self._config.general.no_repeat and pool_size > 1:
|
|
223
251
|
last = self._last_clip_token.get(promo_type)
|
|
224
252
|
if clip.get("media_id") == last:
|
|
253
|
+
repeated = True
|
|
225
254
|
# Draw from the rest of the pool so the no-repeat guarantee
|
|
226
255
|
# always holds (bounded random retries could otherwise give
|
|
227
256
|
# up and return a repeat).
|
|
228
257
|
alternatives = [c for c in pool if c.get("media_id") != last]
|
|
229
258
|
if alternatives:
|
|
230
259
|
clip = self._rng.choice(alternatives)
|
|
260
|
+
logger.debug(
|
|
261
|
+
"Promo clip select [%s] order=random pool_size=%d no_repeat=%s "
|
|
262
|
+
"avoided_repeat=%s -> media_id=%s title=%r",
|
|
263
|
+
promo_type, pool_size, self._config.general.no_repeat,
|
|
264
|
+
repeated, clip.get("media_id"), clip.get("title"),
|
|
265
|
+
)
|
|
266
|
+
if pool_size == 1:
|
|
267
|
+
logger.warning(
|
|
268
|
+
"Promo pool for %r has a single clip; it will repeat every time "
|
|
269
|
+
"(media_id=%s). Add more clips to vary this promo type.",
|
|
270
|
+
promo_type, clip.get("media_id"),
|
|
271
|
+
)
|
|
231
272
|
self._last_clip_token[promo_type] = clip.get("media_id")
|
|
232
273
|
return clip
|
|
233
274
|
|
|
@@ -262,14 +303,30 @@ class PromoDirector:
|
|
|
262
303
|
logger.warning("Promo add failed (%s)", promo_type, exc_info=True)
|
|
263
304
|
return None
|
|
264
305
|
if not add_result or not add_result.get("success"):
|
|
306
|
+
logger.warning(
|
|
307
|
+
"Promo add rejected (%s media_id=%s): result=%r",
|
|
308
|
+
promo_type, clip.get("media_id"), add_result,
|
|
309
|
+
)
|
|
265
310
|
return None
|
|
266
311
|
uid = add_result.get("uid")
|
|
267
312
|
if uid is None:
|
|
313
|
+
# CyTube accepted the add but api-gate could not resolve a uid. The
|
|
314
|
+
# shadow can't track this promo, so the idempotency guard will never
|
|
315
|
+
# see it -> the cadence counter never resets and we'd re-add every
|
|
316
|
+
# poll. Bail loudly so this shows up in logs instead of silently
|
|
317
|
+
# spamming the queue.
|
|
318
|
+
logger.error(
|
|
319
|
+
"Promo add for %s (media_id=%s) returned success but NO uid; "
|
|
320
|
+
"cannot track in shadow (result=%r). Skipping shadow insert to "
|
|
321
|
+
"avoid an untracked, repeatable insertion.",
|
|
322
|
+
promo_type, clip.get("media_id"), add_result,
|
|
323
|
+
)
|
|
268
324
|
return None
|
|
269
325
|
|
|
270
326
|
if after_uid is not None:
|
|
271
327
|
try:
|
|
272
328
|
await self._api_gate.playlist_move(uid, after_uid)
|
|
329
|
+
logger.debug("Promo move ok: uid=%s after_uid=%s", uid, after_uid)
|
|
273
330
|
except Exception:
|
|
274
331
|
logger.warning("Promo move failed (uid=%s after=%s)", uid, after_uid, exc_info=True)
|
|
275
332
|
|
|
@@ -310,10 +367,21 @@ class PromoDirector:
|
|
|
310
367
|
return None
|
|
311
368
|
items = self._shadow.items
|
|
312
369
|
if self._has_lead_in(content_uid, items):
|
|
370
|
+
logger.debug(
|
|
371
|
+
"Viewer's-Choice lead-in already present for paid uid=%s; skipping",
|
|
372
|
+
content_uid,
|
|
373
|
+
)
|
|
313
374
|
return None
|
|
314
375
|
pred = self._direct_pred_uid(content_uid, items)
|
|
315
376
|
if pred is None:
|
|
377
|
+
logger.debug(
|
|
378
|
+
"Viewer's-Choice skipped: no predecessor for paid uid=%s", content_uid
|
|
379
|
+
)
|
|
316
380
|
return None
|
|
381
|
+
logger.info(
|
|
382
|
+
"Viewer's-Choice lead-in (pay path) for paid uid=%s after_uid=%s",
|
|
383
|
+
content_uid, pred,
|
|
384
|
+
)
|
|
317
385
|
return await self._insert_promo(
|
|
318
386
|
"viewers_choice", after_uid=pred, target_uid=content_uid, lead_in=True
|
|
319
387
|
)
|
|
@@ -331,6 +399,10 @@ class PromoDirector:
|
|
|
331
399
|
if np_uid != self._last_np_uid:
|
|
332
400
|
if self._last_np_uid is not None and not self._last_np_is_promo:
|
|
333
401
|
self._content_since_last_general += 1
|
|
402
|
+
logger.debug(
|
|
403
|
+
"Now-playing advanced: %s -> %s (is_promo=%s) content_since_last_general=%d",
|
|
404
|
+
self._last_np_uid, np_uid, np_is_promo, self._content_since_last_general,
|
|
405
|
+
)
|
|
334
406
|
self._last_np_uid = np_uid
|
|
335
407
|
self._last_np_is_promo = np_is_promo
|
|
336
408
|
|
|
@@ -341,8 +413,13 @@ class PromoDirector:
|
|
|
341
413
|
# No-op during an immutable scheduled event (curated content plays as built).
|
|
342
414
|
try:
|
|
343
415
|
if await self._db.is_event_lock_active():
|
|
416
|
+
logger.debug("Promo on_poll skipped: immutable event lock active")
|
|
344
417
|
return
|
|
345
418
|
except Exception:
|
|
419
|
+
logger.warning(
|
|
420
|
+
"Promo on_poll: is_event_lock_active() check failed; skipping this cycle",
|
|
421
|
+
exc_info=True,
|
|
422
|
+
)
|
|
346
423
|
return
|
|
347
424
|
|
|
348
425
|
items = self._shadow.items
|
|
@@ -350,6 +427,7 @@ class PromoDirector:
|
|
|
350
427
|
return
|
|
351
428
|
target = self._next_content(np_uid, items)
|
|
352
429
|
if target is None:
|
|
430
|
+
logger.debug("Promo on_poll: no upcoming content item found")
|
|
353
431
|
return
|
|
354
432
|
target_uid = target.get("uid")
|
|
355
433
|
direct_pred = self._direct_pred_uid(target_uid, items)
|
|
@@ -358,12 +436,24 @@ class PromoDirector:
|
|
|
358
436
|
# Lead-in first so a same-cycle general promo lands ahead of it.
|
|
359
437
|
leadin_type = self._leadin_type_for(target)
|
|
360
438
|
if leadin_type and not self._has_lead_in(target_uid, items):
|
|
439
|
+
logger.info(
|
|
440
|
+
"Promo lead-in due: type=%s target_uid=%s (is_pay=%s dur=%.0fs) after_uid=%s",
|
|
441
|
+
leadin_type, target_uid, target.get("is_pay"),
|
|
442
|
+
_duration_seconds(target), direct_pred,
|
|
443
|
+
)
|
|
361
444
|
await self._insert_promo(
|
|
362
445
|
leadin_type, after_uid=direct_pred, target_uid=target_uid, lead_in=True
|
|
363
446
|
)
|
|
447
|
+
elif leadin_type:
|
|
448
|
+
logger.debug(
|
|
449
|
+
"Promo lead-in already present for target_uid=%s (type=%s)",
|
|
450
|
+
target_uid, leadin_type,
|
|
451
|
+
)
|
|
364
452
|
|
|
365
453
|
# General cadence promo.
|
|
366
|
-
|
|
454
|
+
due = self._general_due(now)
|
|
455
|
+
has_general = self._has_general_before(target_uid, self._shadow.items)
|
|
456
|
+
if due and not has_general:
|
|
367
457
|
chosen = await self._pick_general_type()
|
|
368
458
|
if chosen:
|
|
369
459
|
pool = await self._db.get_promo_pool_items(chosen)
|
|
@@ -371,5 +461,16 @@ class PromoDirector:
|
|
|
371
461
|
chosen, after_uid=content_pred, target_uid=None, lead_in=False, pool=pool
|
|
372
462
|
)
|
|
373
463
|
if inserted is not None:
|
|
464
|
+
logger.info(
|
|
465
|
+
"Promo general inserted: type=%s uid=%s before content_uid=%s "
|
|
466
|
+
"(reset cadence counter from %d)",
|
|
467
|
+
chosen, inserted, target_uid, self._content_since_last_general,
|
|
468
|
+
)
|
|
374
469
|
self._content_since_last_general = 0
|
|
375
470
|
self._last_general_at = now
|
|
471
|
+
elif due and has_general:
|
|
472
|
+
logger.debug(
|
|
473
|
+
"Promo general due but one already precedes target_uid=%s; "
|
|
474
|
+
"skipping (idempotency guard)",
|
|
475
|
+
target_uid,
|
|
476
|
+
)
|
|
@@ -223,11 +223,15 @@ async def cost_preview(request: Request, friendly_token: str, tier: str = "queue
|
|
|
223
223
|
|
|
224
224
|
|
|
225
225
|
@router.get("/history")
|
|
226
|
-
async def queue_history(request: Request,
|
|
227
|
-
|
|
226
|
+
async def queue_history(request: Request, limit: int = 20, offset: int = 0,
|
|
227
|
+
user: dict = Depends(get_current_user)):
|
|
228
|
+
"""Get user's queue history (paginated, most recent first)."""
|
|
229
|
+
limit = max(1, min(limit, 100))
|
|
230
|
+
offset = max(0, offset)
|
|
228
231
|
db = request.app.state.db
|
|
229
|
-
|
|
230
|
-
|
|
232
|
+
items = await db.get_user_queue_history(user["username"], limit=limit, offset=offset)
|
|
233
|
+
total = await db.get_user_queue_history_count(user["username"])
|
|
234
|
+
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
|
231
235
|
|
|
232
236
|
|
|
233
237
|
@router.get("/next-schedule")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from ..auth.session import get_current_user
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/user", tags=["user"])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.get("/balance")
|
|
10
|
+
async def get_balance(request: Request, user: dict = Depends(get_current_user)):
|
|
11
|
+
"""Get user's economy balance."""
|
|
12
|
+
api_gate = request.app.state.api_gate
|
|
13
|
+
return await api_gate.get_balance(user["username"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/account")
|
|
17
|
+
async def get_account(request: Request, user: dict = Depends(get_current_user)):
|
|
18
|
+
"""Get the user's full economy account summary (rank, progress, perks, vanity)."""
|
|
19
|
+
api_gate = request.app.state.api_gate
|
|
20
|
+
return await api_gate.get_account_summary(user["username"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GreetingUpdate(BaseModel):
|
|
24
|
+
value: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ColorUpdate(BaseModel):
|
|
28
|
+
value: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post("/vanity/greeting")
|
|
32
|
+
async def set_vanity_greeting(
|
|
33
|
+
body: GreetingUpdate, request: Request, user: dict = Depends(get_current_user)
|
|
34
|
+
):
|
|
35
|
+
"""Purchase/update the current user's custom greeting."""
|
|
36
|
+
api_gate = request.app.state.api_gate
|
|
37
|
+
try:
|
|
38
|
+
return await api_gate.set_vanity_greeting(user["username"], body.value)
|
|
39
|
+
except Exception as exc: # noqa: BLE001
|
|
40
|
+
raise HTTPException(status_code=400, detail=_economy_error(exc)) from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.post("/vanity/color")
|
|
44
|
+
async def set_vanity_color(
|
|
45
|
+
body: ColorUpdate, request: Request, user: dict = Depends(get_current_user)
|
|
46
|
+
):
|
|
47
|
+
"""Purchase/update the current user's custom chat color (6-digit hex)."""
|
|
48
|
+
api_gate = request.app.state.api_gate
|
|
49
|
+
try:
|
|
50
|
+
return await api_gate.set_vanity_color(user["username"], body.value)
|
|
51
|
+
except Exception as exc: # noqa: BLE001
|
|
52
|
+
raise HTTPException(status_code=400, detail=_economy_error(exc)) from exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _economy_error(exc: Exception) -> str:
|
|
56
|
+
"""Extract a human-readable message from an api-gate HTTP error."""
|
|
57
|
+
import httpx
|
|
58
|
+
|
|
59
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
60
|
+
try:
|
|
61
|
+
detail = exc.response.json().get("detail")
|
|
62
|
+
if detail:
|
|
63
|
+
return str(detail)
|
|
64
|
+
except Exception: # noqa: BLE001
|
|
65
|
+
pass
|
|
66
|
+
return "Purchase failed. Please try again."
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.get("/transactions")
|
|
70
|
+
async def get_transactions(request: Request, limit: int = 20, offset: int = 0,
|
|
71
|
+
user: dict = Depends(get_current_user)):
|
|
72
|
+
"""Get user's transaction history."""
|
|
73
|
+
api_gate = request.app.state.api_gate
|
|
74
|
+
return await api_gate.get_transactions(user["username"], limit=limit, offset=offset)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.get("/profile")
|
|
78
|
+
async def get_profile(request: Request, user: dict = Depends(get_current_user)):
|
|
79
|
+
"""Get current user profile info from api-gate."""
|
|
80
|
+
api_gate = request.app.state.api_gate
|
|
81
|
+
try:
|
|
82
|
+
user_data = await api_gate.get_user(user["username"])
|
|
83
|
+
except Exception:
|
|
84
|
+
user_data = {}
|
|
85
|
+
return {
|
|
86
|
+
"username": user["username"],
|
|
87
|
+
"rank": user["rank"],
|
|
88
|
+
"online": user_data.get("online", False) if user_data else False,
|
|
89
|
+
}
|