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" });
+}