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.
Files changed (105) hide show
  1. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/CHANGELOG.md +26 -0
  2. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/config.example.json +3 -0
  4. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/__main__.py +3 -1
  5. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/api_gate/client.py +15 -0
  6. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/db.py +10 -3
  7. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/config.py +11 -0
  8. kryten_webqueue-0.15.1/kryten_webqueue/logging_config.py +81 -0
  9. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/promos/director.py +105 -4
  10. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/queue.py +8 -4
  11. kryten_webqueue-0.15.1/kryten_webqueue/routes/user.py +89 -0
  12. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/static/css/main.css +164 -0
  13. kryten_webqueue-0.15.1/kryten_webqueue/templates/user/dashboard.html +449 -0
  14. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/pyproject.toml +1 -1
  15. kryten_webqueue-0.14.2/kryten_webqueue/routes/user.py +0 -35
  16. kryten_webqueue-0.14.2/kryten_webqueue/templates/user/dashboard.html +0 -126
  17. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/.github/workflows/python-publish.yml +0 -0
  18. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/.github/workflows/release.yml +0 -0
  19. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/.gitignore +0 -0
  20. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/README.md +0 -0
  21. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/deploy/kryten-webqueue.service +0 -0
  22. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/deploy/nginx-queue.conf +0 -0
  23. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  24. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/IMPL_API_GATE.md +0 -0
  25. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/IMPL_ECONOMY.md +0 -0
  26. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  27. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/IMPL_ROBOT.md +0 -0
  28. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  29. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/PRE_PLAN_GAPS.md +0 -0
  30. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/PRODUCT_PLAN.md +0 -0
  31. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  32. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/__init__.py +0 -0
  33. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  34. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/app.py +0 -0
  35. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/auth/__init__.py +0 -0
  36. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/auth/otp.py +0 -0
  37. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  38. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/auth/session.py +0 -0
  39. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/__init__.py +0 -0
  40. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/images.py +0 -0
  41. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/mediacms.py +0 -0
  42. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/sync.py +0 -0
  43. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/__init__.py +0 -0
  44. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  45. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  46. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  47. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  48. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  49. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  50. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  51. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  52. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/jobs/__init__.py +0 -0
  53. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  54. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/jobs/manager.py +0 -0
  55. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/jobs/tasks.py +0 -0
  56. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/__init__.py +0 -0
  57. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
  58. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/fire.py +0 -0
  59. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/importer.py +0 -0
  60. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/ordering.py +0 -0
  61. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/scheduler.py +0 -0
  62. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/promos/__init__.py +0 -0
  63. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/__init__.py +0 -0
  64. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/ordering.py +0 -0
  65. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/poller.py +0 -0
  66. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/presence.py +0 -0
  67. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/shadow.py +0 -0
  68. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/__init__.py +0 -0
  69. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_catalog.py +0 -0
  70. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
  71. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
  72. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_promos.py +0 -0
  73. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  74. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
  75. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/auth.py +0 -0
  76. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/catalog.py +0 -0
  77. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/pages.py +0 -0
  78. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/static/js/main.js +0 -0
  79. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/index.html +0 -0
  80. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
  81. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/promos.html +0 -0
  82. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  83. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
  84. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/auth/login.html +0 -0
  85. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/base.html +0 -0
  86. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  87. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  88. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  89. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/queue/index.html +0 -0
  90. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/ws/__init__.py +0 -0
  91. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/ws/handler.py +0 -0
  92. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/kryten_webqueue/ws/manager.py +0 -0
  93. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/__init__.py +0 -0
  94. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_config_persistence.py +0 -0
  95. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_fetchurls_sharepoint.py +0 -0
  96. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_phase1.py +0 -0
  97. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_phase2_jobs.py +0 -0
  98. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_phase3_jobs.py +0 -0
  99. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_phase4_live_fixes.py +0 -0
  100. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_playlist_import.py +0 -0
  101. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_presence_refund.py +0 -0
  102. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_promo_director.py +0 -0
  103. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_promo_pool_exclusion.py +0 -0
  104. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.1}/tests/test_queue_announce.py +0 -0
  105. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.14.2
3
+ Version: 0.15.1
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
@@ -5,6 +5,9 @@
5
5
  "secret_key": "CHANGE_ME_long_random_string",
6
6
  "session_ttl_hours": 24,
7
7
 
8
+ "log_level": "INFO",
9
+ "promo_log_level": "DEBUG",
10
+
8
11
  "api_gate_url": "https://www.dropsugar.co:8443/",
9
12
  "api_gate_token": "CHANGE_ME",
10
13
 
@@ -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
- uvicorn.run(app, host=config.host, port=config.port, log_level="info")
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
- return self._rng.choices(candidates, weights=weights, k=1)[0]
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
- idx = self._seq_index.get(promo_type, 0) % len(pool)
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
- if self._config.general.no_repeat and len(pool) > 1:
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
- if self._general_due(now) and not self._has_general_before(target_uid, self._shadow.items):
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, user: dict = Depends(get_current_user)):
227
- """Get user's queue history."""
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
- history = await db.get_user_queue_history(user["username"])
230
- return {"items": history}
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
+ }