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.
- kryten_webqueue/__init__.py +0 -0
- kryten_webqueue/__main__.py +10 -0
- kryten_webqueue/api_gate/__init__.py +0 -0
- kryten_webqueue/api_gate/client.py +113 -0
- kryten_webqueue/app.py +184 -0
- kryten_webqueue/auth/__init__.py +0 -0
- kryten_webqueue/auth/otp.py +10 -0
- kryten_webqueue/auth/rate_limit.py +29 -0
- kryten_webqueue/auth/session.py +40 -0
- kryten_webqueue/catalog/__init__.py +0 -0
- kryten_webqueue/catalog/db.py +562 -0
- kryten_webqueue/catalog/images.py +114 -0
- kryten_webqueue/catalog/sync.py +96 -0
- kryten_webqueue/config.py +46 -0
- kryten_webqueue/playlists/__init__.py +0 -0
- kryten_webqueue/playlists/fire.py +71 -0
- kryten_webqueue/playlists/importer.py +92 -0
- kryten_webqueue/playlists/scheduler.py +72 -0
- kryten_webqueue/queue/__init__.py +0 -0
- kryten_webqueue/queue/ordering.py +186 -0
- kryten_webqueue/queue/poller.py +43 -0
- kryten_webqueue/queue/shadow.py +116 -0
- kryten_webqueue/routes/__init__.py +0 -0
- kryten_webqueue/routes/admin_playlists.py +98 -0
- kryten_webqueue/routes/admin_queue.py +64 -0
- kryten_webqueue/routes/admin_schedules.py +129 -0
- kryten_webqueue/routes/auth.py +83 -0
- kryten_webqueue/routes/catalog.py +44 -0
- kryten_webqueue/routes/pages.py +82 -0
- kryten_webqueue/routes/queue.py +144 -0
- kryten_webqueue/routes/user.py +35 -0
- kryten_webqueue/static/css/main.css +470 -0
- kryten_webqueue/static/js/main.js +26 -0
- kryten_webqueue/templates/admin/index.html +98 -0
- kryten_webqueue/templates/auth/login.html +69 -0
- kryten_webqueue/templates/base.html +41 -0
- kryten_webqueue/templates/catalog/browse.html +105 -0
- kryten_webqueue/templates/queue/index.html +126 -0
- kryten_webqueue/templates/user/dashboard.html +87 -0
- kryten_webqueue/ws/__init__.py +0 -0
- kryten_webqueue/ws/handler.py +59 -0
- kryten_webqueue/ws/manager.py +57 -0
- kryten_webqueue-0.1.1.dist-info/METADATA +127 -0
- kryten_webqueue-0.1.1.dist-info/RECORD +46 -0
- kryten_webqueue-0.1.1.dist-info/WHEEL +4 -0
- 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)
|