fixing permissions

This commit is contained in:
peterino2 2026-02-03 21:20:42 -08:00
parent 3f6ee6c687
commit a26b52c2fa
6 changed files with 240 additions and 69 deletions

View File

@ -193,6 +193,7 @@
const oldWs = M.ws; const oldWs = M.ws;
M.ws = null; M.ws = null;
oldWs.onclose = null; oldWs.onclose = null;
oldWs.onerror = null;
oldWs.close(); oldWs.close();
} }
M.currentChannelId = id; M.currentChannelId = id;
@ -200,6 +201,9 @@
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws"); M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws");
// Track if we've ever connected successfully
let wasConnected = false;
M.ws.onmessage = (e) => { M.ws.onmessage = (e) => {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
// Handle channel list updates // Handle channel list updates
@ -225,6 +229,7 @@
const oldWs = M.ws; const oldWs = M.ws;
M.ws = null; M.ws = null;
oldWs.onclose = null; oldWs.onclose = null;
oldWs.onerror = null;
oldWs.close(); oldWs.close();
} }
M.updateUI(); M.updateUI();
@ -279,18 +284,25 @@
M.handleUpdate(data); M.handleUpdate(data);
}; };
M.ws.onerror = () => {
console.log("[WS] Connection error");
};
M.ws.onclose = () => { M.ws.onclose = () => {
M.synced = false; M.synced = false;
M.ws = null; M.ws = null;
M.$("#sync-indicator").classList.add("disconnected"); M.$("#sync-indicator").classList.add("disconnected");
M.updateUI(); M.updateUI();
// Auto-reconnect if user wants to be synced // Auto-reconnect if user wants to be synced
// Use faster retry (2s) if never connected, slower (3s) if disconnected after connecting
if (M.wantSync) { if (M.wantSync) {
setTimeout(() => M.connectChannel(id), 3000); const delay = wasConnected ? 3000 : 2000;
setTimeout(() => M.connectChannel(id), delay);
} }
}; };
M.ws.onopen = () => { M.ws.onopen = () => {
wasConnected = true;
M.synced = true; M.synced = true;
M.$("#sync-indicator").classList.remove("disconnected"); M.$("#sync-indicator").classList.remove("disconnected");
M.updateUI(); M.updateUI();

View File

@ -56,22 +56,35 @@
<div id="channels-list"></div> <div id="channels-list"></div>
</div> </div>
<div id="library-panel"> <div id="library-panel">
<div id="scan-progress" class="scan-progress hidden"></div> <div class="panel-tabs">
<button class="panel-tab active" data-tab="library">Library</button>
<button class="panel-tab" data-tab="tasks">Tasks</button>
</div>
<div class="panel-views">
<div id="library-view" class="panel-view active">
<div class="panel-header"> <div class="panel-header">
<h3>Library</h3>
<input type="text" id="library-search" placeholder="Search..." class="search-input"> <input type="text" id="library-search" placeholder="Search..." class="search-input">
<input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none"> <input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none">
</div> </div>
<div id="scan-progress" class="scan-progress hidden"></div>
<div id="library"></div> <div id="library"></div>
<div id="upload-progress" class="upload-progress hidden"> <div id="add-panel" class="add-panel hidden">
<div class="upload-progress-bar"></div> <button id="btn-add-close" class="add-panel-close">Close</button>
<span class="upload-progress-text"></span> <div class="add-panel-content">
<button id="btn-upload-files" class="add-option">Upload files...</button>
</div> </div>
<button id="btn-upload" class="upload-btn">Upload files...</button> </div>
<button id="btn-add" class="add-btn">Add to library...</button>
<div id="upload-dropzone" class="upload-dropzone hidden"> <div id="upload-dropzone" class="upload-dropzone hidden">
<div class="dropzone-content">Drop audio files here</div> <div class="dropzone-content">Drop audio files here</div>
</div> </div>
</div> </div>
<div id="tasks-view" class="panel-view">
<div id="tasks-list"></div>
<div id="tasks-empty" class="tasks-empty">No active tasks</div>
</div>
</div>
</div>
<div id="queue-panel"> <div id="queue-panel">
<h3 id="queue-title">Queue</h3> <h3 id="queue-title">Queue</h3>
<div id="now-playing-bar" class="now-playing-bar hidden" title="Click to scroll to current track"></div> <div id="now-playing-bar" class="now-playing-bar hidden" title="Click to scroll to current track"></div>

View File

@ -23,6 +23,27 @@
console.log(`TrackStorage: ${M.cachedTracks.size} tracks cached`); console.log(`TrackStorage: ${M.cachedTracks.size} tracks cached`);
} }
// Setup panel tab switching
function initPanelTabs() {
const tabs = document.querySelectorAll(".panel-tab");
tabs.forEach(tab => {
tab.onclick = () => {
const tabId = tab.dataset.tab;
const panel = tab.closest("#library-panel, #queue-panel");
if (!panel) return;
// Update active tab
panel.querySelectorAll(".panel-tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
// Update active view
panel.querySelectorAll(".panel-view").forEach(v => v.classList.remove("active"));
const view = panel.querySelector(`#${tabId}-view`);
if (view) view.classList.add("active");
};
});
}
// Setup history panel handlers // Setup history panel handlers
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const btnHistory = M.$("#btn-history"); const btnHistory = M.$("#btn-history");
@ -34,6 +55,8 @@
if (btnClose) { if (btnClose) {
btnClose.onclick = () => M.toggleToastHistory(); btnClose.onclick = () => M.toggleToastHistory();
} }
initPanelTabs();
}); });
// Initialize the application // Initialize the application

View File

@ -37,13 +37,49 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
#channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; } #channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; }
#channels-list .listener-mult { color: #666; font-size: 0.55rem; } #channels-list .listener-mult { color: #666; font-size: 0.55rem; }
#library-panel, #queue-panel { flex: 0 0 700px; min-width: 0; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; } #library-panel, #queue-panel { flex: 0 0 700px; min-width: 0; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; }
.scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; } .panel-tabs { display: flex; gap: 0; margin-bottom: 0; flex-shrink: 0; }
.panel-tab { background: #252525; border: none; color: #666; font-family: inherit; font-size: 0.8rem; font-weight: bold; padding: 0.3rem 0.6rem; cursor: pointer; border-radius: 4px 4px 0 0; text-transform: uppercase; letter-spacing: 0.05em; margin-right: 2px; }
.panel-tab:hover { color: #aaa; background: #2a2a2a; }
.panel-tab.active { color: #eee; background: #222; }
.panel-views { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; position: relative; background: #222; border-radius: 0 4px 4px 4px; }
.panel-view { display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; position: relative; padding: 0.4rem; }
.panel-view.active { display: flex; }
.tasks-empty { color: #666; font-size: 0.85rem; padding: 1rem; text-align: center; }
.tasks-empty.hidden { display: none; }
#tasks-list { display: flex; flex-direction: column; gap: 0.3rem; overflow-y: auto; flex: 1; }
.task-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; background: #2a2a1a; border-radius: 4px; font-size: 0.8rem; color: #ea4; position: relative; overflow: hidden; }
.task-item.complete { background: #1a2a1a; color: #4e8; }
.task-item.error { background: #2a1a1a; color: #e44; }
.task-item .task-spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #ea4; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0; }
.task-item.complete .task-spinner { display: none; }
.task-item.error .task-spinner { display: none; }
.task-item .task-icon { flex-shrink: 0; }
.task-item.complete .task-icon::before { content: "✓"; }
.task-item.error .task-icon::before { content: "✗"; }
.task-item .task-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-item .task-progress { font-size: 0.7rem; color: #888; flex-shrink: 0; }
.task-item .task-bar { position: absolute; left: 0; bottom: 0; height: 2px; background: #ea4; transition: width 0.2s; }
.task-item.complete .task-bar { background: #4e8; }
.scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
.scan-progress.hidden { display: none; } .scan-progress.hidden { display: none; }
.scan-progress.complete { color: #4e8; background: #1a2a1a; } .scan-progress.complete { color: #4e8; background: #1a2a1a; }
.scan-progress .spinner { display: inline-block; width: 10px; height: 10px; border: 2px solid #ea4; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; } .scan-progress .spinner { display: inline-block; width: 10px; height: 10px; border: 2px solid #ea4; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
.upload-btn { width: 100%; padding: 0.5rem; background: #2a3a2a; border: 1px dashed #4e8; border-radius: 4px; color: #4e8; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; flex-shrink: 0; } .upload-btn { width: 100%; padding: 0.5rem; background: #2a3a2a; border: 1px dashed #4e8; border-radius: 4px; color: #4e8; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; flex-shrink: 0; }
.upload-btn:hover { background: #3a4a3a; } .upload-btn:hover { background: #3a4a3a; }
.add-btn { width: 100%; padding: 0.5rem; background: #252525; border: 1px solid #444; border-radius: 4px; color: #888; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; flex-shrink: 0; transition: all 0.2s; }
.add-btn:hover { background: #2a2a2a; border-color: #666; color: #aaa; }
.add-btn.hidden { display: none; }
.add-panel { position: absolute; bottom: 0; left: 0; right: 0; background: #1a1a1a; border-top: 1px solid #333; border-radius: 0 0 6px 6px; display: flex; flex-direction: column; height: 50%; overflow: hidden; animation: panelSlideUp 0.2s ease-out; }
.add-panel.hidden { display: none; }
.add-panel.closing { animation: panelSlideDown 0.2s ease-in forwards; }
@keyframes panelSlideUp { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes panelSlideDown { from { transform: translateY(0); opacity: 1; } to { transform: translateY(100%); opacity: 0; } }
.add-panel-close { width: calc(100% - 1rem); margin: 0.5rem; padding: 0.5rem; background: #252525; border: 1px solid #444; border-radius: 4px; color: #888; font-size: 0.85rem; cursor: pointer; flex-shrink: 0; }
.add-panel-close:hover { background: #2a2a2a; border-color: #666; color: #aaa; }
.add-panel-content { flex: 1; overflow-y: auto; padding: 0 0.5rem 0.5rem; display: flex; flex-direction: column; gap: 0.3rem; }
.add-option { width: 100%; padding: 0.6rem 0.8rem; background: #222; border: 1px solid #333; border-radius: 4px; color: #aaa; font-size: 0.85rem; cursor: pointer; text-align: left; transition: all 0.15s; }
.add-option:hover { background: #2a3a2a; border-color: #4e8; color: #4e8; }
.upload-progress { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.5rem; background: #2a2a1a; border-radius: 4px; margin-top: 0.3rem; flex-shrink: 0; position: relative; overflow: hidden; } .upload-progress { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.5rem; background: #2a2a1a; border-radius: 4px; margin-top: 0.3rem; flex-shrink: 0; position: relative; overflow: hidden; }
.upload-progress.hidden { display: none; } .upload-progress.hidden { display: none; }
.upload-progress-bar { position: absolute; left: 0; top: 0; bottom: 0; background: #4a6a4a; transition: width 0.2s; } .upload-progress-bar { position: absolute; left: 0; top: 0; bottom: 0; background: #4a6a4a; transition: width 0.2s; }

View File

@ -2,28 +2,57 @@
const M = window.MusicRoom; const M = window.MusicRoom;
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const uploadBtn = M.$("#btn-upload"); const addBtn = M.$("#btn-add");
const addPanel = M.$("#add-panel");
const addCloseBtn = M.$("#btn-add-close");
const uploadFilesBtn = M.$("#btn-upload-files");
const fileInput = M.$("#file-input"); const fileInput = M.$("#file-input");
const dropzone = M.$("#upload-dropzone"); const dropzone = M.$("#upload-dropzone");
const libraryPanel = M.$("#library-panel"); const libraryPanel = M.$("#library-panel");
const progressEl = M.$("#upload-progress"); const tasksList = M.$("#tasks-list");
const progressBar = progressEl?.querySelector(".upload-progress-bar"); const tasksEmpty = M.$("#tasks-empty");
const progressText = progressEl?.querySelector(".upload-progress-text");
if (!uploadBtn || !fileInput || !dropzone) return; if (!addBtn || !fileInput || !dropzone) return;
// Click upload button opens file dialog function openPanel() {
uploadBtn.onclick = () => { addPanel.classList.remove("hidden", "closing");
addBtn.classList.add("hidden");
}
function closePanel() {
addPanel.classList.add("closing");
addBtn.classList.remove("hidden");
addPanel.addEventListener("animationend", () => {
if (addPanel.classList.contains("closing")) {
addPanel.classList.add("hidden");
addPanel.classList.remove("closing");
}
}, { once: true });
}
// Open add panel
addBtn.onclick = () => {
if (!M.currentUser) { if (!M.currentUser) {
M.showToast("Sign in to upload"); M.showToast("Sign in to add tracks");
return; return;
} }
openPanel();
};
// Close add panel
addCloseBtn.onclick = () => {
closePanel();
};
// Upload files option
uploadFilesBtn.onclick = () => {
fileInput.click(); fileInput.click();
}; };
// File input change // File input change
fileInput.onchange = () => { fileInput.onchange = () => {
if (fileInput.files.length > 0) { if (fileInput.files.length > 0) {
closePanel();
uploadFiles(fileInput.files); uploadFiles(fileInput.files);
fileInput.value = ""; fileInput.value = "";
} }
@ -71,19 +100,91 @@
} }
}; };
function showProgress(current, total, filename) { // Task management
if (!progressEl) return; function updateTasksEmpty() {
const pct = Math.round((current / total) * 100); const hasTasks = tasksList.children.length > 0;
progressBar.style.width = pct + "%"; tasksEmpty.classList.toggle("hidden", hasTasks);
progressText.textContent = `Uploading ${current}/${total}: ${filename}`;
progressEl.classList.remove("hidden");
uploadBtn.classList.add("hidden");
} }
function hideProgress() { function createTask(filename) {
if (!progressEl) return; const task = document.createElement("div");
progressEl.classList.add("hidden"); task.className = "task-item";
uploadBtn.classList.remove("hidden"); task.innerHTML = `
<span class="task-spinner"></span>
<span class="task-icon"></span>
<span class="task-name">${filename}</span>
<span class="task-progress">0%</span>
<div class="task-bar" style="width: 0%"></div>
`;
tasksList.appendChild(task);
updateTasksEmpty();
// Switch to tasks tab
const tasksTab = document.querySelector('.panel-tab[data-tab="tasks"]');
if (tasksTab) tasksTab.click();
return {
setProgress(percent) {
task.querySelector(".task-progress").textContent = `${Math.round(percent)}%`;
task.querySelector(".task-bar").style.width = `${percent}%`;
},
setComplete() {
task.classList.add("complete");
task.querySelector(".task-progress").textContent = "Done";
task.querySelector(".task-bar").style.width = "100%";
// Remove after delay
setTimeout(() => {
task.remove();
updateTasksEmpty();
}, 3000);
},
setError(msg) {
task.classList.add("error");
task.querySelector(".task-progress").textContent = msg || "Failed";
// Remove after delay
setTimeout(() => {
task.remove();
updateTasksEmpty();
}, 5000);
}
};
}
function uploadFile(file) {
return new Promise((resolve) => {
const task = createTask(file.name);
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append("file", file);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
task.setProgress(percent);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
task.setComplete();
resolve({ success: true });
} else if (xhr.status === 409) {
task.setError("Duplicate");
resolve({ success: false, duplicate: true });
} else {
task.setError("Failed");
resolve({ success: false });
}
};
xhr.onerror = () => {
task.setError("Error");
resolve({ success: false });
};
xhr.open("POST", "/api/upload");
xhr.send(formData);
});
} }
async function uploadFiles(files) { async function uploadFiles(files) {
@ -100,44 +201,30 @@
let uploaded = 0; let uploaded = 0;
let failed = 0; let failed = 0;
let duplicates = 0;
const total = audioFiles.length;
for (let i = 0; i < audioFiles.length; i++) { // Upload files in parallel (max 3 concurrent)
const file = audioFiles[i]; const concurrency = 3;
showProgress(i + 1, total, file.name); const queue = [...audioFiles];
const active = [];
try { while (queue.length > 0 || active.length > 0) {
const formData = new FormData(); while (active.length < concurrency && queue.length > 0) {
formData.append("file", file); const file = queue.shift();
const promise = uploadFile(file).then(result => {
const res = await fetch("/api/upload", { if (result.success) uploaded++;
method: "POST", else failed++;
body: formData active.splice(active.indexOf(promise), 1);
}); });
active.push(promise);
if (res.ok) {
uploaded++;
} else if (res.status === 409) {
// File already exists
M.showToast(`Already uploaded: ${file.name}`, "warning");
duplicates++;
} else {
const err = await res.json().catch(() => ({}));
console.error(`Upload failed for ${file.name}:`, err.error || res.status);
failed++;
} }
} catch (e) { if (active.length > 0) {
console.error(`Upload error for ${file.name}:`, e); await Promise.race(active);
failed++;
} }
} }
hideProgress();
if (uploaded > 0) { if (uploaded > 0) {
M.showToast(`Uploaded ${uploaded} track${uploaded > 1 ? 's' : ''}${failed > 0 ? `, ${failed} failed` : ''}`); M.showToast(`Uploaded ${uploaded} track${uploaded > 1 ? 's' : ''}${failed > 0 ? `, ${failed} failed` : ''}`);
} else { } else if (failed > 0) {
M.showToast(`Upload failed`); M.showToast(`Upload failed`);
} }
} }

View File

@ -341,7 +341,7 @@ function userHasPermission(user: ReturnType<typeof getUser>, resourceType: strin
if (user.is_guest && permission === "control") return false; if (user.is_guest && permission === "control") return false;
// Check default permissions from config // Check default permissions from config
if (resourceType === "channel" && config.defaultPermissions.channel?.includes(permission)) { if (resourceType === "channel" && config.defaultPermissions?.includes(permission)) {
return true; return true;
} }
@ -651,8 +651,8 @@ serve({
const permissions = getUserPermissions(user.id); const permissions = getUserPermissions(user.id);
// Add default permissions for all users (except control for guests) // Add default permissions for all users (except control for guests)
const effectivePermissions = [...permissions]; const effectivePermissions = [...permissions];
if (config.defaultPermissions.channel) { if (config.defaultPermissions) {
for (const perm of config.defaultPermissions.channel) { for (const perm of config.defaultPermissions) {
// Guests can never have control permission // Guests can never have control permission
if (user.is_guest && perm === "control") continue; if (user.is_guest && perm === "control") continue;
effectivePermissions.push({ effectivePermissions.push({
@ -999,7 +999,7 @@ serve({
// Check default permissions or user-specific permissions // Check default permissions or user-specific permissions
const canControl = user.is_admin const canControl = user.is_admin
|| config.defaultPermissions.channel?.includes("control") || config.defaultPermissions?.includes("control")
|| hasPermission(userId, "channel", ws.data.channelId, "control"); || hasPermission(userId, "channel", ws.data.channelId, "control");
if (!canControl) { if (!canControl) {
console.log("[WS] User lacks control permission:", user.username); console.log("[WS] User lacks control permission:", user.username);