kryten-webqueue 0.20.1__tar.gz → 0.22.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/CHANGELOG.md +14 -1
  2. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/api_gate/client.py +6 -0
  4. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/app.py +4 -1
  5. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/routes/user.py +29 -1
  6. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/static/css/main.css +4 -0
  7. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/templates/admin/index.html +1 -1
  8. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/templates/user/dashboard.html +75 -1
  9. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/pyproject.toml +1 -1
  10. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/.github/workflows/python-publish.yml +0 -0
  11. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/.github/workflows/release.yml +0 -0
  12. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/.gitignore +0 -0
  13. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/README.md +0 -0
  14. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/config.example.json +0 -0
  15. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/deploy/kryten-webqueue.service +0 -0
  16. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/deploy/nginx-queue.conf +0 -0
  17. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  18. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/docs/IMPL_API_GATE.md +0 -0
  19. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/docs/IMPL_ECONOMY.md +0 -0
  20. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  21. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/docs/IMPL_ROBOT.md +0 -0
  22. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  23. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/docs/PRE_PLAN_GAPS.md +0 -0
  24. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/docs/PRODUCT_PLAN.md +0 -0
  25. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  26. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/docs/UX_POLISH_PLAN.md +0 -0
  27. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/__init__.py +0 -0
  28. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/__main__.py +0 -0
  29. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  30. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/auth/__init__.py +0 -0
  31. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/auth/otp.py +0 -0
  32. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  33. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/auth/session.py +0 -0
  34. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/catalog/__init__.py +0 -0
  35. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/catalog/db.py +0 -0
  36. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/catalog/images.py +0 -0
  37. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  38. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/catalog/sync.py +0 -0
  39. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/config.py +0 -0
  40. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/integrations/__init__.py +0 -0
  41. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  42. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  43. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  44. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  45. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  46. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  47. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  48. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  49. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/jobs/__init__.py +0 -0
  50. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  51. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/jobs/manager.py +0 -0
  52. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/jobs/tasks.py +0 -0
  53. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/logging_config.py +0 -0
  54. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/playlists/__init__.py +0 -0
  55. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  56. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/playlists/fire.py +0 -0
  57. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/playlists/importer.py +0 -0
  58. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/playlists/ordering.py +0 -0
  59. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  60. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/promos/__init__.py +0 -0
  61. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/promos/director.py +0 -0
  62. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/queue/__init__.py +0 -0
  63. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/queue/ordering.py +0 -0
  64. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/queue/poller.py +0 -0
  65. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/queue/presence.py +0 -0
  66. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/queue/shadow.py +0 -0
  67. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/routes/__init__.py +0 -0
  68. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  69. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  70. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  71. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  72. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  73. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  74. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/routes/auth.py +0 -0
  75. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/routes/catalog.py +0 -0
  76. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/routes/pages.py +0 -0
  77. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/routes/queue.py +0 -0
  78. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/static/js/main.js +0 -0
  79. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  80. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  81. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  82. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  83. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/templates/auth/login.html +0 -0
  84. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/templates/base.html +0 -0
  85. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  86. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  87. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  88. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/templates/queue/index.html +0 -0
  89. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/ws/__init__.py +0 -0
  90. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/ws/handler.py +0 -0
  91. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/kryten_webqueue/ws/manager.py +0 -0
  92. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/__init__.py +0 -0
  93. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_config_persistence.py +0 -0
  94. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_fetchurls_sharepoint.py +0 -0
  95. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_phase1.py +0 -0
  96. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_phase2_jobs.py +0 -0
  97. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_phase3_jobs.py +0 -0
  98. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_phase4_live_fixes.py +0 -0
  99. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_playlist_import.py +0 -0
  100. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_presence_refund.py +0 -0
  101. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_promo_director.py +0 -0
  102. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_promo_pool_exclusion.py +0 -0
  103. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_queue_announce.py +0 -0
  104. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_save_results_to_playlist.py +0 -0
  105. {kryten_webqueue-0.20.1 → kryten_webqueue-0.22.0}/tests/test_search_facets.py +0 -0
@@ -4,7 +4,20 @@ All notable changes to this project will be documented in this file.
4
4
 
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
- ## [Unreleased]
7
+ ## [0.22.0] - 2026-06-19
8
+
9
+ ### Added
10
+
11
+ - **Shoutout on the Vanity Items tab.** The dashboard's Vanity tab gains a third item — **Shoutout** — alongside Greeting and Chat color, each now with a short call-to-action. Sending a shoutout posts the user's message to public chat (`📢 <user>: …`) via the new `POST /user/vanity/shoutout` route, which proxies the api-gate `POST /economy/vanity/shoutout` endpoint. The message is the user's own input (validated server-side: trimmed, non-empty, max 200 chars); the cost/availability come from the account summary; the username is always taken from the authenticated session.
12
+
13
+ ## [0.21.0] — 2026-06-17
14
+
15
+ ### Changed
16
+
17
+ - **Catalog sync no longer runs on startup.** The background sync loop now waits a full interval before its first run instead of syncing immediately when the process starts, so a restart won't kick off a sync. Admins can still trigger it on demand with the "Sync Catalog" button.
18
+ - **Friendlier job buttons.** Jobs that open a parameter dialog before running now show **"Begin…"** instead of "Run…", signalling that a dialog (with a Cancel) comes next rather than an immediate action. One-click jobs keep the plain **"Run"** label.
19
+
20
+ [0.21.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.21.0
8
21
 
9
22
  ## [0.20.1] — 2026-06-17
10
23
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.20.1
3
+ Version: 0.22.0
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
@@ -108,6 +108,12 @@ class ApiGateClient:
108
108
  "value": value,
109
109
  })
110
110
 
111
+ async def set_vanity_shoutout(self, username: str, value: str) -> dict:
112
+ return await self.post("/economy/vanity/shoutout", json={
113
+ "username": username,
114
+ "value": value,
115
+ })
116
+
111
117
  async def queue_preview(self, username: str, duration_sec: int, tier: str = "queue") -> dict:
112
118
  return await self.post("/economy/queue-preview", json={
113
119
  "username": username,
@@ -163,12 +163,15 @@ async def lifespan(app: FastAPI):
163
163
  # Background workers
164
164
  async def _catalog_sync_loop():
165
165
  interval = config.catalog_sync_interval_hours * 3600
166
+ # Sync on the interval, NOT immediately on startup — a restart should
167
+ # not trigger a catalog sync. Admins can run it on demand from the
168
+ # "Sync Catalog" button.
166
169
  while True:
170
+ await asyncio.sleep(interval)
167
171
  try:
168
172
  await catalog_sync.sync()
169
173
  except Exception as e:
170
174
  logger.exception(f"Catalog sync error: {type(e).__name__}: {e}")
171
- await asyncio.sleep(interval)
172
175
 
173
176
  async def _otp_cleanup_loop():
174
177
  while True:
@@ -1,5 +1,5 @@
1
1
  from fastapi import APIRouter, Request, Depends, HTTPException
2
- from pydantic import BaseModel
2
+ from pydantic import BaseModel, field_validator
3
3
 
4
4
  from ..auth.session import get_current_user
5
5
 
@@ -28,6 +28,22 @@ class ColorUpdate(BaseModel):
28
28
  value: str
29
29
 
30
30
 
31
+ class ShoutoutRequest(BaseModel):
32
+ # Mirrors the economy's shoutout limits so a bypassed UI still gets a
33
+ # consistent, early rejection instead of forwarding arbitrary-length text.
34
+ value: str
35
+
36
+ @field_validator("value")
37
+ @classmethod
38
+ def _validate_value(cls, v: str) -> str:
39
+ v = v.strip()
40
+ if not v:
41
+ raise ValueError("Shoutout message is required.")
42
+ if len(v) > 200:
43
+ raise ValueError("Message too long (max 200 characters).")
44
+ return v
45
+
46
+
31
47
  @router.post("/vanity/greeting")
32
48
  async def set_vanity_greeting(
33
49
  body: GreetingUpdate, request: Request, user: dict = Depends(get_current_user)
@@ -52,6 +68,18 @@ async def set_vanity_color(
52
68
  raise HTTPException(status_code=400, detail=_economy_error(exc)) from exc
53
69
 
54
70
 
71
+ @router.post("/vanity/shoutout")
72
+ async def set_vanity_shoutout(
73
+ body: ShoutoutRequest, request: Request, user: dict = Depends(get_current_user)
74
+ ):
75
+ """Purchase a shoutout — the bot posts the message to public chat."""
76
+ api_gate = request.app.state.api_gate
77
+ try:
78
+ return await api_gate.set_vanity_shoutout(user["username"], body.value)
79
+ except Exception as exc: # noqa: BLE001
80
+ raise HTTPException(status_code=400, detail=_economy_error(exc)) from exc
81
+
82
+
55
83
  def _economy_error(exc: Exception) -> str:
56
84
  """Extract a human-readable message from an api-gate HTTP error."""
57
85
  import httpx
@@ -975,6 +975,10 @@ a.np-chip {
975
975
  color: var(--text-secondary);
976
976
  font-style: italic;
977
977
  }
978
+ .vanity-cta {
979
+ font-size: 0.78rem;
980
+ margin-top: 0.2rem;
981
+ }
978
982
  .color-swatch {
979
983
  display: inline-block;
980
984
  width: 1rem;
@@ -199,7 +199,7 @@ async function loadJobs() {
199
199
  <span class="job-label">${escapeHtml(j.label)}${hasParams ? ' <span class="job-badge">params</span>' : ''}</span>
200
200
  ${summary}
201
201
  <button class="btn btn-sm" onclick="startJob('${j.name}')" ${j.running ? 'disabled' : ''}>
202
- ${j.running ? 'Running…' : (hasParams ? 'Run…' : 'Run')}
202
+ ${j.running ? 'Running…' : (hasParams ? 'Begin…' : 'Run')}
203
203
  </button>
204
204
  </div>`;
205
205
  }).join('')
@@ -48,13 +48,14 @@
48
48
  </div>
49
49
 
50
50
  <div class="tab-panel" id="tab-vanity" role="tabpanel" hidden>
51
- <p class="muted">Personalize how the bot greets you and your chat color. Each update is a one-time purchase.</p>
51
+ <p class="muted">Personalize how the bot greets you, your chat color, and shout yourself out. Each update is a one-time purchase.</p>
52
52
  <div class="vanity-item">
53
53
  <div class="vanity-row">
54
54
  <span class="vanity-label">Greeting</span>
55
55
  <button class="btn btn-sm" id="edit-greeting" disabled>Edit</button>
56
56
  </div>
57
57
  <div class="vanity-value" id="vanity-greeting">—</div>
58
+ <div class="vanity-cta muted" id="cta-greeting">The bot welcomes you by name when you join.</div>
58
59
  </div>
59
60
  <div class="vanity-item">
60
61
  <div class="vanity-row">
@@ -62,6 +63,15 @@
62
63
  <button class="btn btn-sm" id="edit-color" disabled>Edit</button>
63
64
  </div>
64
65
  <div class="vanity-value" id="vanity-color">—</div>
66
+ <div class="vanity-cta muted" id="cta-color">Stand out — pick a custom color for your chat messages.</div>
67
+ </div>
68
+ <div class="vanity-item">
69
+ <div class="vanity-row">
70
+ <span class="vanity-label">Shoutout</span>
71
+ <button class="btn btn-sm" id="buy-shoutout" disabled>Send</button>
72
+ </div>
73
+ <div class="vanity-value" id="vanity-shoutout">—</div>
74
+ <div class="vanity-cta muted" id="cta-shoutout">Have the bot post your message to the whole channel.</div>
65
75
  </div>
66
76
  </div>
67
77
  </div>
@@ -223,6 +233,20 @@ function renderAccount(a) {
223
233
  cBtn.disabled = enabled.custom_color === false;
224
234
  gBtn.title = costs.custom_greeting ? `${fmt(costs.custom_greeting, sym)} per update` : '';
225
235
  cBtn.title = costs.custom_color ? `${fmt(costs.custom_color, sym)} per update` : '';
236
+
237
+ const shoutEl = document.getElementById('vanity-shoutout');
238
+ const shoutCost = costs.shoutout;
239
+ const shoutAvailable = enabled.shoutout !== false && shoutCost != null;
240
+ if (shoutAvailable) {
241
+ shoutEl.textContent = `Post a one-off message to chat for ${fmt(shoutCost, sym)}.`;
242
+ shoutEl.classList.remove('vanity-unset');
243
+ } else {
244
+ shoutEl.textContent = 'Unavailable';
245
+ shoutEl.classList.add('vanity-unset');
246
+ }
247
+ const sBtn = document.getElementById('buy-shoutout');
248
+ sBtn.disabled = !shoutAvailable;
249
+ sBtn.title = shoutAvailable ? `${fmt(shoutCost, sym)} per shoutout` : '';
226
250
  }
227
251
 
228
252
  // ── Vanity edit dialogs ────────────────────────────────────────
@@ -328,6 +352,55 @@ async function saveColor() {
328
352
  }
329
353
  }
330
354
 
355
+ // ── Shoutout dialog ────────────────────────────────────────────
356
+ function buyShoutout() {
357
+ if (!accountState) return;
358
+ const sym = accountState.currency_symbol || 'Z';
359
+ const costs = accountState.vanity_costs || {};
360
+ const enabled = accountState.vanity_enabled || {};
361
+ const cost = costs.shoutout;
362
+ if (enabled.shoutout === false || cost == null) {
363
+ showToast('Shoutouts are currently unavailable', 'error');
364
+ return;
365
+ }
366
+ showModal(`
367
+ <h3>Shoutout</h3>
368
+ <p class="muted">The bot posts your message to public chat as <strong>📢 ${escapeHtml(accountState.username || 'you')}: …</strong>. Cost: ${fmt(cost, sym)} each.</p>
369
+ <label class="field"><span>Message (max 200 characters)</span>
370
+ <textarea id="shoutout-input" maxlength="200" rows="3" placeholder="Say something to the whole channel"></textarea></label>
371
+ <div class="modal-actions">
372
+ <button class="btn" onclick="closeModal()">Cancel</button>
373
+ <button class="btn btn-primary" id="shoutout-save">Send shoutout</button>
374
+ </div>`);
375
+ document.getElementById('shoutout-save').addEventListener('click', saveShoutout);
376
+ }
377
+
378
+ async function saveShoutout() {
379
+ const value = document.getElementById('shoutout-input').value.trim();
380
+ if (!value) { showToast('Message cannot be empty', 'error'); return; }
381
+ const btn = document.getElementById('shoutout-save');
382
+ btn.disabled = true;
383
+ let resp;
384
+ try {
385
+ resp = await fetch('/user/vanity/shoutout', {
386
+ method: 'POST',
387
+ headers: {'Content-Type': 'application/json'},
388
+ body: JSON.stringify({value}),
389
+ });
390
+ } catch (e) {
391
+ showToast('Network error', 'error'); btn.disabled = false; return;
392
+ }
393
+ const data = await resp.json().catch(() => ({}));
394
+ if (resp.ok) {
395
+ showToast('Shoutout sent', 'success');
396
+ closeModal();
397
+ loadAccount();
398
+ } else {
399
+ showToast(data.detail || 'Shoutout failed', 'error');
400
+ btn.disabled = false;
401
+ }
402
+ }
403
+
331
404
  // ── Queue history (MIDDLE column, paginated) ───────────────────
332
405
  const QUEUE_LIMIT = 20;
333
406
  let queueOffset = 0;
@@ -440,6 +513,7 @@ function closeModal() {
440
513
  // ── Init ───────────────────────────────────────────────────────
441
514
  document.getElementById('edit-greeting').addEventListener('click', editGreeting);
442
515
  document.getElementById('edit-color').addEventListener('click', editColor);
516
+ document.getElementById('buy-shoutout').addEventListener('click', buyShoutout);
443
517
  document.querySelectorAll('.tx-toggle-btn').forEach(b => b.addEventListener('click', () => {
444
518
  document.querySelectorAll('.tx-toggle-btn').forEach(x => x.classList.remove('active'));
445
519
  b.classList.add('active');
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.20.1"
3
+ version = "0.22.0"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"