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