kryten-webqueue 0.9.5__tar.gz → 0.9.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/CHANGELOG.md +23 -0
  2. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/PKG-INFO +1 -1
  3. kryten_webqueue-0.9.7/kryten_webqueue/playlists/importer.py +209 -0
  4. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/queue/ordering.py +76 -10
  5. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/routes/admin_playlists.py +2 -1
  6. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/templates/admin/playlists.html +23 -3
  7. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/templates/admin/schedules.html +1 -1
  8. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/pyproject.toml +1 -1
  9. kryten_webqueue-0.9.7/tests/test_playlist_import.py +136 -0
  10. kryten_webqueue-0.9.7/tests/test_queue_announce.py +106 -0
  11. kryten_webqueue-0.9.5/kryten_webqueue/playlists/importer.py +0 -92
  12. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/.github/workflows/python-publish.yml +0 -0
  13. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/.github/workflows/release.yml +0 -0
  14. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/.gitignore +0 -0
  15. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/README.md +0 -0
  16. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/config.example.json +0 -0
  17. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/deploy/kryten-webqueue.service +0 -0
  18. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/deploy/nginx-queue.conf +0 -0
  19. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/docs/IMPLEMENTATION_SPEC.md +0 -0
  20. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/docs/IMPL_API_GATE.md +0 -0
  21. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/docs/IMPL_ECONOMY.md +0 -0
  22. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/docs/IMPL_KRYTEN_PY.md +0 -0
  23. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/docs/IMPL_ROBOT.md +0 -0
  24. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/docs/PRE_PLAN_GAPS.md +0 -0
  25. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/docs/PRODUCT_PLAN.md +0 -0
  26. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  27. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/__init__.py +0 -0
  28. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/__main__.py +0 -0
  29. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/api_gate/__init__.py +0 -0
  30. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/api_gate/client.py +0 -0
  31. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/app.py +0 -0
  32. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/auth/__init__.py +0 -0
  33. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/auth/otp.py +0 -0
  34. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/auth/rate_limit.py +0 -0
  35. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/auth/session.py +0 -0
  36. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/catalog/__init__.py +0 -0
  37. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/catalog/db.py +0 -0
  38. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/catalog/images.py +0 -0
  39. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/catalog/mediacms.py +0 -0
  40. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/catalog/sync.py +0 -0
  41. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/config.py +0 -0
  42. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/integrations/__init__.py +0 -0
  43. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  44. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  45. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  46. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  47. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  48. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  49. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  50. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  51. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/jobs/__init__.py +0 -0
  52. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/jobs/manager.py +0 -0
  53. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/jobs/tasks.py +0 -0
  54. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/playlists/__init__.py +0 -0
  55. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/playlists/fire.py +0 -0
  56. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/playlists/scheduler.py +0 -0
  57. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/queue/__init__.py +0 -0
  58. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/queue/poller.py +0 -0
  59. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/queue/shadow.py +0 -0
  60. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/routes/__init__.py +0 -0
  61. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/routes/admin_catalog.py +0 -0
  62. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/routes/admin_jobs.py +0 -0
  63. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/routes/admin_queue.py +0 -0
  64. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/routes/admin_schedules.py +0 -0
  65. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/routes/auth.py +0 -0
  66. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/routes/catalog.py +0 -0
  67. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/routes/pages.py +0 -0
  68. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/routes/queue.py +0 -0
  69. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/routes/user.py +0 -0
  70. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/static/css/main.css +0 -0
  71. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/static/js/main.js +0 -0
  72. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/templates/admin/index.html +0 -0
  73. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  74. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/templates/auth/login.html +0 -0
  75. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/templates/base.html +0 -0
  76. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/templates/catalog/browse.html +0 -0
  77. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  78. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  79. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/templates/queue/index.html +0 -0
  80. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/templates/user/dashboard.html +0 -0
  81. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/ws/__init__.py +0 -0
  82. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/ws/handler.py +0 -0
  83. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/kryten_webqueue/ws/manager.py +0 -0
  84. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/tests/__init__.py +0 -0
  85. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/tests/test_phase1.py +0 -0
  86. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/tests/test_phase2_jobs.py +0 -0
  87. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/tests/test_phase3_jobs.py +0 -0
  88. {kryten_webqueue-0.9.5 → kryten_webqueue-0.9.7}/tests/test_phase4_live_fixes.py +0 -0
@@ -6,6 +6,29 @@ 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.7] — 2026-06-11
10
+
11
+ ### Changed
12
+
13
+ - **Paid-queue chat announcement reworded.** A purchased item now announces as `"<title> added to the queue with Zcoin by <user> and is now <position>."` where `<position>` is `next` for the item immediately after the currently-playing one, or an English ordinal counting the now-playing item as first (e.g. `third`, `forty-second`, `one hundred seventh`). Position is computed relative to the currently-playing item and wraps around the playlist.
14
+ - **Admin queueing is no longer announced** in the channel chat (only paid placements are announced).
15
+ - Renamed the admin Schedules heading from “Scheduled Fires” to “Scheduled Events”.
16
+
17
+ ## [0.9.6] — 2026-06-11
18
+
19
+ ### Added
20
+
21
+ - **Richer Bulk Text Import on the admin Playlists editor.** The text import now accepts, one entry per line:
22
+ - **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.
23
+ - **YouTube / youtu.be links** — playlist (`list=`), start-time (`t`/`start`) and all other arguments are stripped, leaving a clean `yt:VIDEOID` item.
24
+ - Legacy `cm:token`, `yt:id`, and bare catalog tokens (unchanged).
25
+ - Trailing free text after a URL (e.g. `URL - My Title`) is used as a title hint.
26
+ - **Upload a local text file** into the importer via a "Choose text file…" button (contents are loaded into the textarea for review before parsing).
27
+
28
+ ### Changed
29
+
30
+ - 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.
31
+
9
32
  ## [0.9.5] — 2026-06-11
10
33
 
11
34
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.9.5
3
+ Version: 0.9.7
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -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
+
@@ -26,7 +26,7 @@ def _add_failure_reason(add_result: dict | None, exc: httpx.HTTPStatusError | No
26
26
 
27
27
 
28
28
  def _announcement_position(shadow, uid: int) -> int | None:
29
- """Position of the item for chat announcement.
29
+ """Position of the item for chat announcement (deprecated; kept for tests).
30
30
 
31
31
  Counting starts at the currently-playing item (position 0), so the next
32
32
  item to play is position 1. The shadow mirrors the full CyTube playlist
@@ -39,13 +39,80 @@ def _announcement_position(shadow, uid: int) -> int | None:
39
39
  return None
40
40
 
41
41
 
42
- async def _announce_queued(api_gate, shadow, *, uid: int, title: str, username: str) -> None:
43
- """Announce a successful queue placement to the channel chat."""
44
- pos = _announcement_position(shadow, uid)
45
- if pos is None:
42
+ _ONES_ORDINAL = {
43
+ "one": "first", "two": "second", "three": "third", "five": "fifth",
44
+ "eight": "eighth", "nine": "ninth", "twelve": "twelfth",
45
+ }
46
+ _ONES = ["", "one", "two", "three", "four", "five", "six", "seven", "eight",
47
+ "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
48
+ "sixteen", "seventeen", "eighteen", "nineteen"]
49
+ _TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
50
+ "eighty", "ninety"]
51
+
52
+
53
+ def _cardinal_words(n: int) -> str:
54
+ """English cardinal words for 1..999 (queue positions never exceed this)."""
55
+ if n < 20:
56
+ return _ONES[n]
57
+ if n < 100:
58
+ tens, ones = divmod(n, 10)
59
+ return _TENS[tens] + (f"-{_ONES[ones]}" if ones else "")
60
+ hundreds, rem = divmod(n, 100)
61
+ head = f"{_ONES[hundreds]} hundred"
62
+ return f"{head} {_cardinal_words(rem)}" if rem else head
63
+
64
+
65
+ def _to_ordinal_word(word: str) -> str:
66
+ """Convert a single cardinal word to its ordinal form."""
67
+ if word in _ONES_ORDINAL:
68
+ return _ONES_ORDINAL[word]
69
+ if word.endswith("y"):
70
+ return word[:-1] + "ieth"
71
+ return word + "th"
72
+
73
+
74
+ def _ordinal_words(n: int) -> str:
75
+ """English ordinal words for n (e.g. 3 -> 'third', 42 -> 'forty-second')."""
76
+ cardinal = _cardinal_words(n)
77
+ # Convert only the final word (handles 'forty-two'->'forty-second',
78
+ # 'one hundred seven'->'one hundred seventh').
79
+ if "-" in cardinal:
80
+ head, last = cardinal.rsplit("-", 1)
81
+ return f"{head}-{_to_ordinal_word(last)}"
82
+ parts = cardinal.rsplit(" ", 1)
83
+ if len(parts) == 2:
84
+ return f"{parts[0]} {_to_ordinal_word(parts[1])}"
85
+ return _to_ordinal_word(cardinal)
86
+
87
+
88
+ async def _announce_paid_queued(api_gate, shadow, *, uid: int, title: str, username: str) -> None:
89
+ """Announce a paid queue placement to the channel chat.
90
+
91
+ Position is counted from the currently-playing item, wrapping around the
92
+ playlist (CyTube loops). The item immediately after now-playing reads
93
+ "next"; everything else uses an English ordinal counting the now-playing
94
+ item as first (so the item two slots away is "third").
95
+ """
96
+ items = shadow.items
97
+ np_uid = await _now_playing_uid(api_gate, shadow)
98
+ np_index = None
99
+ item_index = None
100
+ for i, it in enumerate(items):
101
+ if it.get("uid") == np_uid:
102
+ np_index = i
103
+ if it.get("uid") == uid:
104
+ item_index = i
105
+ if item_index is None:
46
106
  return
107
+ n = len(items)
108
+ offset = item_index if np_index is None else (item_index - np_index) % n
109
+ if offset <= 0:
110
+ return
111
+ position = "next" if offset == 1 else _ordinal_words(offset + 1)
47
112
  try:
48
- await api_gate.send_chat(f"{title} has been queued in position {pos} by {username}")
113
+ await api_gate.send_chat(
114
+ f"{title} added to the queue with Zcoin by {username} and is now {position}."
115
+ )
49
116
  except Exception:
50
117
  logger.warning("Failed to send queue announcement", exc_info=True)
51
118
 
@@ -238,7 +305,7 @@ async def insert_pay_queue(
238
305
  )
239
306
 
240
307
  # Announce placement to the channel
241
- await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
308
+ await _announce_paid_queued(api_gate, shadow, uid=uid, title=title, username=username)
242
309
 
243
310
  return {"success": True, "uid": uid, "request_id": request_id}
244
311
 
@@ -351,7 +418,7 @@ async def insert_pay_playnext(
351
418
  )
352
419
 
353
420
  # Announce placement to the channel
354
- await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
421
+ await _announce_paid_queued(api_gate, shadow, uid=uid, title=title, username=username)
355
422
 
356
423
  return {"success": True, "uid": uid, "request_id": request_id}
357
424
 
@@ -479,8 +546,7 @@ async def insert_admin_queue(
479
546
  title=title, tier="admin", z_cost=0,
480
547
  )
481
548
 
482
- # Announce placement to the channel
483
- await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
549
+ # Admin queueing is intentionally NOT announced in the channel.
484
550
 
485
551
  return {"success": True, "uid": uid, "refunded": removed}
486
552
 
@@ -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
- return await import_playlist_text(db, text)
162
+ config = request.app.state.config
163
+ return await import_playlist_text(db, text, mediacms_url=config.mediacms_url)
@@ -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 token per line. <code>cm:token</code>, <code>yt:id</code>, or a bare catalog token. <code>#</code> comments allowed.</p>
53
- <textarea id="import-text" class="import-text" rows="5" placeholder="abc123def&#10;yt:dQw4w9WgXcQ"></textarea>
54
- <button class="btn btn-sm" onclick="parseImport()">Append Parsed Items</button>
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 &amp; 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&#10;https://youtu.be/dQw4w9WgXcQ?t=42 # start time is stripped&#10;yt:dQw4w9WgXcQ&#10;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`, {
@@ -11,7 +11,7 @@
11
11
 
12
12
  <div class="admin-section">
13
13
  <div class="section-head">
14
- <h2>Scheduled Fires</h2>
14
+ <h2>Scheduled Events</h2>
15
15
  <button class="btn btn-primary" onclick="showScheduleModal()">+ New Schedule</button>
16
16
  </div>
17
17
  <div id="schedules-list">Loading…</div>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.9.5"
3
+ version = "0.9.7"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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"])
@@ -0,0 +1,106 @@
1
+ """Queue chat-announcement behaviour (v0.9.7).
2
+
3
+ Covers the English-ordinal position words, the paid-queue announcement message
4
+ format/position (counted from now-playing, wrapping), and that admin queueing
5
+ is not announced.
6
+ """
7
+
8
+ import pytest
9
+
10
+ from kryten_webqueue.queue import ordering
11
+ from kryten_webqueue.queue.ordering import _ordinal_words, _announce_paid_queued
12
+
13
+
14
+ class _FakeApiGate:
15
+ def __init__(self, np_uid=None):
16
+ self.sent = []
17
+ self._np_uid = np_uid
18
+
19
+ async def get_now_playing(self):
20
+ return {"uid": self._np_uid} if self._np_uid is not None else None
21
+
22
+ async def send_chat(self, message):
23
+ self.sent.append(message)
24
+ return {"success": True}
25
+
26
+
27
+ class _FakeShadow:
28
+ def __init__(self, items, now_playing=None):
29
+ self._items = items
30
+ self.now_playing = now_playing
31
+
32
+ @property
33
+ def items(self):
34
+ return self._items
35
+
36
+
37
+ @pytest.mark.parametrize("n,word", [
38
+ (2, "second"),
39
+ (3, "third"),
40
+ (4, "fourth"),
41
+ (5, "fifth"),
42
+ (8, "eighth"),
43
+ (9, "ninth"),
44
+ (11, "eleventh"),
45
+ (12, "twelfth"),
46
+ (20, "twentieth"),
47
+ (21, "twenty-first"),
48
+ (42, "forty-second"),
49
+ (53, "fifty-third"),
50
+ (100, "one hundredth"),
51
+ (107, "one hundred seventh"),
52
+ (123, "one hundred twenty-third"),
53
+ ])
54
+ def test_ordinal_words(n, word):
55
+ assert _ordinal_words(n) == word
56
+
57
+
58
+ def _items(*uids):
59
+ return [{"uid": u, "title": f"Item {u}"} for u in uids]
60
+
61
+
62
+ async def test_paid_announcement_next():
63
+ # now-playing uid=10 at index 0; the new item uid=11 is immediately next.
64
+ api = _FakeApiGate(np_uid=10)
65
+ shadow = _FakeShadow(_items(10, 11))
66
+ await _announce_paid_queued(api, shadow, uid=11, title="Airplane (1980)", username="Hollis")
67
+ assert api.sent == [
68
+ "Airplane (1980) added to the queue with Zcoin by Hollis and is now next."
69
+ ]
70
+
71
+
72
+ async def test_paid_announcement_third():
73
+ # now-playing uid=10 at index 0; new item is two slots away -> "third".
74
+ api = _FakeApiGate(np_uid=10)
75
+ shadow = _FakeShadow(_items(10, 99, 11))
76
+ await _announce_paid_queued(api, shadow, uid=11, title="Airplane (1980)", username="Hollis")
77
+ assert api.sent == [
78
+ "Airplane (1980) added to the queue with Zcoin by Hollis and is now third."
79
+ ]
80
+
81
+
82
+ async def test_paid_announcement_wraps_around():
83
+ # now-playing is uid=99 at index 2; list wraps: after 99 comes 10 (next),
84
+ # then 11 (third).
85
+ api = _FakeApiGate(np_uid=99)
86
+ shadow = _FakeShadow(_items(10, 11, 99))
87
+ await _announce_paid_queued(api, shadow, uid=11, title="X", username="U")
88
+ assert api.sent == ["X added to the queue with Zcoin by U and is now third."]
89
+
90
+
91
+ async def test_admin_queue_not_announced(monkeypatch):
92
+ """insert_admin_queue must never call the announcement helper."""
93
+ called = False
94
+
95
+ async def _fail(*a, **k):
96
+ nonlocal called
97
+ called = True
98
+
99
+ monkeypatch.setattr(ordering, "_announce_paid_queued", _fail)
100
+ # Source check: the admin path has no announce call (the helper is only wired
101
+ # to the paid paths). This guards against a regression re-adding it.
102
+ import inspect
103
+ src = inspect.getsource(ordering.insert_admin_queue)
104
+ assert "_announce_paid_queued" not in src
105
+ assert "_announce_queued" not in src
106
+ assert called is False
@@ -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}