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
|
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
|