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.
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/CHANGELOG.md +21 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/catalog/db.py +42 -2
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/queue/shadow.py +60 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/catalog.py +8 -4
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/pages.py +5 -4
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/index.html +48 -5
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/schedules.html +15 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/catalog/browse.html +34 -6
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/pyproject.toml +1 -1
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_phase4_live_fixes.py +46 -0
- kryten_webqueue-0.18.0/tests/test_search_facets.py +81 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/.gitignore +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/README.md +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/config.example.json +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/tests/test_queue_announce.py +0 -0
- {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
|
|
@@ -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, *,
|
|
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, *,
|
|
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 = "",
|
|
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
|
-
|
|
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":
|
|
134
|
-
"active_tag":
|
|
134
|
+
"active_category": category,
|
|
135
|
+
"active_tag": tag,
|
|
135
136
|
"query": q,
|
|
136
137
|
"is_admin": is_admin,
|
|
137
138
|
"show_hidden": show_hidden,
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/index.html
RENAMED
|
@@ -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
|
-
|
|
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 %}
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
@@ -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 %}
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
@@ -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
|
-
|
|
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">← 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
|
|
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();
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/_common.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/ytpipe/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/integrations/ytpipe/downloader.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/promos.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/queue/index.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.16.0 → kryten_webqueue-0.18.0}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|