kryten-webqueue 0.9.5__tar.gz → 0.9.6__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.9.5 → kryten_webqueue-0.9.6}/CHANGELOG.md +15 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/PKG-INFO +1 -1
- kryten_webqueue-0.9.6/kryten_webqueue/playlists/importer.py +209 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/routes/admin_playlists.py +2 -1
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/admin/playlists.html +23 -3
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/pyproject.toml +1 -1
- kryten_webqueue-0.9.6/tests/test_playlist_import.py +136 -0
- kryten_webqueue-0.9.5/kryten_webqueue/playlists/importer.py +0 -92
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/.gitignore +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/README.md +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/config.example.json +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/catalog/db.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/tests/__init__.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/tests/test_phase4_live_fixes.py +0 -0
|
@@ -6,6 +6,21 @@ 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.9.6] — 2026-06-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Richer Bulk Text Import on the admin Playlists editor.** The text import now accepts, one entry per line:
|
|
14
|
+
- **dropsugar.co / dropsugar.com links** (watch `?m=TOKEN` or manifest `/api/v1/media/cytube/TOKEN.json`) — resolved against the catalog for title/duration, falling back to a constructed manifest URL when the token isn't catalogued yet.
|
|
15
|
+
- **YouTube / youtu.be links** — playlist (`list=`), start-time (`t`/`start`) and all other arguments are stripped, leaving a clean `yt:VIDEOID` item.
|
|
16
|
+
- Legacy `cm:token`, `yt:id`, and bare catalog tokens (unchanged).
|
|
17
|
+
- Trailing free text after a URL (e.g. `URL - My Title`) is used as a title hint.
|
|
18
|
+
- **Upload a local text file** into the importer via a "Choose text file…" button (contents are loaded into the textarea for review before parsing).
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- The text import parser is now **tolerant**: blank lines, whole-line `#` comments, and inline trailing `#` comments are ignored, and links to unknown sites are skipped (reported as `unsupported_site`) instead of failing the import.
|
|
23
|
+
|
|
9
24
|
## [0.9.5] — 2026-06-11
|
|
10
25
|
|
|
11
26
|
### Changed
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# --- URL parsing helpers (module-level, pure) ---
|
|
8
|
+
|
|
9
|
+
_YT_ID = r"[A-Za-z0-9_-]{11}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract_youtube_id(url: str) -> str | None:
|
|
13
|
+
"""Return the 11-char YouTube video id from any YouTube/youtu.be URL.
|
|
14
|
+
|
|
15
|
+
Strips playlists (``list=``), start times (``t=``/``start=``) and every
|
|
16
|
+
other query/path argument. Returns None when no video id is present (e.g. a
|
|
17
|
+
bare ``/playlist?list=...`` link, which we do not expand)."""
|
|
18
|
+
# youtu.be/<id>
|
|
19
|
+
m = re.search(rf"youtu\.be/({_YT_ID})", url)
|
|
20
|
+
if m:
|
|
21
|
+
return m.group(1)
|
|
22
|
+
# youtube.com/watch?v=<id>
|
|
23
|
+
m = re.search(rf"[?&]v=({_YT_ID})", url)
|
|
24
|
+
if m:
|
|
25
|
+
return m.group(1)
|
|
26
|
+
# youtube.com/shorts/<id>, /embed/<id>, /v/<id>, /live/<id>
|
|
27
|
+
m = re.search(rf"/(?:shorts|embed|v|live)/({_YT_ID})", url)
|
|
28
|
+
if m:
|
|
29
|
+
return m.group(1)
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def extract_dropsugar_token(url: str) -> str | None:
|
|
34
|
+
"""Return the MediaCMS friendly_token from a dropsugar manifest/view URL."""
|
|
35
|
+
m = re.search(r"/media/cytube/([^./?&]+)\.json", url)
|
|
36
|
+
if m:
|
|
37
|
+
return m.group(1)
|
|
38
|
+
m = re.search(r"[?&]m=([^&]+)", url)
|
|
39
|
+
if m:
|
|
40
|
+
return m.group(1)
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_youtube(host: str) -> bool:
|
|
45
|
+
host = host.lower()
|
|
46
|
+
return "youtube.com" in host or "youtu.be" in host
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_dropsugar(host: str) -> bool:
|
|
50
|
+
return "dropsugar." in host.lower()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _manifest_url_for_token(token: str, mediacms_url: str | None) -> str | None:
|
|
54
|
+
if not mediacms_url:
|
|
55
|
+
return None
|
|
56
|
+
return f"{mediacms_url.rstrip('/')}/api/v1/media/cytube/{token}.json?format=json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PlaylistImporter:
|
|
60
|
+
"""Imports items from a saved playlist into the live CyTube queue."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, *, api_gate, db, shadow):
|
|
63
|
+
self._api_gate = api_gate
|
|
64
|
+
self._db = db
|
|
65
|
+
self._shadow = shadow
|
|
66
|
+
|
|
67
|
+
async def import_playlist(self, playlist_id: int) -> dict:
|
|
68
|
+
"""Import all items from a saved playlist into the live queue."""
|
|
69
|
+
items = await self._db.get_saved_playlist_items(playlist_id)
|
|
70
|
+
if not items:
|
|
71
|
+
return {"success": False, "error": "Playlist is empty"}
|
|
72
|
+
|
|
73
|
+
added = 0
|
|
74
|
+
errors = 0
|
|
75
|
+
for item in items:
|
|
76
|
+
try:
|
|
77
|
+
result = await self._api_gate.playlist_add(
|
|
78
|
+
media_type=item["media_type"],
|
|
79
|
+
media_id=item["media_id"],
|
|
80
|
+
position="end",
|
|
81
|
+
)
|
|
82
|
+
if result.get("success"):
|
|
83
|
+
added += 1
|
|
84
|
+
else:
|
|
85
|
+
errors += 1
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.warning(f"Failed to add {item['media_id']}: {e}")
|
|
88
|
+
errors += 1
|
|
89
|
+
|
|
90
|
+
return {"success": True, "added": added, "errors": errors}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def import_playlist_text(db, text: str, *, mediacms_url: str | None = None) -> dict:
|
|
94
|
+
"""Parse the plain-text playlist import format into resolved items.
|
|
95
|
+
|
|
96
|
+
Tolerant by design — never raises; unrecognised lines are reported in
|
|
97
|
+
``errors`` so the import can proceed with whatever resolved.
|
|
98
|
+
|
|
99
|
+
Supported per line (one entry per line):
|
|
100
|
+
- Blank lines are skipped.
|
|
101
|
+
- ``#`` starts a comment: a whole-line comment, or an inline trailing
|
|
102
|
+
comment (everything from the first ``#`` is ignored).
|
|
103
|
+
- **dropsugar.co / dropsugar.com URLs** — watch (``/view?m=TOKEN``) or
|
|
104
|
+
manifest (``/api/v1/media/cytube/TOKEN.json``) links. Resolved against
|
|
105
|
+
the catalog (for title/duration); falls back to a constructed manifest
|
|
106
|
+
URL when the token isn't catalogued yet.
|
|
107
|
+
- **YouTube / youtu.be URLs** — playlist, start-time (``t``) and all other
|
|
108
|
+
arguments are stripped, leaving a clean ``yt:VIDEOID`` item.
|
|
109
|
+
- Other ``http(s)`` URLs (unknown sites) are skipped (reported as
|
|
110
|
+
``unsupported_site``).
|
|
111
|
+
- Legacy tokens: ``cm:token``, ``yt:id``, or a bare catalog token.
|
|
112
|
+
- Trailing free text after a URL (e.g. ``URL - Some Title``) is used as a
|
|
113
|
+
title hint for items not found in the catalog.
|
|
114
|
+
|
|
115
|
+
Returns: ``{"items": [...], "errors": [...]}``.
|
|
116
|
+
"""
|
|
117
|
+
items = []
|
|
118
|
+
errors = []
|
|
119
|
+
url_re = re.compile(r"https?://\S+")
|
|
120
|
+
|
|
121
|
+
for line_num, raw in enumerate(text.splitlines(), 1):
|
|
122
|
+
# Strip inline comments: everything from the first '#'.
|
|
123
|
+
line = raw.split("#", 1)[0].strip()
|
|
124
|
+
if not line:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
url_match = url_re.search(line)
|
|
128
|
+
if url_match:
|
|
129
|
+
url = url_match.group(0).rstrip(".,;")
|
|
130
|
+
# Free text after the URL is a title hint (e.g. " - My Title").
|
|
131
|
+
trailing = line[url_match.end():].strip().lstrip("-–").strip()
|
|
132
|
+
title_hint = trailing or None
|
|
133
|
+
host = re.sub(r"^https?://", "", url).split("/", 1)[0]
|
|
134
|
+
|
|
135
|
+
if _is_youtube(host):
|
|
136
|
+
vid = extract_youtube_id(url)
|
|
137
|
+
if vid:
|
|
138
|
+
items.append({
|
|
139
|
+
"media_type": "yt",
|
|
140
|
+
"media_id": vid,
|
|
141
|
+
"title": title_hint,
|
|
142
|
+
"duration_sec": None,
|
|
143
|
+
})
|
|
144
|
+
else:
|
|
145
|
+
errors.append({"line": line_num, "token": url, "reason": "youtube_no_video_id"})
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
if _is_dropsugar(host):
|
|
149
|
+
token = extract_dropsugar_token(url)
|
|
150
|
+
if not token:
|
|
151
|
+
errors.append({"line": line_num, "token": url, "reason": "no_token_in_url"})
|
|
152
|
+
continue
|
|
153
|
+
catalog_item = await db.get_item_admin(token)
|
|
154
|
+
if catalog_item:
|
|
155
|
+
items.append({
|
|
156
|
+
"media_type": "cm",
|
|
157
|
+
"media_id": catalog_item["manifest_url"],
|
|
158
|
+
"title": catalog_item.get("title") or title_hint,
|
|
159
|
+
"duration_sec": catalog_item.get("duration_sec"),
|
|
160
|
+
})
|
|
161
|
+
else:
|
|
162
|
+
manifest = _manifest_url_for_token(token, mediacms_url)
|
|
163
|
+
if manifest:
|
|
164
|
+
items.append({
|
|
165
|
+
"media_type": "cm",
|
|
166
|
+
"media_id": manifest,
|
|
167
|
+
"title": title_hint,
|
|
168
|
+
"duration_sec": None,
|
|
169
|
+
})
|
|
170
|
+
else:
|
|
171
|
+
errors.append({"line": line_num, "token": token, "reason": "not_in_catalog"})
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
# Any other site — skip tolerantly.
|
|
175
|
+
errors.append({"line": line_num, "token": url, "reason": "unsupported_site"})
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
# --- legacy token forms (no URL on the line) ---
|
|
179
|
+
if line.startswith("cm:"):
|
|
180
|
+
media_id = line[3:].strip()
|
|
181
|
+
catalog_item = await db.get_item_admin(media_id)
|
|
182
|
+
items.append({
|
|
183
|
+
"media_type": "cm",
|
|
184
|
+
"media_id": catalog_item["manifest_url"] if catalog_item else media_id,
|
|
185
|
+
"title": catalog_item["title"] if catalog_item else None,
|
|
186
|
+
"duration_sec": catalog_item["duration_sec"] if catalog_item else None,
|
|
187
|
+
})
|
|
188
|
+
elif line.startswith("yt:"):
|
|
189
|
+
items.append({
|
|
190
|
+
"media_type": "yt",
|
|
191
|
+
"media_id": line[3:].strip(),
|
|
192
|
+
"title": None,
|
|
193
|
+
"duration_sec": None,
|
|
194
|
+
})
|
|
195
|
+
else:
|
|
196
|
+
# Bare token — resolve from catalog.
|
|
197
|
+
catalog_item = await db.get_item_admin(line)
|
|
198
|
+
if catalog_item:
|
|
199
|
+
items.append({
|
|
200
|
+
"media_type": "cm",
|
|
201
|
+
"media_id": catalog_item["manifest_url"],
|
|
202
|
+
"title": catalog_item["title"],
|
|
203
|
+
"duration_sec": catalog_item["duration_sec"],
|
|
204
|
+
})
|
|
205
|
+
else:
|
|
206
|
+
errors.append({"line": line_num, "token": line, "reason": "not_in_catalog"})
|
|
207
|
+
|
|
208
|
+
return {"items": items, "errors": errors}
|
|
209
|
+
|
|
@@ -159,4 +159,5 @@ async def parse_text(request: Request, user: dict = Depends(require_admin)):
|
|
|
159
159
|
body = await request.json()
|
|
160
160
|
text = body.get("text", "")
|
|
161
161
|
db = request.app.state.db
|
|
162
|
-
|
|
162
|
+
config = request.app.state.config
|
|
163
|
+
return await import_playlist_text(db, text, mediacms_url=config.mediacms_url)
|
{kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
@@ -49,9 +49,13 @@
|
|
|
49
49
|
<div id="cat-results" class="cat-results"></div>
|
|
50
50
|
|
|
51
51
|
<h3 style="margin-top:1.5rem;">Bulk Text Import</h3>
|
|
52
|
-
<p class="muted" style="font-size:0.8rem;">One
|
|
53
|
-
<textarea id="import-text" class="import-text" rows="5" placeholder="abc123def yt:dQw4w9WgXcQ"></textarea>
|
|
54
|
-
<
|
|
52
|
+
<p class="muted" style="font-size:0.8rem;">One entry per line: a <strong>dropsugar.co</strong> link, a <strong>YouTube</strong>/<strong>youtu.be</strong> link (playlist & start-time args are stripped), <code>cm:token</code>, <code>yt:id</code>, or a bare catalog token. <code>#</code> starts a comment (whole-line or trailing). Unknown sites are skipped.</p>
|
|
53
|
+
<textarea id="import-text" class="import-text" rows="5" placeholder="https://www.dropsugar.co/view?m=abc123def https://youtu.be/dQw4w9WgXcQ?t=42 # start time is stripped yt:dQw4w9WgXcQ abc123def"></textarea>
|
|
54
|
+
<div class="btn-group" style="margin-top:0.25rem;">
|
|
55
|
+
<button class="btn btn-sm" onclick="parseImport()">Append Parsed Items</button>
|
|
56
|
+
<input type="file" id="import-file" accept=".txt,.csv,text/plain" style="display:none" onchange="loadImportFile(event)">
|
|
57
|
+
<button class="btn btn-sm" onclick="document.getElementById('import-file').click()">Choose text file…</button>
|
|
58
|
+
</div>
|
|
55
59
|
<div id="import-errors" class="import-errors"></div>
|
|
56
60
|
</div>
|
|
57
61
|
</div>
|
|
@@ -263,6 +267,22 @@ async function parseImport() {
|
|
|
263
267
|
document.getElementById('import-text').value = '';
|
|
264
268
|
}
|
|
265
269
|
|
|
270
|
+
function loadImportFile(event) {
|
|
271
|
+
const file = event.target.files && event.target.files[0];
|
|
272
|
+
if (!file) return;
|
|
273
|
+
const reader = new FileReader();
|
|
274
|
+
reader.onload = () => {
|
|
275
|
+
const ta = document.getElementById('import-text');
|
|
276
|
+
// Append (with a newline separator) so the admin can review before parsing.
|
|
277
|
+
ta.value = (ta.value.trim() ? ta.value.replace(/\s*$/, '') + '\n' : '') + reader.result;
|
|
278
|
+
showToast(`Loaded ${file.name}`);
|
|
279
|
+
};
|
|
280
|
+
reader.onerror = () => showToast('Could not read file', 'error');
|
|
281
|
+
reader.readAsText(file);
|
|
282
|
+
// Reset so picking the same file again re-fires change.
|
|
283
|
+
event.target.value = '';
|
|
284
|
+
}
|
|
285
|
+
|
|
266
286
|
async function saveItems() {
|
|
267
287
|
if (editorId === null) return;
|
|
268
288
|
const resp = await fetch(`/admin/playlists/${editorId}/items`, {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Bulk text import parser (admin playlists) — v0.9.6.
|
|
2
|
+
|
|
3
|
+
Covers dropsugar.co URL resolution, YouTube/youtu.be id extraction with arg
|
|
4
|
+
stripping, comment handling (whole-line + inline), and tolerant skipping of
|
|
5
|
+
unknown sites.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from kryten_webqueue.catalog.db import Database
|
|
11
|
+
from kryten_webqueue.playlists.importer import (
|
|
12
|
+
import_playlist_text,
|
|
13
|
+
extract_youtube_id,
|
|
14
|
+
extract_dropsugar_token,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
MEDIACMS = "https://www.dropsugar.com"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
async def db(tmp_path):
|
|
22
|
+
database = Database(str(tmp_path / "test.db"))
|
|
23
|
+
await database.connect()
|
|
24
|
+
await database.run_migrations()
|
|
25
|
+
yield database
|
|
26
|
+
await database.close()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _add_item(db, token, title="Title", *, duration=600):
|
|
30
|
+
await db.insert_catalog({
|
|
31
|
+
"friendly_token": token,
|
|
32
|
+
"title": title,
|
|
33
|
+
"description": "",
|
|
34
|
+
"duration_sec": duration,
|
|
35
|
+
"manifest_url": f"{MEDIACMS}/api/v1/media/cytube/{token}.json?format=json",
|
|
36
|
+
"thumbnail_url": "",
|
|
37
|
+
"synced_at": "2026-01-01T00:00:00+00:00",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --- pure URL helpers ---
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize("url,expected", [
|
|
44
|
+
("https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"),
|
|
45
|
+
("https://youtu.be/dQw4w9WgXcQ?t=42", "dQw4w9WgXcQ"),
|
|
46
|
+
("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"),
|
|
47
|
+
("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123&t=10", "dQw4w9WgXcQ"),
|
|
48
|
+
("https://www.youtube.com/watch?list=PL123&v=dQw4w9WgXcQ", "dQw4w9WgXcQ"),
|
|
49
|
+
("https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"),
|
|
50
|
+
("https://www.youtube.com/embed/dQw4w9WgXcQ?start=5", "dQw4w9WgXcQ"),
|
|
51
|
+
("https://www.youtube.com/playlist?list=PL123", None),
|
|
52
|
+
])
|
|
53
|
+
def test_extract_youtube_id(url, expected):
|
|
54
|
+
assert extract_youtube_id(url) == expected
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize("url,expected", [
|
|
58
|
+
("https://www.dropsugar.co/view?m=kBl82FCgy", "kBl82FCgy"),
|
|
59
|
+
("https://www.dropsugar.co/view?m=kBl82FCgy&foo=bar", "kBl82FCgy"),
|
|
60
|
+
("https://www.dropsugar.com/api/v1/media/cytube/abc123.json?format=json", "abc123"),
|
|
61
|
+
("https://www.dropsugar.co/", None),
|
|
62
|
+
])
|
|
63
|
+
def test_extract_dropsugar_token(url, expected):
|
|
64
|
+
assert extract_dropsugar_token(url) == expected
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- full parse ---
|
|
68
|
+
|
|
69
|
+
async def test_youtube_links_stripped_to_yt_items(db):
|
|
70
|
+
text = (
|
|
71
|
+
"https://youtu.be/dQw4w9WgXcQ?t=42\n"
|
|
72
|
+
"https://www.youtube.com/watch?v=abcdefghijk&list=PL9&t=3\n"
|
|
73
|
+
)
|
|
74
|
+
out = await import_playlist_text(db, text, mediacms_url=MEDIACMS)
|
|
75
|
+
assert [i["media_id"] for i in out["items"]] == ["dQw4w9WgXcQ", "abcdefghijk"]
|
|
76
|
+
assert all(i["media_type"] == "yt" for i in out["items"])
|
|
77
|
+
assert not out["errors"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def test_dropsugar_url_resolves_catalog(db):
|
|
81
|
+
await _add_item(db, "kBl82FCgy", "Hollis Live")
|
|
82
|
+
out = await import_playlist_text(
|
|
83
|
+
db, "https://www.dropsugar.co/view?m=kBl82FCgy - Hollis Live on Channel-Z",
|
|
84
|
+
mediacms_url=MEDIACMS,
|
|
85
|
+
)
|
|
86
|
+
assert len(out["items"]) == 1
|
|
87
|
+
it = out["items"][0]
|
|
88
|
+
assert it["media_type"] == "cm"
|
|
89
|
+
assert it["media_id"] == f"{MEDIACMS}/api/v1/media/cytube/kBl82FCgy.json?format=json"
|
|
90
|
+
assert it["title"] == "Hollis Live"
|
|
91
|
+
assert not out["errors"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def test_dropsugar_url_not_in_catalog_constructs_manifest(db):
|
|
95
|
+
out = await import_playlist_text(
|
|
96
|
+
db, "https://www.dropsugar.co/view?m=newtoken123 - Some Title",
|
|
97
|
+
mediacms_url=MEDIACMS,
|
|
98
|
+
)
|
|
99
|
+
assert len(out["items"]) == 1
|
|
100
|
+
it = out["items"][0]
|
|
101
|
+
assert it["media_type"] == "cm"
|
|
102
|
+
assert it["media_id"] == f"{MEDIACMS}/api/v1/media/cytube/newtoken123.json?format=json"
|
|
103
|
+
assert it["title"] == "Some Title" # trailing text used as title hint
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def test_comments_and_blanks_and_unknown_sites(db):
|
|
107
|
+
await _add_item(db, "good1")
|
|
108
|
+
text = (
|
|
109
|
+
"# a whole-line comment\n"
|
|
110
|
+
"\n"
|
|
111
|
+
"https://www.dropsugar.co/view?m=good1 # inline comment stripped\n"
|
|
112
|
+
"https://example.com/whatever\n"
|
|
113
|
+
" \n"
|
|
114
|
+
"https://vimeo.com/12345 # unknown site\n"
|
|
115
|
+
)
|
|
116
|
+
out = await import_playlist_text(db, text, mediacms_url=MEDIACMS)
|
|
117
|
+
# Only the dropsugar line resolves; the two unknown sites are skipped.
|
|
118
|
+
assert len(out["items"]) == 1
|
|
119
|
+
assert out["items"][0]["media_id"].endswith("good1.json?format=json")
|
|
120
|
+
reasons = {e["reason"] for e in out["errors"]}
|
|
121
|
+
assert reasons == {"unsupported_site"}
|
|
122
|
+
assert len(out["errors"]) == 2
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def test_legacy_tokens_still_work(db):
|
|
126
|
+
await _add_item(db, "bare1", "Bare One")
|
|
127
|
+
text = "yt:dQw4w9WgXcQ\ncm:cmtoken\nbare1\nunknownbare\n"
|
|
128
|
+
out = await import_playlist_text(db, text, mediacms_url=MEDIACMS)
|
|
129
|
+
yt = [i for i in out["items"] if i["media_type"] == "yt"]
|
|
130
|
+
cm = [i for i in out["items"] if i["media_type"] == "cm"]
|
|
131
|
+
assert yt[0]["media_id"] == "dQw4w9WgXcQ"
|
|
132
|
+
# bare1 resolves to its manifest URL; cm:cmtoken passes through.
|
|
133
|
+
assert any(i["media_id"].endswith("bare1.json?format=json") for i in cm)
|
|
134
|
+
assert any(i["media_id"] == "cmtoken" for i in cm)
|
|
135
|
+
# unknownbare is not in catalog -> error
|
|
136
|
+
assert any(e["token"] == "unknownbare" for e in out["errors"])
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
from datetime import datetime, UTC
|
|
3
|
-
|
|
4
|
-
logger = logging.getLogger(__name__)
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class PlaylistImporter:
|
|
8
|
-
"""Imports items from a saved playlist into the live CyTube queue."""
|
|
9
|
-
|
|
10
|
-
def __init__(self, *, api_gate, db, shadow):
|
|
11
|
-
self._api_gate = api_gate
|
|
12
|
-
self._db = db
|
|
13
|
-
self._shadow = shadow
|
|
14
|
-
|
|
15
|
-
async def import_playlist(self, playlist_id: int) -> dict:
|
|
16
|
-
"""Import all items from a saved playlist into the live queue."""
|
|
17
|
-
items = await self._db.get_saved_playlist_items(playlist_id)
|
|
18
|
-
if not items:
|
|
19
|
-
return {"success": False, "error": "Playlist is empty"}
|
|
20
|
-
|
|
21
|
-
added = 0
|
|
22
|
-
errors = 0
|
|
23
|
-
for item in items:
|
|
24
|
-
try:
|
|
25
|
-
result = await self._api_gate.playlist_add(
|
|
26
|
-
media_type=item["media_type"],
|
|
27
|
-
media_id=item["media_id"],
|
|
28
|
-
position="end",
|
|
29
|
-
)
|
|
30
|
-
if result.get("success"):
|
|
31
|
-
added += 1
|
|
32
|
-
else:
|
|
33
|
-
errors += 1
|
|
34
|
-
except Exception as e:
|
|
35
|
-
logger.warning(f"Failed to add {item['media_id']}: {e}")
|
|
36
|
-
errors += 1
|
|
37
|
-
|
|
38
|
-
return {"success": True, "added": added, "errors": errors}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
async def import_playlist_text(db, text: str) -> dict:
|
|
42
|
-
"""Parse plain-text playlist import format.
|
|
43
|
-
|
|
44
|
-
Format:
|
|
45
|
-
- Lines starting with # are comments
|
|
46
|
-
- Blank lines are skipped
|
|
47
|
-
- "type:id" for explicit type (e.g. "yt:dQw4w9WgXcQ")
|
|
48
|
-
- "cm:friendly_token" for MediaCMS items
|
|
49
|
-
- Bare token resolves from catalog
|
|
50
|
-
|
|
51
|
-
Returns: {"items": [...], "errors": [...]}
|
|
52
|
-
"""
|
|
53
|
-
items = []
|
|
54
|
-
errors = []
|
|
55
|
-
|
|
56
|
-
for line_num, line in enumerate(text.splitlines(), 1):
|
|
57
|
-
line = line.strip()
|
|
58
|
-
if not line or line.startswith("#"):
|
|
59
|
-
continue
|
|
60
|
-
|
|
61
|
-
if line.startswith("cm:"):
|
|
62
|
-
media_id = line[3:]
|
|
63
|
-
catalog_item = await db.get_item_admin(media_id)
|
|
64
|
-
items.append({
|
|
65
|
-
"media_type": "cm",
|
|
66
|
-
"media_id": media_id,
|
|
67
|
-
"title": catalog_item["title"] if catalog_item else None,
|
|
68
|
-
"duration_sec": catalog_item["duration_sec"] if catalog_item else None,
|
|
69
|
-
})
|
|
70
|
-
elif ":" in line:
|
|
71
|
-
# Explicit type:id (e.g. yt:abc123)
|
|
72
|
-
media_type, media_id = line.split(":", 1)
|
|
73
|
-
items.append({
|
|
74
|
-
"media_type": media_type,
|
|
75
|
-
"media_id": media_id,
|
|
76
|
-
"title": None,
|
|
77
|
-
"duration_sec": None,
|
|
78
|
-
})
|
|
79
|
-
else:
|
|
80
|
-
# Bare token — resolve from catalog
|
|
81
|
-
catalog_item = await db.get_item_admin(line)
|
|
82
|
-
if catalog_item:
|
|
83
|
-
items.append({
|
|
84
|
-
"media_type": "cm",
|
|
85
|
-
"media_id": catalog_item["friendly_token"],
|
|
86
|
-
"title": catalog_item["title"],
|
|
87
|
-
"duration_sec": catalog_item["duration_sec"],
|
|
88
|
-
})
|
|
89
|
-
else:
|
|
90
|
-
errors.append({"line": line_num, "token": line, "reason": "not_in_catalog"})
|
|
91
|
-
|
|
92
|
-
return {"items": items, "errors": errors}
|
|
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.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/cmsutils/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/cmsutils/_common.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/cmsutils/enrichmeta.py
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/cmsutils/enrichtv.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/cmsutils/fetchurls.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/integrations/ytpipe/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/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
|
{kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.5 → kryten_webqueue-0.9.6}/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
|