blastoise/public/upload.js

548 lines
17 KiB
JavaScript

(function() {
const M = window.MusicRoom;
document.addEventListener("DOMContentLoaded", () => {
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 dropzone = M.$("#upload-dropzone");
const libraryPanel = M.$("#library-panel");
const tasksList = M.$("#tasks-list");
const tasksEmpty = M.$("#tasks-empty");
// Slow queue section elements (created dynamically)
let slowQueueSection = null;
let slowQueuePollInterval = null;
if (!addBtn || !fileInput || !dropzone) return;
function openPanel() {
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) {
M.showToast("Sign in to add tracks");
return;
}
openPanel();
};
// Close add panel
addCloseBtn.onclick = () => {
closePanel();
};
// Upload files option
uploadFilesBtn.onclick = () => {
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.\n\nA playlist will be created automatically.`);
if (confirmed) {
// Confirm playlist download with title for auto-playlist creation
const confirmRes = await fetch("/api/fetch/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: data.items, playlistTitle: data.title })
});
if (confirmRes.ok) {
const confirmData = await confirmRes.json();
M.showToast(`${confirmData.message} → "${confirmData.playlistName}"`);
// Refresh playlists to show the new one
if (M.playlists?.load) M.playlists.load();
// Refresh slow queue display
pollSlowQueue();
} 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
fileInput.onchange = () => {
if (fileInput.files.length > 0) {
closePanel();
uploadFiles(fileInput.files);
fileInput.value = "";
}
};
// Drag and drop on library panel
let dragCounter = 0;
libraryPanel.ondragenter = (e) => {
if (!M.currentUser) return;
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
dragCounter++;
dropzone.classList.remove("hidden");
};
libraryPanel.ondragleave = (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter === 0) {
dropzone.classList.add("hidden");
}
};
libraryPanel.ondragover = (e) => {
if (!M.currentUser) return;
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
libraryPanel.ondrop = (e) => {
e.preventDefault();
dragCounter = 0;
dropzone.classList.add("hidden");
if (!M.currentUser) {
M.showToast("Sign in to upload");
return;
}
const files = e.dataTransfer.files;
if (files.length > 0) {
uploadFiles(files);
}
};
// Task management
const fetchTasks = new Map(); // Map<id, taskHandle>
function updateTasksEmpty() {
const hasActiveTasks = tasksList.children.length > 0;
const hasSlowQueue = slowQueueSection && !slowQueueSection.classList.contains("hidden");
tasksEmpty.classList.toggle("hidden", hasActiveTasks || hasSlowQueue);
}
// Slow queue display
function createSlowQueueSection() {
if (slowQueueSection) return slowQueueSection;
slowQueueSection = document.createElement("div");
slowQueueSection.className = "slow-queue-section hidden";
slowQueueSection.innerHTML = `
<div class="slow-queue-header">
<span class="slow-queue-title">Playlist Queue</span>
<span class="slow-queue-timer"></span>
<button class="slow-queue-cancel-all" title="Cancel all">Cancel All</button>
</div>
<div class="slow-queue-list"></div>
`;
// Wire up cancel all button
slowQueueSection.querySelector(".slow-queue-cancel-all").onclick = async () => {
try {
const res = await fetch("/api/fetch", { method: "DELETE" });
if (res.ok) {
const data = await res.json();
M.showToast(data.message);
pollSlowQueue();
} else {
M.showToast("Failed to cancel", "error");
}
} catch (e) {
M.showToast("Failed to cancel", "error");
}
};
// Insert before tasks-empty
tasksEmpty.parentNode.insertBefore(slowQueueSection, tasksEmpty);
return slowQueueSection;
}
function formatTime(seconds) {
if (seconds <= 0) return "now";
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
}
const QUEUE_PREVIEW_COUNT = 5;
let showAllQueue = false;
function updateSlowQueueDisplay(slowQueue, slowQueueNextIn) {
const section = createSlowQueueSection();
const queuedItems = slowQueue.filter(i => i.status === "queued");
if (queuedItems.length === 0) {
section.classList.add("hidden");
showAllQueue = false;
updateTasksEmpty();
return;
}
section.classList.remove("hidden");
// Update header with count and timer
const timerEl = section.querySelector(".slow-queue-timer");
timerEl.textContent = `${queuedItems.length} queued · next in ${formatTime(slowQueueNextIn)}`;
// Determine how many items to show
const itemsToShow = showAllQueue ? queuedItems : queuedItems.slice(0, QUEUE_PREVIEW_COUNT);
const hiddenCount = queuedItems.length - itemsToShow.length;
// Group items by playlist
const byPlaylist = new Map();
for (const item of itemsToShow) {
const key = item.playlistId || "__none__";
if (!byPlaylist.has(key)) {
byPlaylist.set(key, { name: item.playlistName, items: [] });
}
byPlaylist.get(key).items.push(item);
}
// Update list
const listEl = section.querySelector(".slow-queue-list");
let html = "";
for (const [playlistId, group] of byPlaylist) {
if (group.name) {
html += `<div class="slow-queue-playlist-header">📁 ${group.name}</div>`;
}
html += group.items.map((item, i) => {
const isNext = queuedItems.indexOf(item) === 0;
return `
<div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}">
<span class="slow-queue-item-icon">${isNext ? '⏳' : '·'}</span>
<span class="slow-queue-item-title">${item.title}</span>
<button class="slow-queue-cancel" title="Cancel">✕</button>
</div>
`;
}).join("");
}
// Add show more/less button if needed
if (queuedItems.length > QUEUE_PREVIEW_COUNT) {
if (showAllQueue) {
html += `<button class="slow-queue-show-toggle">Show less</button>`;
} else {
html += `<button class="slow-queue-show-toggle">Show ${hiddenCount} more...</button>`;
}
}
listEl.innerHTML = html;
// Add cancel handlers
listEl.querySelectorAll(".slow-queue-cancel").forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const itemEl = btn.closest(".slow-queue-item");
const itemId = itemEl.dataset.id;
try {
const res = await fetch(`/api/fetch/${itemId}`, { method: "DELETE" });
if (res.ok) {
itemEl.remove();
pollSlowQueue();
} else {
M.showToast("Cannot cancel item", "error");
}
} catch (e) {
M.showToast("Failed to cancel", "error");
}
};
});
// Add show more/less handler
const toggleBtn = listEl.querySelector(".slow-queue-show-toggle");
if (toggleBtn) {
toggleBtn.onclick = () => {
showAllQueue = !showAllQueue;
updateSlowQueueDisplay(slowQueue, slowQueueNextIn);
};
}
updateTasksEmpty();
}
async function pollSlowQueue() {
if (!M.currentUser || M.currentUser.isGuest) return;
try {
const res = await fetch("/api/fetch");
if (res.ok) {
const data = await res.json();
updateSlowQueueDisplay(data.slowQueue || [], data.slowQueueNextIn || 0);
}
} catch (e) {
// Ignore poll errors
}
}
function startSlowQueuePoll() {
if (slowQueuePollInterval) return;
pollSlowQueue();
slowQueuePollInterval = setInterval(pollSlowQueue, 5000);
}
function stopSlowQueuePoll() {
if (slowQueuePollInterval) {
clearInterval(slowQueuePollInterval);
slowQueuePollInterval = null;
}
if (slowQueueSection) {
slowQueueSection.classList.add("hidden");
updateTasksEmpty();
}
}
// Expose start/stop for auth module to call
M.startSlowQueuePoll = startSlowQueuePoll;
M.stopSlowQueuePoll = stopSlowQueuePoll;
// Handle WebSocket fetch progress messages
M.handleFetchProgress = function(data) {
let task = fetchTasks.get(data.id);
// 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);
// Refresh slow queue on completion
pollSlowQueue();
} else if (data.status === "error") {
task.setError(data.error || "Failed");
fetchTasks.delete(data.id);
// Refresh slow queue on error
pollSlowQueue();
}
};
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) {
const validExts = [".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"];
const audioFiles = [...files].filter(f => {
const ext = f.name.toLowerCase().match(/\.[^.]+$/)?.[0];
return ext && validExts.includes(ext);
});
if (audioFiles.length === 0) {
M.showToast("No valid audio files");
return;
}
let uploaded = 0;
let failed = 0;
// Upload files in parallel (max 3 concurrent)
const concurrency = 3;
const queue = [...audioFiles];
const active = [];
while (queue.length > 0 || active.length > 0) {
while (active.length < concurrency && queue.length > 0) {
const file = queue.shift();
const promise = uploadFile(file).then(result => {
if (result.success) uploaded++;
else failed++;
active.splice(active.indexOf(promise), 1);
});
active.push(promise);
}
if (active.length > 0) {
await Promise.race(active);
}
}
if (uploaded > 0) {
M.showToast(`Uploaded ${uploaded} track${uploaded > 1 ? 's' : ''}${failed > 0 ? `, ${failed} failed` : ''}`);
} else if (failed > 0) {
M.showToast(`Upload failed`);
}
}
});
})();