kryten-webqueue 0.28.0__tar.gz → 0.30.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 (127) hide show
  1. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/CHANGELOG.md +21 -0
  2. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/app.py +3 -1
  4. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/catalog/sync.py +13 -2
  5. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/jobs/manager.py +30 -7
  6. kryten_webqueue-0.28.0/kryten_webqueue/templates/admin/playlists.html → kryten_webqueue-0.30.0/kryten_webqueue/static/js/admin-playlists.js +1 -89
  7. kryten_webqueue-0.28.0/kryten_webqueue/templates/admin/promos.html → kryten_webqueue-0.30.0/kryten_webqueue/static/js/admin-promos.js +1 -49
  8. kryten_webqueue-0.28.0/kryten_webqueue/templates/admin/queue_mgmt.html → kryten_webqueue-0.30.0/kryten_webqueue/static/js/admin-queue-mgmt.js +1 -55
  9. kryten_webqueue-0.28.0/kryten_webqueue/templates/admin/schedules.html → kryten_webqueue-0.30.0/kryten_webqueue/static/js/admin-schedules.js +1 -39
  10. kryten_webqueue-0.28.0/kryten_webqueue/templates/admin/index.html → kryten_webqueue-0.30.0/kryten_webqueue/static/js/admin.js +10 -193
  11. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/static/js/main.js +19 -0
  12. kryten_webqueue-0.30.0/kryten_webqueue/templates/admin/index.html +184 -0
  13. kryten_webqueue-0.30.0/kryten_webqueue/templates/admin/playlists.html +69 -0
  14. kryten_webqueue-0.30.0/kryten_webqueue/templates/admin/promos.html +45 -0
  15. kryten_webqueue-0.30.0/kryten_webqueue/templates/admin/queue_mgmt.html +35 -0
  16. kryten_webqueue-0.30.0/kryten_webqueue/templates/admin/schedules.html +25 -0
  17. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/pyproject.toml +1 -1
  18. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/.github/workflows/python-publish.yml +0 -0
  19. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/.github/workflows/release.yml +0 -0
  20. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/.gitignore +0 -0
  21. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/README.md +0 -0
  22. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/config.example.json +0 -0
  23. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/deploy/kryten-webqueue.service +0 -0
  24. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/deploy/nginx-queue.conf +0 -0
  25. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  26. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/IMPL_API_GATE.md +0 -0
  27. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/IMPL_ECONOMY.md +0 -0
  28. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  29. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/IMPL_ROBOT.md +0 -0
  30. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  31. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  32. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
  33. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  34. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/PRE_PLAN_GAPS.md +0 -0
  35. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/PRODUCT_PLAN.md +0 -0
  36. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  37. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/docs/UX_POLISH_PLAN.md +0 -0
  38. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/__init__.py +0 -0
  39. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/__main__.py +0 -0
  40. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  41. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/api_gate/client.py +0 -0
  42. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/auth/__init__.py +0 -0
  43. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/auth/otp.py +0 -0
  44. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  45. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/auth/session.py +0 -0
  46. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/catalog/__init__.py +0 -0
  47. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/catalog/db/__init__.py +0 -0
  48. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  49. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/catalog/db/_connection.py +0 -0
  50. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  51. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  52. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
  53. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/catalog/images.py +0 -0
  54. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  55. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/config.py +0 -0
  56. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/integrations/__init__.py +0 -0
  57. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  58. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  59. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  60. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  61. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  62. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  63. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  64. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  65. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/jobs/__init__.py +0 -0
  66. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  67. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/jobs/tasks.py +0 -0
  68. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/logging_config.py +0 -0
  69. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/playlists/__init__.py +0 -0
  70. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  71. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/playlists/fire.py +0 -0
  72. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/playlists/importer.py +0 -0
  73. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/playlists/ordering.py +0 -0
  74. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  75. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/promos/__init__.py +0 -0
  76. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/promos/director.py +0 -0
  77. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/queue/__init__.py +0 -0
  78. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/queue/ordering.py +0 -0
  79. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/queue/poller.py +0 -0
  80. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/queue/presence.py +0 -0
  81. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/queue/race_poller.py +0 -0
  82. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/queue/shadow.py +0 -0
  83. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/__init__.py +0 -0
  84. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  85. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
  86. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  87. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
  88. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  89. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  90. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  91. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  92. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/auth.py +0 -0
  93. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/catalog.py +0 -0
  94. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/feedback.py +0 -0
  95. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/pages.py +0 -0
  96. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/queue.py +0 -0
  97. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/routes/user.py +0 -0
  98. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/static/css/main.css +0 -0
  99. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/templates/auth/login.html +0 -0
  100. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/templates/base.html +0 -0
  101. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  102. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  103. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  104. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/templates/feedback/index.html +0 -0
  105. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/templates/queue/index.html +0 -0
  106. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/templates/race.html +0 -0
  107. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  108. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/ws/__init__.py +0 -0
  109. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/ws/handler.py +0 -0
  110. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/kryten_webqueue/ws/manager.py +0 -0
  111. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/__init__.py +0 -0
  112. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_config_persistence.py +0 -0
  113. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_feedback.py +0 -0
  114. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_fetchurls_sharepoint.py +0 -0
  115. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_phase1.py +0 -0
  116. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_phase2_jobs.py +0 -0
  117. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_phase3_jobs.py +0 -0
  118. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_phase4_live_fixes.py +0 -0
  119. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_playlist_import.py +0 -0
  120. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_presence_refund.py +0 -0
  121. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_promo_director.py +0 -0
  122. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_promo_pool_exclusion.py +0 -0
  123. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_queue_announce.py +0 -0
  124. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_race_poller.py +0 -0
  125. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_save_results_to_playlist.py +0 -0
  126. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_schedule_lock.py +0 -0
  127. {kryten_webqueue-0.28.0 → kryten_webqueue-0.30.0}/tests/test_search_facets.py +0 -0
@@ -5,6 +5,27 @@ 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.30.0] — 2026-07-04
9
+
10
+ ### Fixed
11
+
12
+ - **Clean shutdown when a catalog sync (or other job) is in progress.** Previously, shutting the server down while a background job was running caused a cascade of WARNING/ERROR log lines as the database connection and HTTP client were closed underneath the still-running job task. Three changes fix this:
13
+ - `JobManager` gains a `stop()` method that cancels all in-flight task and awaits them.
14
+ - The lifespan shutdown now calls `job_manager.stop()` *before* closing the database, HTTP clients, or any other shared resource, so the cancelled tasks can still record their final status cleanly.
15
+ - Background loop tasks (`bg_tasks`) are now properly awaited after cancellation, preventing "Task exception was never retrieved" warnings.
16
+ - `JobManager._execute` wraps every database write in a defensive `try/except` so that a race between task teardown and resource teardown never produces an unhandled exception or spurious ERROR log.
17
+ - `CatalogSync.sync` adds an explicit `asyncio.CancelledError` handler that logs a clean info message and re-raises (letting `JobManager` record the `cancelled` status), and wraps the error-path `finish_sync_log` calls defensively for the same reason.
18
+
19
+ [0.30.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.30.0
20
+
21
+ ## [0.29.0] — 2026-07-04
22
+
23
+ ### Changed
24
+
25
+ - **Admin JavaScript moved to external static files.** All inline `{% block scripts %}` JavaScript has been extracted from the five admin templates into individual per-page files under `static/js/` (`admin.js`, `admin-playlists.js`, `admin-promos.js`, `admin-schedules.js`, `admin-queue-mgmt.js`). Shared utilities `showModal`/`closeModal` and `fmtDur` were promoted to `main.js` (previously only defined per-page). Duplicate `escapeHtml`/`fmtDur`/`showModal`/`closeModal` definitions are removed from per-page files. Each template now references its JS via a single `<script src="/static/js/...">` tag — no functional changes, better cacheability and maintainability.
26
+
27
+ [0.29.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.29.0
28
+
8
29
  ## [0.28.0] — 2026-07-04
9
30
 
10
31
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.28.0
3
+ Version: 0.30.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
@@ -227,9 +227,11 @@ async def lifespan(app: FastAPI):
227
227
 
228
228
  yield
229
229
 
230
- # Shutdown
230
+ # Shutdown — order matters: cancel in-flight work before closing resources.
231
231
  for task in bg_tasks:
232
232
  task.cancel()
233
+ await asyncio.gather(*bg_tasks, return_exceptions=True)
234
+ await job_manager.stop() # cancel running jobs while DB/client still open
233
235
  await poller.stop()
234
236
  await race_poller.stop()
235
237
  await scheduler.stop()
@@ -96,14 +96,25 @@ class CatalogSync:
96
96
 
97
97
  await self._db.finish_sync_log(log_id, stats, "completed")
98
98
  logger.info(f"Catalog sync complete: {stats} ({page} pages)")
99
+ except asyncio.CancelledError:
100
+ # Server is shutting down — propagate cleanly so JobManager can
101
+ # record the cancelled status while the DB is still open.
102
+ logger.info("Catalog sync cancelled (shutdown in progress)")
103
+ raise
99
104
  except httpx.TransportError as exc:
100
105
  logger.error(_describe_httpx_error(exc, f"{self._url}/api/v1/media"))
101
106
  stats["errors"] += 1
102
- await self._db.finish_sync_log(log_id, stats, "error")
107
+ try:
108
+ await self._db.finish_sync_log(log_id, stats, "error")
109
+ except Exception: # noqa: BLE001
110
+ logger.debug("Could not persist sync error status (DB unavailable)")
103
111
  except Exception as exc:
104
112
  logger.exception(f"Catalog sync failed: {type(exc).__name__}: {exc}")
105
113
  stats["errors"] += 1
106
- await self._db.finish_sync_log(log_id, stats, "error")
114
+ try:
115
+ await self._db.finish_sync_log(log_id, stats, "error")
116
+ except Exception: # noqa: BLE001
117
+ logger.debug("Could not persist sync error status (DB unavailable)")
107
118
 
108
119
  async def _process_item(self, media: dict, stats: dict):
109
120
  token = media.get("friendly_token")
@@ -158,6 +158,20 @@ class JobManager:
158
158
  def is_running(self, name: str) -> bool:
159
159
  return name in self._running
160
160
 
161
+ async def stop(self) -> None:
162
+ """Cancel all running job tasks and wait for them to finish.
163
+
164
+ Call this during application shutdown *before* closing shared resources
165
+ (database, HTTP clients) so that in-progress tasks receive
166
+ ``CancelledError`` while those resources are still usable.
167
+ """
168
+ if not self._running:
169
+ return
170
+ logger.info("Stopping %d running job(s) for shutdown", len(self._running))
171
+ for task in list(self._running.values()):
172
+ task.cancel()
173
+ await asyncio.gather(*self._running.values(), return_exceptions=True)
174
+
161
175
  async def run(self, name: str, *, triggered_by: str | None = None,
162
176
  params: dict | None = None) -> dict:
163
177
  """Start a job in the background. Returns immediately.
@@ -191,19 +205,28 @@ class JobManager:
191
205
  detail = json.dumps(result) if result is not None else None
192
206
  await self._db.finish_job_run(run_id, "completed", detail)
193
207
  except asyncio.CancelledError:
194
- await self._db.finish_job_run(run_id, "cancelled", None)
208
+ try:
209
+ await self._db.finish_job_run(run_id, "cancelled", None)
210
+ except Exception: # noqa: BLE001
211
+ logger.debug("Could not persist 'cancelled' status for job '%s' (DB unavailable)", name)
195
212
  raise
196
213
  except JobError as exc:
197
214
  # Expected, user-facing failure (bad input/config): record a clean
198
215
  # message and log without a stack trace.
199
216
  logger.warning("Job '%s' failed: %s", name, exc)
200
- await self._db.finish_job_run(
201
- run_id, "failed", json.dumps({"error": str(exc)})
202
- )
217
+ try:
218
+ await self._db.finish_job_run(
219
+ run_id, "failed", json.dumps({"error": str(exc)})
220
+ )
221
+ except Exception: # noqa: BLE001
222
+ logger.debug("Could not persist 'failed' status for job '%s' (DB unavailable)", name)
203
223
  except Exception as exc: # noqa: BLE001 - record any failure
204
224
  logger.exception("Job '%s' failed", name)
205
- await self._db.finish_job_run(
206
- run_id, "failed", json.dumps({"error": f"{type(exc).__name__}: {exc}"})
207
- )
225
+ try:
226
+ await self._db.finish_job_run(
227
+ run_id, "failed", json.dumps({"error": f"{type(exc).__name__}: {exc}"})
228
+ )
229
+ except Exception: # noqa: BLE001
230
+ logger.debug("Could not persist 'failed' status for job '%s' (DB unavailable)", name)
208
231
  finally:
209
232
  self._running.pop(name, None)
@@ -1,83 +1,7 @@
1
- {% extends "base.html" %}
2
- {% block title %}Playlists - Admin{% endblock %}
3
- {% block body_class %}admin-page{% endblock %}
4
-
5
- {% block content %}
6
- <div class="admin-dashboard">
7
- <h1>Playlists</h1>
8
- <p><a href="/admin">&larr; Back to Admin</a></p>
9
-
10
- <!-- LIST VIEW -->
11
- <div id="list-view">
12
- <div class="admin-section">
13
- <div class="section-head">
14
- <h2>Saved Playlists</h2>
15
- <button class="btn btn-primary" onclick="showCreateModal()">+ New Playlist</button>
16
- </div>
17
- <div id="playlists-list">Loading…</div>
18
- </div>
19
- </div>
20
-
21
- <!-- EDITOR VIEW -->
22
- <div id="editor-view" class="hidden">
23
- <div class="admin-section">
24
- <div class="section-head">
25
- <h2 id="editor-title">Edit Playlist</h2>
26
- <div class="btn-group">
27
- <button class="btn btn-secondary" onclick="closeEditor()">&larr; Back</button>
28
- <button class="btn" onclick="editMeta()">Rename</button>
29
- <button class="btn btn-primary" onclick="saveItems()">Save Items</button>
30
- <button class="btn" onclick="importToLive()" title="Load this playlist into the live CyTube queue">Import to Live</button>
31
- </div>
32
- </div>
33
- <p id="editor-meta" class="muted"></p>
34
-
35
- <div class="editor-grid">
36
- <!-- Working item list -->
37
- <div>
38
- <h3>Items (<span id="item-count">0</span>)</h3>
39
- <ul id="editor-items" class="editor-list"></ul>
40
- </div>
41
-
42
- <!-- Add panel -->
43
- <div>
44
- <h3>Add from Catalog</h3>
45
- <div class="search-form">
46
- <input type="text" id="cat-search" placeholder="Search catalog…" onkeydown="if(event.key==='Enter')catalogSearch()">
47
- <button class="btn btn-sm" onclick="catalogSearch()">Search</button>
48
- </div>
49
- <div id="cat-results" class="cat-results"></div>
50
-
51
- <h3 style="margin-top:1.5rem;">Bulk Text Import</h3>
52
- <p class="muted" style="font-size:0.8rem;">One entry per line: a <strong>dropsugar.co</strong> link, a <strong>YouTube</strong>/<strong>youtu.be</strong> link (playlist &amp; start-time args are stripped), <code>cm:token</code>, <code>yt:id</code>, or a bare catalog token. <code>#</code> starts a comment (whole-line or trailing). Unknown sites are skipped.</p>
53
- <textarea id="import-text" class="import-text" rows="5" placeholder="https://www.dropsugar.co/view?m=abc123def&#10;https://youtu.be/dQw4w9WgXcQ?t=42 # start time is stripped&#10;yt:dQw4w9WgXcQ&#10;abc123def"></textarea>
54
- <div class="btn-group" style="margin-top:0.25rem;">
55
- <button class="btn btn-sm" onclick="parseImport()">Append Parsed Items</button>
56
- <input type="file" id="import-file" accept=".txt,.csv,text/plain" style="display:none" onchange="loadImportFile(event)">
57
- <button class="btn btn-sm" onclick="document.getElementById('import-file').click()">Choose text file…</button>
58
- </div>
59
- <div id="import-errors" class="import-errors"></div>
60
- </div>
61
- </div>
62
- </div>
63
- </div>
64
- </div>
65
- {% endblock %}
66
-
67
- {% block scripts %}
68
- <script>
69
1
  let editorId = null;
70
2
  let editorItems = []; // working list: {media_type, media_id, title, duration_sec}
71
3
  let editorImmutable = false;
72
4
 
73
- function fmtDur(sec) {
74
- sec = Math.floor(sec || 0);
75
- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
76
- if (h > 0) return `${h}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
77
- return `${m}:${s.toString().padStart(2,'0')}`;
78
- }
79
- function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str == null ? '' : str; return d.innerHTML; }
80
-
81
5
  // ---------- LIST ----------
82
6
  async function loadPlaylists() {
83
7
  const resp = await fetch('/admin/playlists/');
@@ -327,16 +251,4 @@ async function saveMeta() {
327
251
  }
328
252
 
329
253
  // ---------- generic modal ----------
330
- function showModal(html) {
331
- closeModal();
332
- const o = document.createElement('div');
333
- o.className = 'modal-overlay'; o.id = 'admin-modal';
334
- o.innerHTML = `<div class="modal-box">${html}</div>`;
335
- o.addEventListener('click', e => { if (e.target === o) closeModal(); });
336
- document.body.appendChild(o);
337
- }
338
- function closeModal() { const m = document.getElementById('admin-modal'); if (m) m.remove(); }
339
-
340
- loadPlaylists();
341
- </script>
342
- {% endblock %}
254
+ loadPlaylists();
@@ -1,47 +1,3 @@
1
- {% extends "base.html" %}
2
- {% block title %}Promos - Admin{% endblock %}
3
- {% block body_class %}admin-page{% endblock %}
4
-
5
- {% block content %}
6
- <div class="admin-dashboard">
7
- <h1>Promos</h1>
8
- <p><a href="/admin">&larr; Back to Admin</a></p>
9
-
10
- <!-- POOL DESIGNATION -->
11
- <div class="admin-section">
12
- <div class="section-head">
13
- <h2>Promo Pools</h2>
14
- <a href="/admin/playlists" class="btn btn-sm">Manage Playlist Items &rarr;</a>
15
- </div>
16
- <p class="muted">
17
- Designate a saved playlist as a promo pool by choosing its type. Pool
18
- items are hidden from the public catalog and excluded from pay-to-play,
19
- and are drawn from automatically by the promo director. Edit a pool's
20
- clips on the <a href="/admin/playlists">Playlists</a> page.
21
- </p>
22
- <div id="pools-list">Loading…</div>
23
- </div>
24
-
25
- <!-- SETTINGS (editable) -->
26
- <div class="admin-section">
27
- <div class="section-head">
28
- <h2>Promo Settings</h2>
29
- <span class="badge badge-accent" title="Saved to the service config file and applied live">editable</span>
30
- </div>
31
- <p class="muted">
32
- Promo cadence and per-type behaviour. Changes are saved to the service
33
- config file and applied to the running promo director immediately — no
34
- restart required.
35
- </p>
36
- <form id="settings-form">
37
- <div id="settings-view">Loading…</div>
38
- </form>
39
- </div>
40
- </div>
41
- {% endblock %}
42
-
43
- {% block scripts %}
44
- <script>
45
1
  const PROMO_TYPE_LABELS = {
46
2
  channel_identity: 'Channel Identity',
47
3
  event: 'Event',
@@ -53,8 +9,6 @@ const PROMO_TYPE_LABELS = {
53
9
  let ALL_PLAYLISTS = [];
54
10
  let PROMO_META = { promo_types: [], general_types: [], lead_in_types: [] };
55
11
 
56
- function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str == null ? '' : str; return d.innerHTML; }
57
-
58
12
  function typeLabel(t) { return PROMO_TYPE_LABELS[t] || t; }
59
13
 
60
14
  // ---------- POOLS ----------
@@ -207,6 +161,4 @@ document.getElementById('settings-form').addEventListener('submit', async (e) =>
207
161
  });
208
162
 
209
163
  loadPools();
210
- loadSettings();
211
- </script>
212
- {% endblock %}
164
+ loadSettings();
@@ -1,47 +1,5 @@
1
- {% extends "base.html" %}
2
- {% block title %}Queue Management - Admin{% endblock %}
3
- {% block body_class %}admin-page{% endblock %}
4
-
5
- {% block content %}
6
- <div class="admin-dashboard">
7
- <h1>Queue Management</h1>
8
- <p><a href="/admin">&larr; Back to Admin</a></p>
9
-
10
- <div class="admin-section">
11
- <div class="section-head">
12
- <h2>Live Queue</h2>
13
- <div class="btn-group">
14
- <button class="btn btn-sm" onclick="showAddModal()">+ Add Item</button>
15
- <button class="btn btn-sm" onclick="loadQueue()">Refresh</button>
16
- <button class="btn btn-sm btn-danger" onclick="clearAll()">Clear Queue</button>
17
- </div>
18
- </div>
19
- <div id="np-line" class="muted" style="margin-bottom:0.75rem;"></div>
20
- <div id="queue-table">Loading…</div>
21
- </div>
22
-
23
- <div class="admin-section">
24
- <div class="section-head">
25
- <h2>Catalog Sync</h2>
26
- <button class="btn btn-sm" onclick="syncNow()">Sync Now</button>
27
- </div>
28
- <div id="sync-logs">Loading…</div>
29
- </div>
30
- </div>
31
- {% endblock %}
32
-
33
- {% block scripts %}
34
- <script>
35
1
  let refreshTimer = null;
36
2
 
37
- function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str == null ? '' : str; return d.innerHTML; }
38
- function fmtDur(sec) {
39
- sec = Math.floor(sec || 0);
40
- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
41
- if (h > 0) return `${h}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
42
- return `${m}:${s.toString().padStart(2,'0')}`;
43
- }
44
-
45
3
  async function loadQueue() {
46
4
  const resp = await fetch('/queue/state');
47
5
  const el = document.getElementById('queue-table');
@@ -168,19 +126,7 @@ async function loadSyncLogs() {
168
126
  </table>`;
169
127
  }
170
128
 
171
- function showModal(html) {
172
- closeModal();
173
- const o = document.createElement('div');
174
- o.className = 'modal-overlay'; o.id = 'admin-modal';
175
- o.innerHTML = `<div class="modal-box">${html}</div>`;
176
- o.addEventListener('click', e => { if (e.target === o) closeModal(); });
177
- document.body.appendChild(o);
178
- }
179
- function closeModal() { const m = document.getElementById('admin-modal'); if (m) m.remove(); }
180
-
181
129
  loadQueue();
182
130
  loadSyncLogs();
183
131
  refreshTimer = setInterval(loadQueue, 5000);
184
- window.addEventListener('beforeunload', () => clearInterval(refreshTimer));
185
- </script>
186
- {% endblock %}
132
+ window.addEventListener('beforeunload', () => clearInterval(refreshTimer));
@@ -1,32 +1,6 @@
1
- {% extends "base.html" %}
2
- {% block title %}Schedules - Admin{% endblock %}
3
- {% block body_class %}admin-page{% endblock %}
4
-
5
- {% block content %}
6
- <div class="admin-dashboard">
7
- <h1>Schedules</h1>
8
- <p><a href="/admin">&larr; Back to Admin</a></p>
9
-
10
- <div id="lock-banner" class="lock-banner hidden"></div>
11
- <div id="active-banner" class="schedule-info hidden" style="margin-bottom:1.5rem;"></div>
12
-
13
- <div class="admin-section">
14
- <div class="section-head">
15
- <h2>Scheduled Events</h2>
16
- <button class="btn btn-primary" onclick="showScheduleModal()">+ New Schedule</button>
17
- </div>
18
- <div id="schedules-list">Loading…</div>
19
- </div>
20
- </div>
21
- {% endblock %}
22
-
23
- {% block scripts %}
24
- <script>
25
1
  let playlistMap = {};
26
2
  let playlistRows = [];
27
3
 
28
- function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str == null ? '' : str; return d.innerHTML; }
29
-
30
4
  async function loadPlaylistsForSelect() {
31
5
  const resp = await fetch('/admin/playlists/');
32
6
  if (!resp.ok) return [];
@@ -257,16 +231,6 @@ async function deleteSchedule(id, label) {
257
231
  loadSchedules();
258
232
  }
259
233
 
260
- function showModal(html) {
261
- closeModal();
262
- const o = document.createElement('div');
263
- o.className = 'modal-overlay'; o.id = 'admin-modal';
264
- o.innerHTML = `<div class="modal-box">${html}</div>`;
265
- o.addEventListener('click', e => { if (e.target === o) closeModal(); });
266
- document.body.appendChild(o);
267
- }
268
- function closeModal() { const m = document.getElementById('admin-modal'); if (m) m.remove(); }
269
-
270
234
  loadSchedules();
271
235
 
272
236
  // Keep the lock indicator and active-event banner fresh without a reload:
@@ -277,6 +241,4 @@ setInterval(() => {
277
241
  loadLockStatus();
278
242
  loadActive();
279
243
  }
280
- }, 15000);
281
- </script>
282
- {% endblock %}
244
+ }, 15000);