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
File without changes
@@ -0,0 +1,10 @@
1
+ import os
2
+ import uvicorn
3
+
4
+ from .config import Config
5
+ from .app import create_app
6
+
7
+ config_path = os.environ.get("WQ_CONFIG", "/etc/kryten-webqueue/config.json")
8
+ config = Config.from_file(config_path)
9
+ app = create_app(config)
10
+ uvicorn.run(app, host=config.host, port=config.port)
File without changes
@@ -0,0 +1,113 @@
1
+ import httpx
2
+
3
+
4
+ class ApiGateClient:
5
+ """HTTP client for kryten-api-gate."""
6
+
7
+ def __init__(self, base_url: str, token: str):
8
+ self._base_url = base_url.rstrip("/") + "/api/v1"
9
+ self._client = httpx.AsyncClient(
10
+ base_url=self._base_url,
11
+ headers={"Authorization": f"Bearer {token}"},
12
+ timeout=httpx.Timeout(10.0, connect=5.0),
13
+ )
14
+
15
+ async def close(self):
16
+ await self._client.aclose()
17
+
18
+ async def get(self, path: str, **params) -> dict:
19
+ resp = await self._client.get(path, params=params)
20
+ resp.raise_for_status()
21
+ return resp.json()
22
+
23
+ async def post(self, path: str, json: dict | None = None) -> dict:
24
+ resp = await self._client.post(path, json=json)
25
+ resp.raise_for_status()
26
+ return resp.json()
27
+
28
+ async def put(self, path: str, json: dict | None = None) -> dict:
29
+ resp = await self._client.put(path, json=json)
30
+ resp.raise_for_status()
31
+ return resp.json()
32
+
33
+ async def delete(self, path: str) -> dict:
34
+ resp = await self._client.delete(path)
35
+ resp.raise_for_status()
36
+ return resp.json()
37
+
38
+ # --- State ---
39
+
40
+ async def get_playlist(self) -> list[dict]:
41
+ result = await self.get("/state/playlist")
42
+ return result.get("items", [])
43
+
44
+ async def get_now_playing(self) -> dict:
45
+ return await self.get("/state/now-playing")
46
+
47
+ async def get_user(self, username: str) -> dict:
48
+ return await self.get(f"/state/user/{username}")
49
+
50
+ # --- Playlist CRUD ---
51
+
52
+ async def playlist_add(self, media_type: str, media_id: str, *, position: str = "end", temp: bool = True) -> dict:
53
+ """Add item to playlist. Returns {"success": bool, "uid": int|None}."""
54
+ return await self.post("/playlist/add", json={
55
+ "type": media_type,
56
+ "id": media_id,
57
+ "position": position,
58
+ "temp": temp,
59
+ })
60
+
61
+ async def playlist_move(self, uid: int, position: int | str) -> dict:
62
+ """Move item. position is a UID (int) or "prepend"/"append"."""
63
+ return await self.put(f"/playlist/{uid}/move", json={"position": position})
64
+
65
+ async def playlist_delete(self, uid: int) -> dict:
66
+ return await self.delete(f"/playlist/{uid}")
67
+
68
+ async def playlist_clear(self) -> dict:
69
+ return await self.delete("/playlist/")
70
+
71
+ async def playlist_jump(self, uid: int) -> dict:
72
+ return await self.post(f"/playlist/{uid}/jump")
73
+
74
+ # --- Chat ---
75
+
76
+ async def send_pm(self, username: str, message: str) -> dict:
77
+ return await self.post("/chat/pm", json={"username": username, "message": message})
78
+
79
+ # --- Admin ---
80
+
81
+ async def get_motd(self) -> str:
82
+ result = await self.get("/admin/motd")
83
+ return result.get("motd", "")
84
+
85
+ # --- Economy proxy ---
86
+
87
+ async def get_balance(self, username: str) -> dict:
88
+ return await self.get(f"/economy/balance/{username}")
89
+
90
+ async def get_transactions(self, username: str, limit: int = 20, offset: int = 0) -> dict:
91
+ return await self.get(f"/economy/transactions/{username}", limit=limit, offset=offset)
92
+
93
+ async def queue_preview(self, username: str, duration_sec: int, tier: str = "queue") -> dict:
94
+ return await self.post("/economy/queue-preview", json={
95
+ "username": username,
96
+ "duration_sec": duration_sec,
97
+ "tier": tier,
98
+ })
99
+
100
+ async def queue_spend(self, username: str, duration_sec: int, tier: str, request_id: str) -> dict:
101
+ return await self.post("/economy/queue-spend", json={
102
+ "username": username,
103
+ "duration_sec": duration_sec,
104
+ "tier": tier,
105
+ "request_id": request_id,
106
+ })
107
+
108
+ async def queue_refund(self, username: str, request_id: str, reason: str) -> dict:
109
+ return await self.post("/economy/queue-refund", json={
110
+ "username": username,
111
+ "request_id": request_id,
112
+ "reason": reason,
113
+ })
kryten_webqueue/app.py ADDED
@@ -0,0 +1,184 @@
1
+ import asyncio
2
+ import logging
3
+ from contextlib import asynccontextmanager
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.staticfiles import StaticFiles
7
+ from fastapi.templating import Jinja2Templates
8
+ from pathlib import Path
9
+
10
+ from .config import Config
11
+ from .catalog.db import Database
12
+ from .api_gate.client import ApiGateClient
13
+ from .catalog.sync import CatalogSync
14
+ from .catalog.images import CoverArtResolver
15
+ from .queue.shadow import QueueShadow
16
+ from .queue.poller import StatePoller
17
+ from .ws.manager import WebSocketManager
18
+ from .playlists.scheduler import PlaylistScheduler
19
+ from .auth.rate_limit import RateLimiter
20
+
21
+ from .routes.auth import router as auth_router
22
+ from .routes.catalog import router as catalog_router
23
+ from .routes.queue import router as queue_router
24
+ from .routes.user import router as user_router
25
+ from .routes.admin_playlists import router as admin_playlists_router
26
+ from .routes.admin_schedules import router as admin_schedules_router
27
+ from .routes.admin_queue import router as admin_queue_router
28
+ from .routes.pages import router as pages_router
29
+ from .ws.handler import router as ws_router
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ @asynccontextmanager
35
+ async def lifespan(app: FastAPI):
36
+ config: Config = app.state.config
37
+
38
+ # Database
39
+ db = Database(config.db_path)
40
+ await db.connect()
41
+ await db.run_migrations()
42
+ app.state.db = db
43
+
44
+ # API Gate client
45
+ api_gate = ApiGateClient(config.api_gate_url, config.api_gate_token)
46
+ app.state.api_gate = api_gate
47
+
48
+ # Catalog sync
49
+ catalog_sync = CatalogSync(
50
+ mediacms_url=config.mediacms_url,
51
+ mediacms_token=config.mediacms_token,
52
+ db=db,
53
+ )
54
+ app.state.catalog_sync = catalog_sync
55
+
56
+ # Cover art resolver
57
+ cover_art = CoverArtResolver(
58
+ image_dir=config.image_dir,
59
+ placeholder_dir=config.placeholder_dir,
60
+ tmdb_api_key=config.tmdb_api_key,
61
+ omdb_api_key=config.omdb_api_key,
62
+ )
63
+ app.state.cover_art = cover_art
64
+
65
+ # WebSocket manager
66
+ ws_manager = WebSocketManager()
67
+ app.state.ws_manager = ws_manager
68
+
69
+ # Queue shadow
70
+ shadow = QueueShadow(db)
71
+ await shadow.load_from_db()
72
+ app.state.shadow = shadow
73
+
74
+ # State poller
75
+ poller = StatePoller(
76
+ api_gate=api_gate,
77
+ shadow=shadow,
78
+ ws_manager=ws_manager,
79
+ interval=config.state_poll_interval_sec,
80
+ )
81
+ await poller.start()
82
+ app.state.poller = poller
83
+
84
+ # Rate limiter
85
+ app.state.rate_limiter = RateLimiter()
86
+
87
+ # Playlist scheduler
88
+ scheduler = PlaylistScheduler(db=db, api_gate=api_gate, shadow=shadow, ws_manager=ws_manager)
89
+ await scheduler.start()
90
+ app.state.scheduler = scheduler
91
+
92
+ # Background workers
93
+ async def _catalog_sync_loop():
94
+ interval = config.catalog_sync_interval_hours * 3600
95
+ while True:
96
+ await asyncio.sleep(interval)
97
+ try:
98
+ await catalog_sync.sync()
99
+ except Exception as e:
100
+ logger.error(f"Catalog sync error: {e}")
101
+
102
+ async def _otp_cleanup_loop():
103
+ while True:
104
+ await asyncio.sleep(3600) # every hour
105
+ try:
106
+ await db.cleanup_expired_otps()
107
+ except Exception as e:
108
+ logger.error(f"OTP cleanup error: {e}")
109
+
110
+ async def _immutability_expiry_loop():
111
+ while True:
112
+ await asyncio.sleep(300) # every 5 minutes
113
+ try:
114
+ await db._execute("""
115
+ UPDATE saved_playlists SET is_immutable = 0
116
+ WHERE id IN (
117
+ SELECT sp.id FROM saved_playlists sp
118
+ JOIN playlist_schedules ps ON ps.playlist_id = sp.id
119
+ WHERE sp.is_immutable = 1
120
+ AND ps.immutability_expires_at IS NOT NULL
121
+ AND ps.immutability_expires_at < datetime('now')
122
+ AND NOT EXISTS (
123
+ SELECT 1 FROM playlist_schedules ps2
124
+ WHERE ps2.playlist_id = sp.id
125
+ AND ps2.is_active = 1
126
+ AND (ps2.immutability_expires_at IS NULL OR ps2.immutability_expires_at > datetime('now'))
127
+ )
128
+ )
129
+ """)
130
+ except Exception as e:
131
+ logger.error(f"Immutability expiry error: {e}")
132
+
133
+ bg_tasks = [
134
+ asyncio.create_task(_catalog_sync_loop()),
135
+ asyncio.create_task(_otp_cleanup_loop()),
136
+ asyncio.create_task(_immutability_expiry_loop()),
137
+ ]
138
+
139
+ logger.info(f"kryten-webqueue started on {config.host}:{config.port}")
140
+
141
+ yield
142
+
143
+ # Shutdown
144
+ for task in bg_tasks:
145
+ task.cancel()
146
+ await poller.stop()
147
+ await scheduler.stop()
148
+ await catalog_sync.close()
149
+ await cover_art.close()
150
+ await api_gate.close()
151
+ await db.close()
152
+ logger.info("kryten-webqueue shut down")
153
+
154
+
155
+ def create_app(config: Config) -> FastAPI:
156
+ app = FastAPI(
157
+ title="kryten-webqueue",
158
+ version="0.1.0",
159
+ lifespan=lifespan,
160
+ )
161
+ app.state.config = config
162
+
163
+ # Register routes
164
+ app.include_router(pages_router)
165
+ app.include_router(auth_router)
166
+ app.include_router(catalog_router)
167
+ app.include_router(queue_router)
168
+ app.include_router(user_router)
169
+ app.include_router(admin_playlists_router)
170
+ app.include_router(admin_schedules_router)
171
+ app.include_router(admin_queue_router)
172
+ app.include_router(ws_router)
173
+
174
+ # Health check
175
+ @app.get("/health")
176
+ async def health():
177
+ return {"status": "ok"}
178
+
179
+ # Static files
180
+ static_dir = Path(__file__).parent / "static"
181
+ if static_dir.exists():
182
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
183
+
184
+ return app
File without changes
@@ -0,0 +1,10 @@
1
+ import secrets
2
+ from datetime import datetime, timedelta, UTC
3
+
4
+
5
+ def generate_otp() -> str:
6
+ return str(secrets.randbelow(1000000)).zfill(6)
7
+
8
+
9
+ def get_otp_expiry(minutes: int = 5) -> str:
10
+ return (datetime.now(UTC) + timedelta(minutes=minutes)).isoformat()
@@ -0,0 +1,29 @@
1
+ import time
2
+ from collections import defaultdict, deque
3
+
4
+
5
+ class RateLimiter:
6
+ """Sliding-window rate limiter for OTP requests."""
7
+
8
+ def __init__(self, max_requests: int = 3, window_seconds: int = 300):
9
+ self._max = max_requests
10
+ self._window = window_seconds
11
+ self._requests: dict[str, deque] = defaultdict(deque)
12
+
13
+ def is_allowed(self, key: str) -> bool:
14
+ now = time.time()
15
+ window = self._requests[key]
16
+ # Remove expired entries
17
+ while window and window[0] < now - self._window:
18
+ window.popleft()
19
+ if len(window) >= self._max:
20
+ return False
21
+ window.append(now)
22
+ return True
23
+
24
+ def remaining(self, key: str) -> int:
25
+ now = time.time()
26
+ window = self._requests[key]
27
+ while window and window[0] < now - self._window:
28
+ window.popleft()
29
+ return max(0, self._max - len(window))
@@ -0,0 +1,40 @@
1
+ import jwt
2
+ from datetime import datetime, timedelta, UTC
3
+ from fastapi import Request, HTTPException
4
+
5
+
6
+ def create_session_token(username: str, rank: int, secret_key: str, ttl_hours: int = 24) -> str:
7
+ payload = {
8
+ "sub": username,
9
+ "rank": rank,
10
+ "iat": datetime.now(UTC),
11
+ "exp": datetime.now(UTC) + timedelta(hours=ttl_hours),
12
+ }
13
+ return jwt.encode(payload, secret_key, algorithm="HS256")
14
+
15
+
16
+ def decode_session_token(token: str, secret_key: str) -> dict:
17
+ return jwt.decode(token, secret_key, algorithms=["HS256"])
18
+
19
+
20
+ async def get_current_user(request: Request) -> dict:
21
+ """FastAPI dependency: extract user from session cookie."""
22
+ token = request.cookies.get("session")
23
+ if not token:
24
+ raise HTTPException(status_code=401, detail="Not authenticated")
25
+ config = request.app.state.config
26
+ try:
27
+ payload = decode_session_token(token, config.secret_key)
28
+ except jwt.ExpiredSignatureError:
29
+ raise HTTPException(status_code=401, detail="Session expired")
30
+ except jwt.InvalidTokenError:
31
+ raise HTTPException(status_code=401, detail="Invalid session")
32
+ return {"username": payload["sub"], "rank": payload["rank"]}
33
+
34
+
35
+ async def require_admin(request: Request) -> dict:
36
+ """FastAPI dependency: require rank >= 3."""
37
+ user = await get_current_user(request)
38
+ if user["rank"] < 3:
39
+ raise HTTPException(status_code=403, detail="Admin required")
40
+ return user
File without changes