kryten-webqueue 0.14.0__tar.gz → 0.14.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/CHANGELOG.md +13 -0
  2. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/docs/PLAN_PRESENCE_AND_PROMOS.md +7 -3
  4. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/catalog/db.py +33 -0
  5. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/config.py +3 -2
  6. kryten_webqueue-0.14.2/kryten_webqueue/playlists/ordering.py +77 -0
  7. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/promos/director.py +7 -4
  8. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/queue/presence.py +7 -2
  9. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/routes/admin_playlists.py +58 -0
  10. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/static/css/main.css +3 -0
  11. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/templates/catalog/browse.html +80 -0
  12. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/pyproject.toml +1 -1
  13. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/tests/test_presence_refund.py +22 -6
  14. kryten_webqueue-0.14.2/tests/test_save_results_to_playlist.py +141 -0
  15. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/.github/workflows/python-publish.yml +0 -0
  16. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/.github/workflows/release.yml +0 -0
  17. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/.gitignore +0 -0
  18. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/README.md +0 -0
  19. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/config.example.json +0 -0
  20. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/deploy/kryten-webqueue.service +0 -0
  21. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/deploy/nginx-queue.conf +0 -0
  22. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/docs/IMPLEMENTATION_SPEC.md +0 -0
  23. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/docs/IMPL_API_GATE.md +0 -0
  24. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/docs/IMPL_ECONOMY.md +0 -0
  25. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/docs/IMPL_KRYTEN_PY.md +0 -0
  26. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/docs/IMPL_ROBOT.md +0 -0
  27. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/docs/PRE_PLAN_GAPS.md +0 -0
  28. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/docs/PRODUCT_PLAN.md +0 -0
  29. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  30. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/__init__.py +0 -0
  31. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/__main__.py +0 -0
  32. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/api_gate/__init__.py +0 -0
  33. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/api_gate/client.py +0 -0
  34. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/app.py +0 -0
  35. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/auth/__init__.py +0 -0
  36. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/auth/otp.py +0 -0
  37. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/auth/rate_limit.py +0 -0
  38. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/auth/session.py +0 -0
  39. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/catalog/__init__.py +0 -0
  40. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/catalog/images.py +0 -0
  41. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/catalog/mediacms.py +0 -0
  42. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/catalog/sync.py +0 -0
  43. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/integrations/__init__.py +0 -0
  44. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  45. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  46. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  47. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  48. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  49. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  50. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  51. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  52. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/jobs/__init__.py +0 -0
  53. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  54. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/jobs/manager.py +0 -0
  55. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/jobs/tasks.py +0 -0
  56. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/playlists/__init__.py +0 -0
  57. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/playlists/bulk_add.py +0 -0
  58. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/playlists/fire.py +0 -0
  59. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/playlists/importer.py +0 -0
  60. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/playlists/scheduler.py +0 -0
  61. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/promos/__init__.py +0 -0
  62. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/queue/__init__.py +0 -0
  63. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/queue/ordering.py +0 -0
  64. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/queue/poller.py +0 -0
  65. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/queue/shadow.py +0 -0
  66. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/routes/__init__.py +0 -0
  67. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/routes/admin_catalog.py +0 -0
  68. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/routes/admin_jobs.py +0 -0
  69. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/routes/admin_promos.py +0 -0
  70. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/routes/admin_queue.py +0 -0
  71. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/routes/admin_schedules.py +0 -0
  72. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/routes/auth.py +0 -0
  73. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/routes/catalog.py +0 -0
  74. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/routes/pages.py +0 -0
  75. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/routes/queue.py +0 -0
  76. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/routes/user.py +0 -0
  77. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/static/js/main.js +0 -0
  78. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/templates/admin/index.html +0 -0
  79. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
  80. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/templates/admin/promos.html +0 -0
  81. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  82. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
  83. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/templates/auth/login.html +0 -0
  84. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/templates/base.html +0 -0
  85. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  86. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  87. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/templates/queue/index.html +0 -0
  88. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
  89. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/ws/__init__.py +0 -0
  90. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/ws/handler.py +0 -0
  91. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/kryten_webqueue/ws/manager.py +0 -0
  92. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/tests/__init__.py +0 -0
  93. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/tests/test_config_persistence.py +0 -0
  94. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/tests/test_fetchurls_sharepoint.py +0 -0
  95. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/tests/test_phase1.py +0 -0
  96. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/tests/test_phase2_jobs.py +0 -0
  97. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/tests/test_phase3_jobs.py +0 -0
  98. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/tests/test_phase4_live_fixes.py +0 -0
  99. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/tests/test_playlist_import.py +0 -0
  100. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/tests/test_promo_director.py +0 -0
  101. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/tests/test_promo_pool_exclusion.py +0 -0
  102. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.2}/tests/test_queue_announce.py +0 -0
@@ -6,6 +6,19 @@ 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.14.2] — 2026-06-13
10
+
11
+ ### Added
12
+
13
+ - **Save all search/browse results to a playlist.** The Browse/search results page now shows an admin-only **Save results to playlist** button. It appends every catalog item matching the current search query or browse facets (across all pages, honouring the hidden-items toggle) to a playlist of your choosing. Where a season/episode marker is detectable in the title (`S01E02`, `1x02`, `Season 1 Episode 2`, …) items are laid out in proper series → season → episode order; everything else falls back to a stable alphabetical placement. Items already in the target playlist are skipped, so re-running is idempotent. Backend: `POST /admin/playlists/{id}/append-results` (admin-only) plus a de-duplicating bulk `Database.append_playlist_items` and a pure `playlists.ordering` helper.
14
+
15
+ ## [0.14.1] — 2026-06-13
16
+
17
+ ### Fixed
18
+
19
+ - **Cancel/refund PM notifications only fire for AFK owners.** A user who *leaves* the channel is no longer connected to CyTube and cannot receive a PM, so the leave path now skips the notification entirely (the refund + WS state update still happen). AFK owners — who are still present — are PM'd as before. `presence_refund.notify_user` now governs the AFK case only.
20
+ - **`no_repeat` promo selection is now deterministic.** When a `no_repeat` random draw matched the previous clip it was retried up to 8 times and could still return a repeat (a flaky guarantee for small pools). It now draws from the pool excluding the last clip, so a consecutive repeat never occurs for pools of 2+.
21
+
9
22
  ## [0.14.0] — 2026-06-13
10
23
 
11
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.14.0
3
+ Version: 0.14.2
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
@@ -419,6 +419,10 @@ load-time. (FP and general promos stay purely poller-driven.)
419
419
  a notice.)
420
420
 
421
421
  **RESOLVED (v0.13.0):** a PM is now sent. On a presence-based cancel the owner
422
- receives a PM ("… cancelled and refunded because you left the channel / you
423
- went AFK"), gated by `presence_refund.notify_user` (default on) and best-effort
424
- so a failed PM never blocks the refund.
422
+ receives a PM ("… cancelled and refunded because you went AFK"), gated by
423
+ `presence_refund.notify_user` (default on) and best-effort so a failed PM never
424
+ blocks the refund.
425
+
426
+ **AMENDED (v0.14.1):** the PM only fires for **AFK** owners. A user who *left*
427
+ the channel is no longer connected to CyTube and cannot receive a PM, so the
428
+ leave path skips the notice (refund + WS state update still happen).
@@ -980,6 +980,39 @@ class Database:
980
980
  await self._db.commit()
981
981
  return count
982
982
 
983
+ async def append_playlist_items(self, playlist_id: int, items: list[dict]) -> int:
984
+ """Append many items to the end of a playlist, skipping any whose
985
+ ``media_id`` is already present. Returns the number actually added."""
986
+ existing_rows = await self._fetch_all(
987
+ "SELECT media_id FROM saved_playlist_items WHERE playlist_id=?", [playlist_id]
988
+ )
989
+ seen = {r["media_id"] for r in existing_rows}
990
+ row = await self._fetch_one(
991
+ "SELECT COALESCE(MAX(position), -1) AS pos FROM saved_playlist_items WHERE playlist_id=?",
992
+ [playlist_id],
993
+ )
994
+ next_pos = (row["pos"] + 1) if row else 0
995
+ added = 0
996
+ for item in items:
997
+ media_id = item.get("media_id")
998
+ if not media_id or media_id in seen:
999
+ continue
1000
+ seen.add(media_id)
1001
+ await self._db.execute(
1002
+ "INSERT INTO saved_playlist_items (playlist_id, position, media_type, media_id, title, duration_sec) "
1003
+ "VALUES (?, ?, ?, ?, ?, ?)",
1004
+ [playlist_id, next_pos, item.get("media_type", "cm"), media_id,
1005
+ item.get("title"), item.get("duration_sec")],
1006
+ )
1007
+ next_pos += 1
1008
+ added += 1
1009
+ if added:
1010
+ await self._db.execute(
1011
+ "UPDATE saved_playlists SET updated_at=datetime('now') WHERE id=?", [playlist_id]
1012
+ )
1013
+ await self._db.commit()
1014
+ return added
1015
+
983
1016
  async def get_most_recent_playlist(self, created_by: str) -> dict | None:
984
1017
  """The given admin's most recently *created* saved playlist, if any."""
985
1018
  return await self._fetch_one(
@@ -35,7 +35,8 @@ class PresenceRefundConfig(BaseModel):
35
35
  off if running against an older Robot whose ``meta.afk`` goes stale.
36
36
 
37
37
  ``notify_user`` PMs the owner when a pending paid item is cancelled & refunded
38
- so the cancellation isn't silent.
38
+ so the cancellation isn't silent. This only applies to **AFK** owners — a
39
+ user who left the channel is no longer connected and cannot receive a PM.
39
40
  """
40
41
 
41
42
  enabled: bool = True
@@ -43,7 +44,7 @@ class PresenceRefundConfig(BaseModel):
43
44
  on_afk: bool = True # needs Kryten-Robot >= 1.10.0 deployed
44
45
  grace_seconds: float = 60.0 # wait before acting; re-check after grace
45
46
  check_interval_seconds: float = 15.0 # how often to evaluate owners
46
- notify_user: bool = True # PM the owner on cancel/refund
47
+ notify_user: bool = True # PM the AFK owner on cancel/refund
47
48
 
48
49
 
49
50
  class PromoTypeConfig(BaseModel):
@@ -0,0 +1,77 @@
1
+ """Season/episode aware ordering for playlists built from search results (0.14.2).
2
+
3
+ When an admin saves a whole result set to a playlist we try to lay episodic
4
+ content out in natural watch order: grouped by series, then by season, then by
5
+ episode. Items with no detectable season/episode marker fall back to an
6
+ alphabetical-by-title placement, and ties always preserve the original input
7
+ order (a stable sort) so the behaviour is deterministic.
8
+ """
9
+
10
+ import re
11
+
12
+ # Ordered most-specific first. Each pattern must expose season as group 1 and
13
+ # episode as group 2.
14
+ _SE_PATTERNS = [
15
+ # S01E02, s1e2, S01 E02, S01.E02, S01-E02
16
+ re.compile(r"[Ss](\d{1,2})\s*[._\- ]?\s*[Ee](\d{1,3})"),
17
+ # Season 1 Episode 2 / Season 1, Episode 02
18
+ re.compile(r"[Ss]eason\s*(\d{1,2}).*?[Ee]pisode\s*(\d{1,3})"),
19
+ # 1x02, 01x003
20
+ re.compile(r"\b(\d{1,2})[Xx](\d{1,3})\b"),
21
+ ]
22
+
23
+
24
+ def parse_season_episode(title: str | None) -> tuple[int, int, int, int] | None:
25
+ """Parse a season/episode marker from ``title``.
26
+
27
+ Returns ``(season, episode, match_start, match_end)`` or ``None`` when no
28
+ recognised marker is present.
29
+ """
30
+ if not title:
31
+ return None
32
+ for pattern in _SE_PATTERNS:
33
+ m = pattern.search(title)
34
+ if not m:
35
+ continue
36
+ try:
37
+ season = int(m.group(1))
38
+ episode = int(m.group(2))
39
+ except (TypeError, ValueError):
40
+ continue
41
+ return season, episode, m.start(), m.end()
42
+ return None
43
+
44
+
45
+ def _series_base(title: str, match_start: int) -> str:
46
+ """Best-effort series name: the text before the season/episode marker.
47
+
48
+ Falls back to the whole title when the marker sits at the very start.
49
+ """
50
+ base = title[:match_start].strip(" \t-–—_.:|")
51
+ return (base or title).strip().lower()
52
+
53
+
54
+ def episode_sort_key(item: dict, index: int) -> tuple:
55
+ """Sort key for a catalog item.
56
+
57
+ Episodic items group under their series name then sort by season/episode.
58
+ Non-episodic items sort by their (lowercased) title. ``index`` is appended
59
+ so equal keys retain the caller's original ordering.
60
+ """
61
+ title = (item.get("title") or "").strip()
62
+ parsed = parse_season_episode(title)
63
+ if parsed:
64
+ season, episode, start, _ = parsed
65
+ return (_series_base(title, start), season, episode, index)
66
+ return (title.lower(), 0, 0, index)
67
+
68
+
69
+ def order_for_playlist(items: list[dict]) -> list[dict]:
70
+ """Return ``items`` ordered for a playlist (season/episode aware, stable)."""
71
+ return [
72
+ item
73
+ for _, item in sorted(
74
+ enumerate(items),
75
+ key=lambda pair: episode_sort_key(pair[1], pair[0]),
76
+ )
77
+ ]
@@ -221,10 +221,13 @@ class PromoDirector:
221
221
  clip = self._rng.choice(pool)
222
222
  if self._config.general.no_repeat and len(pool) > 1:
223
223
  last = self._last_clip_token.get(promo_type)
224
- attempts = 0
225
- while clip.get("media_id") == last and attempts < 8:
226
- clip = self._rng.choice(pool)
227
- attempts += 1
224
+ if clip.get("media_id") == last:
225
+ # Draw from the rest of the pool so the no-repeat guarantee
226
+ # always holds (bounded random retries could otherwise give
227
+ # up and return a repeat).
228
+ alternatives = [c for c in pool if c.get("media_id") != last]
229
+ if alternatives:
230
+ clip = self._rng.choice(alternatives)
228
231
  self._last_clip_token[promo_type] = clip.get("media_id")
229
232
  return clip
230
233
 
@@ -178,19 +178,24 @@ class PresenceRefundMonitor:
178
178
  async def _notify_owner(self, item: dict, reason: str):
179
179
  """PM the owner that their paid item was cancelled & refunded.
180
180
 
181
+ Only AFK owners are notified: a user who *left* the channel is no longer
182
+ connected to CyTube and cannot receive a PM, so there is no point trying.
183
+ AFK users are still present and will see the message.
184
+
181
185
  Best-effort: a failed PM never blocks the cancel/refund itself.
182
186
  """
183
187
  if not getattr(self._config, "notify_user", False):
184
188
  return
189
+ if reason != "owner_afk":
190
+ return # owner left the channel — unreachable by PM
185
191
  owner = item.get("paid_by")
186
192
  if not owner:
187
193
  return
188
- why = "you left the channel" if reason == "owner_left" else "you went AFK"
189
194
  title = item.get("title") or "your queued item"
190
195
  try:
191
196
  await self._api_gate.send_pm(
192
197
  owner,
193
- f"Your queued item \"{title}\" was cancelled and refunded because {why}.",
198
+ f"Your queued item \"{title}\" was cancelled and refunded because you went AFK.",
194
199
  )
195
200
  except Exception:
196
201
  logger.debug("Presence cancel: failed to PM %s", owner, exc_info=True)
@@ -166,6 +166,64 @@ async def append_item(request: Request, playlist_id: int, user: dict = Depends(r
166
166
  return {"success": True, "playlist_id": playlist_id, "name": playlist["name"], "count": count}
167
167
 
168
168
 
169
+ @router.post("/{playlist_id}/append-results")
170
+ async def append_results(request: Request, playlist_id: int, user: dict = Depends(require_admin)):
171
+ """Append every catalog item matching the current browse/search filters to a
172
+ playlist (0.14.2).
173
+
174
+ The browse/search facets are sent in the body so the server re-runs the same
175
+ (unpaginated) query the admin is looking at. Items are laid out in
176
+ season/episode order where a marker is detectable in the title, and any item
177
+ already present in the playlist is skipped.
178
+ """
179
+ from ..playlists.ordering import order_for_playlist
180
+
181
+ body = await request.json()
182
+ db = request.app.state.db
183
+ playlist = await db.get_saved_playlist(playlist_id)
184
+ if not playlist:
185
+ raise HTTPException(404, "Playlist not found")
186
+
187
+ mode = body.get("mode", "browse")
188
+ show_hidden = bool(body.get("show_hidden"))
189
+ sort = body.get("sort") or "default"
190
+
191
+ if mode == "search":
192
+ q = (body.get("q") or "").strip()
193
+ if not q:
194
+ raise HTTPException(400, "Query required for search results")
195
+ total = await db.search_count(q, show_hidden=show_hidden)
196
+ items = await db.search(q, page=1, per_page=max(total, 1),
197
+ show_hidden=show_hidden, sort=sort)
198
+ else:
199
+ category = body.get("category") or None
200
+ tag = body.get("tag") or None
201
+ total = await db.browse_count(category=category, tag=tag, show_hidden=show_hidden)
202
+ items = await db.browse(category=category, tag=tag, page=1, per_page=max(total, 1),
203
+ show_hidden=show_hidden, sort=sort)
204
+
205
+ ordered = order_for_playlist(items)
206
+ playlist_items = [
207
+ {
208
+ "media_type": "cm",
209
+ "media_id": it["manifest_url"],
210
+ "title": it.get("title"),
211
+ "duration_sec": it.get("duration_sec"),
212
+ }
213
+ for it in ordered
214
+ if it.get("manifest_url")
215
+ ]
216
+ added = await db.append_playlist_items(playlist_id, playlist_items)
217
+ count = len(await db.get_saved_playlist_items(playlist_id))
218
+ return {
219
+ "success": True,
220
+ "playlist_id": playlist_id,
221
+ "name": playlist["name"],
222
+ "added": added,
223
+ "count": count,
224
+ }
225
+
226
+
169
227
  @router.post("/parse-text")
170
228
  async def parse_text(request: Request, user: dict = Depends(require_admin)):
171
229
  """Parse the plain-text playlist import format into resolved items.
@@ -150,6 +150,9 @@ a:hover {
150
150
  .admin-hidden-notice a {
151
151
  font-weight: 600;
152
152
  }
153
+ .admin-results-actions {
154
+ margin-top: 0.85rem;
155
+ }
153
156
  .search-form {
154
157
  display: flex;
155
158
  gap: 0.5rem;
@@ -39,6 +39,9 @@
39
39
  <a href="{{ request.url.include_query_params(show_hidden=1) }}">Show hidden items?</a>
40
40
  {% endif %}
41
41
  </div>
42
+ <div class="admin-results-actions">
43
+ <button class="btn btn-sm btn-admin" onclick="saveResultsToPlaylist()">Save results to playlist</button>
44
+ </div>
42
45
  {% endif %}
43
46
  </div>
44
47
 
@@ -233,6 +236,83 @@ async function submitAddToPlaylist(playlistId, token) {
233
236
  }
234
237
  }
235
238
 
239
+ // --- Save all current results to a playlist (rank >= 3) ---
240
+
241
+ async function saveResultsToPlaylist() {
242
+ let playlists = [];
243
+ try {
244
+ const resp = await fetch('/admin/playlists/', { credentials: 'same-origin' });
245
+ if (resp.ok) playlists = await resp.json();
246
+ } catch (e) { /* handled below */ }
247
+ if (!playlists.length) {
248
+ showToast('No playlists yet — create one first', 'error');
249
+ return;
250
+ }
251
+ showResultsPlaylistPicker(playlists);
252
+ }
253
+
254
+ function showResultsPlaylistPicker(playlists) {
255
+ closePlaylistPickerModal();
256
+ const overlay = document.createElement('div');
257
+ overlay.id = 'playlist-picker-modal';
258
+ overlay.className = 'modal-overlay';
259
+ const opts = playlists.map(p => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('');
260
+ const scope = CURRENT_QUERY ? 'search' : 'filter';
261
+ overlay.innerHTML = `
262
+ <div class="modal-box" role="dialog" aria-modal="true">
263
+ <h3>Save all results to playlist</h3>
264
+ <p class="muted">Every item matching the current ${scope} will be appended, ordered by season/episode where detected. Items already in the playlist are skipped.</p>
265
+ <div class="field">
266
+ <label for="playlist-picker-select">Playlist</label>
267
+ <select id="playlist-picker-select">${opts}</select>
268
+ </div>
269
+ <div class="modal-actions">
270
+ <button class="btn btn-secondary" data-action="cancel">Cancel</button>
271
+ <button class="btn btn-primary" data-action="save">Save results</button>
272
+ </div>
273
+ </div>`;
274
+ overlay.addEventListener('click', (e) => {
275
+ if (e.target === overlay) closePlaylistPickerModal();
276
+ const action = e.target.getAttribute('data-action');
277
+ if (action === 'cancel') closePlaylistPickerModal();
278
+ if (action === 'save') {
279
+ const pid = document.getElementById('playlist-picker-select').value;
280
+ closePlaylistPickerModal();
281
+ submitSaveResults(pid);
282
+ }
283
+ });
284
+ document.body.appendChild(overlay);
285
+ }
286
+
287
+ async function submitSaveResults(playlistId) {
288
+ const url = new URL(window.location.href);
289
+ const isSearch = url.pathname.indexOf('/catalog/search') !== -1;
290
+ const body = {
291
+ mode: isSearch ? 'search' : 'browse',
292
+ q: url.searchParams.get('q') || '',
293
+ category: url.searchParams.get('category') || '',
294
+ tag: url.searchParams.get('tag') || '',
295
+ sort: url.searchParams.get('sort') || 'default',
296
+ show_hidden: url.searchParams.get('show_hidden') ? 1 : 0,
297
+ };
298
+ showToast('Saving results…');
299
+ try {
300
+ const resp = await fetch(`/admin/playlists/${playlistId}/append-results`, {
301
+ method: 'POST',
302
+ headers: { 'Content-Type': 'application/json' },
303
+ credentials: 'same-origin',
304
+ body: JSON.stringify(body),
305
+ });
306
+ const data = await resp.json();
307
+ showToast(
308
+ resp.ok ? `Added ${data.added} item(s) — playlist now ${data.count}`
309
+ : (data.detail || `Failed (${resp.status})`),
310
+ resp.ok ? 'success' : 'error');
311
+ } catch (e) {
312
+ showToast(`Network error: ${e.message}`, 'error');
313
+ }
314
+ }
315
+
236
316
  async function addToRecentPlaylist(token) {
237
317
  try {
238
318
  const resp = await fetch('/admin/playlists/recent/append', {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.14.0"
3
+ version = "0.14.2"
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"
@@ -126,12 +126,13 @@ async def test_owner_offline_cancels_paid_keeps_free():
126
126
  assert ws.messages and ws.messages[-1]["type"] == "queue_state"
127
127
 
128
128
 
129
- async def test_cancel_pms_owner_when_notify_enabled():
129
+ async def test_afk_cancel_pms_owner_when_notify_enabled():
130
+ # AFK owners are still connected and CAN be PM'd.
130
131
  shadow = _FakeShadow([_paid(11, "alice", title="Cool Video")], now_playing={"uid": 10})
131
- api = _FakeApiGate({"alice": {"online": False}}, np={"uid": 10})
132
+ api = _FakeApiGate({"alice": {"online": True, "meta": {"afk": True}}}, np={"uid": 10})
132
133
  db = _FakeDb({11: {"request_id": "r11", "username": "alice"}})
133
134
  ws = _FakeWs()
134
- mon = _monitor(api, shadow, db, ws, notify_user=True)
135
+ mon = _monitor(api, shadow, db, ws, notify_user=True, on_afk=True)
135
136
 
136
137
  await mon.check_once() # first sighting: starts grace
137
138
  await mon.check_once() # grace elapsed: acts
@@ -140,21 +141,36 @@ async def test_cancel_pms_owner_when_notify_enabled():
140
141
  user, msg = api.pms[0]
141
142
  assert user == "alice"
142
143
  assert "Cool Video" in msg
143
- assert "left the channel" in msg
144
+ assert "AFK" in msg
144
145
 
145
146
 
146
- async def test_cancel_silent_when_notify_disabled():
147
+ async def test_left_channel_cancel_does_not_pm():
148
+ # A user who LEFT the channel is unreachable by PM — no PM attempted.
147
149
  shadow = _FakeShadow([_paid(11, "alice")], now_playing={"uid": 10})
148
150
  api = _FakeApiGate({"alice": {"online": False}}, np={"uid": 10})
149
151
  db = _FakeDb({11: {"request_id": "r11", "username": "alice"}})
150
152
  ws = _FakeWs()
151
- mon = _monitor(api, shadow, db, ws, notify_user=False)
153
+ mon = _monitor(api, shadow, db, ws, notify_user=True)
152
154
 
153
155
  await mon.check_once()
154
156
  await mon.check_once()
155
157
 
156
158
  assert api.refunds == [("alice", "r11", "owner_left")]
157
159
  assert api.pms == []
160
+
161
+
162
+ async def test_cancel_silent_when_notify_disabled():
163
+ shadow = _FakeShadow([_paid(11, "alice")], now_playing={"uid": 10})
164
+ api = _FakeApiGate({"alice": {"online": True, "meta": {"afk": True}}}, np={"uid": 10})
165
+ db = _FakeDb({11: {"request_id": "r11", "username": "alice"}})
166
+ ws = _FakeWs()
167
+ mon = _monitor(api, shadow, db, ws, notify_user=False, on_afk=True)
168
+
169
+ await mon.check_once()
170
+ await mon.check_once()
171
+
172
+ assert api.refunds == [("alice", "r11", "owner_afk")]
173
+ assert api.pms == []
158
174
  shadow = _FakeShadow([_paid(11, "bob")], now_playing={"uid": 10})
159
175
  api = _FakeApiGate({"bob": {"meta": {"afk": True}}}, np={"uid": 10})
160
176
  db = _FakeDb({11: {"request_id": "r11", "username": "bob"}})
@@ -0,0 +1,141 @@
1
+ """Save-all-results-to-playlist feature (0.14.2).
2
+
3
+ Covers the pure season/episode ordering helper and the bulk, de-duplicating
4
+ playlist append used by the admin "Save results to playlist" button.
5
+ """
6
+
7
+ import pytest
8
+
9
+ from kryten_webqueue.catalog.db import Database
10
+ from kryten_webqueue.playlists.ordering import (
11
+ parse_season_episode,
12
+ order_for_playlist,
13
+ )
14
+
15
+ MEDIACMS = "https://www.dropsugar.com"
16
+
17
+
18
+ @pytest.fixture
19
+ async def db(tmp_path):
20
+ database = Database(str(tmp_path / "test.db"))
21
+ await database.connect()
22
+ await database.run_migrations()
23
+ yield database
24
+ await database.close()
25
+
26
+
27
+ # --- pure: season/episode parsing ---
28
+
29
+ @pytest.mark.parametrize("title,expected", [
30
+ ("Show Name S01E02 - The Title", (1, 2)),
31
+ ("Show Name s1e2", (1, 2)),
32
+ ("Show Name S01 E02", (1, 2)),
33
+ ("Show Name S01.E02", (1, 2)),
34
+ ("Show Name 1x02", (1, 2)),
35
+ ("Show Name 01x003", (1, 3)),
36
+ ("Show Name Season 2 Episode 11", (2, 11)),
37
+ ("A Plain Movie (2020)", None),
38
+ ("", None),
39
+ (None, None),
40
+ ])
41
+ def test_parse_season_episode(title, expected):
42
+ parsed = parse_season_episode(title)
43
+ if expected is None:
44
+ assert parsed is None
45
+ else:
46
+ assert parsed is not None
47
+ assert (parsed[0], parsed[1]) == expected
48
+
49
+
50
+ # --- pure: ordering ---
51
+
52
+ def test_order_groups_series_by_season_episode():
53
+ items = [
54
+ {"title": "Cool Show S01E03"},
55
+ {"title": "Cool Show S02E01"},
56
+ {"title": "Cool Show S01E01"},
57
+ {"title": "Cool Show S01E02"},
58
+ ]
59
+ ordered = [i["title"] for i in order_for_playlist(items)]
60
+ assert ordered == [
61
+ "Cool Show S01E01",
62
+ "Cool Show S01E02",
63
+ "Cool Show S01E03",
64
+ "Cool Show S02E01",
65
+ ]
66
+
67
+
68
+ def test_order_is_stable_for_non_episodic():
69
+ # No S/E markers: alphabetical by title, ties keep input order.
70
+ items = [
71
+ {"title": "Banana"},
72
+ {"title": "apple"},
73
+ {"title": "Cherry"},
74
+ ]
75
+ ordered = [i["title"] for i in order_for_playlist(items)]
76
+ assert ordered == ["apple", "Banana", "Cherry"]
77
+
78
+
79
+ def test_order_separates_distinct_series():
80
+ items = [
81
+ {"title": "Zeta Show S01E02"},
82
+ {"title": "Alpha Show S01E02"},
83
+ {"title": "Alpha Show S01E01"},
84
+ {"title": "Zeta Show S01E01"},
85
+ ]
86
+ ordered = [i["title"] for i in order_for_playlist(items)]
87
+ assert ordered == [
88
+ "Alpha Show S01E01",
89
+ "Alpha Show S01E02",
90
+ "Zeta Show S01E01",
91
+ "Zeta Show S01E02",
92
+ ]
93
+
94
+
95
+ # --- db: bulk append with de-dupe ---
96
+
97
+ async def _make_playlist(db):
98
+ return await db.create_saved_playlist(
99
+ name="Results", description=None, is_immutable=False, created_by="admin",
100
+ )
101
+
102
+
103
+ async def test_append_playlist_items_appends_in_order(db):
104
+ pid = await _make_playlist(db)
105
+ added = await db.append_playlist_items(pid, [
106
+ {"media_type": "cm", "media_id": "a", "title": "A", "duration_sec": 60},
107
+ {"media_type": "cm", "media_id": "b", "title": "B", "duration_sec": 60},
108
+ ])
109
+ assert added == 2
110
+ items = await db.get_saved_playlist_items(pid)
111
+ assert [i["media_id"] for i in items] == ["a", "b"]
112
+ assert [i["position"] for i in items] == [0, 1]
113
+
114
+
115
+ async def test_append_playlist_items_skips_existing(db):
116
+ pid = await _make_playlist(db)
117
+ await db.append_playlist_items(pid, [
118
+ {"media_type": "cm", "media_id": "a", "title": "A"},
119
+ {"media_type": "cm", "media_id": "b", "title": "B"},
120
+ ])
121
+ # Second pass: only "c" is new; "a"/"b" already present.
122
+ added = await db.append_playlist_items(pid, [
123
+ {"media_type": "cm", "media_id": "a", "title": "A"},
124
+ {"media_type": "cm", "media_id": "c", "title": "C"},
125
+ {"media_type": "cm", "media_id": "b", "title": "B"},
126
+ ])
127
+ assert added == 1
128
+ items = await db.get_saved_playlist_items(pid)
129
+ assert [i["media_id"] for i in items] == ["a", "b", "c"]
130
+ assert [i["position"] for i in items] == [0, 1, 2]
131
+
132
+
133
+ async def test_append_playlist_items_dedupes_within_batch(db):
134
+ pid = await _make_playlist(db)
135
+ added = await db.append_playlist_items(pid, [
136
+ {"media_type": "cm", "media_id": "x", "title": "X"},
137
+ {"media_type": "cm", "media_id": "x", "title": "X dup"},
138
+ ])
139
+ assert added == 1
140
+ items = await db.get_saved_playlist_items(pid)
141
+ assert [i["media_id"] for i in items] == ["x"]