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,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})