diff --git a/public/index.html b/public/index.html index dfd5a70..002b1db 100644 --- a/public/index.html +++ b/public/index.html @@ -72,6 +72,17 @@
+ +
+ + diff --git a/public/styles.css b/public/styles.css index 2220169..5a0262f 100644 --- a/public/styles.css +++ b/public/styles.css @@ -70,6 +70,18 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe .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; } diff --git a/public/upload.js b/public/upload.js index be0e971..7dd5765 100644 --- a/public/upload.js +++ b/public/upload.js @@ -49,6 +49,80 @@ 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(); + + // Create a task for this fetch + const task = createTask(`Fetching: ${url.substring(0, 50)}...`); + + 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(); + task.setComplete(); + M.showToast(data.message || "Fetch started"); + } else { + const err = await res.json().catch(() => ({})); + task.setError(err.error || "Failed"); + M.showToast(err.error || "Fetch failed", "error"); + } + } catch (e) { + task.setError("Error"); + 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) { diff --git a/server.ts b/server.ts index 5158937..cb48da6 100644 --- a/server.ts +++ b/server.ts @@ -33,6 +33,7 @@ import { getClientInfo, } from "./auth"; import { Library } from "./library"; +import { createFetchRequest, getUserRequests, startDownload } from "./ytdlp"; // Load config interface Config { @@ -571,6 +572,50 @@ 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 }); + } + + try { + const { url } = await req.json(); + if (!url || typeof url !== "string") { + return Response.json({ error: "URL is required" }, { status: 400 }); + } + + // Create fetch request + const request = createFetchRequest(url, user.id); + console.log(`[Fetch] ${user.username} requested: ${url} (id=${request.id})`); + + // Start download in background (don't await) + startDownload(request, config.musicDir); + + return Response.json({ + message: "Fetch started", + requestId: request.id + }, { headers }); + } catch (e) { + console.error("[Fetch] Error:", e); + return Response.json({ error: "Invalid request" }, { status: 400 }); + } + } + + // API: get fetch requests 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 requests = getUserRequests(user.id); + return Response.json({ requests }, { headers }); + } + // Auth: signup if (path === "/api/auth/signup" && req.method === "POST") { try { diff --git a/ytdlp.ts b/ytdlp.ts new file mode 100644 index 0000000..3d16e38 --- /dev/null +++ b/ytdlp.ts @@ -0,0 +1,79 @@ +// MusicRoom - yt-dlp integration module +// Handles fetching audio from URLs via yt-dlp + +export interface FetchRequest { + id: string; + url: string; + status: "pending" | "downloading" | "processing" | "complete" | "error"; + progress: number; + error?: string; + filename?: string; + createdAt: number; + completedAt?: number; + userId: number; +} + +// In-memory store of active fetch requests +const fetchRequests = new Map(); + +// Generate unique request ID +function generateId(): string { + return Math.random().toString(36).substring(2, 10); +} + +// Get all requests for a user +export function getUserRequests(userId: number): FetchRequest[] { + return [...fetchRequests.values()].filter(r => r.userId === userId); +} + +// Get request by ID +export function getRequest(id: string): FetchRequest | undefined { + return fetchRequests.get(id); +} + +// Create a new fetch request +export function createFetchRequest(url: string, userId: number): FetchRequest { + const request: FetchRequest = { + id: generateId(), + url, + status: "pending", + progress: 0, + createdAt: Date.now(), + userId, + }; + fetchRequests.set(request.id, request); + return request; +} + +// Update request status +export function updateRequest(id: string, updates: Partial): void { + const request = fetchRequests.get(id); + if (request) { + Object.assign(request, updates); + } +} + +// Remove completed/failed requests older than given age (ms) +export function cleanupOldRequests(maxAge: number = 3600000): void { + const now = Date.now(); + for (const [id, request] of fetchRequests) { + if (request.status === "complete" || request.status === "error") { + if (now - request.createdAt > maxAge) { + fetchRequests.delete(id); + } + } + } +} + +// TODO: Implement actual yt-dlp download +// This will: +// 1. Spawn yt-dlp process with URL +// 2. Parse progress output +// 3. Update request status +// 4. Move completed file to music directory +// 5. Trigger library rescan +export async function startDownload(request: FetchRequest, musicDir: string): Promise { + // Stub - to be implemented + console.log(`[ytdlp] Would download: ${request.url} to ${musicDir}`); + updateRequest(request.id, { status: "error", error: "Not implemented yet" }); +}