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,98 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Admin{% endblock %}
3
+ {% block body_class %}admin-page{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="admin-dashboard">
7
+ <h1>Admin Panel</h1>
8
+
9
+ <div class="admin-nav">
10
+ <a href="/admin/playlists" class="btn">Playlists</a>
11
+ <a href="/admin/schedules" class="btn">Schedules</a>
12
+ <a href="/admin/queue-mgmt" class="btn">Queue Management</a>
13
+ </div>
14
+
15
+ <div class="admin-section">
16
+ <h2>Quick Actions</h2>
17
+ <div class="quick-actions">
18
+ <button class="btn btn-danger" onclick="clearQueue()">Clear Queue</button>
19
+ <button class="btn btn-primary" onclick="triggerSync()">Sync Catalog</button>
20
+ <button class="btn" onclick="clearActiveSchedule()">Clear Active Schedule</button>
21
+ </div>
22
+ </div>
23
+
24
+ <div class="admin-section">
25
+ <h2>Queue Status</h2>
26
+ <div id="queue-status">Loading...</div>
27
+ </div>
28
+
29
+ <div class="admin-section">
30
+ <h2>Sync Logs</h2>
31
+ <div id="sync-logs">Loading...</div>
32
+ </div>
33
+ </div>
34
+ {% endblock %}
35
+
36
+ {% block scripts %}
37
+ <script>
38
+ async function clearQueue() {
39
+ if (!confirm('Clear the entire queue? All pay items will be refunded.')) return;
40
+ const resp = await fetch('/admin/queue/clear', {method: 'POST'});
41
+ const data = await resp.json();
42
+ showToast(resp.ok ? `Cleared! ${data.refunded} items refunded.` : 'Failed', resp.ok ? 'success' : 'error');
43
+ }
44
+
45
+ async function triggerSync() {
46
+ const resp = await fetch('/admin/queue/sync-now', {method: 'POST'});
47
+ showToast(resp.ok ? 'Sync started' : 'Failed', resp.ok ? 'success' : 'error');
48
+ }
49
+
50
+ async function clearActiveSchedule() {
51
+ const resp = await fetch('/admin/schedules/clear-active', {method: 'POST'});
52
+ showToast(resp.ok ? 'Active schedule cleared' : 'Failed', resp.ok ? 'success' : 'error');
53
+ }
54
+
55
+ async function loadAdminData() {
56
+ // Queue status
57
+ const qResp = await fetch('/queue/state');
58
+ if (qResp.ok) {
59
+ const state = await qResp.json();
60
+ document.getElementById('queue-status').innerHTML = `
61
+ <p>Items in queue: ${(state.items || []).length}</p>
62
+ <p>Now playing: ${state.now_playing ? escapeHtml(state.now_playing.title || 'Unknown') : 'Nothing'}</p>
63
+ `;
64
+ }
65
+
66
+ // Sync logs
67
+ const sResp = await fetch('/admin/queue/sync-logs');
68
+ if (sResp.ok) {
69
+ const logs = await sResp.json();
70
+ const el = document.getElementById('sync-logs');
71
+ if (logs.length > 0) {
72
+ el.innerHTML = `<table class="admin-table">
73
+ <tr><th>Started</th><th>Status</th><th>New</th><th>Updated</th><th>Errors</th></tr>
74
+ ${logs.slice(0, 5).map(l => `
75
+ <tr>
76
+ <td>${l.started_at || ''}</td>
77
+ <td>${l.status || ''}</td>
78
+ <td>${l.items_new || 0}</td>
79
+ <td>${l.items_updated || 0}</td>
80
+ <td>${l.errors || 0}</td>
81
+ </tr>
82
+ `).join('')}
83
+ </table>`;
84
+ } else {
85
+ el.innerHTML = '<p>No sync logs yet</p>';
86
+ }
87
+ }
88
+ }
89
+
90
+ function escapeHtml(str) {
91
+ const div = document.createElement('div');
92
+ div.textContent = str;
93
+ return div.innerHTML;
94
+ }
95
+
96
+ loadAdminData();
97
+ </script>
98
+ {% endblock %}
@@ -0,0 +1,69 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Login{% endblock %}
3
+ {% block body_class %}auth-page{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="auth-container">
7
+ <h1>Login</h1>
8
+ <p>Enter your CyTube username to receive a one-time code via PM.</p>
9
+
10
+ <div id="otp-request" class="auth-form">
11
+ <input type="text" id="username" placeholder="CyTube username" autocomplete="username" maxlength="50">
12
+ <button id="request-otp-btn" class="btn btn-primary">Send Code</button>
13
+ </div>
14
+
15
+ <div id="otp-verify" class="auth-form hidden">
16
+ <p class="otp-sent-msg">Code sent! Check your PMs from Kryten.</p>
17
+ <input type="text" id="otp-code" placeholder="6-digit code" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
18
+ <button id="verify-otp-btn" class="btn btn-primary">Verify</button>
19
+ </div>
20
+
21
+ <div id="auth-error" class="error-msg hidden"></div>
22
+ </div>
23
+ {% endblock %}
24
+
25
+ {% block scripts %}
26
+ <script>
27
+ document.getElementById('request-otp-btn').addEventListener('click', async () => {
28
+ const username = document.getElementById('username').value.trim();
29
+ if (!username) return;
30
+ const errEl = document.getElementById('auth-error');
31
+ errEl.classList.add('hidden');
32
+
33
+ const resp = await fetch('/auth/otp/request', {
34
+ method: 'POST',
35
+ headers: {'Content-Type': 'application/json'},
36
+ body: JSON.stringify({username})
37
+ });
38
+ if (resp.ok) {
39
+ document.getElementById('otp-request').classList.add('hidden');
40
+ document.getElementById('otp-verify').classList.remove('hidden');
41
+ } else {
42
+ const data = await resp.json();
43
+ errEl.textContent = data.detail || 'Failed to send OTP';
44
+ errEl.classList.remove('hidden');
45
+ }
46
+ });
47
+
48
+ document.getElementById('verify-otp-btn').addEventListener('click', async () => {
49
+ const username = document.getElementById('username').value.trim();
50
+ const code = document.getElementById('otp-code').value.trim();
51
+ if (!code) return;
52
+ const errEl = document.getElementById('auth-error');
53
+ errEl.classList.add('hidden');
54
+
55
+ const resp = await fetch('/auth/otp/verify', {
56
+ method: 'POST',
57
+ headers: {'Content-Type': 'application/json'},
58
+ body: JSON.stringify({username, code})
59
+ });
60
+ if (resp.ok) {
61
+ window.location.href = '/catalog/browse';
62
+ } else {
63
+ const data = await resp.json();
64
+ errEl.textContent = data.detail || 'Invalid code';
65
+ errEl.classList.remove('hidden');
66
+ }
67
+ });
68
+ </script>
69
+ {% endblock %}
@@ -0,0 +1,41 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}Queue{% endblock %} — DropSugar</title>
7
+ <link rel="stylesheet" href="/static/css/main.css">
8
+ {% block head %}{% endblock %}
9
+ </head>
10
+ <body class="{% block body_class %}{% endblock %}">
11
+ <nav class="navbar">
12
+ <div class="nav-brand">
13
+ <a href="/catalog/browse">DropSugar Queue</a>
14
+ </div>
15
+ <div class="nav-links">
16
+ <a href="/catalog/browse">Browse</a>
17
+ <a href="/queue">Queue</a>
18
+ {% if user %}
19
+ <a href="/user/dashboard">{{ user.username }}</a>
20
+ {% if user.rank >= 3 %}
21
+ <a href="/admin">Admin</a>
22
+ {% endif %}
23
+ <a href="#" id="logout-btn">Logout</a>
24
+ {% else %}
25
+ <a href="/auth/login">Login</a>
26
+ {% endif %}
27
+ </div>
28
+ </nav>
29
+
30
+ <main class="container">
31
+ {% block content %}{% endblock %}
32
+ </main>
33
+
34
+ <footer class="footer">
35
+ <p>&copy; DropSugar — Powered by kryten-webqueue</p>
36
+ </footer>
37
+
38
+ <script src="/static/js/main.js"></script>
39
+ {% block scripts %}{% endblock %}
40
+ </body>
41
+ </html>
@@ -0,0 +1,105 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Browse{% endblock %}
3
+ {% block body_class %}catalog-page{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="catalog-header">
7
+ <h1>Browse Catalog</h1>
8
+ <div class="catalog-controls">
9
+ <form class="search-form" action="/catalog/search" method="get">
10
+ <input type="text" name="q" placeholder="Search movies & shows..." value="{{ query or '' }}">
11
+ <button type="submit" class="btn btn-sm">Search</button>
12
+ </form>
13
+ <div class="category-filter">
14
+ <select id="category-select" onchange="filterCategory(this.value)">
15
+ <option value="">All Categories</option>
16
+ {% for cat in categories %}
17
+ <option value="{{ cat.slug }}" {% if active_category == cat.slug %}selected{% endif %}>{{ cat.name }}</option>
18
+ {% endfor %}
19
+ </select>
20
+ </div>
21
+ </div>
22
+ </div>
23
+
24
+ <div class="catalog-grid">
25
+ {% for item in items %}
26
+ <div class="catalog-card" data-token="{{ item.friendly_token }}">
27
+ <div class="card-poster">
28
+ {% if item.cover_art_path %}
29
+ <img src="/static/images/{{ item.cover_art_path }}/400.webp"
30
+ srcset="/static/images/{{ item.cover_art_path }}/200.webp 200w,
31
+ /static/images/{{ item.cover_art_path }}/400.webp 400w,
32
+ /static/images/{{ item.cover_art_path }}/800.webp 800w"
33
+ sizes="(max-width: 600px) 200px, 400px"
34
+ alt="{{ item.title }}" loading="lazy">
35
+ {% else %}
36
+ <div class="card-poster-placeholder">
37
+ <span>{{ item.title[:1] }}</span>
38
+ </div>
39
+ {% endif %}
40
+ </div>
41
+ <div class="card-info">
42
+ <h3 class="card-title">{{ item.title }}</h3>
43
+ <span class="card-duration">{{ (item.duration_sec // 60) }}m</span>
44
+ </div>
45
+ <div class="card-actions">
46
+ <button class="btn btn-sm btn-queue" onclick="queueItem('{{ item.friendly_token }}')">Queue</button>
47
+ <button class="btn btn-sm btn-playnext" onclick="playNext('{{ item.friendly_token }}')">Play Next</button>
48
+ </div>
49
+ </div>
50
+ {% endfor %}
51
+ </div>
52
+
53
+ {% if not items %}
54
+ <div class="empty-state">
55
+ <p>No items found.</p>
56
+ </div>
57
+ {% endif %}
58
+
59
+ <div class="pagination">
60
+ {% if page > 1 %}
61
+ <a href="?page={{ page - 1 }}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm">← Prev</a>
62
+ {% endif %}
63
+ <span class="page-num">Page {{ page }}</span>
64
+ {% if items | length == 24 %}
65
+ <a href="?page={{ page + 1 }}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm">Next →</a>
66
+ {% endif %}
67
+ </div>
68
+ {% endblock %}
69
+
70
+ {% block scripts %}
71
+ <script>
72
+ function filterCategory(slug) {
73
+ const url = slug ? `/catalog/browse?category=${slug}` : '/catalog/browse';
74
+ window.location.href = url;
75
+ }
76
+
77
+ async function queueItem(token) {
78
+ const resp = await fetch('/queue/add', {
79
+ method: 'POST',
80
+ headers: {'Content-Type': 'application/json'},
81
+ body: JSON.stringify({friendly_token: token, tier: 'queue'})
82
+ });
83
+ const data = await resp.json();
84
+ if (resp.ok) {
85
+ showToast('Added to queue!');
86
+ } else {
87
+ showToast(data.detail || 'Failed to queue', 'error');
88
+ }
89
+ }
90
+
91
+ async function playNext(token) {
92
+ const resp = await fetch('/queue/playnext', {
93
+ method: 'POST',
94
+ headers: {'Content-Type': 'application/json'},
95
+ body: JSON.stringify({friendly_token: token})
96
+ });
97
+ const data = await resp.json();
98
+ if (resp.ok) {
99
+ showToast('Playing next!');
100
+ } else {
101
+ showToast(data.detail || 'Failed', 'error');
102
+ }
103
+ }
104
+ </script>
105
+ {% endblock %}
@@ -0,0 +1,126 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Queue{% endblock %}
3
+ {% block body_class %}queue-page{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="queue-layout">
7
+ <div class="now-playing-section">
8
+ <h2>Now Playing</h2>
9
+ <div id="now-playing" class="now-playing-card">
10
+ <p class="empty-state">Nothing playing</p>
11
+ </div>
12
+ </div>
13
+
14
+ <div class="queue-section">
15
+ <h2>Up Next</h2>
16
+ <div id="queue-list" class="queue-list">
17
+ <p class="empty-state">Queue is empty</p>
18
+ </div>
19
+ </div>
20
+
21
+ <div class="queue-info-sidebar">
22
+ <div id="schedule-info" class="schedule-info hidden">
23
+ <h3>Scheduled Playlist</h3>
24
+ <p id="schedule-label"></p>
25
+ <p id="schedule-time"></p>
26
+ </div>
27
+ <div class="queue-stats">
28
+ <p>Connected: <span id="ws-status" class="ws-disconnected">●</span></p>
29
+ </div>
30
+ </div>
31
+ </div>
32
+ {% endblock %}
33
+
34
+ {% block scripts %}
35
+ <script>
36
+ let ws = null;
37
+ let reconnectTimer = null;
38
+
39
+ function connectWebSocket() {
40
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
41
+ ws = new WebSocket(`${proto}//${location.host}/ws`);
42
+
43
+ ws.onopen = () => {
44
+ document.getElementById('ws-status').className = 'ws-connected';
45
+ document.getElementById('ws-status').title = 'Connected';
46
+ };
47
+
48
+ ws.onclose = () => {
49
+ document.getElementById('ws-status').className = 'ws-disconnected';
50
+ reconnectTimer = setTimeout(connectWebSocket, 3000);
51
+ };
52
+
53
+ ws.onmessage = (event) => {
54
+ const msg = JSON.parse(event.data);
55
+ if (msg.type === 'queue_state' || msg.type === 'queue_update') {
56
+ renderQueue(msg.data);
57
+ } else if (msg.type === 'pong') {
58
+ // keepalive ack
59
+ } else if (msg.type === 'schedule_fired') {
60
+ showToast(`Scheduled playlist loaded: ${msg.data.playlist_name}`);
61
+ }
62
+ };
63
+
64
+ // Keepalive
65
+ setInterval(() => {
66
+ if (ws && ws.readyState === WebSocket.OPEN) {
67
+ ws.send(JSON.stringify({type: 'ping'}));
68
+ }
69
+ }, 30000);
70
+ }
71
+
72
+ function renderQueue(state) {
73
+ const npEl = document.getElementById('now-playing');
74
+ const qlEl = document.getElementById('queue-list');
75
+
76
+ if (state.now_playing) {
77
+ const np = state.now_playing;
78
+ npEl.innerHTML = `
79
+ <div class="np-info">
80
+ <h3>${escapeHtml(np.title || 'Unknown')}</h3>
81
+ <div class="np-progress">
82
+ <div class="progress-bar" style="width: ${((np.currentTime || 0) / (np.duration || 1) * 100)}%"></div>
83
+ </div>
84
+ <span class="np-time">${formatTime(np.currentTime || 0)} / ${formatTime(np.duration || 0)}</span>
85
+ </div>
86
+ `;
87
+ } else {
88
+ npEl.innerHTML = '<p class="empty-state">Nothing playing</p>';
89
+ }
90
+
91
+ if (state.items && state.items.length > 0) {
92
+ qlEl.innerHTML = state.items.map((item, i) => `
93
+ <div class="queue-item ${item.is_pay ? 'queue-item-paid' : ''}" data-uid="${item.uid}">
94
+ <span class="qi-pos">${i + 1}</span>
95
+ <span class="qi-title">${escapeHtml(item.title || 'Unknown')}</span>
96
+ <span class="qi-duration">${formatTime(item.duration_sec || 0)}</span>
97
+ ${item.is_pay ? `<span class="qi-badge badge-paid">${item.tier}</span>` : ''}
98
+ ${item.paid_by ? `<span class="qi-user">${escapeHtml(item.paid_by)}</span>` : ''}
99
+ <span class="qi-eta">${item.estimated_start_at ? formatEta(item.estimated_start_at) : ''}</span>
100
+ </div>
101
+ `).join('');
102
+ } else {
103
+ qlEl.innerHTML = '<p class="empty-state">Queue is empty</p>';
104
+ }
105
+ }
106
+
107
+ function formatTime(sec) {
108
+ const m = Math.floor(sec / 60);
109
+ const s = Math.floor(sec % 60);
110
+ return `${m}:${s.toString().padStart(2, '0')}`;
111
+ }
112
+
113
+ function formatEta(isoStr) {
114
+ const d = new Date(isoStr);
115
+ return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
116
+ }
117
+
118
+ function escapeHtml(str) {
119
+ const div = document.createElement('div');
120
+ div.textContent = str;
121
+ return div.innerHTML;
122
+ }
123
+
124
+ connectWebSocket();
125
+ </script>
126
+ {% endblock %}
@@ -0,0 +1,87 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Dashboard{% endblock %}
3
+ {% block body_class %}user-page{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="user-dashboard">
7
+ <h1>{{ user.username }}</h1>
8
+
9
+ <div class="dashboard-grid">
10
+ <div class="balance-card">
11
+ <h2>Balance</h2>
12
+ <p class="balance-amount" id="balance-amount">Loading...</p>
13
+ </div>
14
+
15
+ <div class="history-card">
16
+ <h2>Queue History</h2>
17
+ <div id="queue-history">
18
+ <p class="empty-state">Loading...</p>
19
+ </div>
20
+ </div>
21
+
22
+ <div class="transactions-card">
23
+ <h2>Recent Transactions</h2>
24
+ <div id="transactions">
25
+ <p class="empty-state">Loading...</p>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ {% endblock %}
31
+
32
+ {% block scripts %}
33
+ <script>
34
+ async function loadDashboard() {
35
+ // Balance
36
+ const balResp = await fetch('/user/balance');
37
+ if (balResp.ok) {
38
+ const bal = await balResp.json();
39
+ document.getElementById('balance-amount').textContent = `${bal.balance || 0} Z`;
40
+ }
41
+
42
+ // Queue history
43
+ const histResp = await fetch('/queue/history');
44
+ if (histResp.ok) {
45
+ const hist = await histResp.json();
46
+ const el = document.getElementById('queue-history');
47
+ if (hist.items && hist.items.length > 0) {
48
+ el.innerHTML = hist.items.slice(0, 20).map(h => `
49
+ <div class="history-item">
50
+ <span class="hi-title">${escapeHtml(h.title || 'Unknown')}</span>
51
+ <span class="hi-cost">${h.z_cost} Z</span>
52
+ <span class="hi-tier badge-${h.tier}">${h.tier}</span>
53
+ </div>
54
+ `).join('');
55
+ } else {
56
+ el.innerHTML = '<p class="empty-state">No queue history yet</p>';
57
+ }
58
+ }
59
+
60
+ // Transactions
61
+ const txResp = await fetch('/user/transactions');
62
+ if (txResp.ok) {
63
+ const tx = await txResp.json();
64
+ const el = document.getElementById('transactions');
65
+ const items = tx.transactions || tx.items || [];
66
+ if (items.length > 0) {
67
+ el.innerHTML = items.slice(0, 20).map(t => `
68
+ <div class="tx-item">
69
+ <span class="tx-desc">${escapeHtml(t.description || t.type || '')}</span>
70
+ <span class="tx-amount ${t.amount > 0 ? 'tx-credit' : 'tx-debit'}">${t.amount > 0 ? '+' : ''}${t.amount} Z</span>
71
+ </div>
72
+ `).join('');
73
+ } else {
74
+ el.innerHTML = '<p class="empty-state">No transactions</p>';
75
+ }
76
+ }
77
+ }
78
+
79
+ function escapeHtml(str) {
80
+ const div = document.createElement('div');
81
+ div.textContent = str;
82
+ return div.innerHTML;
83
+ }
84
+
85
+ loadDashboard();
86
+ </script>
87
+ {% endblock %}
File without changes
@@ -0,0 +1,59 @@
1
+ import json
2
+ import logging
3
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
4
+ import jwt
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ router = APIRouter()
9
+
10
+
11
+ @router.websocket("/ws")
12
+ async def websocket_endpoint(ws: WebSocket):
13
+ """WebSocket endpoint. Authenticates via session cookie on upgrade."""
14
+ # Authenticate from cookie
15
+ token = ws.cookies.get("session")
16
+ if not token:
17
+ await ws.close(code=4001, reason="Not authenticated")
18
+ return
19
+
20
+ config = ws.app.state.config
21
+ try:
22
+ payload = jwt.decode(token, config.secret_key, algorithms=["HS256"])
23
+ username = payload["sub"]
24
+ except jwt.InvalidTokenError:
25
+ await ws.close(code=4001, reason="Invalid session")
26
+ return
27
+
28
+ await ws.accept()
29
+ manager = ws.app.state.ws_manager
30
+ shadow = ws.app.state.shadow
31
+
32
+ await manager.connect(username, ws)
33
+
34
+ # Send current queue state on connect
35
+ try:
36
+ state = shadow.get_queue_state()
37
+ await ws.send_text(json.dumps({"type": "queue_state", "data": state}))
38
+ except Exception:
39
+ await manager.disconnect(username)
40
+ return
41
+
42
+ # Message loop
43
+ try:
44
+ while True:
45
+ text = await ws.receive_text()
46
+ try:
47
+ msg = json.loads(text)
48
+ except json.JSONDecodeError:
49
+ continue
50
+
51
+ msg_type = msg.get("type")
52
+ if msg_type == "ping":
53
+ await ws.send_text(json.dumps({"type": "pong"}))
54
+ except WebSocketDisconnect:
55
+ pass
56
+ except Exception as e:
57
+ logger.warning(f"WS error for {username}: {e}")
58
+ finally:
59
+ await manager.disconnect(username)
@@ -0,0 +1,57 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ from fastapi import WebSocket
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class WebSocketManager:
10
+ """Manages active WebSocket connections and broadcasts."""
11
+
12
+ def __init__(self):
13
+ self._connections: dict[str, WebSocket] = {}
14
+ self._lock = asyncio.Lock()
15
+
16
+ async def connect(self, username: str, ws: WebSocket):
17
+ async with self._lock:
18
+ # Disconnect existing connection for same user
19
+ if username in self._connections:
20
+ try:
21
+ await self._connections[username].close()
22
+ except Exception:
23
+ pass
24
+ self._connections[username] = ws
25
+ logger.info(f"WS connected: {username} (total: {len(self._connections)})")
26
+
27
+ async def disconnect(self, username: str):
28
+ async with self._lock:
29
+ self._connections.pop(username, None)
30
+ logger.info(f"WS disconnected: {username} (total: {len(self._connections)})")
31
+
32
+ async def broadcast(self, message: dict):
33
+ """Broadcast a message to all connected clients."""
34
+ data = json.dumps(message)
35
+ async with self._lock:
36
+ stale = []
37
+ for username, ws in self._connections.items():
38
+ try:
39
+ await ws.send_text(data)
40
+ except Exception:
41
+ stale.append(username)
42
+ for username in stale:
43
+ self._connections.pop(username, None)
44
+
45
+ async def send_to(self, username: str, message: dict):
46
+ """Send a message to a specific user."""
47
+ async with self._lock:
48
+ ws = self._connections.get(username)
49
+ if ws:
50
+ try:
51
+ await ws.send_text(json.dumps(message))
52
+ except Exception:
53
+ await self.disconnect(username)
54
+
55
+ @property
56
+ def connection_count(self) -> int:
57
+ return len(self._connections)