updated agents and created the ability to rename
This commit is contained in:
parent
c2e852f2cc
commit
48cdf912bc
18
AGENTS.md
18
AGENTS.md
|
|
@ -110,10 +110,26 @@ GET /api/library → List all tracks with id, filename, title, dura
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
- **server.ts** — Bun entrypoint. HTTP routes and WebSocket handlers.
|
- **server.ts** — Bun entrypoint. Imports and starts the server.
|
||||||
|
- **config.ts** — Config types, loading, and exports (port, musicDir, etc).
|
||||||
|
- **state.ts** — Shared application state (channels Map, userConnections Map, library).
|
||||||
|
- **init.ts** — Server initialization, channel loading, tick interval, library events.
|
||||||
|
- **broadcast.ts** — Broadcast utilities (broadcastToAll, sendToUser, broadcastChannelList).
|
||||||
|
- **websocket.ts** — WebSocket open/close/message handlers.
|
||||||
- **channel.ts** — `Channel` class. Queue, current index, time tracking, broadcasting.
|
- **channel.ts** — `Channel` class. Queue, current index, time tracking, broadcasting.
|
||||||
- **library.ts** — `Library` class. Scans music directory, computes content hashes.
|
- **library.ts** — `Library` class. Scans music directory, computes content hashes.
|
||||||
- **db.ts** — SQLite database for users, sessions, tracks.
|
- **db.ts** — SQLite database for users, sessions, tracks.
|
||||||
|
- **auth.ts** — Auth helpers (getUser, requirePermission, session cookies).
|
||||||
|
- **ytdlp.ts** — yt-dlp integration for fetching audio from URLs.
|
||||||
|
|
||||||
|
### Routes (routes/)
|
||||||
|
- **index.ts** — Main router, dispatches to route handlers.
|
||||||
|
- **helpers.ts** — Shared route helpers (getOrCreateUser, userHasPermission).
|
||||||
|
- **auth.ts** — Auth endpoints (signup, login, logout, me, admin).
|
||||||
|
- **channels.ts** — Channel CRUD and control (list, create, delete, jump, seek, queue, mode).
|
||||||
|
- **tracks.ts** — Library listing, file upload, audio serving with range support.
|
||||||
|
- **fetch.ts** — yt-dlp fetch endpoints (check URL, confirm playlist, queue status).
|
||||||
|
- **static.ts** — Static file serving (index.html, styles.css, JS files).
|
||||||
|
|
||||||
### Client (public/)
|
### Client (public/)
|
||||||
- **core.js** — Global state namespace (`window.MusicRoom`)
|
- **core.js** — Global state namespace (`window.MusicRoom`)
|
||||||
|
|
|
||||||
4
db.ts
4
db.ts
|
|
@ -384,6 +384,10 @@ export function deleteChannelFromDb(id: string): void {
|
||||||
db.query("DELETE FROM channels WHERE id = ?").run(id);
|
db.query("DELETE FROM channels WHERE id = ?").run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateChannelName(id: string, name: string): void {
|
||||||
|
db.query("UPDATE channels SET name = ? WHERE id = ?").run(name, id);
|
||||||
|
}
|
||||||
|
|
||||||
// Queue persistence functions
|
// Queue persistence functions
|
||||||
export function saveChannelQueue(channelId: string, trackIds: string[]): void {
|
export function saveChannelQueue(channelId: string, trackIds: string[]): void {
|
||||||
db.query("BEGIN").run();
|
db.query("BEGIN").run();
|
||||||
|
|
|
||||||
|
|
@ -158,16 +158,60 @@
|
||||||
(M.currentUser.isAdmin || ch.createdBy === M.currentUser.id);
|
(M.currentUser.isAdmin || ch.createdBy === M.currentUser.id);
|
||||||
const deleteBtn = canDelete ? `<button class="btn-delete-channel" title="Delete channel">×</button>` : "";
|
const deleteBtn = canDelete ? `<button class="btn-delete-channel" title="Delete channel">×</button>` : "";
|
||||||
|
|
||||||
|
// Show rename button for signed-in non-guests
|
||||||
|
const canRename = M.currentUser && !M.currentUser.isGuest;
|
||||||
|
const renameBtn = canRename ? `<button class="btn-rename-channel" title="Rename 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>
|
||||||
|
<input class="channel-name-input" type="text" value="${ch.name.replace(/"/g, '"')}" style="display:none;">
|
||||||
|
${renameBtn}
|
||||||
${deleteBtn}
|
${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>
|
||||||
`;
|
`;
|
||||||
const headerEl = div.querySelector(".channel-header");
|
const headerEl = div.querySelector(".channel-header");
|
||||||
headerEl.querySelector(".channel-name").onclick = () => M.switchChannel(ch.id);
|
const nameSpan = headerEl.querySelector(".channel-name");
|
||||||
|
const nameInput = headerEl.querySelector(".channel-name-input");
|
||||||
|
nameSpan.onclick = () => M.switchChannel(ch.id);
|
||||||
|
|
||||||
|
const renBtn = headerEl.querySelector(".btn-rename-channel");
|
||||||
|
if (renBtn) {
|
||||||
|
renBtn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
nameSpan.style.display = "none";
|
||||||
|
renBtn.style.display = "none";
|
||||||
|
nameInput.style.display = "inline-block";
|
||||||
|
nameInput.focus();
|
||||||
|
nameInput.select();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle inline rename
|
||||||
|
const submitRename = async () => {
|
||||||
|
const newName = nameInput.value.trim();
|
||||||
|
if (newName && newName !== ch.name) {
|
||||||
|
await M.renameChannel(ch.id, newName);
|
||||||
|
} else {
|
||||||
|
// Restore original display
|
||||||
|
nameSpan.style.display = "";
|
||||||
|
if (renBtn) renBtn.style.display = "";
|
||||||
|
nameInput.style.display = "none";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
nameInput.onblur = submitRename;
|
||||||
|
nameInput.onkeydown = (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
nameInput.blur();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
nameInput.value = ch.name;
|
||||||
|
nameInput.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const delBtn = headerEl.querySelector(".btn-delete-channel");
|
const delBtn = headerEl.querySelector(".btn-delete-channel");
|
||||||
if (delBtn) {
|
if (delBtn) {
|
||||||
delBtn.onclick = (e) => {
|
delBtn.onclick = (e) => {
|
||||||
|
|
@ -411,4 +455,23 @@
|
||||||
}
|
}
|
||||||
M.updateUI();
|
M.updateUI();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rename channel
|
||||||
|
M.renameChannel = async function(channelId, newName) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/channels/${channelId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: newName.trim() })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
M.showToast(data.error || "Failed to rename channel", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
M.showToast(`Channel renamed to "${data.name}"`);
|
||||||
|
} catch (e) {
|
||||||
|
M.showToast("Failed to rename channel", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<h1>Blastoise <span id="sync-indicator"></span></h1>
|
<div id="site-header">
|
||||||
|
<h1>Blastoise <span id="sync-indicator"></span></h1>
|
||||||
|
<a href="https://git.peterino.com/peterino/blastoise/issues/new" target="_blank" id="btn-report-bug">Suggestions/Bugs</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="login-panel">
|
<div id="login-panel">
|
||||||
<h2>Sign in to continue</h2>
|
<h2>Sign in to continue</h2>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,14 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
#auth-section .admin-badge { background: #c4f; color: #111; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.65rem; }
|
#auth-section .admin-badge { background: #c4f; color: #111; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.65rem; }
|
||||||
#btn-logout { background: none; border: none; color: #e44; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; }
|
#btn-logout { background: none; border: none; color: #e44; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; }
|
||||||
#btn-logout:hover { opacity: 1; text-decoration: underline; }
|
#btn-logout:hover { opacity: 1; text-decoration: underline; }
|
||||||
|
#btn-logout.guest-signin { background: #4e8; color: #111; font-size: 0.85rem; font-weight: 600; padding: 0.4rem 1rem; border-radius: 4px; opacity: 1; }
|
||||||
|
#btn-logout.guest-signin:hover { background: #5fa; text-decoration: none; }
|
||||||
#btn-kick-others { background: none; border: none; color: #ea4; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; }
|
#btn-kick-others { background: none; border: none; color: #ea4; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; }
|
||||||
#btn-kick-others:hover { opacity: 1; text-decoration: underline; }
|
#btn-kick-others:hover { opacity: 1; text-decoration: underline; }
|
||||||
|
#site-header { display: flex; align-items: baseline; gap: 1rem; margin-bottom: 0.5rem; }
|
||||||
|
#site-header h1 { margin: 0; }
|
||||||
|
#btn-report-bug { color: #4e8; font-size: 0.8rem; text-decoration: none; padding: 0.3rem 0.6rem; border: 1px solid #4e8; border-radius: 4px; }
|
||||||
|
#btn-report-bug:hover { background: #4e8; color: #111; }
|
||||||
#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.85rem; }
|
#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.85rem; }
|
||||||
|
|
||||||
/* Main content - library and queue */
|
/* Main content - library and queue */
|
||||||
|
|
@ -28,10 +34,14 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
#channels-list .channel-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 0.1rem 0; border-radius: 3px; }
|
#channels-list .channel-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 0.1rem 0; border-radius: 3px; }
|
||||||
#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 .channel-name-input { flex: 1; font-size: 0.8rem; background: #111; color: #eee; border: 1px solid #4e8; border-radius: 3px; padding: 0.1rem 0.3rem; outline: none; min-width: 0; }
|
||||||
#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 .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 .channel-header:hover .btn-delete-channel { opacity: 1; }
|
||||||
#channels-list .btn-delete-channel:hover { color: #e44; }
|
#channels-list .btn-delete-channel:hover { color: #e44; }
|
||||||
|
#channels-list .btn-rename-channel { background: none; border: none; color: #666; font-size: 0.7rem; cursor: pointer; padding: 0 0.2rem; line-height: 1; opacity: 0; transition: opacity 0.15s; }
|
||||||
|
#channels-list .channel-header:hover .btn-rename-channel { opacity: 1; }
|
||||||
|
#channels-list .btn-rename-channel:hover { color: #4e8; }
|
||||||
#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; }
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,15 @@
|
||||||
if (M.currentUser.isGuest) {
|
if (M.currentUser.isGuest) {
|
||||||
M.$("#current-username").textContent = "Guest";
|
M.$("#current-username").textContent = "Guest";
|
||||||
M.$("#btn-logout").textContent = "Sign In";
|
M.$("#btn-logout").textContent = "Sign In";
|
||||||
|
M.$("#btn-logout").classList.add("guest-signin");
|
||||||
} else {
|
} else {
|
||||||
M.$("#current-username").textContent = M.currentUser.username;
|
M.$("#current-username").textContent = M.currentUser.username;
|
||||||
M.$("#btn-logout").textContent = "Logout";
|
M.$("#btn-logout").textContent = "Logout";
|
||||||
|
M.$("#btn-logout").classList.remove("guest-signin");
|
||||||
}
|
}
|
||||||
M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none";
|
M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none";
|
||||||
|
// Re-render channel list to update rename/delete buttons
|
||||||
|
if (M.renderChannelList) M.renderChannelList();
|
||||||
} else {
|
} else {
|
||||||
M.$("#login-panel").classList.remove("hidden");
|
M.$("#login-panel").classList.remove("hidden");
|
||||||
M.$("#player-content").classList.remove("visible");
|
M.$("#player-content").classList.remove("visible");
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
deleteChannelFromDb,
|
deleteChannelFromDb,
|
||||||
saveChannelQueue,
|
saveChannelQueue,
|
||||||
updateChannelState,
|
updateChannelState,
|
||||||
|
updateChannelName,
|
||||||
} from "../db";
|
} from "../db";
|
||||||
import { state } from "../state";
|
import { state } from "../state";
|
||||||
import { broadcastChannelList } from "../broadcast";
|
import { broadcastChannelList } from "../broadcast";
|
||||||
|
|
@ -130,6 +131,38 @@ export function handleDeleteChannel(req: Request, server: any, channelId: string
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PATCH /api/channels/:id - rename channel
|
||||||
|
export async function handleRenameChannel(req: Request, server: any, channelId: string): Promise<Response> {
|
||||||
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (user.is_guest) {
|
||||||
|
return Response.json({ error: "Guests cannot rename channels" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const channel = state.channels.get(channelId);
|
||||||
|
if (!channel) {
|
||||||
|
return Response.json({ error: "Channel not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { name } = await req.json();
|
||||||
|
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
const newName = name.trim();
|
||||||
|
channel.name = newName;
|
||||||
|
updateChannelName(channelId, newName);
|
||||||
|
console.log(`[Channel] Renamed "${channelId}" to "${newName}" by user ${user.id}`);
|
||||||
|
broadcastChannelList();
|
||||||
|
return Response.json({ success: true, name: newName });
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/channels/:id - get channel state
|
// GET /api/channels/:id - get channel state
|
||||||
export function handleGetChannel(channelId: string): Response {
|
export function handleGetChannel(channelId: string): Response {
|
||||||
const channel = state.channels.get(channelId);
|
const channel = state.channels.get(channelId);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
handleSeek,
|
handleSeek,
|
||||||
handleModifyQueue,
|
handleModifyQueue,
|
||||||
handleSetMode,
|
handleSetMode,
|
||||||
|
handleRenameChannel,
|
||||||
} from "./channels";
|
} from "./channels";
|
||||||
|
|
||||||
// Track routes
|
// Track routes
|
||||||
|
|
@ -85,6 +86,11 @@ export function createRouter() {
|
||||||
return handleDeleteChannel(req, server, channelDeleteMatch[1]);
|
return handleDeleteChannel(req, server, channelDeleteMatch[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const channelPatchMatch = path.match(/^\/api\/channels\/([^/]+)$/);
|
||||||
|
if (channelPatchMatch && req.method === "PATCH") {
|
||||||
|
return handleRenameChannel(req, server, channelPatchMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
const channelGetMatch = path.match(/^\/api\/channels\/([^/]+)$/);
|
const channelGetMatch = path.match(/^\/api\/channels\/([^/]+)$/);
|
||||||
if (channelGetMatch && req.method === "GET") {
|
if (channelGetMatch && req.method === "GET") {
|
||||||
return handleGetChannel(channelGetMatch[1]);
|
return handleGetChannel(channelGetMatch[1]);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue