kryten-webqueue 0.34.7__tar.gz → 0.34.10__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.34.7 → kryten_webqueue-0.34.10}/CHANGELOG.md +36 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/PKG-INFO +1 -1
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/app.py +2 -3
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/db/_catalog.py +24 -121
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/db/_playlists.py +71 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/playlists/fire.py +0 -20
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/playlists/importer.py +3 -23
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/completion.py +6 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_catalog.py +2 -4
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_playlists.py +2 -8
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/admin-playlists.js +4 -9
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/admin/playlists.html +1 -2
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/pyproject.toml +1 -1
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_recently_played_hide.py +156 -233
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/.github/workflows/gitleaks.yml +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/.gitignore +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/.gitleaks.toml +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/.pre-commit-config.yaml +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/AGENT-WORKFLOW-GUIDE.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/README.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/config.example.json +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/SPEC_EMOTE_REHOST.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/db/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/db/_connection.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/jobs/job_scheduler.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_moderation.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/feedback.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/admin.js +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/__init__.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_feedback.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_search_facets.py +0 -0
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.34.10] - 2026-08-13
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Startup crash.** `app.py` referenced the now-removed `playlist_pass` key in
|
|
10
|
+
the return value of `purge_promo_hide_state()`.
|
|
11
|
+
|
|
12
|
+
## [0.34.9] - 2026-08-13
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **Playlist continuation is now driven by rotation, not pass tracking.**
|
|
17
|
+
Replaced the broken `playlist_item_played` pass-continuation mechanism with
|
|
18
|
+
automatic playlist rotation: when an item plays past the 50% threshold,
|
|
19
|
+
`CompletionRecorder` calls `rotate_playlist_item_to_bottom`, which moves the
|
|
20
|
+
item to the end of every mutable playlist containing it. `fire_schedule` and
|
|
21
|
+
`PlaylistImporter` always load from position 0 — no filtering needed. Mutable
|
|
22
|
+
playlists now naturally advance across preemptions without ever getting stuck.
|
|
23
|
+
|
|
24
|
+
- **Recently-played hiding simplified.** All played items (episodes, movies,
|
|
25
|
+
everything) now record a `play_completions` time-window row. The `playlist_item_played`
|
|
26
|
+
table is no longer written to; the "release whole collection on last item"
|
|
27
|
+
behaviour is removed. The per-item time-window applies uniformly to all catalog
|
|
28
|
+
items.
|
|
29
|
+
|
|
30
|
+
- **Admin playlist UI.** "Load Entire List" button removed; "Import to Live"
|
|
31
|
+
always loads the full playlist in current order (which is the rotated order).
|
|
32
|
+
|
|
33
|
+
### Removed
|
|
34
|
+
|
|
35
|
+
- `get_playlist_played_media_ids` DB method
|
|
36
|
+
- `skip_played` parameter from `PlaylistImporter.import_playlist`
|
|
37
|
+
- `?full=1` query parameter from `POST /admin/playlists/{id}/import`
|
|
38
|
+
- `by_playlist_pass` key from `GET /admin/catalog/recently-played/debug` response
|
|
39
|
+
- `playlist_pass` key from `POST /admin/catalog/{token}/clear-played` response
|
|
40
|
+
|
|
5
41
|
## [0.34.7] - 2026-08-13
|
|
6
42
|
|
|
7
43
|
### Fixed
|
|
@@ -57,11 +57,10 @@ async def lifespan(app: FastAPI):
|
|
|
57
57
|
# recently-played rules). Cheap and idempotent; also cleans rows written by
|
|
58
58
|
# older builds that classified promos only by pool membership.
|
|
59
59
|
purged = await db.purge_promo_hide_state()
|
|
60
|
-
if purged["completions"]
|
|
60
|
+
if purged["completions"]:
|
|
61
61
|
logger.info(
|
|
62
|
-
"Purged promo recently-played state: %d completion(s)
|
|
62
|
+
"Purged promo recently-played state: %d completion(s)",
|
|
63
63
|
purged["completions"],
|
|
64
|
-
purged["playlist_pass"],
|
|
65
64
|
)
|
|
66
65
|
# Any job run still marked 'running' is an orphan from a prior crash/restart
|
|
67
66
|
# (the running flag is in-memory only). Reconcile before registering jobs.
|
|
@@ -102,22 +102,13 @@ def _promo_hidden_media_subquery() -> tuple[str, list]:
|
|
|
102
102
|
def _recently_played_exclusion(alias: str, days: int) -> tuple[str, list]:
|
|
103
103
|
"""SQL fragment (+ params) hiding recently-played items from regular users.
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
the last ``days`` days. A completion is only recorded when an item
|
|
109
|
-
actually played past a threshold (see ``CompletionRecorder``), so an item
|
|
110
|
-
that was queued and then refunded/removed before playing never counts.
|
|
111
|
-
|
|
112
|
-
2. **Mutable-playlist pass** — the item is a short (<1h) episode of a mutable
|
|
113
|
-
(TV-show) playlist that has played in the current pass and not yet reset
|
|
114
|
-
(``playlist_item_played``). These are governed by playlist position, not
|
|
115
|
-
the day window, so a whole show collection releases together when its last
|
|
116
|
-
item plays.
|
|
105
|
+
Items with a ``play_completions`` row within the last ``days`` days are
|
|
106
|
+
hidden. A completion is only recorded when an item played past a threshold
|
|
107
|
+
(see ``CompletionRecorder``), so refunded/skipped items never hide.
|
|
117
108
|
|
|
118
109
|
The fragment is prefixed with ``AND`` so it can be appended to an existing
|
|
119
110
|
WHERE clause referencing the catalog row under ``alias``. Caller must ensure
|
|
120
|
-
``days > 0`` before applying (a non-positive window disables
|
|
111
|
+
``days > 0`` before applying (a non-positive window disables this).
|
|
121
112
|
"""
|
|
122
113
|
sql = f"""
|
|
123
114
|
AND {alias}.friendly_token NOT IN (
|
|
@@ -125,10 +116,6 @@ def _recently_played_exclusion(alias: str, days: int) -> tuple[str, list]:
|
|
|
125
116
|
WHERE pc.media_type = 'cm'
|
|
126
117
|
AND pc.completed_at >= datetime('now', ?)
|
|
127
118
|
)
|
|
128
|
-
AND {alias}.friendly_token NOT IN (
|
|
129
|
-
SELECT pip.media_id FROM playlist_item_played pip
|
|
130
|
-
WHERE pip.media_type = 'cm'
|
|
131
|
-
)
|
|
132
119
|
"""
|
|
133
120
|
return sql, [f"-{int(days)} days"]
|
|
134
121
|
|
|
@@ -419,23 +406,17 @@ class _CatalogMixin:
|
|
|
419
406
|
)
|
|
420
407
|
|
|
421
408
|
async def record_play_completion(
|
|
422
|
-
self,
|
|
409
|
+
self,
|
|
410
|
+
*,
|
|
411
|
+
friendly_token: str,
|
|
412
|
+
duration_sec: int | None = None,
|
|
413
|
+
media_type: str = "cm",
|
|
423
414
|
) -> None:
|
|
424
|
-
"""Record a genuine play-completion
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
the playlist's last item (MAX position) clears the whole pass so the
|
|
430
|
-
collection releases together. Everything else (movies, standalone clips,
|
|
431
|
-
long items even inside a mutable playlist) gets a time-boxed
|
|
432
|
-
``play_completions`` row instead.
|
|
433
|
-
|
|
434
|
-
Promo-pool clips are exempt entirely: they are already excluded from the
|
|
435
|
-
public catalog and must never be treated like normal (mutable/immutable)
|
|
436
|
-
playlist items, so playing one records nothing. The same exemption covers
|
|
437
|
-
any promo/bumper hidden by category or tag (e.g. ``Z Channel Promos`` /
|
|
438
|
-
``channelz``), which is how most station promos are actually classified.
|
|
415
|
+
"""Record a genuine play-completion for catalog-hide purposes.
|
|
416
|
+
|
|
417
|
+
Promo-pool clips and hidden-category/tag items are exempt. Everything
|
|
418
|
+
else gets a time-boxed ``play_completions`` row that hides the item from
|
|
419
|
+
regular users until the configured window expires.
|
|
439
420
|
"""
|
|
440
421
|
sub, sub_params = _promo_hidden_media_subquery()
|
|
441
422
|
promo = await self._fetch_one(
|
|
@@ -444,84 +425,28 @@ class _CatalogMixin:
|
|
|
444
425
|
)
|
|
445
426
|
if promo:
|
|
446
427
|
return
|
|
447
|
-
|
|
448
|
-
""
|
|
449
|
-
|
|
450
|
-
(SELECT MAX(position) FROM saved_playlist_items
|
|
451
|
-
WHERE playlist_id = spi.playlist_id) AS max_pos
|
|
452
|
-
FROM saved_playlist_items spi
|
|
453
|
-
JOIN saved_playlists sp ON sp.id = spi.playlist_id
|
|
454
|
-
WHERE spi.media_id = ? AND spi.media_type = ?
|
|
455
|
-
AND sp.is_immutable = 0 AND sp.promo_type IS NULL
|
|
456
|
-
""",
|
|
457
|
-
[friendly_token, media_type],
|
|
458
|
-
)
|
|
459
|
-
governed = False
|
|
460
|
-
if duration_sec is not None and duration_sec < 3600:
|
|
461
|
-
for r in rows:
|
|
462
|
-
governed = True
|
|
463
|
-
if r["position"] == r["max_pos"]:
|
|
464
|
-
# Last item of the pass reached — release the whole collection.
|
|
465
|
-
await self._execute(
|
|
466
|
-
"DELETE FROM playlist_item_played WHERE playlist_id = ?",
|
|
467
|
-
[r["playlist_id"]],
|
|
468
|
-
)
|
|
469
|
-
else:
|
|
470
|
-
await self._execute(
|
|
471
|
-
"INSERT OR REPLACE INTO playlist_item_played "
|
|
472
|
-
"(playlist_id, position, media_type, media_id) VALUES (?, ?, ?, ?)",
|
|
473
|
-
[r["playlist_id"], r["position"], media_type, friendly_token],
|
|
474
|
-
)
|
|
475
|
-
if not governed:
|
|
476
|
-
await self._execute(
|
|
477
|
-
"INSERT INTO play_completions (media_type, media_id) VALUES (?, ?)",
|
|
478
|
-
[media_type, friendly_token],
|
|
479
|
-
)
|
|
480
|
-
|
|
481
|
-
async def get_playlist_played_media_ids(self, playlist_id: int) -> set[str]:
|
|
482
|
-
"""media_ids already played in the current pass of a mutable playlist.
|
|
483
|
-
|
|
484
|
-
Backed by ``playlist_item_played`` (populated only for short episodes of
|
|
485
|
-
mutable playlists and cleared when the playlist's last item plays), so
|
|
486
|
-
firing can skip already-played episodes and continue the pass instead of
|
|
487
|
-
replaying from the start.
|
|
488
|
-
"""
|
|
489
|
-
rows = await self._fetch_all(
|
|
490
|
-
"SELECT media_id FROM playlist_item_played WHERE playlist_id = ?",
|
|
491
|
-
[playlist_id],
|
|
428
|
+
await self._execute(
|
|
429
|
+
"INSERT INTO play_completions (media_type, media_id) VALUES (?, ?)",
|
|
430
|
+
[media_type, friendly_token],
|
|
492
431
|
)
|
|
493
|
-
return {r["media_id"] for r in rows}
|
|
494
432
|
|
|
495
433
|
async def clear_play_state(
|
|
496
434
|
self, friendly_token: str, *, media_type: str = "cm"
|
|
497
435
|
) -> dict:
|
|
498
|
-
"""Remove
|
|
436
|
+
"""Remove recently-played hide state for an item (admin helper).
|
|
499
437
|
|
|
500
|
-
Deletes
|
|
501
|
-
|
|
502
|
-
immediately reappears in the public catalog. Returns the row counts
|
|
503
|
-
removed.
|
|
438
|
+
Deletes ``play_completions`` rows so the item reappears in the public
|
|
439
|
+
catalog immediately. Returns the count removed.
|
|
504
440
|
"""
|
|
505
441
|
pc = await self._fetch_one(
|
|
506
442
|
"SELECT COUNT(*) AS c FROM play_completions WHERE media_id = ? AND media_type = ?",
|
|
507
443
|
[friendly_token, media_type],
|
|
508
444
|
)
|
|
509
|
-
pip = await self._fetch_one(
|
|
510
|
-
"SELECT COUNT(*) AS c FROM playlist_item_played WHERE media_id = ? AND media_type = ?",
|
|
511
|
-
[friendly_token, media_type],
|
|
512
|
-
)
|
|
513
445
|
await self._execute(
|
|
514
446
|
"DELETE FROM play_completions WHERE media_id = ? AND media_type = ?",
|
|
515
447
|
[friendly_token, media_type],
|
|
516
448
|
)
|
|
517
|
-
|
|
518
|
-
"DELETE FROM playlist_item_played WHERE media_id = ? AND media_type = ?",
|
|
519
|
-
[friendly_token, media_type],
|
|
520
|
-
)
|
|
521
|
-
return {
|
|
522
|
-
"completions": pc["c"] if pc else 0,
|
|
523
|
-
"playlist_pass": pip["c"] if pip else 0,
|
|
524
|
-
}
|
|
449
|
+
return {"completions": pc["c"] if pc else 0}
|
|
525
450
|
|
|
526
451
|
async def purge_promo_hide_state(self) -> dict:
|
|
527
452
|
"""Remove any recently-played hide state recorded for promos/bumpers.
|
|
@@ -530,34 +455,20 @@ class _CatalogMixin:
|
|
|
530
455
|
are already skipped in ``record_play_completion``; this purges rows
|
|
531
456
|
written before that exemption existed (run once at startup). Covers promo
|
|
532
457
|
pools plus hidden-category/tag promos (the usual station-promo case).
|
|
533
|
-
Returns the row
|
|
458
|
+
Returns the row count removed.
|
|
534
459
|
"""
|
|
535
460
|
sub, sub_params = _promo_hidden_media_subquery()
|
|
536
461
|
pc = await self._fetch_one(
|
|
537
462
|
f"SELECT COUNT(*) AS c FROM play_completions WHERE media_id IN ({sub})",
|
|
538
463
|
sub_params,
|
|
539
464
|
)
|
|
540
|
-
pip = await self._fetch_one(
|
|
541
|
-
f"SELECT COUNT(*) AS c FROM playlist_item_played WHERE media_id IN ({sub})",
|
|
542
|
-
sub_params,
|
|
543
|
-
)
|
|
544
465
|
await self._execute(
|
|
545
466
|
f"DELETE FROM play_completions WHERE media_id IN ({sub})", sub_params
|
|
546
467
|
)
|
|
547
|
-
|
|
548
|
-
f"DELETE FROM playlist_item_played WHERE media_id IN ({sub})", sub_params
|
|
549
|
-
)
|
|
550
|
-
return {
|
|
551
|
-
"completions": pc["c"] if pc else 0,
|
|
552
|
-
"playlist_pass": pip["c"] if pip else 0,
|
|
553
|
-
}
|
|
468
|
+
return {"completions": pc["c"] if pc else 0}
|
|
554
469
|
|
|
555
470
|
async def get_recently_played_debug(self, days: int) -> dict:
|
|
556
|
-
"""Snapshot of what the recently-played rules currently hide (admin test).
|
|
557
|
-
|
|
558
|
-
Mirrors the two exclusion signals used by browse/search so an admin can
|
|
559
|
-
see exactly which titles a regular user would NOT see, and why.
|
|
560
|
-
"""
|
|
471
|
+
"""Snapshot of what the recently-played rules currently hide (admin test)."""
|
|
561
472
|
by_completion = await self._fetch_all(
|
|
562
473
|
"SELECT pc.media_id, c.title, MAX(pc.completed_at) AS last_completed "
|
|
563
474
|
"FROM play_completions pc "
|
|
@@ -566,17 +477,9 @@ class _CatalogMixin:
|
|
|
566
477
|
"GROUP BY pc.media_id ORDER BY last_completed DESC",
|
|
567
478
|
[f"-{int(days)} days"],
|
|
568
479
|
)
|
|
569
|
-
by_playlist_pass = await self._fetch_all(
|
|
570
|
-
"SELECT pip.media_id, c.title, pip.playlist_id, sp.name AS playlist_name, pip.position "
|
|
571
|
-
"FROM playlist_item_played pip "
|
|
572
|
-
"JOIN saved_playlists sp ON sp.id = pip.playlist_id "
|
|
573
|
-
"LEFT JOIN catalog c ON c.friendly_token = pip.media_id "
|
|
574
|
-
"WHERE pip.media_type = 'cm' ORDER BY sp.name, pip.position",
|
|
575
|
-
)
|
|
576
480
|
return {
|
|
577
481
|
"window_days": days,
|
|
578
482
|
"by_completion": by_completion,
|
|
579
|
-
"by_playlist_pass": by_playlist_pass,
|
|
580
483
|
}
|
|
581
484
|
|
|
582
485
|
async def get_catalog_brief(
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
logger = logging.getLogger(__name__)
|
|
4
|
+
|
|
5
|
+
|
|
1
6
|
class _PlaylistsMixin:
|
|
2
7
|
"""Saved playlists, promo pools, schedules, and active schedule methods."""
|
|
3
8
|
|
|
@@ -159,6 +164,72 @@ class _PlaylistsMixin:
|
|
|
159
164
|
await self._db.commit()
|
|
160
165
|
return added
|
|
161
166
|
|
|
167
|
+
async def rotate_playlist_item_to_bottom(
|
|
168
|
+
self, media_id: str, media_type: str = "cm"
|
|
169
|
+
) -> int:
|
|
170
|
+
"""Move a played item to the end of every mutable playlist containing it.
|
|
171
|
+
|
|
172
|
+
Played items sink to the bottom so the next fire always loads the
|
|
173
|
+
oldest-played item last. Idempotent when the item is already at
|
|
174
|
+
MAX(position). Returns the number of playlists rotated.
|
|
175
|
+
"""
|
|
176
|
+
playlists = await self._fetch_all(
|
|
177
|
+
"""
|
|
178
|
+
SELECT spi.playlist_id
|
|
179
|
+
FROM saved_playlist_items spi
|
|
180
|
+
JOIN saved_playlists sp ON sp.id = spi.playlist_id
|
|
181
|
+
WHERE spi.media_id = ? AND spi.media_type = ?
|
|
182
|
+
AND sp.is_immutable = 0 AND sp.promo_type IS NULL
|
|
183
|
+
""",
|
|
184
|
+
[media_id, media_type],
|
|
185
|
+
)
|
|
186
|
+
rotated = 0
|
|
187
|
+
for r in playlists:
|
|
188
|
+
playlist_id = r["playlist_id"]
|
|
189
|
+
items = await self._fetch_all(
|
|
190
|
+
"SELECT * FROM saved_playlist_items WHERE playlist_id = ? ORDER BY position",
|
|
191
|
+
[playlist_id],
|
|
192
|
+
)
|
|
193
|
+
target_idx = next(
|
|
194
|
+
(
|
|
195
|
+
i
|
|
196
|
+
for i, it in enumerate(items)
|
|
197
|
+
if it["media_id"] == media_id and it["media_type"] == media_type
|
|
198
|
+
),
|
|
199
|
+
None,
|
|
200
|
+
)
|
|
201
|
+
if target_idx is None or target_idx == len(items) - 1:
|
|
202
|
+
continue # not found or already at bottom
|
|
203
|
+
# Move target to end; delete-then-reinsert avoids position UNIQUE conflicts.
|
|
204
|
+
reordered = (
|
|
205
|
+
items[:target_idx] + items[target_idx + 1 :] + [items[target_idx]]
|
|
206
|
+
)
|
|
207
|
+
await self._db.execute(
|
|
208
|
+
"DELETE FROM saved_playlist_items WHERE playlist_id=?", [playlist_id]
|
|
209
|
+
)
|
|
210
|
+
for new_pos, item in enumerate(reordered):
|
|
211
|
+
await self._db.execute(
|
|
212
|
+
"INSERT INTO saved_playlist_items "
|
|
213
|
+
"(playlist_id, position, media_type, media_id, title, duration_sec) "
|
|
214
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
215
|
+
[
|
|
216
|
+
playlist_id,
|
|
217
|
+
new_pos,
|
|
218
|
+
item["media_type"],
|
|
219
|
+
item["media_id"],
|
|
220
|
+
item.get("title"),
|
|
221
|
+
item.get("duration_sec"),
|
|
222
|
+
],
|
|
223
|
+
)
|
|
224
|
+
await self._db.execute(
|
|
225
|
+
"UPDATE saved_playlists SET updated_at=datetime('now') WHERE id=?",
|
|
226
|
+
[playlist_id],
|
|
227
|
+
)
|
|
228
|
+
await self._db.commit()
|
|
229
|
+
logger.info("Rotated %r to bottom of playlist %d", media_id, playlist_id)
|
|
230
|
+
rotated += 1
|
|
231
|
+
return rotated
|
|
232
|
+
|
|
162
233
|
async def get_most_recent_playlist(self, created_by: str) -> dict | None:
|
|
163
234
|
"""The given admin's most recently *created* saved playlist, if any."""
|
|
164
235
|
return await self._fetch_one(
|
|
@@ -63,19 +63,6 @@ async def fire_schedule(
|
|
|
63
63
|
|
|
64
64
|
# Load scheduled playlist items
|
|
65
65
|
items = await db.get_saved_playlist_items(playlist_id)
|
|
66
|
-
# For a mutable (TV-show) playlist, skip episodes already played in the
|
|
67
|
-
# current pass so a re-fire continues where it left off instead of
|
|
68
|
-
# replaying season 1. Immutable/promo playlists never have played rows,
|
|
69
|
-
# so this is a no-op for them.
|
|
70
|
-
played = await db.get_playlist_played_media_ids(playlist_id)
|
|
71
|
-
if played:
|
|
72
|
-
before = len(items)
|
|
73
|
-
items = [it for it in items if it["media_id"] not in played]
|
|
74
|
-
if before != len(items):
|
|
75
|
-
logger.info(
|
|
76
|
-
f"Schedule {schedule_id}: skipped {before - len(items)} already-played "
|
|
77
|
-
f"episode(s) from mutable playlist {playlist_id}"
|
|
78
|
-
)
|
|
79
66
|
total_duration = 0
|
|
80
67
|
last_item_uid = None
|
|
81
68
|
last_item_media_id = None
|
|
@@ -115,13 +102,6 @@ async def fire_schedule(
|
|
|
115
102
|
fallback_id = schedule.get("fallback_playlist_id")
|
|
116
103
|
if fallback_id:
|
|
117
104
|
fallback_items = await db.get_saved_playlist_items(fallback_id)
|
|
118
|
-
# Same continuation logic as the main playlist: skip already-played
|
|
119
|
-
# episodes of the (mutable) fallback so it advances across fires.
|
|
120
|
-
fb_played = await db.get_playlist_played_media_ids(fallback_id)
|
|
121
|
-
if fb_played:
|
|
122
|
-
fallback_items = [
|
|
123
|
-
it for it in fallback_items if it["media_id"] not in fb_played
|
|
124
|
-
]
|
|
125
105
|
for index, item in enumerate(fallback_items):
|
|
126
106
|
if index and add_delay_sec:
|
|
127
107
|
await asyncio.sleep(add_delay_sec)
|
|
@@ -80,32 +80,12 @@ class PlaylistImporter:
|
|
|
80
80
|
self._add_max_retries = add_max_retries
|
|
81
81
|
self._promo_director = promo_director
|
|
82
82
|
|
|
83
|
-
async def import_playlist(
|
|
84
|
-
|
|
85
|
-
) -> dict:
|
|
86
|
-
"""Import items from a saved playlist into the live queue.
|
|
87
|
-
|
|
88
|
-
By default (``skip_played=True``) already-played episodes of the current
|
|
89
|
-
pass are skipped, so a mutable (TV-show) playlist continues where it left
|
|
90
|
-
off instead of replaying from the start — the same rule automated firing
|
|
91
|
-
uses. Pass ``skip_played=False`` to force-load the entire list regardless
|
|
92
|
-
of pass state.
|
|
93
|
-
"""
|
|
83
|
+
async def import_playlist(self, playlist_id: int) -> dict:
|
|
84
|
+
"""Import items from a saved playlist into the live queue."""
|
|
94
85
|
items = await self._db.get_saved_playlist_items(playlist_id)
|
|
95
86
|
if not items:
|
|
96
87
|
return {"success": False, "error": "Playlist is empty"}
|
|
97
88
|
|
|
98
|
-
skipped = 0
|
|
99
|
-
if skip_played:
|
|
100
|
-
played = await self._db.get_playlist_played_media_ids(playlist_id)
|
|
101
|
-
if played:
|
|
102
|
-
before = len(items)
|
|
103
|
-
items = [it for it in items if it["media_id"] not in played]
|
|
104
|
-
skipped = before - len(items)
|
|
105
|
-
if not items:
|
|
106
|
-
# Everything's already been played this pass (nothing left to add).
|
|
107
|
-
return {"success": True, "added": 0, "errors": 0, "skipped": skipped}
|
|
108
|
-
|
|
109
89
|
# Suppress promo insertion for the whole load so promos aren't slotted
|
|
110
90
|
# between items while the playlist is still being built.
|
|
111
91
|
suppress_ctx = (
|
|
@@ -139,7 +119,7 @@ class PlaylistImporter:
|
|
|
139
119
|
logger.warning(f"Failed to add {item['media_id']}: {e}")
|
|
140
120
|
errors += 1
|
|
141
121
|
|
|
142
|
-
return {"success": True, "added": added, "errors": errors
|
|
122
|
+
return {"success": True, "added": added, "errors": errors}
|
|
143
123
|
|
|
144
124
|
|
|
145
125
|
async def import_playlist_text(
|
|
@@ -99,3 +99,9 @@ class CompletionRecorder:
|
|
|
99
99
|
logger.warning(
|
|
100
100
|
"Failed to record play completion for %s", token, exc_info=True
|
|
101
101
|
)
|
|
102
|
+
try:
|
|
103
|
+
await self._db.rotate_playlist_item_to_bottom(token)
|
|
104
|
+
except Exception:
|
|
105
|
+
logger.warning(
|
|
106
|
+
"Failed to rotate playlist item for %s", token, exc_info=True
|
|
107
|
+
)
|
|
@@ -66,10 +66,8 @@ async def mark_played(
|
|
|
66
66
|
):
|
|
67
67
|
"""Simulate a genuine play-completion for an item (testing aid).
|
|
68
68
|
|
|
69
|
-
Routes through the same ``record_play_completion`` the poll loop uses
|
|
70
|
-
|
|
71
|
-
current pass (and reset it when the last item is marked), everything else
|
|
72
|
-
gets a time-boxed completion. The item then hides from regular users exactly
|
|
69
|
+
Routes through the same ``record_play_completion`` the poll loop uses.
|
|
70
|
+
The item then hides from regular users for the configured window, exactly
|
|
73
71
|
as it would after real playback.
|
|
74
72
|
"""
|
|
75
73
|
db = request.app.state.db
|
{kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_playlists.py
RENAMED
|
@@ -108,15 +108,9 @@ async def replace_items(
|
|
|
108
108
|
async def import_to_live(
|
|
109
109
|
request: Request,
|
|
110
110
|
playlist_id: int,
|
|
111
|
-
full: int = 0,
|
|
112
111
|
user: dict = Depends(require_admin),
|
|
113
112
|
):
|
|
114
|
-
"""Import a saved playlist into the live CyTube queue.
|
|
115
|
-
|
|
116
|
-
By default this honors the current-pass played/skip rules (a mutable TV-show
|
|
117
|
-
playlist continues where it left off). Pass ``?full=1`` to force-load the
|
|
118
|
-
entire list regardless of what's already been played this pass.
|
|
119
|
-
"""
|
|
113
|
+
"""Import a saved playlist into the live CyTube queue."""
|
|
120
114
|
from ..playlists.importer import PlaylistImporter
|
|
121
115
|
|
|
122
116
|
db = request.app.state.db
|
|
@@ -133,7 +127,7 @@ async def import_to_live(
|
|
|
133
127
|
add_max_retries=config.playlist_bulk_add_max_retries,
|
|
134
128
|
promo_director=getattr(request.app.state, "promo_director", None),
|
|
135
129
|
)
|
|
136
|
-
result = await importer.import_playlist(playlist_id
|
|
130
|
+
result = await importer.import_playlist(playlist_id)
|
|
137
131
|
return result
|
|
138
132
|
|
|
139
133
|
|
{kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/admin-playlists.js
RENAMED
|
@@ -218,18 +218,13 @@ async function saveItems() {
|
|
|
218
218
|
showToast(resp.ok ? 'Saved' : 'Save failed', resp.ok ? 'success' : 'error');
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
async function importToLive(
|
|
221
|
+
async function importToLive() {
|
|
222
222
|
if (editorId === null) return;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
: 'Load this playlist into the live CyTube queue now?';
|
|
226
|
-
if (!confirm(prompt)) return;
|
|
227
|
-
const url = `/admin/playlists/${editorId}/import${full ? '?full=1' : ''}`;
|
|
228
|
-
const resp = await fetch(url, {method: 'POST'});
|
|
223
|
+
if (!confirm('Load this playlist into the live CyTube queue now?')) return;
|
|
224
|
+
const resp = await fetch(`/admin/playlists/${editorId}/import`, {method: 'POST'});
|
|
229
225
|
const data = await resp.json().catch(() => ({}));
|
|
230
226
|
if (resp.ok && data.success) {
|
|
231
|
-
|
|
232
|
-
showToast(`Imported ${data.added} item(s)${data.errors ? `, ${data.errors} errors` : ''}${skipped}`);
|
|
227
|
+
showToast(`Imported ${data.added} item(s)${data.errors ? `, ${data.errors} errors` : ''}`);
|
|
233
228
|
} else {
|
|
234
229
|
showToast(data.error || 'Import failed', 'error');
|
|
235
230
|
}
|
{kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
<button class="btn btn-secondary" onclick="closeEditor()">← Back</button>
|
|
28
28
|
<button class="btn" onclick="editMeta()">Rename</button>
|
|
29
29
|
<button class="btn btn-primary" onclick="saveItems()">Save Items</button>
|
|
30
|
-
<button class="btn" onclick="importToLive()" title="Load this playlist into the live CyTube queue
|
|
31
|
-
<button class="btn" onclick="importToLive(true)" title="Load every item in this playlist into the live queue, ignoring what's already been played this pass">Load Entire List</button>
|
|
30
|
+
<button class="btn" onclick="importToLive()" title="Load this playlist into the live CyTube queue">Import to Live</button>
|
|
32
31
|
</div>
|
|
33
32
|
</div>
|
|
34
33
|
<p id="editor-meta" class="muted"></p>
|