kryten-webqueue 0.1.1__py3-none-any.whl

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 (46) hide show
  1. kryten_webqueue/__init__.py +0 -0
  2. kryten_webqueue/__main__.py +10 -0
  3. kryten_webqueue/api_gate/__init__.py +0 -0
  4. kryten_webqueue/api_gate/client.py +113 -0
  5. kryten_webqueue/app.py +184 -0
  6. kryten_webqueue/auth/__init__.py +0 -0
  7. kryten_webqueue/auth/otp.py +10 -0
  8. kryten_webqueue/auth/rate_limit.py +29 -0
  9. kryten_webqueue/auth/session.py +40 -0
  10. kryten_webqueue/catalog/__init__.py +0 -0
  11. kryten_webqueue/catalog/db.py +562 -0
  12. kryten_webqueue/catalog/images.py +114 -0
  13. kryten_webqueue/catalog/sync.py +96 -0
  14. kryten_webqueue/config.py +46 -0
  15. kryten_webqueue/playlists/__init__.py +0 -0
  16. kryten_webqueue/playlists/fire.py +71 -0
  17. kryten_webqueue/playlists/importer.py +92 -0
  18. kryten_webqueue/playlists/scheduler.py +72 -0
  19. kryten_webqueue/queue/__init__.py +0 -0
  20. kryten_webqueue/queue/ordering.py +186 -0
  21. kryten_webqueue/queue/poller.py +43 -0
  22. kryten_webqueue/queue/shadow.py +116 -0
  23. kryten_webqueue/routes/__init__.py +0 -0
  24. kryten_webqueue/routes/admin_playlists.py +98 -0
  25. kryten_webqueue/routes/admin_queue.py +64 -0
  26. kryten_webqueue/routes/admin_schedules.py +129 -0
  27. kryten_webqueue/routes/auth.py +83 -0
  28. kryten_webqueue/routes/catalog.py +44 -0
  29. kryten_webqueue/routes/pages.py +82 -0
  30. kryten_webqueue/routes/queue.py +144 -0
  31. kryten_webqueue/routes/user.py +35 -0
  32. kryten_webqueue/static/css/main.css +470 -0
  33. kryten_webqueue/static/js/main.js +26 -0
  34. kryten_webqueue/templates/admin/index.html +98 -0
  35. kryten_webqueue/templates/auth/login.html +69 -0
  36. kryten_webqueue/templates/base.html +41 -0
  37. kryten_webqueue/templates/catalog/browse.html +105 -0
  38. kryten_webqueue/templates/queue/index.html +126 -0
  39. kryten_webqueue/templates/user/dashboard.html +87 -0
  40. kryten_webqueue/ws/__init__.py +0 -0
  41. kryten_webqueue/ws/handler.py +59 -0
  42. kryten_webqueue/ws/manager.py +57 -0
  43. kryten_webqueue-0.1.1.dist-info/METADATA +127 -0
  44. kryten_webqueue-0.1.1.dist-info/RECORD +46 -0
  45. kryten_webqueue-0.1.1.dist-info/WHEEL +4 -0
  46. kryten_webqueue-0.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,96 @@
1
+ import httpx
2
+ import logging
3
+ from datetime import datetime, UTC
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class CatalogSync:
9
+ """Synchronizes catalog data from MediaCMS."""
10
+
11
+ def __init__(self, *, mediacms_url: str, mediacms_token: str, db):
12
+ self._url = mediacms_url.rstrip("/")
13
+ self._token = mediacms_token
14
+ self._db = db
15
+ self._client = httpx.AsyncClient(
16
+ headers={"Authorization": f"Token {mediacms_token}"},
17
+ timeout=30.0,
18
+ )
19
+
20
+ async def close(self):
21
+ await self._client.aclose()
22
+
23
+ async def sync(self):
24
+ """Full catalog sync from MediaCMS API."""
25
+ log_id = await self._db.start_sync_log()
26
+ stats = {"seen": 0, "new": 0, "updated": 0, "errors": 0}
27
+
28
+ try:
29
+ page = 1
30
+ while True:
31
+ resp = await self._client.get(
32
+ f"{self._url}/api/v1/media",
33
+ params={"page": page, "page_size": 50},
34
+ )
35
+ if resp.status_code != 200:
36
+ logger.error(f"MediaCMS API returned {resp.status_code}")
37
+ stats["errors"] += 1
38
+ break
39
+
40
+ data = resp.json()
41
+ results = data if isinstance(data, list) else data.get("results", [])
42
+
43
+ if not results:
44
+ break
45
+
46
+ for media in results:
47
+ stats["seen"] += 1
48
+ try:
49
+ await self._process_item(media, stats)
50
+ except Exception as e:
51
+ logger.warning(f"Error processing {media.get('friendly_token')}: {e}")
52
+ stats["errors"] += 1
53
+
54
+ # Check for next page
55
+ if isinstance(data, dict) and not data.get("next"):
56
+ break
57
+ page += 1
58
+
59
+ await self._db.finish_sync_log(log_id, stats, "completed")
60
+ logger.info(f"Catalog sync: {stats}")
61
+ except Exception as e:
62
+ logger.error(f"Catalog sync failed: {e}")
63
+ stats["errors"] += 1
64
+ await self._db.finish_sync_log(log_id, stats, "error")
65
+
66
+ async def _process_item(self, media: dict, stats: dict):
67
+ token = media.get("friendly_token")
68
+ if not token:
69
+ return
70
+
71
+ now = datetime.now(UTC).isoformat()
72
+ row = {
73
+ "friendly_token": token,
74
+ "title": media.get("title", "Untitled"),
75
+ "description": media.get("description", ""),
76
+ "duration_sec": media.get("duration") or 0,
77
+ "manifest_url": self._build_manifest_url(media),
78
+ "thumbnail_url": media.get("thumbnail_url", ""),
79
+ "synced_at": now,
80
+ }
81
+
82
+ existing = await self._db.get_item_admin(token)
83
+ if existing:
84
+ await self._db.update_catalog(token, row)
85
+ stats["updated"] += 1
86
+ else:
87
+ await self._db.insert_catalog(row)
88
+ stats["new"] += 1
89
+
90
+ def _build_manifest_url(self, media: dict) -> str:
91
+ hls_file = media.get("hls_file")
92
+ if hls_file:
93
+ return hls_file if hls_file.startswith("http") else f"{self._url}{hls_file}"
94
+ # Fallback to original URL
95
+ original = media.get("original_media_url", "")
96
+ return original if original.startswith("http") else f"{self._url}{original}"
@@ -0,0 +1,46 @@
1
+ from pathlib import Path
2
+ from pydantic import BaseModel
3
+ import json
4
+
5
+
6
+ class Config(BaseModel):
7
+ """Application configuration loaded from JSON file."""
8
+
9
+ # Server
10
+ channel: str = "Q_A"
11
+ host: str = "0.0.0.0"
12
+ port: int = 2010
13
+ secret_key: str
14
+ session_ttl_hours: int = 24
15
+
16
+ # API Gate
17
+ api_gate_url: str = "http://127.0.0.1:24444"
18
+ api_gate_token: str
19
+
20
+ # MediaCMS
21
+ mediacms_url: str = "https://www.dropsugar.com"
22
+ mediacms_token: str
23
+
24
+ # Cover art APIs
25
+ tmdb_api_key: str = ""
26
+ omdb_api_key: str = ""
27
+
28
+ # Database
29
+ db_path: str = "/var/lib/kryten-webqueue/webqueue.db"
30
+
31
+ # Images
32
+ image_dir: str = "/var/lib/kryten-webqueue/images"
33
+ placeholder_dir: str = "/var/lib/kryten-webqueue/images/placeholders"
34
+
35
+ # Scheduling
36
+ catalog_sync_interval_hours: int = 4
37
+ pre_fire_lock_minutes_default: int = 15
38
+ state_poll_interval_sec: float = 3.0
39
+
40
+ # Monitoring
41
+ prometheus_port: int = 28292
42
+
43
+ @classmethod
44
+ def from_file(cls, path: str | Path) -> "Config":
45
+ with open(path) as f:
46
+ return cls(**json.load(f))
File without changes
@@ -0,0 +1,71 @@
1
+ import asyncio
2
+ import logging
3
+ from datetime import datetime, timedelta, UTC
4
+
5
+ from ..queue.ordering import refund_item
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ _queue_lock = asyncio.Lock()
10
+
11
+
12
+ async def fire_schedule(*, schedule_id: int, api_gate, db, shadow, ws_manager):
13
+ """Fire a scheduled playlist: clear queue, refund displaced pay items, load playlist."""
14
+ async with _queue_lock:
15
+ schedule = await db.get_schedule(schedule_id)
16
+ if not schedule:
17
+ logger.error(f"Schedule {schedule_id} not found")
18
+ return
19
+
20
+ playlist_id = schedule["playlist_id"]
21
+ playlist = await db.get_saved_playlist(playlist_id)
22
+ if not playlist:
23
+ logger.error(f"Playlist {playlist_id} not found for schedule {schedule_id}")
24
+ return
25
+
26
+ # Refund all pay items currently in queue
27
+ pay_items = await db.get_pay_items()
28
+ for item in pay_items:
29
+ await refund_item(api_gate=api_gate, db=db, uid=item["uid"], reason="schedule_displaced")
30
+
31
+ # Clear the CyTube playlist
32
+ await api_gate.playlist_clear()
33
+
34
+ # Load scheduled playlist items
35
+ items = await db.get_saved_playlist_items(playlist_id)
36
+ total_duration = 0
37
+ for item in items:
38
+ try:
39
+ await api_gate.playlist_add(
40
+ media_type=item["media_type"],
41
+ media_id=item["media_id"],
42
+ position="end",
43
+ )
44
+ total_duration += item.get("duration_sec", 0) or 0
45
+ except Exception as e:
46
+ logger.warning(f"Schedule fire: failed to add {item['media_id']}: {e}")
47
+
48
+ # Update active schedule
49
+ now = datetime.now(UTC)
50
+ await db.set_active_schedule(
51
+ schedule_id=schedule_id,
52
+ playlist_id=playlist_id,
53
+ is_immutable=playlist.get("is_immutable", False),
54
+ started_at=now.isoformat(),
55
+ estimated_end_at=(now + timedelta(seconds=total_duration)).isoformat(),
56
+ )
57
+
58
+ # Mark schedule as fired
59
+ await db.mark_schedule_fired(schedule_id, now.isoformat())
60
+
61
+ # Notify WS clients
62
+ await ws_manager.broadcast({
63
+ "type": "schedule_fired",
64
+ "data": {
65
+ "schedule_id": schedule_id,
66
+ "playlist_name": playlist["name"],
67
+ "is_immutable": playlist.get("is_immutable", False),
68
+ },
69
+ })
70
+
71
+ logger.info(f"Schedule {schedule_id} fired: playlist '{playlist['name']}' ({len(items)} items)")
@@ -0,0 +1,92 @@
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}
@@ -0,0 +1,72 @@
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
File without changes
@@ -0,0 +1,186 @@
1
+ import asyncio
2
+ import uuid
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ # Module-level lock for queue ordering
8
+ _queue_lock = asyncio.Lock()
9
+
10
+
11
+ async def insert_pay_queue(
12
+ *,
13
+ api_gate,
14
+ shadow,
15
+ db,
16
+ username: str,
17
+ media_type: str,
18
+ media_id: str,
19
+ title: str,
20
+ duration_sec: int,
21
+ tier: str,
22
+ z_cost: int,
23
+ ) -> dict:
24
+ """Insert a paid item at the end of the pay-queue section (FIFO)."""
25
+ async with _queue_lock:
26
+ request_id = str(uuid.uuid4())
27
+
28
+ # Spend currency
29
+ spend_result = await api_gate.queue_spend(
30
+ username=username,
31
+ duration_sec=duration_sec,
32
+ tier=tier,
33
+ request_id=request_id,
34
+ )
35
+ if not spend_result.get("success"):
36
+ return {"success": False, "error": spend_result.get("error", "Spend failed")}
37
+
38
+ # Find position: after last pay item, or prepend if none
39
+ last_pay_uid = await db.get_last_pay_uid()
40
+ position = "end" if not last_pay_uid else str(last_pay_uid)
41
+
42
+ # Add to CyTube playlist
43
+ add_result = await api_gate.playlist_add(
44
+ media_type=media_type,
45
+ media_id=media_id,
46
+ position=position,
47
+ )
48
+ if not add_result.get("success"):
49
+ # Refund on failure
50
+ await api_gate.queue_refund(username=username, request_id=request_id, reason="add_failed")
51
+ return {"success": False, "error": "Failed to add to playlist"}
52
+
53
+ uid = add_result["uid"]
54
+
55
+ # Move after last pay UID if needed
56
+ if last_pay_uid:
57
+ await api_gate.playlist_move(uid, last_pay_uid)
58
+
59
+ # Record spend
60
+ await db.save_spend_request(
61
+ request_id, username=username, uid=uid,
62
+ friendly_token=media_id if media_type == "cm" else None,
63
+ tier=tier, z_cost=z_cost,
64
+ )
65
+
66
+ # Update local shadow
67
+ item = {
68
+ "uid": uid,
69
+ "title": title,
70
+ "media_type": media_type,
71
+ "media_id": media_id,
72
+ "duration_sec": duration_sec,
73
+ "is_pay": True,
74
+ "paid_by": username,
75
+ "tier": tier,
76
+ "z_cost": z_cost,
77
+ "schedule_id": None,
78
+ }
79
+ # Position after last pay
80
+ if last_pay_uid:
81
+ pos = await db.get_shadow_position_after(last_pay_uid)
82
+ else:
83
+ pos = len(shadow.items)
84
+ await shadow.insert_at(item, pos)
85
+
86
+ # Queue history
87
+ await db.add_queue_history(
88
+ username=username, friendly_token=media_id if media_type == "cm" else None,
89
+ title=title, tier=tier, z_cost=z_cost,
90
+ )
91
+
92
+ return {"success": True, "uid": uid, "request_id": request_id}
93
+
94
+
95
+ async def insert_pay_playnext(
96
+ *,
97
+ api_gate,
98
+ shadow,
99
+ db,
100
+ username: str,
101
+ media_type: str,
102
+ media_id: str,
103
+ title: str,
104
+ duration_sec: int,
105
+ tier: str,
106
+ z_cost: int,
107
+ ) -> dict:
108
+ """Insert a paid item at position 0 (play next)."""
109
+ async with _queue_lock:
110
+ request_id = str(uuid.uuid4())
111
+
112
+ # Spend currency
113
+ spend_result = await api_gate.queue_spend(
114
+ username=username,
115
+ duration_sec=duration_sec,
116
+ tier=tier,
117
+ request_id=request_id,
118
+ )
119
+ if not spend_result.get("success"):
120
+ return {"success": False, "error": spend_result.get("error", "Spend failed")}
121
+
122
+ # Add to CyTube playlist at prepend position
123
+ add_result = await api_gate.playlist_add(
124
+ media_type=media_type,
125
+ media_id=media_id,
126
+ position="end",
127
+ )
128
+ if not add_result.get("success"):
129
+ await api_gate.queue_refund(username=username, request_id=request_id, reason="add_failed")
130
+ return {"success": False, "error": "Failed to add to playlist"}
131
+
132
+ uid = add_result["uid"]
133
+
134
+ # Move to front
135
+ await api_gate.playlist_move(uid, "prepend")
136
+
137
+ # Record spend
138
+ await db.save_spend_request(
139
+ request_id, username=username, uid=uid,
140
+ friendly_token=media_id if media_type == "cm" else None,
141
+ tier=tier, z_cost=z_cost,
142
+ )
143
+
144
+ # Update local shadow at position 0
145
+ item = {
146
+ "uid": uid,
147
+ "title": title,
148
+ "media_type": media_type,
149
+ "media_id": media_id,
150
+ "duration_sec": duration_sec,
151
+ "is_pay": True,
152
+ "paid_by": username,
153
+ "tier": tier,
154
+ "z_cost": z_cost,
155
+ "schedule_id": None,
156
+ }
157
+ await shadow.insert_at(item, 0)
158
+
159
+ await db.add_queue_history(
160
+ username=username, friendly_token=media_id if media_type == "cm" else None,
161
+ title=title, tier=tier, z_cost=z_cost,
162
+ )
163
+
164
+ return {"success": True, "uid": uid, "request_id": request_id}
165
+
166
+
167
+ async def refund_item(*, api_gate, db, uid: int, reason: str) -> bool:
168
+ """Refund a paid queue item."""
169
+ request_id = await db.get_request_id_for_uid(uid)
170
+ if not request_id:
171
+ return False
172
+
173
+ # Look up the spend to find username
174
+ row = await db._fetch_one("SELECT username FROM spend_requests WHERE request_id=?", [request_id])
175
+ if not row:
176
+ return False
177
+
178
+ result = await api_gate.queue_refund(
179
+ username=row["username"],
180
+ request_id=request_id,
181
+ reason=reason,
182
+ )
183
+ if result.get("success"):
184
+ await db.mark_spend_refunded(request_id)
185
+ return True
186
+ return False
@@ -0,0 +1,43 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+
7
+ class StatePoller:
8
+ """Polls api-gate at a fixed interval to keep QueueShadow in sync."""
9
+
10
+ def __init__(self, *, api_gate, shadow, ws_manager, interval: float = 3.0):
11
+ self._api_gate = api_gate
12
+ self._shadow = shadow
13
+ self._ws_manager = ws_manager
14
+ self._interval = interval
15
+ self._task: asyncio.Task | None = None
16
+
17
+ async def start(self):
18
+ self._task = asyncio.create_task(self._loop())
19
+ logger.info(f"StatePoller started (interval={self._interval}s)")
20
+
21
+ async def stop(self):
22
+ if self._task:
23
+ self._task.cancel()
24
+ try:
25
+ await self._task
26
+ except asyncio.CancelledError:
27
+ pass
28
+ logger.info("StatePoller stopped")
29
+
30
+ async def _loop(self):
31
+ while True:
32
+ try:
33
+ playlist = await self._api_gate.get_playlist()
34
+ now_playing = await self._api_gate.get_now_playing()
35
+ await self._shadow.apply_poll_result(playlist, now_playing)
36
+ # Broadcast updated state
37
+ state = self._shadow.get_queue_state()
38
+ await self._ws_manager.broadcast({"type": "queue_state", "data": state})
39
+ except asyncio.CancelledError:
40
+ raise
41
+ except Exception as e:
42
+ logger.warning(f"Poll error: {e}")
43
+ await asyncio.sleep(self._interval)