kryten-webqueue 0.8.0__tar.gz → 0.8.1__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 (68) hide show
  1. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/CHANGELOG.md +6 -0
  2. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/PKG-INFO +2 -1
  3. kryten_webqueue-0.8.1/kryten_webqueue/playlists/scheduler.py +131 -0
  4. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/pyproject.toml +2 -1
  5. kryten_webqueue-0.8.0/kryten_webqueue/playlists/scheduler.py +0 -72
  6. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/.github/workflows/python-publish.yml +0 -0
  7. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/.github/workflows/release.yml +0 -0
  8. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/.gitignore +0 -0
  9. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/README.md +0 -0
  10. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/config.example.json +0 -0
  11. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/deploy/kryten-webqueue.service +0 -0
  12. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/deploy/nginx-queue.conf +0 -0
  13. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  14. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/docs/IMPL_API_GATE.md +0 -0
  15. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/docs/IMPL_ECONOMY.md +0 -0
  16. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  17. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/docs/IMPL_ROBOT.md +0 -0
  18. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/docs/PRE_PLAN_GAPS.md +0 -0
  19. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/docs/PRODUCT_PLAN.md +0 -0
  20. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/__init__.py +0 -0
  21. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/__main__.py +0 -0
  22. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  23. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/api_gate/client.py +0 -0
  24. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/app.py +0 -0
  25. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/auth/__init__.py +0 -0
  26. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/auth/otp.py +0 -0
  27. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  28. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/auth/session.py +0 -0
  29. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/catalog/__init__.py +0 -0
  30. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/catalog/db.py +0 -0
  31. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/catalog/images.py +0 -0
  32. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/catalog/sync.py +0 -0
  33. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/config.py +0 -0
  34. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/jobs/__init__.py +0 -0
  35. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/jobs/manager.py +0 -0
  36. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/playlists/__init__.py +0 -0
  37. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/playlists/fire.py +0 -0
  38. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/playlists/importer.py +0 -0
  39. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/queue/__init__.py +0 -0
  40. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/queue/ordering.py +0 -0
  41. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/queue/poller.py +0 -0
  42. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/queue/shadow.py +0 -0
  43. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/__init__.py +0 -0
  44. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
  45. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
  46. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  47. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
  48. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/auth.py +0 -0
  49. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/catalog.py +0 -0
  50. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/pages.py +0 -0
  51. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/queue.py +0 -0
  52. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/user.py +0 -0
  53. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/static/css/main.css +0 -0
  54. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/static/js/main.js +0 -0
  55. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/admin/index.html +0 -0
  56. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
  57. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  58. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
  59. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/auth/login.html +0 -0
  60. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/base.html +0 -0
  61. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  62. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  63. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  64. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/queue/index.html +0 -0
  65. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  66. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/ws/__init__.py +0 -0
  67. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/ws/handler.py +0 -0
  68. {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.1}/kryten_webqueue/ws/manager.py +0 -0
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  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
+ ## [0.8.1] - 2026-06-08
8
+
9
+ ### Added
10
+
11
+ - **True RRULE-based recurring schedules.** Recurring schedules now auto-re-arm: after an automatic timed fire, the scheduler computes the next occurrence from the schedule's `rrule` (anchored on its fire time), advances `fire_at`, clears `fired_at`, and registers the next job — no manual re-arming needed. On startup, recurring schedules whose fire time elapsed while the service was down are advanced to their next future occurrence. Manual "Fire Now" intentionally does **not** advance the recurrence; the originally scheduled occurrence stays armed. Unparseable or exhausted rules are logged and left inert. Adds an explicit `python-dateutil` dependency for RRULE parsing.
12
+
7
13
  ## [0.8.0] - 2026-06-08
8
14
 
9
15
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.8.0
3
+ Version: 0.8.1
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
@@ -13,6 +13,7 @@ Requires-Dist: jinja2>=3.1
13
13
  Requires-Dist: pillow>=10.0
14
14
  Requires-Dist: pydantic>=2.0
15
15
  Requires-Dist: pyjwt>=2.8
16
+ Requires-Dist: python-dateutil>=2.8
16
17
  Requires-Dist: uvicorn[standard]>=0.30
17
18
  Requires-Dist: websockets>=12.0
18
19
  Provides-Extra: dev
@@ -0,0 +1,131 @@
1
+ import logging
2
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
3
+ from apscheduler.triggers.date import DateTrigger
4
+ from datetime import datetime, UTC
5
+
6
+ from dateutil.rrule import rrulestr
7
+
8
+ from .fire import fire_schedule
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _next_occurrence(rrule_str: str, dtstart: datetime, after: datetime) -> datetime | None:
14
+ """Return the next RRULE occurrence strictly after ``after``.
15
+
16
+ ``dtstart`` anchors the recurrence pattern (typically the schedule's current
17
+ fire time). Returns None when the rule is exhausted or unparseable.
18
+ """
19
+ try:
20
+ rule = rrulestr(rrule_str, dtstart=dtstart)
21
+ return rule.after(after, inc=False)
22
+ except Exception as e:
23
+ logger.warning(f"Could not compute next occurrence for rrule {rrule_str!r}: {e}")
24
+ return None
25
+
26
+
27
+ class PlaylistScheduler:
28
+ """APScheduler-based scheduler for playlist fire events."""
29
+
30
+ def __init__(self, *, db, api_gate, shadow, ws_manager):
31
+ self._db = db
32
+ self._api_gate = api_gate
33
+ self._shadow = shadow
34
+ self._ws_manager = ws_manager
35
+ self._scheduler = AsyncIOScheduler()
36
+
37
+ async def start(self):
38
+ """Start scheduler and load all pending schedules."""
39
+ self._scheduler.start()
40
+ await self._load_schedules()
41
+ logger.info("PlaylistScheduler started")
42
+
43
+ async def stop(self):
44
+ self._scheduler.shutdown(wait=False)
45
+ logger.info("PlaylistScheduler stopped")
46
+
47
+ @staticmethod
48
+ def _parse_fire_at(value: str) -> datetime:
49
+ """Parse a stored fire_at ISO string into a UTC-aware datetime."""
50
+ dt = datetime.fromisoformat(value)
51
+ if dt.tzinfo is None:
52
+ dt = dt.replace(tzinfo=UTC)
53
+ return dt
54
+
55
+ async def _load_schedules(self):
56
+ """Load active schedules from DB and register jobs.
57
+
58
+ Recurring schedules whose fire time has already passed (e.g. while the
59
+ service was down) are advanced to their next future occurrence.
60
+ """
61
+ schedules = await self._db.get_schedules()
62
+ now = datetime.now(UTC)
63
+ for sched in schedules:
64
+ if not sched.get("is_active"):
65
+ continue
66
+ fire_at = self._parse_fire_at(sched["fire_at"])
67
+ if fire_at > now:
68
+ self._add_job(sched["id"], fire_at)
69
+ elif sched.get("is_recurring") and sched.get("rrule"):
70
+ nxt = _next_occurrence(sched["rrule"], fire_at, now)
71
+ if nxt:
72
+ nxt_utc = nxt.astimezone(UTC)
73
+ await self._db.update_schedule(
74
+ sched["id"], fire_at=nxt_utc.isoformat(), fired_at=None
75
+ )
76
+ self._add_job(sched["id"], nxt_utc)
77
+ logger.info(
78
+ f"Advanced missed recurring schedule {sched['id']} to {nxt_utc}"
79
+ )
80
+
81
+ def _add_job(self, schedule_id: int, fire_at: datetime):
82
+ job_id = f"schedule_{schedule_id}"
83
+ self._scheduler.add_job(
84
+ self._fire,
85
+ trigger=DateTrigger(run_date=fire_at),
86
+ id=job_id,
87
+ replace_existing=True,
88
+ kwargs={"schedule_id": schedule_id},
89
+ )
90
+ logger.info(f"Scheduled job {job_id} for {fire_at}")
91
+
92
+ async def _fire(self, schedule_id: int):
93
+ await fire_schedule(
94
+ schedule_id=schedule_id,
95
+ api_gate=self._api_gate,
96
+ db=self._db,
97
+ shadow=self._shadow,
98
+ ws_manager=self._ws_manager,
99
+ )
100
+ # After an automatic timed fire, advance recurring schedules to their
101
+ # next occurrence and re-arm. (Manual "Fire Now" does NOT advance the
102
+ # recurrence — the originally scheduled occurrence stays armed.)
103
+ await self._reschedule_if_recurring(schedule_id)
104
+
105
+ async def _reschedule_if_recurring(self, schedule_id: int):
106
+ sched = await self._db.get_schedule(schedule_id)
107
+ if not sched or not sched.get("is_active"):
108
+ return
109
+ if not sched.get("is_recurring") or not sched.get("rrule"):
110
+ return
111
+ fired_from = self._parse_fire_at(sched["fire_at"])
112
+ nxt = _next_occurrence(sched["rrule"], fired_from, fired_from)
113
+ if not nxt:
114
+ logger.info(f"Recurring schedule {schedule_id} has no further occurrences")
115
+ return
116
+ nxt_utc = nxt.astimezone(UTC)
117
+ await self._db.update_schedule(
118
+ schedule_id, fire_at=nxt_utc.isoformat(), fired_at=None
119
+ )
120
+ self._add_job(schedule_id, nxt_utc)
121
+ logger.info(f"Recurring schedule {schedule_id} re-armed for {nxt_utc}")
122
+
123
+ async def add_schedule(self, schedule_id: int, fire_at: datetime):
124
+ self._add_job(schedule_id, fire_at)
125
+
126
+ async def remove_schedule(self, schedule_id: int):
127
+ job_id = f"schedule_{schedule_id}"
128
+ try:
129
+ self._scheduler.remove_job(job_id)
130
+ except Exception:
131
+ pass
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.8.0"
3
+ version = "0.8.1"
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"
@@ -19,6 +19,7 @@ dependencies = [
19
19
  "jinja2>=3.1",
20
20
  "websockets>=12.0",
21
21
  "pydantic>=2.0",
22
+ "python-dateutil>=2.8",
22
23
  ]
23
24
 
24
25
  [project.optional-dependencies]
@@ -1,72 +0,0 @@
1
- import logging
2
- from apscheduler.schedulers.asyncio import AsyncIOScheduler
3
- from apscheduler.triggers.date import DateTrigger
4
- from datetime import datetime, UTC
5
-
6
- from .fire import fire_schedule
7
-
8
- logger = logging.getLogger(__name__)
9
-
10
-
11
- class PlaylistScheduler:
12
- """APScheduler-based scheduler for playlist fire events."""
13
-
14
- def __init__(self, *, db, api_gate, shadow, ws_manager):
15
- self._db = db
16
- self._api_gate = api_gate
17
- self._shadow = shadow
18
- self._ws_manager = ws_manager
19
- self._scheduler = AsyncIOScheduler()
20
-
21
- async def start(self):
22
- """Start scheduler and load all pending schedules."""
23
- self._scheduler.start()
24
- await self._load_schedules()
25
- logger.info("PlaylistScheduler started")
26
-
27
- async def stop(self):
28
- self._scheduler.shutdown(wait=False)
29
- logger.info("PlaylistScheduler stopped")
30
-
31
- async def _load_schedules(self):
32
- """Load active schedules from DB and register jobs."""
33
- schedules = await self._db.get_schedules()
34
- now = datetime.now(UTC)
35
- for sched in schedules:
36
- if not sched.get("is_active"):
37
- continue
38
- fire_at_str = sched["fire_at"]
39
- fire_at = datetime.fromisoformat(fire_at_str)
40
- if fire_at <= now:
41
- continue
42
- self._add_job(sched["id"], fire_at)
43
-
44
- def _add_job(self, schedule_id: int, fire_at: datetime):
45
- job_id = f"schedule_{schedule_id}"
46
- self._scheduler.add_job(
47
- self._fire,
48
- trigger=DateTrigger(run_date=fire_at),
49
- id=job_id,
50
- replace_existing=True,
51
- kwargs={"schedule_id": schedule_id},
52
- )
53
- logger.info(f"Scheduled job {job_id} for {fire_at}")
54
-
55
- async def _fire(self, schedule_id: int):
56
- await fire_schedule(
57
- schedule_id=schedule_id,
58
- api_gate=self._api_gate,
59
- db=self._db,
60
- shadow=self._shadow,
61
- ws_manager=self._ws_manager,
62
- )
63
-
64
- async def add_schedule(self, schedule_id: int, fire_at: datetime):
65
- self._add_job(schedule_id, fire_at)
66
-
67
- async def remove_schedule(self, schedule_id: int):
68
- job_id = f"schedule_{schedule_id}"
69
- try:
70
- self._scheduler.remove_job(job_id)
71
- except Exception:
72
- pass