kryten-webqueue 0.16.0__tar.gz → 0.18.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.16.0 → kryten_webqueue-0.18.0}/CHANGELOG.md +21 -0
  2. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/catalog/db.py +42 -2
  4. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/queue/shadow.py +60 -0
  5. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/catalog.py +8 -4
  6. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/pages.py +5 -4
  7. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/index.html +48 -5
  8. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/schedules.html +15 -0
  9. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/catalog/browse.html +34 -6
  10. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/pyproject.toml +1 -1
  11. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_phase4_live_fixes.py +46 -0
  12. kryten_webqueue-0.18.0/tests/test_search_facets.py +81 -0
  13. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/.github/workflows/python-publish.yml +0 -0
  14. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/.github/workflows/release.yml +0 -0
  15. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/.gitignore +0 -0
  16. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/README.md +0 -0
  17. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/config.example.json +0 -0
  18. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/deploy/kryten-webqueue.service +0 -0
  19. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/deploy/nginx-queue.conf +0 -0
  20. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  21. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/IMPL_API_GATE.md +0 -0
  22. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/IMPL_ECONOMY.md +0 -0
  23. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  24. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/IMPL_ROBOT.md +0 -0
  25. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  26. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/PRE_PLAN_GAPS.md +0 -0
  27. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/PRODUCT_PLAN.md +0 -0
  28. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  29. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/UX_POLISH_PLAN.md +0 -0
  30. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/__init__.py +0 -0
  31. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/__main__.py +0 -0
  32. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  33. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/api_gate/client.py +0 -0
  34. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/app.py +0 -0
  35. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/auth/__init__.py +0 -0
  36. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/auth/otp.py +0 -0
  37. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  38. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/auth/session.py +0 -0
  39. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/catalog/__init__.py +0 -0
  40. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/catalog/images.py +0 -0
  41. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  42. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/catalog/sync.py +0 -0
  43. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/config.py +0 -0
  44. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/__init__.py +0 -0
  45. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  46. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  47. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  48. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  49. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  50. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  51. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  52. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  53. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/jobs/__init__.py +0 -0
  54. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  55. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/jobs/manager.py +0 -0
  56. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/jobs/tasks.py +0 -0
  57. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/logging_config.py +0 -0
  58. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/playlists/__init__.py +0 -0
  59. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  60. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/playlists/fire.py +0 -0
  61. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/playlists/importer.py +0 -0
  62. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/playlists/ordering.py +0 -0
  63. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  64. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/promos/__init__.py +0 -0
  65. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/promos/director.py +0 -0
  66. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/queue/__init__.py +0 -0
  67. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/queue/ordering.py +0 -0
  68. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/queue/poller.py +0 -0
  69. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/queue/presence.py +0 -0
  70. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/__init__.py +0 -0
  71. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  72. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  73. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  74. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  75. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  76. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  77. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/auth.py +0 -0
  78. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/queue.py +0 -0
  79. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/user.py +0 -0
  80. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/static/css/main.css +0 -0
  81. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/static/js/main.js +0 -0
  82. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  83. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  84. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  85. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/auth/login.html +0 -0
  86. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/base.html +0 -0
  87. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  88. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  89. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/queue/index.html +0 -0
  90. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  91. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/ws/__init__.py +0 -0
  92. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/ws/handler.py +0 -0
  93. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/ws/manager.py +0 -0
  94. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/__init__.py +0 -0
  95. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_config_persistence.py +0 -0
  96. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_fetchurls_sharepoint.py +0 -0
  97. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_phase1.py +0 -0
  98. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_phase2_jobs.py +0 -0
  99. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_phase3_jobs.py +0 -0
  100. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_playlist_import.py +0 -0
  101. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_presence_refund.py +0 -0
  102. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_promo_director.py +0 -0
  103. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_promo_pool_exclusion.py +0 -0
  104. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_queue_announce.py +0 -0
  105. {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_save_results_to_playlist.py +0 -0
@@ -6,6 +6,27 @@ 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.18.0] — 2026-06-17
10
+
11
+ ### Fixed
12
+
13
+ - **Active scheduled event lingered on the admin page after it ended.** The `active_schedule` row was only removed by the manual "Clear Active Schedule" button — the lock auto-lifted but the row (and its banner) stayed. The queue shadow now clears it automatically on the next poll once the event is genuinely over, via two signals: the last scheduled item has left the queue (event temp items auto-remove after playing), or the estimated end is more than 5 minutes in the past (safety net for a missed boundary or a restart mid-event). The schedules page also hides a well-past banner immediately and re-checks every 15s.
14
+
15
+ ### Added
16
+
17
+ - **Live admin dashboard.** The admin page now subscribes to the same `/ws` feed as the public queue, so the queue item count and now-playing update without a reload. Job status refreshes every 5s while the tab is visible (jobs are DB-polled, not broadcast), and a fired schedule triggers an immediate jobs refresh.
18
+
19
+ [0.18.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.18.0
20
+
21
+ ## [0.17.0] — 2026-06-17
22
+
23
+ ### Added
24
+
25
+ - **Search now combines with category/tag filters.** A free-text search ANDs with the selected category and/or tag instead of ignoring them. `db.search()` / `db.search_count()` accept `category` and `tag`; the `/catalog/search` page and JSON route pass them through and keep the dropdowns populated/selected; `applyFacets()` includes the active facets when a query is present. (Shared `_facet_filter()` SQL helper keeps browse and search behavior identical.)
26
+ - **Clear empty-results messaging.** When a search and/or facet filter returns nothing, the catalog page now explains *why* — naming the active query and filters — and offers one-click escapes ("Search without filters", "Clear filters", "Back to browse") instead of a bare "No items found."
27
+
28
+ [0.17.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.17.0
29
+
9
30
  ## [0.16.0] — 2026-06-17
10
31
 
11
32
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.16.0
3
+ Version: 0.18.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
@@ -59,6 +59,36 @@ def _hidden_exclusion(alias: str = "c") -> tuple[str, list]:
59
59
  return sql, [*HIDDEN_CATEGORY_NAMES, *HIDDEN_TAG_NAMES]
60
60
 
61
61
 
62
+ def _facet_filter(alias: str, category: str | None, tag: str | None) -> tuple[str, list]:
63
+ """SQL fragment (+ params) AND-filtering by a category slug and/or tag name.
64
+
65
+ Each filter is an ``AND friendly_token IN (...)`` subquery on the catalog row
66
+ under ``alias``; an absent filter contributes nothing. Shared by browse() and
67
+ search() so the two paths narrow results identically.
68
+ """
69
+ sql = ""
70
+ params: list = []
71
+ if category:
72
+ sql += f"""
73
+ AND {alias}.friendly_token IN (
74
+ SELECT cc.friendly_token FROM catalog_categories cc
75
+ JOIN categories cat ON cc.category_id = cat.id
76
+ WHERE cat.slug = ?
77
+ )
78
+ """
79
+ params.append(category)
80
+ if tag:
81
+ sql += f"""
82
+ AND {alias}.friendly_token IN (
83
+ SELECT ct.friendly_token FROM catalog_tags ct
84
+ JOIN tags t ON ct.tag_id = t.id
85
+ WHERE t.name = ?
86
+ )
87
+ """
88
+ params.append(tag)
89
+ return sql, params
90
+
91
+
62
92
  # Default quality-weighted ordering (see browse() for rationale).
63
93
  _DEFAULT_ORDER = """
64
94
  ORDER BY
@@ -446,7 +476,8 @@ class Database:
446
476
  row = await self._fetch_one(query, params)
447
477
  return row["cnt"] if row else 0
448
478
 
449
- async def search(self, query_text: str, *, page: int = 1, per_page: int = 24, show_hidden: bool = False, sort: str = "default") -> list[dict]:
479
+ async def search(self, query_text: str, *, category: str | None = None, tag: str | None = None,
480
+ page: int = 1, per_page: int = 24, show_hidden: bool = False, sort: str = "default") -> list[dict]:
450
481
  sql = """
451
482
  SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.cover_art_source, c.thumbnail_url, c.manifest_url,
452
483
  rank AS relevance
@@ -464,6 +495,11 @@ class Database:
464
495
  excl_sql, excl_params = _hidden_exclusion("c")
465
496
  sql += excl_sql
466
497
  params.extend(excl_params)
498
+ # Category/tag facets AND with the text match (same subqueries browse()
499
+ # uses), so a search can be narrowed by the selected facets.
500
+ facet_sql, facet_params = _facet_filter("c", category, tag)
501
+ sql += facet_sql
502
+ params.extend(facet_params)
467
503
  # Relevance is the natural default for a text query; other sort keys let
468
504
  # the user reorder the matched set explicitly.
469
505
  sql += " ORDER BY rank " if (sort or "default") == "default" else _browse_order_clause(sort)
@@ -471,7 +507,8 @@ class Database:
471
507
  params.extend([per_page, (page - 1) * per_page])
472
508
  return await self._fetch_all(sql, params)
473
509
 
474
- async def search_count(self, query_text: str, *, show_hidden: bool = False) -> int:
510
+ async def search_count(self, query_text: str, *, category: str | None = None, tag: str | None = None,
511
+ show_hidden: bool = False) -> int:
475
512
  sql = """
476
513
  SELECT COUNT(*) as cnt
477
514
  FROM catalog_fts fts
@@ -488,6 +525,9 @@ class Database:
488
525
  excl_sql, excl_params = _hidden_exclusion("c")
489
526
  sql += excl_sql
490
527
  params.extend(excl_params)
528
+ facet_sql, facet_params = _facet_filter("c", category, tag)
529
+ sql += facet_sql
530
+ params.extend(facet_params)
491
531
  row = await self._fetch_one(sql, params)
492
532
  return row["cnt"] if row else 0
493
533
 
@@ -23,6 +23,19 @@ def _to_seconds(value) -> float:
23
23
  return 0.0
24
24
 
25
25
 
26
+ def _parse_iso(value) -> datetime | None:
27
+ """Parse an ISO-8601 timestamp to an aware UTC datetime, or None."""
28
+ if not value:
29
+ return None
30
+ try:
31
+ dt = datetime.fromisoformat(str(value))
32
+ except (ValueError, TypeError):
33
+ return None
34
+ if dt.tzinfo is None:
35
+ dt = dt.replace(tzinfo=UTC)
36
+ return dt
37
+
38
+
26
39
  class QueueShadow:
27
40
  """Local mirror of the CyTube playlist state."""
28
41
 
@@ -116,6 +129,7 @@ class QueueShadow:
116
129
  self._items = new_items
117
130
  await self._recalculate_estimated_starts()
118
131
  await self._maybe_lift_event_lock()
132
+ await self._maybe_expire_active_schedule()
119
133
 
120
134
  async def _maybe_lift_event_lock(self):
121
135
  """Auto-lift a scheduled-event lock once its last item begins playing.
@@ -142,6 +156,52 @@ class QueueShadow:
142
156
  except (TypeError, ValueError):
143
157
  return
144
158
 
159
+ async def _maybe_expire_active_schedule(self):
160
+ """Clear the active-schedule row once the event is genuinely over.
161
+
162
+ The lock auto-lifts when the last scheduled item *starts* (see
163
+ :meth:`_maybe_lift_event_lock`), but the row itself used to linger so the
164
+ admin banner kept showing a finished event. This clears it via two
165
+ signals:
166
+
167
+ * **Event-driven (primary):** the last scheduled item has left the queue
168
+ (event items are temp and auto-remove after playing), and something is
169
+ still playing — so the event has played out.
170
+ * **Time safety net:** the estimated end is well in the past (covers a
171
+ missed boundary, a restart mid-event, or ``last_item_uid`` never being
172
+ captured).
173
+ """
174
+ active = await self._db.get_active_schedule()
175
+ if not active:
176
+ return
177
+
178
+ # Event-driven: last scheduled item is gone from the queue.
179
+ last_uid = active.get("last_item_uid")
180
+ if last_uid is not None and self._now_playing is not None:
181
+ try:
182
+ last_uid_int = int(last_uid)
183
+ except (TypeError, ValueError):
184
+ last_uid_int = None
185
+ if last_uid_int is not None:
186
+ present = False
187
+ for it in self._items:
188
+ try:
189
+ if int(it.get("uid")) == last_uid_int:
190
+ present = True
191
+ break
192
+ except (TypeError, ValueError):
193
+ continue
194
+ if not present:
195
+ await self._db.clear_active_schedule()
196
+ logger.info("Active schedule cleared: last scheduled item has played out")
197
+ return
198
+
199
+ # Time safety net: estimated end well in the past.
200
+ end_dt = _parse_iso(active.get("estimated_end_at"))
201
+ if end_dt is not None and datetime.now(UTC) > end_dt + timedelta(minutes=5):
202
+ await self._db.clear_active_schedule()
203
+ logger.info("Active schedule cleared: estimated end passed (stale row)")
204
+
145
205
  def _now_playing_index(self) -> int | None:
146
206
  """Index of the currently-playing item within ``self._items``.
147
207
 
@@ -19,15 +19,19 @@ async def browse(request: Request, category: str | None = None, tag: str | None
19
19
 
20
20
 
21
21
  @router.get("/search")
22
- async def search(request: Request, q: str = "", page: int = 1, show_hidden: int = 0,
22
+ async def search(request: Request, q: str = "", category: str | None = None, tag: str | None = None,
23
+ page: int = 1, show_hidden: int = 0,
23
24
  sort: str = "default", user: dict = Depends(get_current_user)):
24
- """Full-text search of catalog."""
25
+ """Full-text search of catalog, optionally narrowed by category/tag."""
25
26
  if not q.strip():
26
27
  raise HTTPException(400, "Query required")
27
28
  db = request.app.state.db
28
29
  show_hidden = bool(show_hidden) and (user.get("rank") or 0) >= 3
29
- items = await db.search(q, page=page, show_hidden=show_hidden, sort=sort)
30
- return {"items": items, "query": q, "page": page, "sort": sort}
30
+ items = await db.search(q, category=category, tag=tag, page=page, show_hidden=show_hidden, sort=sort)
31
+ categories = await db.get_categories(show_hidden=show_hidden)
32
+ tags = await db.get_tags(show_hidden=show_hidden)
33
+ return {"items": items, "categories": categories, "tags": tags,
34
+ "query": q, "active_category": category, "active_tag": tag, "page": page, "sort": sort}
31
35
 
32
36
 
33
37
  @router.get("/item/{friendly_token}")
@@ -107,6 +107,7 @@ async def catalog_browse_page(request: Request, category: str | None = None,
107
107
 
108
108
  @router.get("/catalog/search", response_class=HTMLResponse)
109
109
  async def catalog_search_page(request: Request, q: str = "", page: int = 1,
110
+ category: str | None = None, tag: str | None = None,
110
111
  show_hidden: int = 0, sort: str = "default"):
111
112
  user = _get_user_or_none(request)
112
113
  if not user:
@@ -117,8 +118,8 @@ async def catalog_search_page(request: Request, q: str = "", page: int = 1,
117
118
  is_admin = (user.get("rank") or 0) >= 3
118
119
  show_hidden = bool(show_hidden) and is_admin
119
120
  sort = sort if sort in _VALID_SORTS else "default"
120
- items = await db.search(q, page=page, show_hidden=show_hidden, sort=sort)
121
- total = await db.search_count(q, show_hidden=show_hidden)
121
+ items = await db.search(q, category=category, tag=tag, page=page, show_hidden=show_hidden, sort=sort)
122
+ total = await db.search_count(q, category=category, tag=tag, show_hidden=show_hidden)
122
123
  total_pages = max(1, (total + 23) // 24)
123
124
  categories = await db.get_categories(show_hidden=show_hidden)
124
125
  tags = await db.get_tags(show_hidden=show_hidden)
@@ -130,8 +131,8 @@ async def catalog_search_page(request: Request, q: str = "", page: int = 1,
130
131
  "tags": tags,
131
132
  "page": page,
132
133
  "total_pages": total_pages,
133
- "active_category": None,
134
- "active_tag": None,
134
+ "active_category": category,
135
+ "active_tag": tag,
135
136
  "query": q,
136
137
  "is_admin": is_admin,
137
138
  "show_hidden": show_hidden,
@@ -234,11 +234,7 @@ async function loadAdminData() {
234
234
  // Queue status
235
235
  const qResp = await fetch('/queue/state');
236
236
  if (qResp.ok) {
237
- const state = await qResp.json();
238
- document.getElementById('queue-status').innerHTML = `
239
- <p>Items in queue: ${(state.items || []).length}</p>
240
- <p>Now playing: ${state.now_playing ? escapeHtml(state.now_playing.title || 'Unknown') : 'Nothing'}</p>
241
- `;
237
+ renderQueueStatus(await qResp.json());
242
238
  }
243
239
 
244
240
  // Sync logs
@@ -265,6 +261,46 @@ async function loadAdminData() {
265
261
  }
266
262
  }
267
263
 
264
+ // Render the live queue-status block. Shared by the initial load and the
265
+ // WebSocket so the count + now-playing stay current without a reload.
266
+ function renderQueueStatus(state) {
267
+ const el = document.getElementById('queue-status');
268
+ if (!el) return;
269
+ const np = state && state.now_playing;
270
+ el.innerHTML = `
271
+ <p>Items in queue: ${((state && state.items) || []).length}</p>
272
+ <p>Now playing: ${np ? escapeHtml(np.title || 'Unknown') : 'Nothing'}</p>
273
+ `;
274
+ }
275
+
276
+ // Subscribe to the same /ws feed the public queue page uses, so queue size and
277
+ // now-playing update live. Jobs are DB-polled (below) since they aren't
278
+ // broadcast.
279
+ let adminWs = null;
280
+ let adminWsReconnect = null;
281
+ function connectAdminWebSocket() {
282
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
283
+ adminWs = new WebSocket(`${proto}//${location.host}/ws`);
284
+ adminWs.onmessage = (event) => {
285
+ let msg;
286
+ try { msg = JSON.parse(event.data); } catch (e) { return; }
287
+ if (msg.type === 'queue_state' || msg.type === 'queue_update') {
288
+ renderQueueStatus(msg.data);
289
+ } else if (msg.type === 'schedule_fired') {
290
+ showToast(`Scheduled playlist loaded: ${msg.data.playlist_name}`);
291
+ loadJobs();
292
+ }
293
+ };
294
+ adminWs.onclose = () => {
295
+ adminWsReconnect = setTimeout(connectAdminWebSocket, 3000);
296
+ };
297
+ setInterval(() => {
298
+ if (adminWs && adminWs.readyState === WebSocket.OPEN) {
299
+ adminWs.send(JSON.stringify({type: 'ping'}));
300
+ }
301
+ }, 30000);
302
+ }
303
+
268
304
  function escapeHtml(str) {
269
305
  const div = document.createElement('div');
270
306
  div.textContent = str;
@@ -294,5 +330,12 @@ function summarizeRunDetail(detail) {
294
330
 
295
331
  loadAdminData();
296
332
  loadJobs();
333
+ connectAdminWebSocket();
334
+
335
+ // Jobs are DB-polled (not broadcast), so refresh them periodically while the
336
+ // tab is visible to reflect running/finished status without a reload.
337
+ setInterval(() => {
338
+ if (document.visibilityState === 'visible') loadJobs();
339
+ }, 5000);
297
340
  </script>
298
341
  {% endblock %}
@@ -42,6 +42,15 @@ async function loadActive() {
42
42
  if (!resp.ok) { banner.classList.add('hidden'); return; }
43
43
  const a = await resp.json();
44
44
  if (!a || !a.schedule_id) { banner.classList.add('hidden'); return; }
45
+ // Treat a well-past estimated end as ended even before the backend clears
46
+ // the row on its next poll, so the banner never lingers.
47
+ if (a.estimated_end_at) {
48
+ const end = new Date(a.estimated_end_at);
49
+ if (!isNaN(end) && (Date.now() - end.getTime()) > 5 * 60 * 1000) {
50
+ banner.classList.add('hidden');
51
+ return;
52
+ }
53
+ }
45
54
  const name = playlistMap[a.playlist_id] || `Playlist #${a.playlist_id}`;
46
55
  banner.classList.remove('hidden');
47
56
  const locked = a.is_immutable && !a.lock_disabled;
@@ -203,5 +212,11 @@ function showModal(html) {
203
212
  function closeModal() { const m = document.getElementById('admin-modal'); if (m) m.remove(); }
204
213
 
205
214
  loadSchedules();
215
+
216
+ // Keep the active-event banner fresh without a reload: re-check every 15s while
217
+ // the tab is visible (the backend clears the row once the event plays out).
218
+ setInterval(() => {
219
+ if (document.visibilityState === 'visible') loadActive();
220
+ }, 15000);
206
221
  </script>
207
222
  {% endblock %}
@@ -101,13 +101,41 @@
101
101
  {% endfor %}
102
102
  </div>
103
103
 
104
+ {% set sort_q = ('&sort=' ~ sort) if (sort and sort != 'default') else '' %}
104
105
  {% if not items %}
105
106
  <div class="empty-state">
106
- <p>No items found.</p>
107
+ {% if query and (active_category or active_tag) %}
108
+ <p>No results for "{{ query }}" within the selected filter{% if active_category and active_tag %}s{% endif %}.</p>
109
+ <p class="muted">
110
+ Active:
111
+ {% if active_category %}<strong>category</strong>{% endif %}
112
+ {% if active_category and active_tag %} and {% endif %}
113
+ {% if active_tag %}<strong>tag “{{ active_tag }}”</strong>{% endif %}.
114
+ Try widening your search.
115
+ </p>
116
+ <p>
117
+ <a class="btn btn-sm" href="/catalog/search?q={{ query | urlencode }}{{ sort_q }}">Search without filters</a>
118
+ <a class="btn btn-sm" href="/catalog/browse">Clear search</a>
119
+ </p>
120
+ {% elif query %}
121
+ <p>No results for "{{ query }}".</p>
122
+ <p class="muted">Check the spelling or try fewer / different words.</p>
123
+ <p><a class="btn btn-sm" href="/catalog/browse">Back to browse</a></p>
124
+ {% elif active_category or active_tag %}
125
+ <p>No items match the selected filter{% if active_category and active_tag %}s{% endif %}.</p>
126
+ <p class="muted">
127
+ Active:
128
+ {% if active_category %}<strong>category</strong>{% endif %}
129
+ {% if active_category and active_tag %} and {% endif %}
130
+ {% if active_tag %}<strong>tag “{{ active_tag }}”</strong>{% endif %}.
131
+ </p>
132
+ <p><a class="btn btn-sm" href="/catalog/browse">Clear filters</a></p>
133
+ {% else %}
134
+ <p>No items found.</p>
135
+ {% endif %}
107
136
  </div>
108
137
  {% endif %}
109
138
 
110
- {% set sort_q = ('&sort=' ~ sort) if (sort and sort != 'default') else '' %}
111
139
  <div class="pagination">
112
140
  {% if page > 1 %}
113
141
  <a href="?page={{ page - 1 }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}{% if active_tag %}&tag={{ active_tag | urlencode }}{% endif %}{{ sort_q }}" class="btn btn-sm">&larr; Prev</a>
@@ -141,13 +169,13 @@ function applyFacets() {
141
169
  const sort = document.getElementById('sort-select').value;
142
170
  try { localStorage.setItem('browse_sort', sort); } catch (e) { /* ignore */ }
143
171
  const params = new URLSearchParams();
144
- // On a search results page, preserve the query and stay on /catalog/search.
172
+ // On a search results page keep the query AND apply the facets so search
173
+ // results are narrowed by category/tag (they AND together server-side).
145
174
  if (CURRENT_QUERY) {
146
175
  params.set('q', CURRENT_QUERY);
147
- } else {
148
- if (cat) params.set('category', cat);
149
- if (tag) params.set('tag', tag);
150
176
  }
177
+ if (cat) params.set('category', cat);
178
+ if (tag) params.set('tag', tag);
151
179
  if (sort && sort !== 'default') params.set('sort', sort);
152
180
  const base = CURRENT_QUERY ? '/catalog/search' : '/catalog/browse';
153
181
  const qs = params.toString();
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.16.0"
3
+ version = "0.18.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"
@@ -152,6 +152,52 @@ async def test_event_lock_stays_until_last_item(db):
152
152
  assert await db.is_event_lock_active() is True
153
153
 
154
154
 
155
+ # --- active-schedule auto-expiry (v0.18.0) ---
156
+
157
+ async def test_active_schedule_cleared_when_last_item_plays_out(db):
158
+ await _make_event(db, last_item_uid=3)
159
+ shadow = QueueShadow(db)
160
+ # The last scheduled item (uid=3) is still in the queue -> row kept.
161
+ await shadow.apply_poll_result(
162
+ [_polled(1), _polled(2), _polled(3)], {"uid": 2, "seconds": 100, "currentTime": 0}
163
+ )
164
+ assert (await db.get_active_schedule()) is not None
165
+ # uid=3 has now left the queue (played out, temp item removed) -> row cleared.
166
+ await shadow.apply_poll_result(
167
+ [_polled(4), _polled(5)], {"uid": 4, "seconds": 100, "currentTime": 0}
168
+ )
169
+ assert (await db.get_active_schedule()) is None
170
+
171
+
172
+ async def test_active_schedule_not_cleared_when_nothing_playing(db):
173
+ await _make_event(db, last_item_uid=3)
174
+ shadow = QueueShadow(db)
175
+ # Last item absent but nothing is playing (transient empty poll) -> keep row.
176
+ await shadow.apply_poll_result([], None)
177
+ assert (await db.get_active_schedule()) is not None
178
+
179
+
180
+ async def test_active_schedule_cleared_when_estimated_end_passed(db):
181
+ # last_item_uid is None so the event-driven path can't fire; rely on the
182
+ # time safety net with an estimated end well in the past.
183
+ pid = await db.create_saved_playlist(
184
+ name="Stale", description=None, is_immutable=True, created_by="admin"
185
+ )
186
+ sid = await db.create_schedule(
187
+ playlist_id=pid, label="Stale", fire_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
188
+ is_active=1, created_by="admin",
189
+ )
190
+ past = datetime.now(UTC) - timedelta(hours=1)
191
+ await db.set_active_schedule(
192
+ schedule_id=sid, playlist_id=pid, is_immutable=True,
193
+ started_at=(past - timedelta(hours=1)).isoformat(),
194
+ estimated_end_at=past.isoformat(), last_item_uid=None,
195
+ )
196
+ shadow = QueueShadow(db)
197
+ await shadow.apply_poll_result([_polled(1)], {"uid": 1, "seconds": 100, "currentTime": 0})
198
+ assert (await db.get_active_schedule()) is None
199
+
200
+
155
201
  # --- #4 pre-fire lock override ---
156
202
 
157
203
  async def test_pre_fire_lock_can_be_disabled(db):
@@ -0,0 +1,81 @@
1
+ """Search combines with category/tag facets (Item 4 of the UX polish plan).
2
+
3
+ A free-text search now ANDs with the selected category and/or tag, mirroring how
4
+ browse() already filters. These tests pin that behavior at the DB layer.
5
+ """
6
+
7
+ import pytest
8
+
9
+ from kryten_webqueue.catalog.db import Database
10
+
11
+ MEDIACMS = "https://www.dropsugar.com"
12
+
13
+
14
+ @pytest.fixture
15
+ async def db(tmp_path):
16
+ database = Database(str(tmp_path / "search_facets.db"))
17
+ await database.connect()
18
+ await database.run_migrations()
19
+ yield database
20
+ await database.close()
21
+
22
+
23
+ async def _add_catalog(db, token, title):
24
+ await db.insert_catalog({
25
+ "friendly_token": token,
26
+ "title": title,
27
+ "description": "",
28
+ "duration_sec": 600,
29
+ "manifest_url": f"{MEDIACMS}/api/v1/media/cytube/{token}.json?format=json",
30
+ "thumbnail_url": "",
31
+ "synced_at": "2026-01-01T00:00:00+00:00",
32
+ })
33
+
34
+
35
+ async def test_search_ands_with_category_and_tag(db):
36
+ # Three items all matching the query "Dragon", differentiated by facets.
37
+ await _add_catalog(db, "tok_action", "Dragon Action")
38
+ await _add_catalog(db, "tok_comedy", "Dragon Comedy")
39
+ await _add_catalog(db, "tok_plain", "Dragon Plain")
40
+
41
+ action_id = await db.upsert_category("Action")
42
+ comedy_id = await db.upsert_category("Comedy")
43
+ await db.set_catalog_categories("tok_action", [action_id])
44
+ await db.set_catalog_categories("tok_comedy", [comedy_id])
45
+
46
+ epic_tag = await db.upsert_tag("Epic")
47
+ await db.set_catalog_tags("tok_action", [epic_tag])
48
+
49
+ # Resolve the Action slug (upsert derives it).
50
+ action_slug = next(c["slug"] for c in await db.get_categories() if c["name"] == "Action")
51
+
52
+ # Plain query: all three match.
53
+ assert {r["friendly_token"] for r in await db.search("Dragon")} == {
54
+ "tok_action", "tok_comedy", "tok_plain",
55
+ }
56
+ assert await db.search_count("Dragon") == 3
57
+
58
+ # Query + category: only the Action item.
59
+ cat_results = {r["friendly_token"] for r in await db.search("Dragon", category=action_slug)}
60
+ assert cat_results == {"tok_action"}
61
+ assert await db.search_count("Dragon", category=action_slug) == 1
62
+
63
+ # Query + tag: only the Epic-tagged item.
64
+ tag_results = {r["friendly_token"] for r in await db.search("Dragon", tag="Epic")}
65
+ assert tag_results == {"tok_action"}
66
+ assert await db.search_count("Dragon", tag="Epic") == 1
67
+
68
+ # Query + category + tag that don't co-occur: empty (true intersection).
69
+ assert await db.search("Dragon", category=action_slug, tag="Nonexistent") == []
70
+ assert await db.search_count("Dragon", category=action_slug, tag="Nonexistent") == 0
71
+
72
+
73
+ async def test_search_facet_with_no_text_match_is_empty(db):
74
+ await _add_catalog(db, "tok1", "Comedy Night")
75
+ comedy_id = await db.upsert_category("Comedy")
76
+ await db.set_catalog_categories("tok1", [comedy_id])
77
+ comedy_slug = next(c["slug"] for c in await db.get_categories() if c["name"] == "Comedy")
78
+
79
+ # The category matches the item, but the query does not -> no results.
80
+ assert await db.search("Horror", category=comedy_slug) == []
81
+ assert await db.search_count("Horror", category=comedy_slug) == 0