kryten-webqueue 0.15.1__tar.gz → 0.15.2__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.15.1 → kryten_webqueue-0.15.2}/CHANGELOG.md +8 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/PKG-INFO +1 -1
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/app.py +1 -0
- kryten_webqueue-0.15.2/kryten_webqueue/playlists/fire.py +136 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/importer.py +33 -21
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/scheduler.py +4 -1
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/promos/director.py +71 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_playlists.py +1 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_schedules.py +1 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/pyproject.toml +1 -1
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_promo_director.py +74 -0
- kryten_webqueue-0.15.1/kryten_webqueue/playlists/fire.py +0 -120
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/.gitignore +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/README.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/config.example.json +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/db.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_save_results_to_playlist.py +0 -0
|
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.15.2] — 2026-06-17
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Promos were inserted into immutable playlists during a schedule fire.** A scheduled event clears the queue and loads its items over several seconds (throttled adds + 422 retries), but the event lock that suppresses promos was only recorded *after* the entire load finished. Meanwhile the state poller (every ~3s) saw a partially-built queue with no lock and slotted a general promo between the freshly-added immutable items. `PromoDirector` now exposes a re-entrant `suppressed()` guard that `fire_schedule` (and manual playlist import) holds for the whole load — spanning through `set_active_schedule`, so for an immutable event the persistent lock is already live by the time suppression lifts (a clean handoff with no race window). When suppression releases, the next poll re-baselines now-playing instead of treating the bulk load as content advancing, so no promo fires on the wrong boundary. This also satisfies the general rule: never evaluate promos while a bulk queue insert/append is in progress, regardless of playlist type.
|
|
14
|
+
|
|
15
|
+
[0.15.2]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.15.2
|
|
16
|
+
|
|
9
17
|
## [0.15.1] — 2026-06-17
|
|
10
18
|
|
|
11
19
|
### Fixed
|
|
@@ -147,6 +147,7 @@ async def lifespan(app: FastAPI):
|
|
|
147
147
|
db=db, api_gate=api_gate, shadow=shadow, ws_manager=ws_manager,
|
|
148
148
|
add_delay_sec=config.playlist_bulk_add_delay_sec,
|
|
149
149
|
add_max_retries=config.playlist_bulk_add_max_retries,
|
|
150
|
+
promo_director=promo_director,
|
|
150
151
|
)
|
|
151
152
|
await scheduler.start()
|
|
152
153
|
app.state.scheduler = scheduler
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from contextlib import nullcontext
|
|
4
|
+
from datetime import datetime, timedelta, UTC
|
|
5
|
+
|
|
6
|
+
from ..queue.ordering import refund_item
|
|
7
|
+
from .bulk_add import add_item_throttled
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
_queue_lock = asyncio.Lock()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def fire_schedule(
|
|
15
|
+
*,
|
|
16
|
+
schedule_id: int,
|
|
17
|
+
api_gate,
|
|
18
|
+
db,
|
|
19
|
+
shadow,
|
|
20
|
+
ws_manager,
|
|
21
|
+
add_delay_sec: float = 0.0,
|
|
22
|
+
add_max_retries: int = 0,
|
|
23
|
+
promo_director=None,
|
|
24
|
+
):
|
|
25
|
+
"""Fire a scheduled playlist: clear queue, refund displaced pay items, load playlist.
|
|
26
|
+
|
|
27
|
+
Promo insertion is suppressed for the whole load (via ``promo_director``):
|
|
28
|
+
promos must never be slotted between items while the playlist is still being
|
|
29
|
+
built. Suppression spans through ``set_active_schedule`` so that, for an
|
|
30
|
+
immutable event, the persistent event lock is already recorded by the time
|
|
31
|
+
suppression lifts — a clean handoff with no window for a stray insertion.
|
|
32
|
+
"""
|
|
33
|
+
async with _queue_lock:
|
|
34
|
+
schedule = await db.get_schedule(schedule_id)
|
|
35
|
+
if not schedule:
|
|
36
|
+
logger.error(f"Schedule {schedule_id} not found")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
playlist_id = schedule["playlist_id"]
|
|
40
|
+
playlist = await db.get_saved_playlist(playlist_id)
|
|
41
|
+
if not playlist:
|
|
42
|
+
logger.error(f"Playlist {playlist_id} not found for schedule {schedule_id}")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
suppress_ctx = (
|
|
46
|
+
promo_director.suppressed(f"schedule fire {schedule_id}")
|
|
47
|
+
if promo_director is not None
|
|
48
|
+
else nullcontext()
|
|
49
|
+
)
|
|
50
|
+
with suppress_ctx:
|
|
51
|
+
# Refund all pay items currently in queue
|
|
52
|
+
pay_items = await db.get_pay_items()
|
|
53
|
+
for item in pay_items:
|
|
54
|
+
await refund_item(api_gate=api_gate, db=db, uid=item["uid"], reason="schedule_displaced")
|
|
55
|
+
|
|
56
|
+
# Clear the CyTube playlist
|
|
57
|
+
await api_gate.playlist_clear()
|
|
58
|
+
|
|
59
|
+
# Load scheduled playlist items
|
|
60
|
+
items = await db.get_saved_playlist_items(playlist_id)
|
|
61
|
+
total_duration = 0
|
|
62
|
+
last_item_uid = None
|
|
63
|
+
for index, item in enumerate(items):
|
|
64
|
+
# Throttle consecutive adds so CyTube can validate each item before
|
|
65
|
+
# the next arrives (avoids transient queueFail/422 under load).
|
|
66
|
+
if index and add_delay_sec:
|
|
67
|
+
await asyncio.sleep(add_delay_sec)
|
|
68
|
+
try:
|
|
69
|
+
add_result = await add_item_throttled(
|
|
70
|
+
api_gate,
|
|
71
|
+
media_type=item["media_type"],
|
|
72
|
+
media_id=item["media_id"],
|
|
73
|
+
position="end",
|
|
74
|
+
max_retries=add_max_retries,
|
|
75
|
+
retry_delay_sec=add_delay_sec or 0.5,
|
|
76
|
+
)
|
|
77
|
+
if isinstance(add_result, dict) and add_result.get("uid") is not None:
|
|
78
|
+
last_item_uid = add_result["uid"]
|
|
79
|
+
total_duration += item.get("duration_sec", 0) or 0
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.warning(f"Schedule fire: failed to add {item['media_id']}: {e}")
|
|
82
|
+
|
|
83
|
+
# Append the optional fallback (mutable) playlist AFTER the event items so
|
|
84
|
+
# the live queue isn't left empty once the event is exhausted. The
|
|
85
|
+
# fallback items are not part of the "scheduled event", so they do not
|
|
86
|
+
# change last_item_uid (the event lock still lifts when the last EVENT
|
|
87
|
+
# item begins) and they remain available for pay-to-play/search.
|
|
88
|
+
fallback_id = schedule.get("fallback_playlist_id")
|
|
89
|
+
if fallback_id:
|
|
90
|
+
fallback_items = await db.get_saved_playlist_items(fallback_id)
|
|
91
|
+
for index, item in enumerate(fallback_items):
|
|
92
|
+
if index and add_delay_sec:
|
|
93
|
+
await asyncio.sleep(add_delay_sec)
|
|
94
|
+
try:
|
|
95
|
+
await add_item_throttled(
|
|
96
|
+
api_gate,
|
|
97
|
+
media_type=item["media_type"],
|
|
98
|
+
media_id=item["media_id"],
|
|
99
|
+
position="end",
|
|
100
|
+
max_retries=add_max_retries,
|
|
101
|
+
retry_delay_sec=add_delay_sec or 0.5,
|
|
102
|
+
)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.warning(f"Schedule fire: failed to add fallback {item['media_id']}: {e}")
|
|
105
|
+
if fallback_items:
|
|
106
|
+
logger.info(
|
|
107
|
+
f"Schedule {schedule_id}: appended {len(fallback_items)} fallback item(s) "
|
|
108
|
+
f"from playlist {fallback_id}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Update active schedule (recorded *inside* the suppression window so
|
|
112
|
+
# the event lock is live before promos resume).
|
|
113
|
+
now = datetime.now(UTC)
|
|
114
|
+
await db.set_active_schedule(
|
|
115
|
+
schedule_id=schedule_id,
|
|
116
|
+
playlist_id=playlist_id,
|
|
117
|
+
is_immutable=playlist.get("is_immutable", False),
|
|
118
|
+
started_at=now.isoformat(),
|
|
119
|
+
estimated_end_at=(now + timedelta(seconds=total_duration)).isoformat(),
|
|
120
|
+
last_item_uid=last_item_uid,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Mark schedule as fired
|
|
124
|
+
await db.mark_schedule_fired(schedule_id, now.isoformat())
|
|
125
|
+
|
|
126
|
+
# Notify WS clients
|
|
127
|
+
await ws_manager.broadcast({
|
|
128
|
+
"type": "schedule_fired",
|
|
129
|
+
"data": {
|
|
130
|
+
"schedule_id": schedule_id,
|
|
131
|
+
"playlist_name": playlist["name"],
|
|
132
|
+
"is_immutable": playlist.get("is_immutable", False),
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
logger.info(f"Schedule {schedule_id} fired: playlist '{playlist['name']}' ({len(items)} items)")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
3
|
import re
|
|
4
|
+
from contextlib import nullcontext
|
|
4
5
|
|
|
5
6
|
from .bulk_add import add_item_throttled
|
|
6
7
|
|
|
@@ -62,12 +63,14 @@ def _manifest_url_for_token(token: str, mediacms_url: str | None) -> str | None:
|
|
|
62
63
|
class PlaylistImporter:
|
|
63
64
|
"""Imports items from a saved playlist into the live CyTube queue."""
|
|
64
65
|
|
|
65
|
-
def __init__(self, *, api_gate, db, shadow, add_delay_sec: float = 0.0, add_max_retries: int = 0
|
|
66
|
+
def __init__(self, *, api_gate, db, shadow, add_delay_sec: float = 0.0, add_max_retries: int = 0,
|
|
67
|
+
promo_director=None):
|
|
66
68
|
self._api_gate = api_gate
|
|
67
69
|
self._db = db
|
|
68
70
|
self._shadow = shadow
|
|
69
71
|
self._add_delay_sec = add_delay_sec
|
|
70
72
|
self._add_max_retries = add_max_retries
|
|
73
|
+
self._promo_director = promo_director
|
|
71
74
|
|
|
72
75
|
async def import_playlist(self, playlist_id: int) -> dict:
|
|
73
76
|
"""Import all items from a saved playlist into the live queue."""
|
|
@@ -75,29 +78,38 @@ class PlaylistImporter:
|
|
|
75
78
|
if not items:
|
|
76
79
|
return {"success": False, "error": "Playlist is empty"}
|
|
77
80
|
|
|
81
|
+
# Suppress promo insertion for the whole load so promos aren't slotted
|
|
82
|
+
# between items while the playlist is still being built.
|
|
83
|
+
suppress_ctx = (
|
|
84
|
+
self._promo_director.suppressed(f"playlist import {playlist_id}")
|
|
85
|
+
if self._promo_director is not None
|
|
86
|
+
else nullcontext()
|
|
87
|
+
)
|
|
88
|
+
|
|
78
89
|
added = 0
|
|
79
90
|
errors = 0
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
91
|
+
with suppress_ctx:
|
|
92
|
+
for index, item in enumerate(items):
|
|
93
|
+
# Throttle consecutive adds so CyTube can validate each item before
|
|
94
|
+
# the next arrives (avoids transient queueFail/422 under load).
|
|
95
|
+
if index and self._add_delay_sec:
|
|
96
|
+
await asyncio.sleep(self._add_delay_sec)
|
|
97
|
+
try:
|
|
98
|
+
result = await add_item_throttled(
|
|
99
|
+
self._api_gate,
|
|
100
|
+
media_type=item["media_type"],
|
|
101
|
+
media_id=item["media_id"],
|
|
102
|
+
position="end",
|
|
103
|
+
max_retries=self._add_max_retries,
|
|
104
|
+
retry_delay_sec=self._add_delay_sec or 0.5,
|
|
105
|
+
)
|
|
106
|
+
if result.get("success"):
|
|
107
|
+
added += 1
|
|
108
|
+
else:
|
|
109
|
+
errors += 1
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.warning(f"Failed to add {item['media_id']}: {e}")
|
|
97
112
|
errors += 1
|
|
98
|
-
except Exception as e:
|
|
99
|
-
logger.warning(f"Failed to add {item['media_id']}: {e}")
|
|
100
|
-
errors += 1
|
|
101
113
|
|
|
102
114
|
return {"success": True, "added": added, "errors": errors}
|
|
103
115
|
|
|
@@ -27,13 +27,15 @@ def _next_occurrence(rrule_str: str, dtstart: datetime, after: datetime) -> date
|
|
|
27
27
|
class PlaylistScheduler:
|
|
28
28
|
"""APScheduler-based scheduler for playlist fire events."""
|
|
29
29
|
|
|
30
|
-
def __init__(self, *, db, api_gate, shadow, ws_manager, add_delay_sec: float = 0.0, add_max_retries: int = 0
|
|
30
|
+
def __init__(self, *, db, api_gate, shadow, ws_manager, add_delay_sec: float = 0.0, add_max_retries: int = 0,
|
|
31
|
+
promo_director=None):
|
|
31
32
|
self._db = db
|
|
32
33
|
self._api_gate = api_gate
|
|
33
34
|
self._shadow = shadow
|
|
34
35
|
self._ws_manager = ws_manager
|
|
35
36
|
self._add_delay_sec = add_delay_sec
|
|
36
37
|
self._add_max_retries = add_max_retries
|
|
38
|
+
self._promo_director = promo_director
|
|
37
39
|
self._scheduler = AsyncIOScheduler()
|
|
38
40
|
|
|
39
41
|
async def start(self):
|
|
@@ -100,6 +102,7 @@ class PlaylistScheduler:
|
|
|
100
102
|
ws_manager=self._ws_manager,
|
|
101
103
|
add_delay_sec=self._add_delay_sec,
|
|
102
104
|
add_max_retries=self._add_max_retries,
|
|
105
|
+
promo_director=self._promo_director,
|
|
103
106
|
)
|
|
104
107
|
# After an automatic timed fire, advance recurring schedules to their
|
|
105
108
|
# next occurrence and re-arm. (Manual "Fire Now" does NOT advance the
|
|
@@ -22,6 +22,7 @@ director is a no-op while an immutable scheduled event is locking the queue.
|
|
|
22
22
|
|
|
23
23
|
import logging
|
|
24
24
|
import random
|
|
25
|
+
from contextlib import contextmanager
|
|
25
26
|
from datetime import datetime, UTC
|
|
26
27
|
|
|
27
28
|
from ..queue.ordering import _now_playing_uid
|
|
@@ -83,6 +84,17 @@ class PromoDirector:
|
|
|
83
84
|
self._last_clip_token: dict[str, str] = {} # promo_type -> last clip media_id
|
|
84
85
|
self._seq_index: dict[str, int] = {} # promo_type -> next sequential index
|
|
85
86
|
|
|
87
|
+
# Suppression guard. Held (re-entrantly) by bulk live-queue loaders
|
|
88
|
+
# (schedule fire, playlist import) so promos are never inserted *into* a
|
|
89
|
+
# playlist while it is still being built. Without this, the poller runs
|
|
90
|
+
# every few seconds during the multi-second load and slots promos
|
|
91
|
+
# between freshly-added items — including immutable ones, because the
|
|
92
|
+
# event lock isn't recorded until the load finishes.
|
|
93
|
+
self._suppress_depth: int = 0
|
|
94
|
+
# Set when suppression lifts so the next poll re-baselines now-playing
|
|
95
|
+
# instead of treating the post-load discontinuity as content advancing.
|
|
96
|
+
self._needs_rebaseline: bool = False
|
|
97
|
+
|
|
86
98
|
# Injectable for tests
|
|
87
99
|
self._rng = random.Random()
|
|
88
100
|
self._now = lambda: datetime.now(UTC)
|
|
@@ -97,6 +109,36 @@ class PromoDirector:
|
|
|
97
109
|
self._config = config
|
|
98
110
|
logger.info("PromoDirector config updated (enabled=%s)", getattr(config, "enabled", None))
|
|
99
111
|
|
|
112
|
+
@property
|
|
113
|
+
def is_suppressed(self) -> bool:
|
|
114
|
+
"""True while a bulk live-queue operation has paused promo insertion."""
|
|
115
|
+
return self._suppress_depth > 0
|
|
116
|
+
|
|
117
|
+
@contextmanager
|
|
118
|
+
def suppressed(self, reason: str):
|
|
119
|
+
"""Pause promo insertion for the duration of a bulk queue operation.
|
|
120
|
+
|
|
121
|
+
Re-entrant: nested holders each bump a depth counter; promos resume only
|
|
122
|
+
once the outermost holder exits. Use around any operation that adds a
|
|
123
|
+
run of items to the *live* queue (schedule fire, playlist import) so the
|
|
124
|
+
director never inserts a promo into a playlist that is still loading.
|
|
125
|
+
|
|
126
|
+
On release the next poll re-baselines now-playing rather than counting
|
|
127
|
+
the load as content advancing, so a promo doesn't fire on the wrong
|
|
128
|
+
boundary immediately afterwards.
|
|
129
|
+
"""
|
|
130
|
+
self._suppress_depth += 1
|
|
131
|
+
if self._suppress_depth == 1:
|
|
132
|
+
logger.info("Promo insertion suppressed: %s", reason)
|
|
133
|
+
try:
|
|
134
|
+
yield
|
|
135
|
+
finally:
|
|
136
|
+
self._suppress_depth -= 1
|
|
137
|
+
if self._suppress_depth <= 0:
|
|
138
|
+
self._suppress_depth = 0
|
|
139
|
+
self._needs_rebaseline = True
|
|
140
|
+
logger.info("Promo insertion resumed (after: %s)", reason)
|
|
141
|
+
|
|
100
142
|
# --- Play-order helpers -------------------------------------------------
|
|
101
143
|
|
|
102
144
|
@staticmethod
|
|
@@ -365,6 +407,13 @@ class PromoDirector:
|
|
|
365
407
|
"""
|
|
366
408
|
if not self._config.enabled:
|
|
367
409
|
return None
|
|
410
|
+
if self._suppress_depth > 0:
|
|
411
|
+
logger.debug(
|
|
412
|
+
"Viewer's-Choice skipped for paid uid=%s: insertion suppressed "
|
|
413
|
+
"(bulk queue operation in progress)",
|
|
414
|
+
content_uid,
|
|
415
|
+
)
|
|
416
|
+
return None
|
|
368
417
|
items = self._shadow.items
|
|
369
418
|
if self._has_lead_in(content_uid, items):
|
|
370
419
|
logger.debug(
|
|
@@ -392,9 +441,31 @@ class PromoDirector:
|
|
|
392
441
|
if not cfg.enabled:
|
|
393
442
|
return
|
|
394
443
|
|
|
444
|
+
# Frozen while a bulk live-queue operation (schedule fire / playlist
|
|
445
|
+
# import) is loading items. Promos must never land *inside* a playlist
|
|
446
|
+
# that is still being built — this is what slips promos between immutable
|
|
447
|
+
# items before the event lock is recorded.
|
|
448
|
+
if self._suppress_depth > 0:
|
|
449
|
+
logger.debug(
|
|
450
|
+
"Promo on_poll skipped: insertion suppressed (bulk queue operation in progress)"
|
|
451
|
+
)
|
|
452
|
+
return
|
|
453
|
+
|
|
395
454
|
np_uid = await _now_playing_uid(self._api_gate, self._shadow)
|
|
396
455
|
np_is_promo = self._is_promo_uid(np_uid)
|
|
397
456
|
|
|
457
|
+
# After a bulk queue operation the playlist is discontinuous; re-baseline
|
|
458
|
+
# now-playing without counting it as content advancing so we don't fire a
|
|
459
|
+
# promo on the wrong boundary on the very next cycle.
|
|
460
|
+
if self._needs_rebaseline:
|
|
461
|
+
self._needs_rebaseline = False
|
|
462
|
+
self._last_np_uid = np_uid
|
|
463
|
+
self._last_np_is_promo = np_is_promo
|
|
464
|
+
logger.debug(
|
|
465
|
+
"Promo baseline reset after bulk queue operation (np_uid=%s)", np_uid
|
|
466
|
+
)
|
|
467
|
+
return
|
|
468
|
+
|
|
398
469
|
# Advance detection: a finished *content* item bumps the cadence counter.
|
|
399
470
|
if np_uid != self._last_np_uid:
|
|
400
471
|
if self._last_np_uid is not None and not self._last_np_is_promo:
|
|
@@ -112,6 +112,7 @@ async def import_to_live(request: Request, playlist_id: int, user: dict = Depend
|
|
|
112
112
|
shadow=request.app.state.shadow,
|
|
113
113
|
add_delay_sec=config.playlist_bulk_add_delay_sec,
|
|
114
114
|
add_max_retries=config.playlist_bulk_add_max_retries,
|
|
115
|
+
promo_director=getattr(request.app.state, "promo_director", None),
|
|
115
116
|
)
|
|
116
117
|
result = await importer.import_playlist(playlist_id)
|
|
117
118
|
return result
|
|
@@ -121,6 +121,7 @@ async def fire_now(request: Request, schedule_id: int, user: dict = Depends(requ
|
|
|
121
121
|
ws_manager=request.app.state.ws_manager,
|
|
122
122
|
add_delay_sec=config.playlist_bulk_add_delay_sec,
|
|
123
123
|
add_max_retries=config.playlist_bulk_add_max_retries,
|
|
124
|
+
promo_director=getattr(request.app.state, "promo_director", None),
|
|
124
125
|
)
|
|
125
126
|
return {"success": True}
|
|
126
127
|
|
|
@@ -323,6 +323,80 @@ async def test_noop_during_immutable_event():
|
|
|
323
323
|
assert _promo_items(shadow) == []
|
|
324
324
|
|
|
325
325
|
|
|
326
|
+
async def test_noop_while_suppressed():
|
|
327
|
+
# While a bulk queue load holds the suppression guard, on_poll must insert
|
|
328
|
+
# nothing — even though a Feature-Presentation lead-in is otherwise due.
|
|
329
|
+
shadow = _FakeShadow([_content(10), _content(20, duration=3600)], now_playing={"uid": 10})
|
|
330
|
+
api = _FakeApiGate(now_playing={"uid": 10})
|
|
331
|
+
db = _FakeDb(pools={"feature_presentation": [_clip("fp1")]})
|
|
332
|
+
d = _director(api, shadow, db)
|
|
333
|
+
|
|
334
|
+
with d.suppressed("bulk load"):
|
|
335
|
+
assert d.is_suppressed is True
|
|
336
|
+
await d.on_poll()
|
|
337
|
+
assert _promo_items(shadow) == []
|
|
338
|
+
assert api.adds == []
|
|
339
|
+
|
|
340
|
+
# Guard released; depth back to zero.
|
|
341
|
+
assert d.is_suppressed is False
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
async def test_suppression_is_reentrant():
|
|
345
|
+
shadow = _FakeShadow([_content(10), _content(20, duration=3600)], now_playing={"uid": 10})
|
|
346
|
+
api = _FakeApiGate(now_playing={"uid": 10})
|
|
347
|
+
db = _FakeDb(pools={"feature_presentation": [_clip("fp1")]})
|
|
348
|
+
d = _director(api, shadow, db)
|
|
349
|
+
|
|
350
|
+
with d.suppressed("outer"):
|
|
351
|
+
with d.suppressed("inner"):
|
|
352
|
+
assert d.is_suppressed is True
|
|
353
|
+
await d.on_poll()
|
|
354
|
+
assert _promo_items(shadow) == []
|
|
355
|
+
# Inner released, outer still holds.
|
|
356
|
+
assert d.is_suppressed is True
|
|
357
|
+
await d.on_poll()
|
|
358
|
+
assert _promo_items(shadow) == []
|
|
359
|
+
assert d.is_suppressed is False
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def test_first_poll_after_suppression_rebaselines_without_inserting():
|
|
363
|
+
# When suppression lifts, the next poll re-baselines now-playing instead of
|
|
364
|
+
# treating the bulk load as content advancing — so no promo fires on that
|
|
365
|
+
# boundary. The following poll resumes normal behaviour.
|
|
366
|
+
shadow = _FakeShadow([_content(10), _content(20, duration=3600)], now_playing={"uid": 10})
|
|
367
|
+
api = _FakeApiGate(now_playing={"uid": 10})
|
|
368
|
+
db = _FakeDb(pools={"feature_presentation": [_clip("fp1")]})
|
|
369
|
+
d = _director(api, shadow, db)
|
|
370
|
+
|
|
371
|
+
with d.suppressed("bulk load"):
|
|
372
|
+
await d.on_poll()
|
|
373
|
+
assert _promo_items(shadow) == []
|
|
374
|
+
|
|
375
|
+
# First post-suppression poll: re-baseline only, no insertion.
|
|
376
|
+
await d.on_poll()
|
|
377
|
+
assert _promo_items(shadow) == []
|
|
378
|
+
|
|
379
|
+
# Second poll: normal evaluation resumes and the FP lead-in is inserted.
|
|
380
|
+
await d.on_poll()
|
|
381
|
+
promos = _promo_items(shadow)
|
|
382
|
+
assert len(promos) == 1
|
|
383
|
+
assert promos[0]["promo_type"] == "feature_presentation"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async def test_suppression_released_on_exception():
|
|
387
|
+
shadow = _FakeShadow([_content(10), _content(20, duration=3600)], now_playing={"uid": 10})
|
|
388
|
+
api = _FakeApiGate(now_playing={"uid": 10})
|
|
389
|
+
db = _FakeDb(pools={"feature_presentation": [_clip("fp1")]})
|
|
390
|
+
d = _director(api, shadow, db)
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
with d.suppressed("bulk load"):
|
|
394
|
+
raise RuntimeError("load failed")
|
|
395
|
+
except RuntimeError:
|
|
396
|
+
pass
|
|
397
|
+
assert d.is_suppressed is False
|
|
398
|
+
|
|
399
|
+
|
|
326
400
|
async def test_disabled_director_noop():
|
|
327
401
|
shadow = _FakeShadow([_content(10), _content(20, duration=3600)], now_playing={"uid": 10})
|
|
328
402
|
api = _FakeApiGate(now_playing={"uid": 10})
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import logging
|
|
3
|
-
from datetime import datetime, timedelta, UTC
|
|
4
|
-
|
|
5
|
-
from ..queue.ordering import refund_item
|
|
6
|
-
from .bulk_add import add_item_throttled
|
|
7
|
-
|
|
8
|
-
logger = logging.getLogger(__name__)
|
|
9
|
-
|
|
10
|
-
_queue_lock = asyncio.Lock()
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
async def fire_schedule(
|
|
14
|
-
*,
|
|
15
|
-
schedule_id: int,
|
|
16
|
-
api_gate,
|
|
17
|
-
db,
|
|
18
|
-
shadow,
|
|
19
|
-
ws_manager,
|
|
20
|
-
add_delay_sec: float = 0.0,
|
|
21
|
-
add_max_retries: int = 0,
|
|
22
|
-
):
|
|
23
|
-
"""Fire a scheduled playlist: clear queue, refund displaced pay items, load playlist."""
|
|
24
|
-
async with _queue_lock:
|
|
25
|
-
schedule = await db.get_schedule(schedule_id)
|
|
26
|
-
if not schedule:
|
|
27
|
-
logger.error(f"Schedule {schedule_id} not found")
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
playlist_id = schedule["playlist_id"]
|
|
31
|
-
playlist = await db.get_saved_playlist(playlist_id)
|
|
32
|
-
if not playlist:
|
|
33
|
-
logger.error(f"Playlist {playlist_id} not found for schedule {schedule_id}")
|
|
34
|
-
return
|
|
35
|
-
|
|
36
|
-
# Refund all pay items currently in queue
|
|
37
|
-
pay_items = await db.get_pay_items()
|
|
38
|
-
for item in pay_items:
|
|
39
|
-
await refund_item(api_gate=api_gate, db=db, uid=item["uid"], reason="schedule_displaced")
|
|
40
|
-
|
|
41
|
-
# Clear the CyTube playlist
|
|
42
|
-
await api_gate.playlist_clear()
|
|
43
|
-
|
|
44
|
-
# Load scheduled playlist items
|
|
45
|
-
items = await db.get_saved_playlist_items(playlist_id)
|
|
46
|
-
total_duration = 0
|
|
47
|
-
last_item_uid = None
|
|
48
|
-
for index, item in enumerate(items):
|
|
49
|
-
# Throttle consecutive adds so CyTube can validate each item before
|
|
50
|
-
# the next arrives (avoids transient queueFail/422 under load).
|
|
51
|
-
if index and add_delay_sec:
|
|
52
|
-
await asyncio.sleep(add_delay_sec)
|
|
53
|
-
try:
|
|
54
|
-
add_result = await add_item_throttled(
|
|
55
|
-
api_gate,
|
|
56
|
-
media_type=item["media_type"],
|
|
57
|
-
media_id=item["media_id"],
|
|
58
|
-
position="end",
|
|
59
|
-
max_retries=add_max_retries,
|
|
60
|
-
retry_delay_sec=add_delay_sec or 0.5,
|
|
61
|
-
)
|
|
62
|
-
if isinstance(add_result, dict) and add_result.get("uid") is not None:
|
|
63
|
-
last_item_uid = add_result["uid"]
|
|
64
|
-
total_duration += item.get("duration_sec", 0) or 0
|
|
65
|
-
except Exception as e:
|
|
66
|
-
logger.warning(f"Schedule fire: failed to add {item['media_id']}: {e}")
|
|
67
|
-
|
|
68
|
-
# Append the optional fallback (mutable) playlist AFTER the event items so
|
|
69
|
-
# the live queue isn't left empty once the event is exhausted. The
|
|
70
|
-
# fallback items are not part of the "scheduled event", so they do not
|
|
71
|
-
# change last_item_uid (the event lock still lifts when the last EVENT
|
|
72
|
-
# item begins) and they remain available for pay-to-play/search.
|
|
73
|
-
fallback_id = schedule.get("fallback_playlist_id")
|
|
74
|
-
if fallback_id:
|
|
75
|
-
fallback_items = await db.get_saved_playlist_items(fallback_id)
|
|
76
|
-
for index, item in enumerate(fallback_items):
|
|
77
|
-
if index and add_delay_sec:
|
|
78
|
-
await asyncio.sleep(add_delay_sec)
|
|
79
|
-
try:
|
|
80
|
-
await add_item_throttled(
|
|
81
|
-
api_gate,
|
|
82
|
-
media_type=item["media_type"],
|
|
83
|
-
media_id=item["media_id"],
|
|
84
|
-
position="end",
|
|
85
|
-
max_retries=add_max_retries,
|
|
86
|
-
retry_delay_sec=add_delay_sec or 0.5,
|
|
87
|
-
)
|
|
88
|
-
except Exception as e:
|
|
89
|
-
logger.warning(f"Schedule fire: failed to add fallback {item['media_id']}: {e}")
|
|
90
|
-
if fallback_items:
|
|
91
|
-
logger.info(
|
|
92
|
-
f"Schedule {schedule_id}: appended {len(fallback_items)} fallback item(s) "
|
|
93
|
-
f"from playlist {fallback_id}"
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
# Update active schedule
|
|
97
|
-
now = datetime.now(UTC)
|
|
98
|
-
await db.set_active_schedule(
|
|
99
|
-
schedule_id=schedule_id,
|
|
100
|
-
playlist_id=playlist_id,
|
|
101
|
-
is_immutable=playlist.get("is_immutable", False),
|
|
102
|
-
started_at=now.isoformat(),
|
|
103
|
-
estimated_end_at=(now + timedelta(seconds=total_duration)).isoformat(),
|
|
104
|
-
last_item_uid=last_item_uid,
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
# Mark schedule as fired
|
|
108
|
-
await db.mark_schedule_fired(schedule_id, now.isoformat())
|
|
109
|
-
|
|
110
|
-
# Notify WS clients
|
|
111
|
-
await ws_manager.broadcast({
|
|
112
|
-
"type": "schedule_fired",
|
|
113
|
-
"data": {
|
|
114
|
-
"schedule_id": schedule_id,
|
|
115
|
-
"playlist_name": playlist["name"],
|
|
116
|
-
"is_immutable": playlist.get("is_immutable", False),
|
|
117
|
-
},
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
logger.info(f"Schedule {schedule_id} fired: playlist '{playlist['name']}' ({len(items)} items)")
|
|
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.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/_common.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichtv.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/fetchurls.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/ytpipe/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/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
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/index.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/promos.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/queue/index.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/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
|