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,116 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from datetime import datetime, timedelta, UTC
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QueueShadow:
|
|
9
|
+
"""Local mirror of the CyTube playlist state."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, db, now_playing: dict | None = None):
|
|
12
|
+
self._db = db
|
|
13
|
+
self._items: list[dict] = []
|
|
14
|
+
self._now_playing: dict | None = now_playing
|
|
15
|
+
self._lock = asyncio.Lock()
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def items(self) -> list[dict]:
|
|
19
|
+
return self._items.copy()
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def now_playing(self) -> dict | None:
|
|
23
|
+
return self._now_playing
|
|
24
|
+
|
|
25
|
+
async def load_from_db(self):
|
|
26
|
+
"""Load initial state from database."""
|
|
27
|
+
self._items = await self._db.get_shadow_items()
|
|
28
|
+
|
|
29
|
+
async def apply_poll_result(self, playlist_items: list[dict], now_playing: dict | None):
|
|
30
|
+
"""Reconcile polled state with local shadow."""
|
|
31
|
+
async with self._lock:
|
|
32
|
+
self._now_playing = now_playing
|
|
33
|
+
|
|
34
|
+
polled_uids = {item["uid"] for item in playlist_items}
|
|
35
|
+
local_uids = {item["uid"] for item in self._items}
|
|
36
|
+
|
|
37
|
+
# Removed items
|
|
38
|
+
removed = local_uids - polled_uids
|
|
39
|
+
if removed:
|
|
40
|
+
await self._db.remove_shadow_items(removed)
|
|
41
|
+
|
|
42
|
+
# Rebuild item list from poll result, preserving local metadata
|
|
43
|
+
local_map = {item["uid"]: item for item in self._items}
|
|
44
|
+
new_items = []
|
|
45
|
+
|
|
46
|
+
for pos, polled in enumerate(playlist_items):
|
|
47
|
+
uid = polled["uid"]
|
|
48
|
+
if uid in local_map:
|
|
49
|
+
# Preserve local metadata, update position
|
|
50
|
+
merged = {**local_map[uid], **polled, "position": pos}
|
|
51
|
+
else:
|
|
52
|
+
# New item from external source
|
|
53
|
+
merged = {
|
|
54
|
+
"uid": uid,
|
|
55
|
+
"position": pos,
|
|
56
|
+
"title": polled.get("title", ""),
|
|
57
|
+
"media_type": polled.get("type", "unknown"),
|
|
58
|
+
"media_id": polled.get("id", ""),
|
|
59
|
+
"duration_sec": polled.get("duration", 0),
|
|
60
|
+
"is_pay": False,
|
|
61
|
+
"paid_by": None,
|
|
62
|
+
"tier": None,
|
|
63
|
+
"z_cost": None,
|
|
64
|
+
"schedule_id": None,
|
|
65
|
+
"added_at": datetime.now(UTC).isoformat(),
|
|
66
|
+
}
|
|
67
|
+
await self._db.upsert_shadow_item(merged)
|
|
68
|
+
|
|
69
|
+
new_items.append(merged)
|
|
70
|
+
|
|
71
|
+
self._items = new_items
|
|
72
|
+
await self._recalculate_estimated_starts()
|
|
73
|
+
|
|
74
|
+
async def _recalculate_estimated_starts(self):
|
|
75
|
+
"""Recalculate estimated start times based on position and now-playing."""
|
|
76
|
+
if not self._items:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
# Start from now-playing elapsed or now
|
|
80
|
+
start_cursor = datetime.now(UTC)
|
|
81
|
+
if self._now_playing:
|
|
82
|
+
remaining = (self._now_playing.get("duration", 0) or 0) - (self._now_playing.get("currentTime", 0) or 0)
|
|
83
|
+
start_cursor += timedelta(seconds=max(0, remaining))
|
|
84
|
+
|
|
85
|
+
for item in self._items:
|
|
86
|
+
item["estimated_start_at"] = start_cursor.isoformat()
|
|
87
|
+
duration = item.get("duration_sec", 0) or 0
|
|
88
|
+
start_cursor += timedelta(seconds=duration)
|
|
89
|
+
|
|
90
|
+
async def insert_at(self, item: dict, position: int):
|
|
91
|
+
"""Insert a new item at given position in local shadow."""
|
|
92
|
+
async with self._lock:
|
|
93
|
+
item["position"] = position
|
|
94
|
+
self._items.insert(position, item)
|
|
95
|
+
# Re-index
|
|
96
|
+
for i, it in enumerate(self._items):
|
|
97
|
+
it["position"] = i
|
|
98
|
+
await self._db.upsert_shadow_item(item)
|
|
99
|
+
await self._recalculate_estimated_starts()
|
|
100
|
+
|
|
101
|
+
async def remove(self, uid: int):
|
|
102
|
+
"""Remove item from shadow."""
|
|
103
|
+
async with self._lock:
|
|
104
|
+
self._items = [it for it in self._items if it["uid"] != uid]
|
|
105
|
+
for i, it in enumerate(self._items):
|
|
106
|
+
it["position"] = i
|
|
107
|
+
await self._db.remove_shadow_items({uid})
|
|
108
|
+
await self._recalculate_estimated_starts()
|
|
109
|
+
|
|
110
|
+
def get_queue_state(self) -> dict:
|
|
111
|
+
"""Return serializable queue state for WebSocket broadcast."""
|
|
112
|
+
return {
|
|
113
|
+
"items": self._items,
|
|
114
|
+
"now_playing": self._now_playing,
|
|
115
|
+
"updated_at": datetime.now(UTC).isoformat(),
|
|
116
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from ..auth.session import require_admin
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/admin/playlists", tags=["admin"])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.get("/")
|
|
10
|
+
async def list_playlists(request: Request, user: dict = Depends(require_admin)):
|
|
11
|
+
"""List all saved playlists."""
|
|
12
|
+
db = request.app.state.db
|
|
13
|
+
return await db.get_saved_playlists()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/{playlist_id}")
|
|
17
|
+
async def get_playlist(request: Request, playlist_id: int, user: dict = Depends(require_admin)):
|
|
18
|
+
"""Get a saved playlist with its items."""
|
|
19
|
+
db = request.app.state.db
|
|
20
|
+
playlist = await db.get_saved_playlist(playlist_id)
|
|
21
|
+
if not playlist:
|
|
22
|
+
raise HTTPException(404, "Playlist not found")
|
|
23
|
+
items = await db.get_saved_playlist_items(playlist_id)
|
|
24
|
+
return {**playlist, "items": items}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.post("/")
|
|
28
|
+
async def create_playlist(request: Request, user: dict = Depends(require_admin)):
|
|
29
|
+
"""Create a new saved playlist."""
|
|
30
|
+
body = await request.json()
|
|
31
|
+
db = request.app.state.db
|
|
32
|
+
playlist_id = await db.create_saved_playlist(
|
|
33
|
+
name=body["name"],
|
|
34
|
+
description=body.get("description"),
|
|
35
|
+
is_immutable=body.get("is_immutable", False),
|
|
36
|
+
created_by=user["username"],
|
|
37
|
+
)
|
|
38
|
+
return {"id": playlist_id}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.put("/{playlist_id}")
|
|
42
|
+
async def update_playlist(request: Request, playlist_id: int, user: dict = Depends(require_admin)):
|
|
43
|
+
"""Update playlist metadata."""
|
|
44
|
+
body = await request.json()
|
|
45
|
+
db = request.app.state.db
|
|
46
|
+
playlist = await db.get_saved_playlist(playlist_id)
|
|
47
|
+
if not playlist:
|
|
48
|
+
raise HTTPException(404, "Playlist not found")
|
|
49
|
+
await db.update_saved_playlist(
|
|
50
|
+
playlist_id,
|
|
51
|
+
name=body.get("name", playlist["name"]),
|
|
52
|
+
description=body.get("description", playlist.get("description")),
|
|
53
|
+
is_immutable=body.get("is_immutable", playlist.get("is_immutable", False)),
|
|
54
|
+
)
|
|
55
|
+
return {"success": True}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.delete("/{playlist_id}")
|
|
59
|
+
async def delete_playlist(request: Request, playlist_id: int, user: dict = Depends(require_admin)):
|
|
60
|
+
"""Delete a saved playlist."""
|
|
61
|
+
db = request.app.state.db
|
|
62
|
+
playlist = await db.get_saved_playlist(playlist_id)
|
|
63
|
+
if not playlist:
|
|
64
|
+
raise HTTPException(404, "Playlist not found")
|
|
65
|
+
await db.delete_saved_playlist(playlist_id)
|
|
66
|
+
return {"success": True}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.put("/{playlist_id}/items")
|
|
70
|
+
async def replace_items(request: Request, playlist_id: int, user: dict = Depends(require_admin)):
|
|
71
|
+
"""Replace all items in a playlist."""
|
|
72
|
+
body = await request.json()
|
|
73
|
+
items = body.get("items", [])
|
|
74
|
+
db = request.app.state.db
|
|
75
|
+
playlist = await db.get_saved_playlist(playlist_id)
|
|
76
|
+
if not playlist:
|
|
77
|
+
raise HTTPException(404, "Playlist not found")
|
|
78
|
+
await db.replace_playlist_items(playlist_id, items)
|
|
79
|
+
return {"success": True, "count": len(items)}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.post("/{playlist_id}/import")
|
|
83
|
+
async def import_to_live(request: Request, playlist_id: int, user: dict = Depends(require_admin)):
|
|
84
|
+
"""Import a saved playlist into the live CyTube queue."""
|
|
85
|
+
from ..playlists.importer import PlaylistImporter
|
|
86
|
+
|
|
87
|
+
db = request.app.state.db
|
|
88
|
+
playlist = await db.get_saved_playlist(playlist_id)
|
|
89
|
+
if not playlist:
|
|
90
|
+
raise HTTPException(404, "Playlist not found")
|
|
91
|
+
|
|
92
|
+
importer = PlaylistImporter(
|
|
93
|
+
api_gate=request.app.state.api_gate,
|
|
94
|
+
db=db,
|
|
95
|
+
shadow=request.app.state.shadow,
|
|
96
|
+
)
|
|
97
|
+
result = await importer.import_playlist(playlist_id)
|
|
98
|
+
return result
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
2
|
+
|
|
3
|
+
from ..auth.session import require_admin
|
|
4
|
+
from ..queue.ordering import refund_item
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/admin/queue", tags=["admin"])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.post("/clear")
|
|
10
|
+
async def clear_queue(request: Request, user: dict = Depends(require_admin)):
|
|
11
|
+
"""Clear the CyTube playlist (refunds all pay items)."""
|
|
12
|
+
db = request.app.state.db
|
|
13
|
+
api_gate = request.app.state.api_gate
|
|
14
|
+
shadow = request.app.state.shadow
|
|
15
|
+
|
|
16
|
+
# Refund all pay items
|
|
17
|
+
pay_items = await db.get_pay_items()
|
|
18
|
+
for item in pay_items:
|
|
19
|
+
await refund_item(api_gate=api_gate, db=db, uid=item["uid"], reason="admin_clear")
|
|
20
|
+
|
|
21
|
+
# Clear playlist
|
|
22
|
+
await api_gate.playlist_clear()
|
|
23
|
+
return {"success": True, "refunded": len(pay_items)}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.delete("/{uid}")
|
|
27
|
+
async def remove_item(request: Request, uid: int, user: dict = Depends(require_admin)):
|
|
28
|
+
"""Remove an item from queue (with refund if paid)."""
|
|
29
|
+
db = request.app.state.db
|
|
30
|
+
api_gate = request.app.state.api_gate
|
|
31
|
+
shadow = request.app.state.shadow
|
|
32
|
+
|
|
33
|
+
# Try refund
|
|
34
|
+
await refund_item(api_gate=api_gate, db=db, uid=uid, reason="admin_remove")
|
|
35
|
+
|
|
36
|
+
# Remove from CyTube
|
|
37
|
+
await api_gate.playlist_delete(uid)
|
|
38
|
+
await shadow.remove(uid)
|
|
39
|
+
return {"success": True}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post("/{uid}/jump")
|
|
43
|
+
async def jump_to(request: Request, uid: int, user: dict = Depends(require_admin)):
|
|
44
|
+
"""Jump to a specific item in the playlist."""
|
|
45
|
+
api_gate = request.app.state.api_gate
|
|
46
|
+
await api_gate.playlist_jump(uid)
|
|
47
|
+
return {"success": True}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.get("/sync-logs")
|
|
51
|
+
async def get_sync_logs(request: Request, user: dict = Depends(require_admin)):
|
|
52
|
+
"""Get recent catalog sync logs."""
|
|
53
|
+
db = request.app.state.db
|
|
54
|
+
return await db.get_sync_logs()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.post("/sync-now")
|
|
58
|
+
async def trigger_sync(request: Request, user: dict = Depends(require_admin)):
|
|
59
|
+
"""Trigger an immediate catalog sync."""
|
|
60
|
+
catalog_sync = request.app.state.catalog_sync
|
|
61
|
+
# Run in background to not block response
|
|
62
|
+
import asyncio
|
|
63
|
+
asyncio.create_task(catalog_sync.sync())
|
|
64
|
+
return {"success": True, "message": "Sync started"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from ..auth.session import require_admin
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/admin/schedules", tags=["admin"])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.get("/")
|
|
10
|
+
async def list_schedules(request: Request, user: dict = Depends(require_admin)):
|
|
11
|
+
"""List all playlist schedules."""
|
|
12
|
+
db = request.app.state.db
|
|
13
|
+
return await db.get_schedules()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/active")
|
|
17
|
+
async def get_active(request: Request, user: dict = Depends(require_admin)):
|
|
18
|
+
"""Get the currently active schedule."""
|
|
19
|
+
db = request.app.state.db
|
|
20
|
+
active = await db.get_active_schedule()
|
|
21
|
+
return active or {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/next")
|
|
25
|
+
async def get_next(request: Request, user: dict = Depends(require_admin)):
|
|
26
|
+
"""Get the next upcoming schedule."""
|
|
27
|
+
db = request.app.state.db
|
|
28
|
+
next_sched = await db.get_next_schedule()
|
|
29
|
+
return next_sched or {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/{schedule_id}")
|
|
33
|
+
async def get_schedule(request: Request, schedule_id: int, user: dict = Depends(require_admin)):
|
|
34
|
+
"""Get a specific schedule."""
|
|
35
|
+
db = request.app.state.db
|
|
36
|
+
sched = await db.get_schedule(schedule_id)
|
|
37
|
+
if not sched:
|
|
38
|
+
raise HTTPException(404, "Schedule not found")
|
|
39
|
+
return sched
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post("/")
|
|
43
|
+
async def create_schedule(request: Request, user: dict = Depends(require_admin)):
|
|
44
|
+
"""Create a new schedule."""
|
|
45
|
+
body = await request.json()
|
|
46
|
+
db = request.app.state.db
|
|
47
|
+
scheduler = request.app.state.scheduler
|
|
48
|
+
|
|
49
|
+
schedule_id = await db.create_schedule(
|
|
50
|
+
playlist_id=body["playlist_id"],
|
|
51
|
+
label=body["label"],
|
|
52
|
+
fire_at=body["fire_at"],
|
|
53
|
+
is_recurring=body.get("is_recurring", False),
|
|
54
|
+
rrule=body.get("rrule"),
|
|
55
|
+
pre_fire_lock_minutes=body.get("pre_fire_lock_minutes", 15),
|
|
56
|
+
is_active=True,
|
|
57
|
+
created_by=user["username"],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Register with APScheduler
|
|
61
|
+
fire_at = datetime.fromisoformat(body["fire_at"])
|
|
62
|
+
await scheduler.add_schedule(schedule_id, fire_at)
|
|
63
|
+
|
|
64
|
+
return {"id": schedule_id}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.put("/{schedule_id}")
|
|
68
|
+
async def update_schedule(request: Request, schedule_id: int, user: dict = Depends(require_admin)):
|
|
69
|
+
"""Update a schedule."""
|
|
70
|
+
body = await request.json()
|
|
71
|
+
db = request.app.state.db
|
|
72
|
+
scheduler = request.app.state.scheduler
|
|
73
|
+
|
|
74
|
+
sched = await db.get_schedule(schedule_id)
|
|
75
|
+
if not sched:
|
|
76
|
+
raise HTTPException(404, "Schedule not found")
|
|
77
|
+
|
|
78
|
+
await db.update_schedule(schedule_id, **body)
|
|
79
|
+
|
|
80
|
+
# Re-register if fire_at changed
|
|
81
|
+
if "fire_at" in body:
|
|
82
|
+
await scheduler.remove_schedule(schedule_id)
|
|
83
|
+
fire_at = datetime.fromisoformat(body["fire_at"])
|
|
84
|
+
await scheduler.add_schedule(schedule_id, fire_at)
|
|
85
|
+
|
|
86
|
+
return {"success": True}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.delete("/{schedule_id}")
|
|
90
|
+
async def delete_schedule(request: Request, schedule_id: int, user: dict = Depends(require_admin)):
|
|
91
|
+
"""Delete a schedule."""
|
|
92
|
+
db = request.app.state.db
|
|
93
|
+
scheduler = request.app.state.scheduler
|
|
94
|
+
|
|
95
|
+
sched = await db.get_schedule(schedule_id)
|
|
96
|
+
if not sched:
|
|
97
|
+
raise HTTPException(404, "Schedule not found")
|
|
98
|
+
|
|
99
|
+
await scheduler.remove_schedule(schedule_id)
|
|
100
|
+
await db.delete_schedule(schedule_id)
|
|
101
|
+
return {"success": True}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@router.post("/{schedule_id}/fire")
|
|
105
|
+
async def fire_now(request: Request, schedule_id: int, user: dict = Depends(require_admin)):
|
|
106
|
+
"""Manually fire a schedule immediately."""
|
|
107
|
+
from ..playlists.fire import fire_schedule
|
|
108
|
+
|
|
109
|
+
db = request.app.state.db
|
|
110
|
+
sched = await db.get_schedule(schedule_id)
|
|
111
|
+
if not sched:
|
|
112
|
+
raise HTTPException(404, "Schedule not found")
|
|
113
|
+
|
|
114
|
+
await fire_schedule(
|
|
115
|
+
schedule_id=schedule_id,
|
|
116
|
+
api_gate=request.app.state.api_gate,
|
|
117
|
+
db=db,
|
|
118
|
+
shadow=request.app.state.shadow,
|
|
119
|
+
ws_manager=request.app.state.ws_manager,
|
|
120
|
+
)
|
|
121
|
+
return {"success": True}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@router.post("/clear-active")
|
|
125
|
+
async def clear_active(request: Request, user: dict = Depends(require_admin)):
|
|
126
|
+
"""Clear the active schedule (return to free mode)."""
|
|
127
|
+
db = request.app.state.db
|
|
128
|
+
await db.clear_active_schedule()
|
|
129
|
+
return {"success": True}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request, HTTPException, Response, Depends
|
|
2
|
+
|
|
3
|
+
from ..auth.otp import generate_otp, get_otp_expiry
|
|
4
|
+
from ..auth.session import create_session_token, get_current_user
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.post("/otp/request")
|
|
10
|
+
async def request_otp(request: Request):
|
|
11
|
+
"""Request a one-time password delivered via PM."""
|
|
12
|
+
body = await request.json()
|
|
13
|
+
username = body.get("username", "").strip()
|
|
14
|
+
if not username:
|
|
15
|
+
raise HTTPException(400, "username required")
|
|
16
|
+
|
|
17
|
+
rate_limiter = request.app.state.rate_limiter
|
|
18
|
+
if not rate_limiter.is_allowed(f"otp:{username}"):
|
|
19
|
+
raise HTTPException(429, "Too many OTP requests. Try again later.")
|
|
20
|
+
|
|
21
|
+
# Generate OTP
|
|
22
|
+
code = generate_otp()
|
|
23
|
+
expires_at = get_otp_expiry()
|
|
24
|
+
|
|
25
|
+
# Store in DB
|
|
26
|
+
db = request.app.state.db
|
|
27
|
+
await db.store_otp(username, code, expires_at)
|
|
28
|
+
|
|
29
|
+
# Deliver via PM through api-gate
|
|
30
|
+
api_gate = request.app.state.api_gate
|
|
31
|
+
await api_gate.send_pm(username, f"Your login code: {code} (expires in 5 minutes)")
|
|
32
|
+
|
|
33
|
+
return {"success": True}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.post("/otp/verify")
|
|
37
|
+
async def verify_otp(request: Request, response: Response):
|
|
38
|
+
"""Verify OTP and issue session cookie."""
|
|
39
|
+
body = await request.json()
|
|
40
|
+
username = body.get("username", "").strip()
|
|
41
|
+
code = body.get("code", "").strip()
|
|
42
|
+
if not username or not code:
|
|
43
|
+
raise HTTPException(400, "username and code required")
|
|
44
|
+
|
|
45
|
+
db = request.app.state.db
|
|
46
|
+
valid = await db.verify_otp(username, code)
|
|
47
|
+
if not valid:
|
|
48
|
+
raise HTTPException(401, "Invalid or expired code")
|
|
49
|
+
|
|
50
|
+
# Look up user rank from api-gate
|
|
51
|
+
api_gate = request.app.state.api_gate
|
|
52
|
+
try:
|
|
53
|
+
user_data = await api_gate.get_user(username)
|
|
54
|
+
rank = user_data.get("rank", 1) if user_data else 1
|
|
55
|
+
except Exception:
|
|
56
|
+
rank = 1
|
|
57
|
+
|
|
58
|
+
# Issue JWT session cookie
|
|
59
|
+
config = request.app.state.config
|
|
60
|
+
token = create_session_token(username, rank, config.secret_key, config.session_ttl_hours)
|
|
61
|
+
|
|
62
|
+
response.set_cookie(
|
|
63
|
+
key="session",
|
|
64
|
+
value=token,
|
|
65
|
+
httponly=True,
|
|
66
|
+
secure=True,
|
|
67
|
+
samesite="strict",
|
|
68
|
+
max_age=config.session_ttl_hours * 3600,
|
|
69
|
+
)
|
|
70
|
+
return {"success": True, "username": username, "rank": rank}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.post("/logout")
|
|
74
|
+
async def logout(response: Response, user: dict = Depends(get_current_user)):
|
|
75
|
+
"""Clear session cookie."""
|
|
76
|
+
response.delete_cookie("session")
|
|
77
|
+
return {"success": True}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.get("/me")
|
|
81
|
+
async def me(user: dict = Depends(get_current_user)):
|
|
82
|
+
"""Return current session info."""
|
|
83
|
+
return user
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
2
|
+
|
|
3
|
+
from ..auth.session import get_current_user
|
|
4
|
+
|
|
5
|
+
router = APIRouter(prefix="/catalog", tags=["catalog"])
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.get("/browse")
|
|
9
|
+
async def browse(request: Request, category: str | None = None, page: int = 1,
|
|
10
|
+
user: dict = Depends(get_current_user)):
|
|
11
|
+
"""Browse catalog with optional category filter."""
|
|
12
|
+
db = request.app.state.db
|
|
13
|
+
items = await db.browse(category=category, page=page)
|
|
14
|
+
categories = await db.get_categories()
|
|
15
|
+
return {"items": items, "categories": categories, "page": page}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/search")
|
|
19
|
+
async def search(request: Request, q: str = "", page: int = 1,
|
|
20
|
+
user: dict = Depends(get_current_user)):
|
|
21
|
+
"""Full-text search of catalog."""
|
|
22
|
+
if not q.strip():
|
|
23
|
+
raise HTTPException(400, "Query required")
|
|
24
|
+
db = request.app.state.db
|
|
25
|
+
items = await db.search(q, page=page)
|
|
26
|
+
return {"items": items, "query": q, "page": page}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.get("/item/{friendly_token}")
|
|
30
|
+
async def get_item(request: Request, friendly_token: str,
|
|
31
|
+
user: dict = Depends(get_current_user)):
|
|
32
|
+
"""Get single catalog item detail."""
|
|
33
|
+
db = request.app.state.db
|
|
34
|
+
item = await db.get_item(friendly_token)
|
|
35
|
+
if not item:
|
|
36
|
+
raise HTTPException(404, "Item not found")
|
|
37
|
+
return item
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/categories")
|
|
41
|
+
async def list_categories(request: Request, user: dict = Depends(get_current_user)):
|
|
42
|
+
"""List all categories."""
|
|
43
|
+
db = request.app.state.db
|
|
44
|
+
return await db.get_categories()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request, Depends
|
|
2
|
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
3
|
+
from fastapi.templating import Jinja2Templates
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..auth.session import get_current_user
|
|
7
|
+
|
|
8
|
+
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
|
9
|
+
|
|
10
|
+
router = APIRouter(tags=["pages"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_user_or_none(request: Request) -> dict | None:
|
|
14
|
+
"""Non-throwing user extraction for page rendering."""
|
|
15
|
+
token = request.cookies.get("session")
|
|
16
|
+
if not token:
|
|
17
|
+
return None
|
|
18
|
+
import jwt
|
|
19
|
+
try:
|
|
20
|
+
payload = jwt.decode(token, request.app.state.config.secret_key, algorithms=["HS256"])
|
|
21
|
+
return {"username": payload["sub"], "rank": payload["rank"]}
|
|
22
|
+
except Exception:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.get("/", response_class=HTMLResponse)
|
|
27
|
+
async def home(request: Request):
|
|
28
|
+
user = _get_user_or_none(request)
|
|
29
|
+
if user:
|
|
30
|
+
return RedirectResponse("/catalog/browse")
|
|
31
|
+
return RedirectResponse("/auth/login")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("/auth/login", response_class=HTMLResponse)
|
|
35
|
+
async def login_page(request: Request):
|
|
36
|
+
user = _get_user_or_none(request)
|
|
37
|
+
if user:
|
|
38
|
+
return RedirectResponse("/catalog/browse")
|
|
39
|
+
return templates.TemplateResponse("auth/login.html", {"request": request, "user": None})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.get("/catalog/browse", response_class=HTMLResponse)
|
|
43
|
+
async def catalog_browse_page(request: Request, category: str | None = None, page: int = 1):
|
|
44
|
+
user = _get_user_or_none(request)
|
|
45
|
+
if not user:
|
|
46
|
+
return RedirectResponse("/auth/login")
|
|
47
|
+
db = request.app.state.db
|
|
48
|
+
items = await db.browse(category=category, page=page)
|
|
49
|
+
categories = await db.get_categories()
|
|
50
|
+
return templates.TemplateResponse("catalog/browse.html", {
|
|
51
|
+
"request": request,
|
|
52
|
+
"user": user,
|
|
53
|
+
"items": items,
|
|
54
|
+
"categories": categories,
|
|
55
|
+
"page": page,
|
|
56
|
+
"active_category": category,
|
|
57
|
+
"query": None,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.get("/queue", response_class=HTMLResponse)
|
|
62
|
+
async def queue_page(request: Request):
|
|
63
|
+
user = _get_user_or_none(request)
|
|
64
|
+
if not user:
|
|
65
|
+
return RedirectResponse("/auth/login")
|
|
66
|
+
return templates.TemplateResponse("queue/index.html", {"request": request, "user": user})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.get("/user/dashboard", response_class=HTMLResponse)
|
|
70
|
+
async def user_dashboard_page(request: Request):
|
|
71
|
+
user = _get_user_or_none(request)
|
|
72
|
+
if not user:
|
|
73
|
+
return RedirectResponse("/auth/login")
|
|
74
|
+
return templates.TemplateResponse("user/dashboard.html", {"request": request, "user": user})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.get("/admin", response_class=HTMLResponse)
|
|
78
|
+
async def admin_page(request: Request):
|
|
79
|
+
user = _get_user_or_none(request)
|
|
80
|
+
if not user or user.get("rank", 0) < 3:
|
|
81
|
+
return RedirectResponse("/auth/login")
|
|
82
|
+
return templates.TemplateResponse("admin/index.html", {"request": request, "user": user})
|