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,144 @@
1
+ from fastapi import APIRouter, Request, Depends, HTTPException
2
+
3
+ from ..auth.session import get_current_user
4
+ from ..queue.ordering import insert_pay_queue, insert_pay_playnext
5
+
6
+ router = APIRouter(prefix="/queue", tags=["queue"])
7
+
8
+
9
+ @router.get("/state")
10
+ async def get_queue_state(request: Request, user: dict = Depends(get_current_user)):
11
+ """Get current queue state."""
12
+ shadow = request.app.state.shadow
13
+ return shadow.get_queue_state()
14
+
15
+
16
+ @router.post("/add")
17
+ async def add_to_queue(request: Request, user: dict = Depends(get_current_user)):
18
+ """Add an item to the pay queue (FIFO)."""
19
+ body = await request.json()
20
+ friendly_token = body.get("friendly_token")
21
+ tier = body.get("tier", "queue")
22
+
23
+ if not friendly_token:
24
+ raise HTTPException(400, "friendly_token required")
25
+
26
+ db = request.app.state.db
27
+ api_gate = request.app.state.api_gate
28
+ shadow = request.app.state.shadow
29
+
30
+ # Check pre-fire lock
31
+ if await db.is_pre_fire_lock_active():
32
+ raise HTTPException(423, "Queue is locked: scheduled playlist firing soon")
33
+
34
+ # Look up catalog item
35
+ item = await db.get_item(friendly_token)
36
+ if not item:
37
+ raise HTTPException(404, "Item not found in catalog")
38
+
39
+ # Preview cost
40
+ preview = await api_gate.queue_preview(
41
+ username=user["username"],
42
+ duration_sec=item["duration_sec"],
43
+ tier=tier,
44
+ )
45
+ if not preview.get("success"):
46
+ raise HTTPException(400, preview.get("error", "Cost preview failed"))
47
+
48
+ z_cost = preview["cost"]
49
+
50
+ result = await insert_pay_queue(
51
+ api_gate=api_gate,
52
+ shadow=shadow,
53
+ db=db,
54
+ username=user["username"],
55
+ media_type="cm",
56
+ media_id=friendly_token,
57
+ title=item["title"],
58
+ duration_sec=item["duration_sec"],
59
+ tier=tier,
60
+ z_cost=z_cost,
61
+ )
62
+
63
+ if not result["success"]:
64
+ raise HTTPException(400, result.get("error", "Queue add failed"))
65
+ return result
66
+
67
+
68
+ @router.post("/playnext")
69
+ async def play_next(request: Request, user: dict = Depends(get_current_user)):
70
+ """Add item as play-next (premium tier)."""
71
+ body = await request.json()
72
+ friendly_token = body.get("friendly_token")
73
+ tier = "playnext"
74
+
75
+ if not friendly_token:
76
+ raise HTTPException(400, "friendly_token required")
77
+
78
+ db = request.app.state.db
79
+ api_gate = request.app.state.api_gate
80
+ shadow = request.app.state.shadow
81
+
82
+ # Check pre-fire lock
83
+ if await db.is_pre_fire_lock_active():
84
+ raise HTTPException(423, "Queue is locked: scheduled playlist firing soon")
85
+
86
+ # Look up catalog item
87
+ item = await db.get_item(friendly_token)
88
+ if not item:
89
+ raise HTTPException(404, "Item not found in catalog")
90
+
91
+ # Preview cost
92
+ preview = await api_gate.queue_preview(
93
+ username=user["username"],
94
+ duration_sec=item["duration_sec"],
95
+ tier=tier,
96
+ )
97
+ if not preview.get("success"):
98
+ raise HTTPException(400, preview.get("error", "Cost preview failed"))
99
+
100
+ z_cost = preview["cost"]
101
+
102
+ result = await insert_pay_playnext(
103
+ api_gate=api_gate,
104
+ shadow=shadow,
105
+ db=db,
106
+ username=user["username"],
107
+ media_type="cm",
108
+ media_id=friendly_token,
109
+ title=item["title"],
110
+ duration_sec=item["duration_sec"],
111
+ tier=tier,
112
+ z_cost=z_cost,
113
+ )
114
+
115
+ if not result["success"]:
116
+ raise HTTPException(400, result.get("error", "Playnext failed"))
117
+ return result
118
+
119
+
120
+ @router.get("/preview")
121
+ async def cost_preview(request: Request, friendly_token: str, tier: str = "queue",
122
+ user: dict = Depends(get_current_user)):
123
+ """Preview cost for queuing an item."""
124
+ db = request.app.state.db
125
+ api_gate = request.app.state.api_gate
126
+
127
+ item = await db.get_item(friendly_token)
128
+ if not item:
129
+ raise HTTPException(404, "Item not found")
130
+
131
+ preview = await api_gate.queue_preview(
132
+ username=user["username"],
133
+ duration_sec=item["duration_sec"],
134
+ tier=tier,
135
+ )
136
+ return preview
137
+
138
+
139
+ @router.get("/history")
140
+ async def queue_history(request: Request, user: dict = Depends(get_current_user)):
141
+ """Get user's queue history."""
142
+ db = request.app.state.db
143
+ history = await db.get_user_queue_history(user["username"])
144
+ return {"items": history}
@@ -0,0 +1,35 @@
1
+ from fastapi import APIRouter, Request, Depends
2
+
3
+ from ..auth.session import get_current_user
4
+
5
+ router = APIRouter(prefix="/user", tags=["user"])
6
+
7
+
8
+ @router.get("/balance")
9
+ async def get_balance(request: Request, user: dict = Depends(get_current_user)):
10
+ """Get user's economy balance."""
11
+ api_gate = request.app.state.api_gate
12
+ return await api_gate.get_balance(user["username"])
13
+
14
+
15
+ @router.get("/transactions")
16
+ async def get_transactions(request: Request, limit: int = 20, offset: int = 0,
17
+ user: dict = Depends(get_current_user)):
18
+ """Get user's transaction history."""
19
+ api_gate = request.app.state.api_gate
20
+ return await api_gate.get_transactions(user["username"], limit=limit, offset=offset)
21
+
22
+
23
+ @router.get("/profile")
24
+ async def get_profile(request: Request, user: dict = Depends(get_current_user)):
25
+ """Get current user profile info from api-gate."""
26
+ api_gate = request.app.state.api_gate
27
+ try:
28
+ user_data = await api_gate.get_user(user["username"])
29
+ except Exception:
30
+ user_data = {}
31
+ return {
32
+ "username": user["username"],
33
+ "rank": user["rank"],
34
+ "online": user_data.get("online", False) if user_data else False,
35
+ }
@@ -0,0 +1,470 @@
1
+ /* kryten-webqueue — Main Stylesheet */
2
+
3
+ :root {
4
+ --bg-primary: #0f0f14;
5
+ --bg-secondary: #1a1a24;
6
+ --bg-card: #22222e;
7
+ --bg-hover: #2a2a3a;
8
+ --text-primary: #e8e8f0;
9
+ --text-secondary: #9090a8;
10
+ --accent: #6c5ce7;
11
+ --accent-hover: #7f70f0;
12
+ --success: #00b894;
13
+ --warning: #fdcb6e;
14
+ --danger: #e17055;
15
+ --border: #33334a;
16
+ --radius: 8px;
17
+ --shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
18
+ }
19
+
20
+ * {
21
+ margin: 0;
22
+ padding: 0;
23
+ box-sizing: border-box;
24
+ }
25
+
26
+ body {
27
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
28
+ background: var(--bg-primary);
29
+ color: var(--text-primary);
30
+ line-height: 1.6;
31
+ min-height: 100vh;
32
+ }
33
+
34
+ a {
35
+ color: var(--accent);
36
+ text-decoration: none;
37
+ }
38
+ a:hover {
39
+ color: var(--accent-hover);
40
+ }
41
+
42
+ /* Navbar */
43
+ .navbar {
44
+ display: flex;
45
+ justify-content: space-between;
46
+ align-items: center;
47
+ padding: 0.75rem 2rem;
48
+ background: var(--bg-secondary);
49
+ border-bottom: 1px solid var(--border);
50
+ position: sticky;
51
+ top: 0;
52
+ z-index: 100;
53
+ }
54
+ .nav-brand a {
55
+ font-size: 1.2rem;
56
+ font-weight: 700;
57
+ color: var(--text-primary);
58
+ }
59
+ .nav-links {
60
+ display: flex;
61
+ gap: 1.5rem;
62
+ }
63
+ .nav-links a {
64
+ color: var(--text-secondary);
65
+ font-size: 0.9rem;
66
+ }
67
+ .nav-links a:hover {
68
+ color: var(--text-primary);
69
+ }
70
+
71
+ /* Container */
72
+ .container {
73
+ max-width: 1400px;
74
+ margin: 0 auto;
75
+ padding: 2rem;
76
+ }
77
+
78
+ /* Buttons */
79
+ .btn {
80
+ display: inline-block;
81
+ padding: 0.5rem 1rem;
82
+ border: 1px solid var(--border);
83
+ border-radius: var(--radius);
84
+ background: var(--bg-card);
85
+ color: var(--text-primary);
86
+ font-size: 0.9rem;
87
+ cursor: pointer;
88
+ transition: all 0.2s;
89
+ }
90
+ .btn:hover {
91
+ background: var(--bg-hover);
92
+ }
93
+ .btn-primary {
94
+ background: var(--accent);
95
+ border-color: var(--accent);
96
+ color: white;
97
+ }
98
+ .btn-primary:hover {
99
+ background: var(--accent-hover);
100
+ }
101
+ .btn-danger {
102
+ background: var(--danger);
103
+ border-color: var(--danger);
104
+ color: white;
105
+ }
106
+ .btn-sm {
107
+ padding: 0.3rem 0.6rem;
108
+ font-size: 0.8rem;
109
+ }
110
+
111
+ /* Catalog Grid */
112
+ .catalog-header {
113
+ margin-bottom: 1.5rem;
114
+ }
115
+ .catalog-header h1 {
116
+ margin-bottom: 1rem;
117
+ }
118
+ .catalog-controls {
119
+ display: flex;
120
+ gap: 1rem;
121
+ align-items: center;
122
+ flex-wrap: wrap;
123
+ }
124
+ .search-form {
125
+ display: flex;
126
+ gap: 0.5rem;
127
+ }
128
+ .search-form input {
129
+ padding: 0.5rem 1rem;
130
+ border: 1px solid var(--border);
131
+ border-radius: var(--radius);
132
+ background: var(--bg-secondary);
133
+ color: var(--text-primary);
134
+ width: 300px;
135
+ }
136
+ .category-filter select {
137
+ padding: 0.5rem;
138
+ border: 1px solid var(--border);
139
+ border-radius: var(--radius);
140
+ background: var(--bg-secondary);
141
+ color: var(--text-primary);
142
+ }
143
+
144
+ .catalog-grid {
145
+ display: grid;
146
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
147
+ gap: 1.5rem;
148
+ }
149
+ .catalog-card {
150
+ background: var(--bg-card);
151
+ border-radius: var(--radius);
152
+ overflow: hidden;
153
+ transition: transform 0.2s, box-shadow 0.2s;
154
+ }
155
+ .catalog-card:hover {
156
+ transform: translateY(-4px);
157
+ box-shadow: var(--shadow);
158
+ }
159
+ .card-poster {
160
+ aspect-ratio: 2/3;
161
+ overflow: hidden;
162
+ background: var(--bg-secondary);
163
+ }
164
+ .card-poster img {
165
+ width: 100%;
166
+ height: 100%;
167
+ object-fit: cover;
168
+ }
169
+ .card-poster-placeholder {
170
+ width: 100%;
171
+ height: 100%;
172
+ display: flex;
173
+ align-items: center;
174
+ justify-content: center;
175
+ font-size: 3rem;
176
+ color: var(--text-secondary);
177
+ background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card));
178
+ }
179
+ .card-info {
180
+ padding: 0.75rem;
181
+ }
182
+ .card-title {
183
+ font-size: 0.85rem;
184
+ font-weight: 600;
185
+ margin-bottom: 0.25rem;
186
+ white-space: nowrap;
187
+ overflow: hidden;
188
+ text-overflow: ellipsis;
189
+ }
190
+ .card-duration {
191
+ font-size: 0.75rem;
192
+ color: var(--text-secondary);
193
+ }
194
+ .card-actions {
195
+ padding: 0 0.75rem 0.75rem;
196
+ display: flex;
197
+ gap: 0.5rem;
198
+ }
199
+ .btn-queue {
200
+ background: var(--accent);
201
+ border-color: var(--accent);
202
+ color: white;
203
+ }
204
+ .btn-playnext {
205
+ background: var(--warning);
206
+ border-color: var(--warning);
207
+ color: #333;
208
+ }
209
+
210
+ /* Queue Page */
211
+ .queue-layout {
212
+ display: grid;
213
+ grid-template-columns: 1fr 2fr 250px;
214
+ gap: 2rem;
215
+ }
216
+ @media (max-width: 900px) {
217
+ .queue-layout {
218
+ grid-template-columns: 1fr;
219
+ }
220
+ }
221
+
222
+ .now-playing-card {
223
+ background: var(--bg-card);
224
+ border-radius: var(--radius);
225
+ padding: 1.5rem;
226
+ }
227
+ .np-info h3 {
228
+ margin-bottom: 0.5rem;
229
+ }
230
+ .np-progress {
231
+ height: 4px;
232
+ background: var(--border);
233
+ border-radius: 2px;
234
+ overflow: hidden;
235
+ margin: 0.5rem 0;
236
+ }
237
+ .progress-bar {
238
+ height: 100%;
239
+ background: var(--accent);
240
+ transition: width 1s linear;
241
+ }
242
+ .np-time {
243
+ font-size: 0.8rem;
244
+ color: var(--text-secondary);
245
+ }
246
+
247
+ .queue-list {
248
+ display: flex;
249
+ flex-direction: column;
250
+ gap: 0.5rem;
251
+ }
252
+ .queue-item {
253
+ display: flex;
254
+ align-items: center;
255
+ gap: 0.75rem;
256
+ padding: 0.75rem 1rem;
257
+ background: var(--bg-card);
258
+ border-radius: var(--radius);
259
+ border-left: 3px solid transparent;
260
+ }
261
+ .queue-item-paid {
262
+ border-left-color: var(--accent);
263
+ }
264
+ .qi-pos {
265
+ font-size: 0.75rem;
266
+ color: var(--text-secondary);
267
+ min-width: 1.5rem;
268
+ }
269
+ .qi-title {
270
+ flex: 1;
271
+ font-size: 0.9rem;
272
+ white-space: nowrap;
273
+ overflow: hidden;
274
+ text-overflow: ellipsis;
275
+ }
276
+ .qi-duration {
277
+ font-size: 0.8rem;
278
+ color: var(--text-secondary);
279
+ }
280
+ .qi-badge {
281
+ font-size: 0.7rem;
282
+ padding: 0.15rem 0.4rem;
283
+ border-radius: 3px;
284
+ background: var(--accent);
285
+ color: white;
286
+ }
287
+ .badge-paid {
288
+ background: var(--accent);
289
+ }
290
+ .qi-user {
291
+ font-size: 0.75rem;
292
+ color: var(--text-secondary);
293
+ }
294
+ .qi-eta {
295
+ font-size: 0.75rem;
296
+ color: var(--text-secondary);
297
+ }
298
+
299
+ /* WebSocket status */
300
+ .ws-connected {
301
+ color: var(--success);
302
+ }
303
+ .ws-disconnected {
304
+ color: var(--danger);
305
+ }
306
+
307
+ /* Auth */
308
+ .auth-container {
309
+ max-width: 400px;
310
+ margin: 4rem auto;
311
+ text-align: center;
312
+ }
313
+ .auth-container h1 {
314
+ margin-bottom: 1rem;
315
+ }
316
+ .auth-form {
317
+ display: flex;
318
+ flex-direction: column;
319
+ gap: 0.75rem;
320
+ margin-top: 1.5rem;
321
+ }
322
+ .auth-form input {
323
+ padding: 0.75rem 1rem;
324
+ border: 1px solid var(--border);
325
+ border-radius: var(--radius);
326
+ background: var(--bg-secondary);
327
+ color: var(--text-primary);
328
+ font-size: 1rem;
329
+ text-align: center;
330
+ }
331
+ .hidden {
332
+ display: none !important;
333
+ }
334
+ .error-msg {
335
+ color: var(--danger);
336
+ margin-top: 1rem;
337
+ }
338
+ .otp-sent-msg {
339
+ color: var(--success);
340
+ }
341
+
342
+ /* User Dashboard */
343
+ .dashboard-grid {
344
+ display: grid;
345
+ grid-template-columns: 1fr 1fr 1fr;
346
+ gap: 1.5rem;
347
+ margin-top: 1.5rem;
348
+ }
349
+ @media (max-width: 900px) {
350
+ .dashboard-grid {
351
+ grid-template-columns: 1fr;
352
+ }
353
+ }
354
+ .balance-card, .history-card, .transactions-card {
355
+ background: var(--bg-card);
356
+ border-radius: var(--radius);
357
+ padding: 1.5rem;
358
+ }
359
+ .balance-amount {
360
+ font-size: 2rem;
361
+ font-weight: 700;
362
+ color: var(--accent);
363
+ margin-top: 0.5rem;
364
+ }
365
+ .history-item, .tx-item {
366
+ display: flex;
367
+ justify-content: space-between;
368
+ padding: 0.5rem 0;
369
+ border-bottom: 1px solid var(--border);
370
+ font-size: 0.85rem;
371
+ }
372
+ .tx-credit {
373
+ color: var(--success);
374
+ }
375
+ .tx-debit {
376
+ color: var(--danger);
377
+ }
378
+
379
+ /* Admin */
380
+ .admin-nav {
381
+ display: flex;
382
+ gap: 1rem;
383
+ margin-bottom: 2rem;
384
+ }
385
+ .admin-section {
386
+ margin-bottom: 2rem;
387
+ }
388
+ .admin-section h2 {
389
+ margin-bottom: 1rem;
390
+ }
391
+ .quick-actions {
392
+ display: flex;
393
+ gap: 1rem;
394
+ }
395
+ .admin-table {
396
+ width: 100%;
397
+ border-collapse: collapse;
398
+ font-size: 0.85rem;
399
+ }
400
+ .admin-table th, .admin-table td {
401
+ padding: 0.5rem;
402
+ text-align: left;
403
+ border-bottom: 1px solid var(--border);
404
+ }
405
+ .admin-table th {
406
+ color: var(--text-secondary);
407
+ }
408
+
409
+ /* Empty state */
410
+ .empty-state {
411
+ color: var(--text-secondary);
412
+ text-align: center;
413
+ padding: 2rem;
414
+ }
415
+
416
+ /* Pagination */
417
+ .pagination {
418
+ display: flex;
419
+ align-items: center;
420
+ justify-content: center;
421
+ gap: 1rem;
422
+ margin-top: 2rem;
423
+ }
424
+ .page-num {
425
+ color: var(--text-secondary);
426
+ font-size: 0.9rem;
427
+ }
428
+
429
+ /* Toast */
430
+ .toast {
431
+ position: fixed;
432
+ bottom: 2rem;
433
+ right: 2rem;
434
+ padding: 0.75rem 1.5rem;
435
+ border-radius: var(--radius);
436
+ background: var(--bg-card);
437
+ border: 1px solid var(--border);
438
+ color: var(--text-primary);
439
+ font-size: 0.9rem;
440
+ z-index: 1000;
441
+ animation: slideUp 0.3s ease;
442
+ }
443
+ .toast-success {
444
+ border-color: var(--success);
445
+ }
446
+ .toast-error {
447
+ border-color: var(--danger);
448
+ }
449
+ @keyframes slideUp {
450
+ from { transform: translateY(20px); opacity: 0; }
451
+ to { transform: translateY(0); opacity: 1; }
452
+ }
453
+
454
+ /* Footer */
455
+ .footer {
456
+ text-align: center;
457
+ padding: 2rem;
458
+ color: var(--text-secondary);
459
+ font-size: 0.8rem;
460
+ border-top: 1px solid var(--border);
461
+ margin-top: 4rem;
462
+ }
463
+
464
+ /* Schedule info */
465
+ .schedule-info {
466
+ background: var(--bg-card);
467
+ border-radius: var(--radius);
468
+ padding: 1rem;
469
+ border-left: 3px solid var(--warning);
470
+ }
@@ -0,0 +1,26 @@
1
+ /* kryten-webqueue — Main JavaScript */
2
+
3
+ // Toast notification system
4
+ function showToast(message, type = 'success') {
5
+ const toast = document.createElement('div');
6
+ toast.className = `toast toast-${type}`;
7
+ toast.textContent = message;
8
+ document.body.appendChild(toast);
9
+ setTimeout(() => {
10
+ toast.style.opacity = '0';
11
+ toast.style.transition = 'opacity 0.3s';
12
+ setTimeout(() => toast.remove(), 300);
13
+ }, 3000);
14
+ }
15
+
16
+ // Logout handler
17
+ document.addEventListener('DOMContentLoaded', () => {
18
+ const logoutBtn = document.getElementById('logout-btn');
19
+ if (logoutBtn) {
20
+ logoutBtn.addEventListener('click', async (e) => {
21
+ e.preventDefault();
22
+ await fetch('/auth/logout', { method: 'POST' });
23
+ window.location.href = '/auth/login';
24
+ });
25
+ }
26
+ });