Compare commits
11 Commits
release-0.
...
integratio
| Author | SHA1 | Date |
|---|---|---|
|
|
58346647e5 | |
|
|
5a318e7d8c | |
|
|
cc9324fb65 | |
|
|
a26b52c2fa | |
|
|
3f6ee6c687 | |
|
|
f027ab90b2 | |
|
|
16774a4156 | |
|
|
4ae6004239 | |
|
|
a90a9f0a0d | |
|
|
19b1066e73 | |
|
|
43038d3983 |
21
db.ts
21
db.ts
|
|
@ -386,15 +386,20 @@ export function deleteChannelFromDb(id: string): void {
|
||||||
|
|
||||||
// Queue persistence functions
|
// Queue persistence functions
|
||||||
export function saveChannelQueue(channelId: string, trackIds: string[]): void {
|
export function saveChannelQueue(channelId: string, trackIds: string[]): void {
|
||||||
// Delete existing queue
|
db.query("BEGIN").run();
|
||||||
db.query("DELETE FROM channel_queue WHERE channel_id = ?").run(channelId);
|
try {
|
||||||
|
db.query("DELETE FROM channel_queue WHERE channel_id = ?").run(channelId);
|
||||||
|
|
||||||
// Insert new queue
|
const insert = db.query(
|
||||||
const insert = db.query(
|
"INSERT INTO channel_queue (channel_id, track_id, position) VALUES (?, ?, ?)"
|
||||||
"INSERT INTO channel_queue (channel_id, track_id, position) VALUES (?, ?, ?)"
|
);
|
||||||
);
|
for (let i = 0; i < trackIds.length; i++) {
|
||||||
for (let i = 0; i < trackIds.length; i++) {
|
insert.run(channelId, trackIds[i], i);
|
||||||
insert.run(channelId, trackIds[i], i);
|
}
|
||||||
|
db.query("COMMIT").run();
|
||||||
|
} catch (e) {
|
||||||
|
db.query("ROLLBACK").run();
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,28 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Delete a channel
|
||||||
|
M.deleteChannel = async function(channelId) {
|
||||||
|
const channel = M.channels?.find(c => c.id === channelId);
|
||||||
|
if (!channel) return;
|
||||||
|
if (channel.isDefault) {
|
||||||
|
M.showToast("Cannot delete default channel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Delete channel "${channel.name}"?`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/channels/${channelId}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
M.showToast(err.error || "Failed to delete channel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
M.showToast(`Channel "${channel.name}" deleted`);
|
||||||
|
} catch (e) {
|
||||||
|
M.showToast("Failed to delete channel");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// New channel creation with slideout input
|
// New channel creation with slideout input
|
||||||
M.createNewChannel = async function() {
|
M.createNewChannel = async function() {
|
||||||
const header = M.$("#channels-panel .panel-header");
|
const header = M.$("#channels-panel .panel-header");
|
||||||
|
|
@ -65,6 +87,7 @@
|
||||||
input.type = "text";
|
input.type = "text";
|
||||||
input.className = "new-channel-input";
|
input.className = "new-channel-input";
|
||||||
input.placeholder = "Channel name...";
|
input.placeholder = "Channel name...";
|
||||||
|
input.maxLength = 64;
|
||||||
|
|
||||||
const submit = document.createElement("button");
|
const submit = document.createElement("button");
|
||||||
submit.className = "btn-submit-channel";
|
submit.className = "btn-submit-channel";
|
||||||
|
|
@ -129,14 +152,29 @@
|
||||||
const listenersHtml = Object.entries(counts).map(([name, count]) =>
|
const listenersHtml = Object.entries(counts).map(([name, count]) =>
|
||||||
`<div class="listener">${name}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
|
`<div class="listener">${name}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
|
||||||
).join("");
|
).join("");
|
||||||
|
|
||||||
|
// Show delete button for non-default channels if user is admin or creator
|
||||||
|
const canDelete = !ch.isDefault && M.currentUser &&
|
||||||
|
(M.currentUser.isAdmin || ch.createdBy === M.currentUser.id);
|
||||||
|
const deleteBtn = canDelete ? `<button class="btn-delete-channel" title="Delete channel">×</button>` : "";
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="channel-header">
|
<div class="channel-header">
|
||||||
<span class="channel-name">${ch.name}</span>
|
<span class="channel-name">${ch.name}</span>
|
||||||
|
${deleteBtn}
|
||||||
<span class="listener-count">${ch.listenerCount}</span>
|
<span class="listener-count">${ch.listenerCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="channel-listeners">${listenersHtml}</div>
|
<div class="channel-listeners">${listenersHtml}</div>
|
||||||
`;
|
`;
|
||||||
div.querySelector(".channel-header").onclick = () => M.switchChannel(ch.id);
|
const headerEl = div.querySelector(".channel-header");
|
||||||
|
headerEl.querySelector(".channel-name").onclick = () => M.switchChannel(ch.id);
|
||||||
|
const delBtn = headerEl.querySelector(".btn-delete-channel");
|
||||||
|
if (delBtn) {
|
||||||
|
delBtn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
M.deleteChannel(ch.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -155,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;
|
||||||
|
|
@ -162,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
|
||||||
|
|
@ -187,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();
|
||||||
|
|
@ -237,22 +280,36 @@
|
||||||
M.showToast(data.message, data.toastType || "info");
|
M.showToast(data.message, data.toastType || "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Handle fetch progress from ytdlp
|
||||||
|
if (data.type && data.type.startsWith("fetch_")) {
|
||||||
|
if (M.handleFetchProgress) {
|
||||||
|
M.handleFetchProgress(data);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Normal channel state update
|
// Normal channel state update
|
||||||
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();
|
||||||
|
|
@ -268,24 +325,9 @@
|
||||||
currentIndex: data.currentIndex
|
currentIndex: data.currentIndex
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data.track) {
|
|
||||||
M.setTrackTitle("No tracks");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
M.$("#channel-name").textContent = data.channelName || "";
|
M.$("#channel-name").textContent = data.channelName || "";
|
||||||
M.serverTimestamp = data.currentTimestamp;
|
|
||||||
M.serverTrackDuration = data.track.duration;
|
|
||||||
M.lastServerUpdate = Date.now();
|
|
||||||
const wasServerPaused = M.serverPaused;
|
|
||||||
M.serverPaused = data.paused ?? true;
|
|
||||||
|
|
||||||
// Update playback mode if provided
|
// Update queue if provided (do this before early return for no track)
|
||||||
if (data.playbackMode && data.playbackMode !== M.playbackMode) {
|
|
||||||
M.playbackMode = data.playbackMode;
|
|
||||||
if (M.updateModeButton) M.updateModeButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update queue if provided
|
|
||||||
if (data.queue) {
|
if (data.queue) {
|
||||||
M.queue = data.queue;
|
M.queue = data.queue;
|
||||||
M.currentIndex = data.currentIndex ?? 0;
|
M.currentIndex = data.currentIndex ?? 0;
|
||||||
|
|
@ -295,6 +337,22 @@
|
||||||
M.renderQueue();
|
M.renderQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update playback mode if provided
|
||||||
|
if (data.playbackMode && data.playbackMode !== M.playbackMode) {
|
||||||
|
M.playbackMode = data.playbackMode;
|
||||||
|
if (M.updateModeButton) M.updateModeButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.track) {
|
||||||
|
M.setTrackTitle("No tracks");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
M.serverTimestamp = data.currentTimestamp;
|
||||||
|
M.serverTrackDuration = data.track.duration;
|
||||||
|
M.lastServerUpdate = Date.now();
|
||||||
|
const wasServerPaused = M.serverPaused;
|
||||||
|
M.serverPaused = data.paused ?? true;
|
||||||
|
|
||||||
// Cache track info for local mode - use track.id (content hash) as the identifier
|
// Cache track info for local mode - use track.id (content hash) as the identifier
|
||||||
const trackId = data.track.id || data.track.filename; // Fallback for compatibility
|
const trackId = data.track.id || data.track.filename; // Fallback for compatibility
|
||||||
const isNewTrack = trackId !== M.currentTrackId;
|
const isNewTrack = trackId !== M.currentTrackId;
|
||||||
|
|
|
||||||
|
|
@ -56,20 +56,44 @@
|
||||||
<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">
|
||||||
<div class="panel-header">
|
<button class="panel-tab active" data-tab="library">Library</button>
|
||||||
<h3>Library</h3>
|
<button class="panel-tab" data-tab="tasks">Tasks</button>
|
||||||
<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">
|
|
||||||
</div>
|
</div>
|
||||||
<div id="library"></div>
|
<div class="panel-views">
|
||||||
<div id="upload-progress" class="upload-progress hidden">
|
<div id="library-view" class="panel-view active">
|
||||||
<div class="upload-progress-bar"></div>
|
<div class="panel-header">
|
||||||
<span class="upload-progress-text"></span>
|
<input type="text" id="library-search" placeholder="Search..." class="search-input">
|
||||||
</div>
|
<input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none">
|
||||||
<button id="btn-upload" class="upload-btn">Upload files...</button>
|
</div>
|
||||||
<div id="upload-dropzone" class="upload-dropzone hidden">
|
<div id="scan-progress" class="scan-progress hidden"></div>
|
||||||
<div class="dropzone-content">Drop audio files here</div>
|
<div id="library"></div>
|
||||||
|
<div id="add-panel" class="add-panel hidden">
|
||||||
|
<button id="btn-add-close" class="add-panel-close">Close</button>
|
||||||
|
<div class="add-panel-content">
|
||||||
|
<button id="btn-upload-files" class="add-option">Upload files...</button>
|
||||||
|
<button id="btn-fetch-url" class="add-option">Fetch from website...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="fetch-dialog" class="fetch-dialog hidden">
|
||||||
|
<div class="fetch-dialog-header">
|
||||||
|
<span>Fetch from URL</span>
|
||||||
|
<button id="btn-fetch-close" class="fetch-dialog-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="fetch-dialog-content">
|
||||||
|
<input type="text" id="fetch-url-input" class="fetch-url-input" placeholder="https://youtube.com/watch?v=...">
|
||||||
|
<button id="btn-fetch-submit" class="fetch-submit-btn">Fetch</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="btn-add" class="add-btn">Add to library...</button>
|
||||||
|
<div id="upload-dropzone" class="upload-dropzone hidden">
|
||||||
|
<div class="dropzone-content">Drop audio files here</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>
|
</div>
|
||||||
<div id="queue-panel">
|
<div id="queue-panel">
|
||||||
|
|
|
||||||
|
|
@ -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,10 +55,22 @@
|
||||||
if (btnClose) {
|
if (btnClose) {
|
||||||
btnClose.onclick = () => M.toggleToastHistory();
|
btnClose.onclick = () => M.toggleToastHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initPanelTabs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update UI based on server status
|
||||||
|
function updateFeatureVisibility() {
|
||||||
|
const fetchBtn = M.$("#btn-fetch-url");
|
||||||
|
if (fetchBtn) {
|
||||||
|
const ytdlpEnabled = M.serverStatus?.ytdlp?.enabled && M.serverStatus?.ytdlp?.available;
|
||||||
|
fetchBtn.style.display = ytdlpEnabled ? "" : "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize the application
|
// Initialize the application
|
||||||
Promise.all([initStorage(), loadServerStatus()]).then(async () => {
|
Promise.all([initStorage(), loadServerStatus()]).then(async () => {
|
||||||
|
updateFeatureVisibility();
|
||||||
await M.loadLibrary();
|
await M.loadLibrary();
|
||||||
await M.loadCurrentUser();
|
await M.loadCurrentUser();
|
||||||
if (M.currentUser) {
|
if (M.currentUser) {
|
||||||
|
|
|
||||||
|
|
@ -29,18 +29,69 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
#channels-list .channel-header:hover { background: #222; }
|
#channels-list .channel-header:hover { background: #222; }
|
||||||
#channels-list .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; }
|
#channels-list .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; }
|
||||||
#channels-list .listener-count { font-size: 0.65rem; color: #666; flex-shrink: 0; margin-left: 0.3rem; }
|
#channels-list .listener-count { font-size: 0.65rem; color: #666; flex-shrink: 0; margin-left: 0.3rem; }
|
||||||
|
#channels-list .btn-delete-channel { background: none; border: none; color: #666; font-size: 0.9rem; cursor: pointer; padding: 0 0.2rem; line-height: 1; opacity: 0; transition: opacity 0.15s; }
|
||||||
|
#channels-list .channel-header:hover .btn-delete-channel { opacity: 1; }
|
||||||
|
#channels-list .btn-delete-channel:hover { color: #e44; }
|
||||||
#channels-list .channel-listeners { display: flex; flex-direction: column; margin-left: 0.5rem; border-left: 1px solid #333; padding-left: 0.3rem; }
|
#channels-list .channel-listeners { display: flex; flex-direction: column; margin-left: 0.5rem; border-left: 1px solid #333; padding-left: 0.3rem; }
|
||||||
#channels-list .listener { font-size: 0.65rem; color: #aaa; padding: 0.05rem 0; position: relative; }
|
#channels-list .listener { font-size: 0.65rem; color: #aaa; padding: 0.05rem 0; position: relative; }
|
||||||
#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; }
|
||||||
|
.fetch-dialog { 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; animation: panelSlideUp 0.2s ease-out; }
|
||||||
|
.fetch-dialog.hidden { display: none; }
|
||||||
|
.fetch-dialog.closing { animation: panelSlideDown 0.2s ease-in forwards; }
|
||||||
|
.fetch-dialog-header { display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 0.5rem; border-bottom: 1px solid #333; }
|
||||||
|
.fetch-dialog-header span { font-size: 0.8rem; color: #aaa; }
|
||||||
|
.fetch-dialog-close { background: none; border: none; color: #666; font-size: 1.2rem; cursor: pointer; padding: 0 0.3rem; line-height: 1; }
|
||||||
|
.fetch-dialog-close:hover { color: #aaa; }
|
||||||
|
.fetch-dialog-content { padding: 0.5rem; display: flex; gap: 0.4rem; }
|
||||||
|
.fetch-url-input { flex: 1; padding: 0.4rem 0.6rem; background: #222; border: 1px solid #444; border-radius: 4px; color: #eee; font-size: 0.85rem; font-family: inherit; }
|
||||||
|
.fetch-url-input:focus { outline: none; border-color: #4e8; }
|
||||||
|
.fetch-submit-btn { padding: 0.4rem 0.8rem; background: #2a3a2a; border: 1px solid #4e8; border-radius: 4px; color: #4e8; font-size: 0.85rem; cursor: pointer; font-family: inherit; }
|
||||||
|
.fetch-submit-btn:hover { background: #3a4a3a; }
|
||||||
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@
|
||||||
|
|
||||||
// Update general UI state
|
// Update general UI state
|
||||||
M.updateUI = function() {
|
M.updateUI = function() {
|
||||||
const isPlaying = M.synced ? !M.serverPaused : !M.audio.paused;
|
const isConnecting = M.wantSync && !M.synced;
|
||||||
|
// While connecting, treat as not playing (paused state)
|
||||||
|
const isPlaying = M.synced ? !M.serverPaused : (!isConnecting && !M.audio.paused);
|
||||||
M.$("#btn-sync").classList.toggle("synced", M.wantSync);
|
M.$("#btn-sync").classList.toggle("synced", M.wantSync);
|
||||||
M.$("#btn-sync").classList.toggle("connected", M.synced);
|
M.$("#btn-sync").classList.toggle("connected", M.synced);
|
||||||
M.$("#btn-sync").title = M.wantSync ? "Unsync" : "Sync";
|
M.$("#btn-sync").title = M.wantSync ? "Unsync" : "Sync";
|
||||||
|
|
|
||||||
312
public/upload.js
312
public/upload.js
|
|
@ -2,28 +2,153 @@
|
||||||
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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch from URL option
|
||||||
|
const fetchUrlBtn = M.$("#btn-fetch-url");
|
||||||
|
const fetchDialog = M.$("#fetch-dialog");
|
||||||
|
const fetchCloseBtn = M.$("#btn-fetch-close");
|
||||||
|
const fetchUrlInput = M.$("#fetch-url-input");
|
||||||
|
const fetchSubmitBtn = M.$("#btn-fetch-submit");
|
||||||
|
|
||||||
|
function openFetchDialog() {
|
||||||
|
closePanel();
|
||||||
|
fetchDialog.classList.remove("hidden", "closing");
|
||||||
|
fetchUrlInput.value = "";
|
||||||
|
fetchUrlInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFetchDialog() {
|
||||||
|
fetchDialog.classList.add("closing");
|
||||||
|
fetchDialog.addEventListener("animationend", () => {
|
||||||
|
if (fetchDialog.classList.contains("closing")) {
|
||||||
|
fetchDialog.classList.add("hidden");
|
||||||
|
fetchDialog.classList.remove("closing");
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchUrlBtn) {
|
||||||
|
fetchUrlBtn.onclick = openFetchDialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchCloseBtn) {
|
||||||
|
fetchCloseBtn.onclick = closeFetchDialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchSubmitBtn) {
|
||||||
|
fetchSubmitBtn.onclick = async () => {
|
||||||
|
const url = fetchUrlInput.value.trim();
|
||||||
|
if (!url) {
|
||||||
|
M.showToast("Please enter a URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeFetchDialog();
|
||||||
|
M.showToast("Checking URL...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/fetch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.type === "playlist") {
|
||||||
|
// Ask user to confirm playlist download
|
||||||
|
const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.`);
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
// Confirm playlist download
|
||||||
|
const confirmRes = await fetch("/api/fetch/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ items: data.items })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmRes.ok) {
|
||||||
|
const confirmData = await confirmRes.json();
|
||||||
|
M.showToast(confirmData.message);
|
||||||
|
// Tasks will be created by WebSocket progress messages
|
||||||
|
} else {
|
||||||
|
const err = await confirmRes.json().catch(() => ({}));
|
||||||
|
M.showToast(err.error || "Failed to queue playlist", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data.type === "single") {
|
||||||
|
M.showToast(`Queued: ${data.title}`);
|
||||||
|
// Task will be created by WebSocket progress messages
|
||||||
|
} else {
|
||||||
|
M.showToast(data.message || "Fetch started");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
M.showToast(err.error || "Fetch failed", "error");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
M.showToast("Fetch failed", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchUrlInput) {
|
||||||
|
fetchUrlInput.onkeydown = (e) => {
|
||||||
|
if (e.key === "Enter") fetchSubmitBtn.click();
|
||||||
|
if (e.key === "Escape") closeFetchDialog();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +196,122 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function showProgress(current, total, filename) {
|
// Task management
|
||||||
if (!progressEl) return;
|
const fetchTasks = new Map(); // Map<id, taskHandle>
|
||||||
const pct = Math.round((current / total) * 100);
|
|
||||||
progressBar.style.width = pct + "%";
|
function updateTasksEmpty() {
|
||||||
progressText.textContent = `Uploading ${current}/${total}: ${filename}`;
|
const hasTasks = tasksList.children.length > 0;
|
||||||
progressEl.classList.remove("hidden");
|
tasksEmpty.classList.toggle("hidden", hasTasks);
|
||||||
uploadBtn.classList.add("hidden");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideProgress() {
|
// Handle WebSocket fetch progress messages
|
||||||
if (!progressEl) return;
|
M.handleFetchProgress = function(data) {
|
||||||
progressEl.classList.add("hidden");
|
let task = fetchTasks.get(data.id);
|
||||||
uploadBtn.classList.remove("hidden");
|
|
||||||
|
// Create task if we don't have one for this id
|
||||||
|
if (!task && data.status !== "complete" && data.status !== "error") {
|
||||||
|
task = createTask(data.title || "Downloading...", data.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
if (data.status === "downloading" || data.status === "queued") {
|
||||||
|
task.setProgress(data.progress || 0);
|
||||||
|
} else if (data.status === "complete") {
|
||||||
|
task.setComplete();
|
||||||
|
fetchTasks.delete(data.id);
|
||||||
|
} else if (data.status === "error") {
|
||||||
|
task.setError(data.error || "Failed");
|
||||||
|
fetchTasks.delete(data.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTask(filename, fetchId) {
|
||||||
|
const task = document.createElement("div");
|
||||||
|
task.className = "task-item";
|
||||||
|
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();
|
||||||
|
|
||||||
|
const taskHandle = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store fetch tasks for WebSocket updates
|
||||||
|
if (fetchId) {
|
||||||
|
fetchTasks.set(fetchId, taskHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return taskHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +328,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++;
|
if (active.length > 0) {
|
||||||
} else if (res.status === 409) {
|
await Promise.race(active);
|
||||||
// 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) {
|
|
||||||
console.error(`Upload error for ${file.name}:`, e);
|
|
||||||
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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
217
server.ts
217
server.ts
|
|
@ -33,15 +33,37 @@ import {
|
||||||
getClientInfo,
|
getClientInfo,
|
||||||
} from "./auth";
|
} from "./auth";
|
||||||
import { Library } from "./library";
|
import { Library } from "./library";
|
||||||
|
import {
|
||||||
|
initYtdlp,
|
||||||
|
getStatus as getYtdlpStatus,
|
||||||
|
isAvailable as isYtdlpAvailable,
|
||||||
|
checkUrl,
|
||||||
|
addToFastQueue,
|
||||||
|
addToSlowQueue,
|
||||||
|
getUserQueues,
|
||||||
|
setProgressCallback,
|
||||||
|
type QueueItem
|
||||||
|
} from "./ytdlp";
|
||||||
|
|
||||||
// Load config
|
// Load config
|
||||||
|
interface YtdlpConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
command: string;
|
||||||
|
ffmpegCommand: string;
|
||||||
|
updateCommand: string | null;
|
||||||
|
fastQueueConcurrent: number;
|
||||||
|
slowQueueInterval: number;
|
||||||
|
allowPlaylists: boolean;
|
||||||
|
autoUpdate: boolean;
|
||||||
|
updateCheckInterval: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Config {
|
interface Config {
|
||||||
port: number;
|
port: number;
|
||||||
musicDir: string;
|
musicDir: string;
|
||||||
allowGuests: boolean;
|
allowGuests: boolean;
|
||||||
defaultPermissions: {
|
defaultPermissions: string[];
|
||||||
channel?: string[];
|
ytdlp?: YtdlpConfig;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_PATH = join(import.meta.dir, "config.json");
|
const CONFIG_PATH = join(import.meta.dir, "config.json");
|
||||||
|
|
@ -49,7 +71,19 @@ const CONFIG_PATH = join(import.meta.dir, "config.json");
|
||||||
const DEFAULT_CONFIG: Config = {
|
const DEFAULT_CONFIG: Config = {
|
||||||
port: 3001,
|
port: 3001,
|
||||||
musicDir: "./music",
|
musicDir: "./music",
|
||||||
allowGuests: true
|
allowGuests: true,
|
||||||
|
defaultPermissions: ["listen", "control"],
|
||||||
|
ytdlp: {
|
||||||
|
enabled: false,
|
||||||
|
command: "yt-dlp",
|
||||||
|
ffmpegCommand: "ffmpeg",
|
||||||
|
updateCommand: "yt-dlp -U",
|
||||||
|
fastQueueConcurrent: 2,
|
||||||
|
slowQueueInterval: 180,
|
||||||
|
allowPlaylists: true,
|
||||||
|
autoUpdate: true,
|
||||||
|
updateCheckInterval: 86400
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create default config if missing
|
// Create default config if missing
|
||||||
|
|
@ -57,6 +91,8 @@ const configFile = file(CONFIG_PATH);
|
||||||
if (!(await configFile.exists())) {
|
if (!(await configFile.exists())) {
|
||||||
console.log("[Config] Creating default config.json...");
|
console.log("[Config] Creating default config.json...");
|
||||||
await Bun.write(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2));
|
await Bun.write(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2));
|
||||||
|
console.log("Config created at config.json. Have a look at it then restart the server. Bye!");
|
||||||
|
process.exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: Config = await configFile.json();
|
const config: Config = await configFile.json();
|
||||||
|
|
@ -66,6 +102,18 @@ const PUBLIC_DIR = join(import.meta.dir, "public");
|
||||||
|
|
||||||
console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`);
|
console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`);
|
||||||
|
|
||||||
|
// Initialize yt-dlp if configured
|
||||||
|
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
|
||||||
|
const ytdlpStatus = await initYtdlp({
|
||||||
|
enabled: ytdlpConfig.enabled,
|
||||||
|
command: ytdlpConfig.command,
|
||||||
|
ffmpegCommand: ytdlpConfig.ffmpegCommand,
|
||||||
|
musicDir: MUSIC_DIR,
|
||||||
|
fastQueueConcurrent: ytdlpConfig.fastQueueConcurrent,
|
||||||
|
slowQueueInterval: ytdlpConfig.slowQueueInterval,
|
||||||
|
allowPlaylists: ytdlpConfig.allowPlaylists
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize library
|
// Initialize library
|
||||||
const library = new Library(MUSIC_DIR);
|
const library = new Library(MUSIC_DIR);
|
||||||
|
|
||||||
|
|
@ -241,6 +289,30 @@ function broadcastToAll(message: object) {
|
||||||
console.log(`[Broadcast] Sent to ${clientCount} clients`);
|
console.log(`[Broadcast] Sent to ${clientCount} clients`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send message to specific user's connections
|
||||||
|
function sendToUser(userId: number, message: object) {
|
||||||
|
const connections = userConnections.get(userId);
|
||||||
|
if (connections) {
|
||||||
|
const data = JSON.stringify(message);
|
||||||
|
for (const ws of connections) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up ytdlp progress callback
|
||||||
|
setProgressCallback((item) => {
|
||||||
|
sendToUser(item.userId, {
|
||||||
|
type: `fetch_${item.status === "downloading" ? "progress" : item.status}`,
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
status: item.status,
|
||||||
|
progress: item.progress,
|
||||||
|
queueType: item.queueType,
|
||||||
|
error: item.error
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Broadcast channel list to all clients
|
// Broadcast channel list to all clients
|
||||||
function broadcastChannelList() {
|
function broadcastChannelList() {
|
||||||
const list = [...channels.values()].map(c => c.getListInfo());
|
const list = [...channels.values()].map(c => c.getListInfo());
|
||||||
|
|
@ -338,7 +410,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,6 +443,7 @@ serve({
|
||||||
allowSignups: true,
|
allowSignups: true,
|
||||||
channelCount: channels.size,
|
channelCount: channels.size,
|
||||||
defaultPermissions: config.defaultPermissions,
|
defaultPermissions: config.defaultPermissions,
|
||||||
|
ytdlp: getYtdlpStatus()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,6 +471,9 @@ serve({
|
||||||
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
||||||
return Response.json({ error: "Name is required" }, { status: 400 });
|
return Response.json({ error: "Name is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (name.trim().length > 64) {
|
||||||
|
return Response.json({ error: "Name must be 64 characters or less" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
// Build track list from trackIds or default to full library
|
// Build track list from trackIds or default to full library
|
||||||
let tracks: Track[];
|
let tracks: Track[];
|
||||||
|
|
@ -415,15 +491,8 @@ serve({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Default to full library
|
// Default to empty playlist
|
||||||
tracks = library.getAllTracks()
|
tracks = [];
|
||||||
.filter(t => t.duration > 0)
|
|
||||||
.map(t => ({
|
|
||||||
id: t.id,
|
|
||||||
filename: t.filename,
|
|
||||||
title: t.title || t.filename.replace(/\.[^.]+$/, ""),
|
|
||||||
duration: t.duration,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelId = generateChannelId();
|
const channelId = generateChannelId();
|
||||||
|
|
@ -479,6 +548,18 @@ serve({
|
||||||
if (!user.is_admin && channel.createdBy !== user.id) {
|
if (!user.is_admin && channel.createdBy !== user.id) {
|
||||||
return Response.json({ error: "Access denied" }, { status: 403 });
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move connected clients to default channel before deleting
|
||||||
|
const defaultChannel = [...channels.values()].find(c => c.isDefault);
|
||||||
|
if (defaultChannel && channel.clients.size > 0) {
|
||||||
|
for (const ws of channel.clients) {
|
||||||
|
channel.removeClient(ws);
|
||||||
|
ws.data.channelId = defaultChannel.id;
|
||||||
|
defaultChannel.addClient(ws);
|
||||||
|
ws.send(JSON.stringify({ type: "switched", channelId: defaultChannel.id }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
channels.delete(channelId);
|
channels.delete(channelId);
|
||||||
deleteChannelFromDb(channelId);
|
deleteChannelFromDb(channelId);
|
||||||
broadcastChannelList();
|
broadcastChannelList();
|
||||||
|
|
@ -560,6 +641,108 @@ serve({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API: fetch from URL (yt-dlp)
|
||||||
|
if (path === "/api/fetch" && req.method === "POST") {
|
||||||
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (user.is_guest) {
|
||||||
|
return Response.json({ error: "Guests cannot fetch from URLs" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if feature is enabled
|
||||||
|
if (!ytdlpConfig.enabled) {
|
||||||
|
return Response.json({ error: "Feature disabled" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!isYtdlpAvailable()) {
|
||||||
|
return Response.json({ error: "yt-dlp not available" }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { url } = await req.json();
|
||||||
|
if (!url || typeof url !== "string") {
|
||||||
|
return Response.json({ error: "URL is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Fetch] ${user.username} checking URL: ${url}`);
|
||||||
|
|
||||||
|
// Check URL to detect playlist vs single video
|
||||||
|
const info = await checkUrl(url);
|
||||||
|
|
||||||
|
if (info.type === "playlist") {
|
||||||
|
if (!ytdlpConfig.allowPlaylists) {
|
||||||
|
return Response.json({ error: "Playlist downloads are disabled" }, { status: 403 });
|
||||||
|
}
|
||||||
|
// Return playlist info for confirmation
|
||||||
|
return Response.json(info, { headers });
|
||||||
|
} else {
|
||||||
|
// Single video - add to fast queue immediately
|
||||||
|
const item = addToFastQueue(info.url, info.title, user.id);
|
||||||
|
console.log(`[Fetch] ${user.username} queued: ${info.title} (id=${item.id})`);
|
||||||
|
return Response.json({
|
||||||
|
type: "single",
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
queueType: "fast"
|
||||||
|
}, { headers });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[Fetch] Error:", e);
|
||||||
|
return Response.json({ error: e.message || "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API: confirm playlist download
|
||||||
|
if (path === "/api/fetch/confirm" && req.method === "POST") {
|
||||||
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (user.is_guest) {
|
||||||
|
return Response.json({ error: "Guests cannot fetch from URLs" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!ytdlpConfig.enabled || !isYtdlpAvailable()) {
|
||||||
|
return Response.json({ error: "Feature not available" }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { items } = await req.json();
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return Response.json({ error: "Items required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueItems = addToSlowQueue(items, user.id);
|
||||||
|
const estimatedMinutes = Math.ceil(queueItems.length * ytdlpConfig.slowQueueInterval / 60);
|
||||||
|
const hours = Math.floor(estimatedMinutes / 60);
|
||||||
|
const mins = estimatedMinutes % 60;
|
||||||
|
const estimatedTime = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||||
|
|
||||||
|
console.log(`[Fetch] ${user.username} confirmed playlist: ${queueItems.length} items`);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
message: `Added ${queueItems.length} items to queue`,
|
||||||
|
queueType: "slow",
|
||||||
|
estimatedTime,
|
||||||
|
items: queueItems.map(i => ({ id: i.id, title: i.title }))
|
||||||
|
}, { headers });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Fetch] Confirm error:", e);
|
||||||
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API: get fetch queue status for current user
|
||||||
|
if (path === "/api/fetch" && req.method === "GET") {
|
||||||
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const queues = getUserQueues(user.id);
|
||||||
|
return Response.json(queues, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
// Auth: signup
|
// Auth: signup
|
||||||
if (path === "/api/auth/signup" && req.method === "POST") {
|
if (path === "/api/auth/signup" && req.method === "POST") {
|
||||||
try {
|
try {
|
||||||
|
|
@ -640,8 +823,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({
|
||||||
|
|
@ -988,7 +1171,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);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,416 @@
|
||||||
|
// MusicRoom - yt-dlp integration module
|
||||||
|
// Handles fetching audio from URLs via yt-dlp
|
||||||
|
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
export interface QueueItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
userId: number;
|
||||||
|
status: "queued" | "downloading" | "complete" | "error";
|
||||||
|
progress: number;
|
||||||
|
queueType: "fast" | "slow";
|
||||||
|
error?: string;
|
||||||
|
filename?: string;
|
||||||
|
createdAt: number;
|
||||||
|
completedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YtdlpStatus {
|
||||||
|
available: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
version: string | null;
|
||||||
|
ffmpeg: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistInfo {
|
||||||
|
type: "playlist";
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
items: { id: string; url: string; title: string }[];
|
||||||
|
requiresConfirmation: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SingleVideoInfo {
|
||||||
|
type: "single";
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressCallback = (item: QueueItem) => void;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
let ytdlpCommand = "yt-dlp";
|
||||||
|
let ffmpegCommand = "ffmpeg";
|
||||||
|
let musicDir = "./music";
|
||||||
|
let fastQueueConcurrent = 2;
|
||||||
|
let slowQueueInterval = 180;
|
||||||
|
let allowPlaylists = true;
|
||||||
|
|
||||||
|
// Status
|
||||||
|
let ytdlpAvailable = false;
|
||||||
|
let ytdlpVersion: string | null = null;
|
||||||
|
let ffmpegAvailable = false;
|
||||||
|
let featureEnabled = false;
|
||||||
|
|
||||||
|
// Queues
|
||||||
|
const fastQueue: QueueItem[] = [];
|
||||||
|
const slowQueue: QueueItem[] = [];
|
||||||
|
let activeDownloads = 0;
|
||||||
|
let slowQueueTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let lastSlowDownload = 0;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
let onProgress: ProgressCallback | null = null;
|
||||||
|
|
||||||
|
// Generate unique ID
|
||||||
|
function generateId(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize ytdlp module
|
||||||
|
export async function initYtdlp(config: {
|
||||||
|
enabled: boolean;
|
||||||
|
command: string;
|
||||||
|
ffmpegCommand: string;
|
||||||
|
musicDir: string;
|
||||||
|
fastQueueConcurrent: number;
|
||||||
|
slowQueueInterval: number;
|
||||||
|
allowPlaylists: boolean;
|
||||||
|
}): Promise<YtdlpStatus> {
|
||||||
|
featureEnabled = config.enabled;
|
||||||
|
ytdlpCommand = config.command;
|
||||||
|
ffmpegCommand = config.ffmpegCommand;
|
||||||
|
musicDir = config.musicDir;
|
||||||
|
fastQueueConcurrent = config.fastQueueConcurrent;
|
||||||
|
slowQueueInterval = config.slowQueueInterval;
|
||||||
|
allowPlaylists = config.allowPlaylists;
|
||||||
|
|
||||||
|
if (!featureEnabled) {
|
||||||
|
console.log("[ytdlp] Feature disabled in config");
|
||||||
|
return { available: false, enabled: false, version: null, ffmpeg: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check yt-dlp availability
|
||||||
|
try {
|
||||||
|
ytdlpVersion = await runCommand(ytdlpCommand, ["--version"]);
|
||||||
|
ytdlpAvailable = true;
|
||||||
|
console.log(`[ytdlp] Found yt-dlp version: ${ytdlpVersion.trim()}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[ytdlp] yt-dlp not found (command: ${ytdlpCommand})`);
|
||||||
|
ytdlpAvailable = false;
|
||||||
|
featureEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ffmpeg availability
|
||||||
|
try {
|
||||||
|
await runCommand(ffmpegCommand, ["-version"]);
|
||||||
|
ffmpegAvailable = true;
|
||||||
|
console.log("[ytdlp] ffmpeg available");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[ytdlp] ffmpeg not found - audio extraction may fail");
|
||||||
|
ffmpegAvailable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start slow queue processor
|
||||||
|
if (featureEnabled) {
|
||||||
|
startSlowQueueProcessor();
|
||||||
|
}
|
||||||
|
|
||||||
|
return getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a command and return stdout
|
||||||
|
function runCommand(cmd: string, args: string[]): Promise<string> {
|
||||||
|
const fullCmd = `${cmd} ${args.join(" ")}`;
|
||||||
|
console.log(`[ytdlp] Running: ${fullCmd}`);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(cmd, args);
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
proc.stdout.on("data", (data) => { stdout += data; });
|
||||||
|
proc.stderr.on("data", (data) => { stderr += data; });
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
console.log(`[ytdlp] Command exited with code ${code}`);
|
||||||
|
if (code === 0) resolve(stdout);
|
||||||
|
else reject(new Error(stderr || `Exit code ${code}`));
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current status
|
||||||
|
export function getStatus(): YtdlpStatus {
|
||||||
|
return {
|
||||||
|
available: ytdlpAvailable,
|
||||||
|
enabled: featureEnabled,
|
||||||
|
version: ytdlpVersion,
|
||||||
|
ffmpeg: ffmpegAvailable
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if feature is enabled and available
|
||||||
|
export function isAvailable(): boolean {
|
||||||
|
return featureEnabled && ytdlpAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set progress callback
|
||||||
|
export function setProgressCallback(callback: ProgressCallback): void {
|
||||||
|
onProgress = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all queue items
|
||||||
|
export function getQueues(): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const nextIn = Math.max(0, Math.floor((lastSlowDownload + slowQueueInterval * 1000 - now) / 1000));
|
||||||
|
return {
|
||||||
|
fastQueue: [...fastQueue],
|
||||||
|
slowQueue: [...slowQueue],
|
||||||
|
slowQueueNextIn: nextIn
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get queue items for a specific user
|
||||||
|
export function getUserQueues(userId: number): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } {
|
||||||
|
const queues = getQueues();
|
||||||
|
return {
|
||||||
|
fastQueue: queues.fastQueue.filter(i => i.userId === userId),
|
||||||
|
slowQueue: queues.slowQueue.filter(i => i.userId === userId),
|
||||||
|
slowQueueNextIn: queues.slowQueueNextIn
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check URL and detect if it's a playlist
|
||||||
|
export async function checkUrl(url: string): Promise<PlaylistInfo | SingleVideoInfo> {
|
||||||
|
const args = ["--flat-playlist", "--dump-json", "--no-warnings", url];
|
||||||
|
const output = await runCommand(ytdlpCommand, args);
|
||||||
|
|
||||||
|
// Parse JSON lines
|
||||||
|
const lines = output.trim().split("\n").filter(l => l);
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
throw new Error("No video found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length === 1) {
|
||||||
|
const data = JSON.parse(lines[0]);
|
||||||
|
if (data._type === "playlist") {
|
||||||
|
// It's a playlist with entries
|
||||||
|
const items = (data.entries || []).map((e: any) => ({
|
||||||
|
id: generateId(),
|
||||||
|
url: e.url || e.webpage_url || `https://youtube.com/watch?v=${e.id}`,
|
||||||
|
title: e.title || "Unknown"
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
type: "playlist",
|
||||||
|
title: data.title || "Playlist",
|
||||||
|
count: items.length,
|
||||||
|
items,
|
||||||
|
requiresConfirmation: true
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Single video
|
||||||
|
return {
|
||||||
|
type: "single",
|
||||||
|
id: generateId(),
|
||||||
|
title: data.title || "Unknown",
|
||||||
|
url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple JSON lines = playlist
|
||||||
|
const items = lines.map(line => {
|
||||||
|
const data = JSON.parse(line);
|
||||||
|
return {
|
||||||
|
id: generateId(),
|
||||||
|
url: data.url || data.webpage_url || url,
|
||||||
|
title: data.title || "Unknown"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
type: "playlist",
|
||||||
|
title: "Playlist",
|
||||||
|
count: items.length,
|
||||||
|
items,
|
||||||
|
requiresConfirmation: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add single video to fast queue
|
||||||
|
export function addToFastQueue(url: string, title: string, userId: number): QueueItem {
|
||||||
|
const item: QueueItem = {
|
||||||
|
id: generateId(),
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
userId,
|
||||||
|
status: "queued",
|
||||||
|
progress: 0,
|
||||||
|
queueType: "fast",
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
fastQueue.push(item);
|
||||||
|
processNextFast();
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add items to slow queue (for playlists)
|
||||||
|
export function addToSlowQueue(items: { url: string; title: string }[], userId: number): QueueItem[] {
|
||||||
|
const queueItems: QueueItem[] = items.map(item => ({
|
||||||
|
id: generateId(),
|
||||||
|
url: item.url,
|
||||||
|
title: item.title,
|
||||||
|
userId,
|
||||||
|
status: "queued" as const,
|
||||||
|
progress: 0,
|
||||||
|
queueType: "slow" as const,
|
||||||
|
createdAt: Date.now()
|
||||||
|
}));
|
||||||
|
slowQueue.push(...queueItems);
|
||||||
|
return queueItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process next item in fast queue
|
||||||
|
function processNextFast(): void {
|
||||||
|
if (activeDownloads >= fastQueueConcurrent) return;
|
||||||
|
|
||||||
|
const item = fastQueue.find(i => i.status === "queued");
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
activeDownloads++;
|
||||||
|
downloadItem(item).finally(() => {
|
||||||
|
activeDownloads--;
|
||||||
|
processNextFast();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start slow queue processor
|
||||||
|
function startSlowQueueProcessor(): void {
|
||||||
|
if (slowQueueTimer) return;
|
||||||
|
|
||||||
|
const processNext = () => {
|
||||||
|
const item = slowQueue.find(i => i.status === "queued");
|
||||||
|
if (item) {
|
||||||
|
lastSlowDownload = Date.now();
|
||||||
|
downloadItem(item).finally(() => {
|
||||||
|
slowQueueTimer = setTimeout(processNext, slowQueueInterval * 1000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
slowQueueTimer = setTimeout(processNext, 5000); // Check again in 5s
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start immediately if there are items
|
||||||
|
const hasQueued = slowQueue.some(i => i.status === "queued");
|
||||||
|
if (hasQueued) {
|
||||||
|
processNext();
|
||||||
|
} else {
|
||||||
|
slowQueueTimer = setTimeout(processNext, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download a single item
|
||||||
|
async function downloadItem(item: QueueItem): Promise<void> {
|
||||||
|
item.status = "downloading";
|
||||||
|
item.progress = 0;
|
||||||
|
notifyProgress(item);
|
||||||
|
|
||||||
|
console.log(`[ytdlp] Starting download: ${item.title} (${item.url})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outputTemplate = join(musicDir, "%(title)s.%(ext)s");
|
||||||
|
const args = [
|
||||||
|
"-x",
|
||||||
|
"--audio-format", "mp3",
|
||||||
|
"-o", outputTemplate,
|
||||||
|
"--progress",
|
||||||
|
"--newline",
|
||||||
|
"--no-warnings",
|
||||||
|
item.url
|
||||||
|
];
|
||||||
|
|
||||||
|
const fullCmd = `${ytdlpCommand} ${args.join(" ")}`;
|
||||||
|
console.log(`[ytdlp] Running: ${fullCmd}`);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const proc = spawn(ytdlpCommand, args);
|
||||||
|
|
||||||
|
proc.stdout.on("data", (data) => {
|
||||||
|
const line = data.toString();
|
||||||
|
console.log(`[ytdlp] ${line.trim()}`);
|
||||||
|
// Parse progress from yt-dlp output
|
||||||
|
const match = line.match(/(\d+\.?\d*)%/);
|
||||||
|
if (match) {
|
||||||
|
item.progress = parseFloat(match[1]);
|
||||||
|
notifyProgress(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on("data", (data) => {
|
||||||
|
console.error(`[ytdlp] stderr: ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
console.log(`[ytdlp] Download finished with code ${code}`);
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`yt-dlp exited with code ${code}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[ytdlp] Complete: ${item.title}`);
|
||||||
|
item.status = "complete";
|
||||||
|
item.progress = 100;
|
||||||
|
item.completedAt = Date.now();
|
||||||
|
notifyProgress(item);
|
||||||
|
|
||||||
|
// Remove from queue after delay
|
||||||
|
setTimeout(() => removeFromQueue(item), 5000);
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
item.status = "error";
|
||||||
|
item.error = e.message || "Download failed";
|
||||||
|
notifyProgress(item);
|
||||||
|
|
||||||
|
// Remove from queue after delay
|
||||||
|
setTimeout(() => removeFromQueue(item), 10000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove item from queue
|
||||||
|
function removeFromQueue(item: QueueItem): void {
|
||||||
|
if (item.queueType === "fast") {
|
||||||
|
const idx = fastQueue.findIndex(i => i.id === item.id);
|
||||||
|
if (idx !== -1) fastQueue.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
const idx = slowQueue.findIndex(i => i.id === item.id);
|
||||||
|
if (idx !== -1) slowQueue.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify progress callback
|
||||||
|
function notifyProgress(item: QueueItem): void {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup old completed/failed items
|
||||||
|
export function cleanupOldItems(maxAge: number = 3600000): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const cleanup = (queue: QueueItem[]) => {
|
||||||
|
for (let i = queue.length - 1; i >= 0; i--) {
|
||||||
|
const item = queue[i];
|
||||||
|
if ((item.status === "complete" || item.status === "error") &&
|
||||||
|
now - item.createdAt > maxAge) {
|
||||||
|
queue.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cleanup(fastQueue);
|
||||||
|
cleanup(slowQueue);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue