updated agents and created the ability to rename

This commit is contained in:
peterino2 2026-02-05 23:29:27 -08:00
parent c2e852f2cc
commit 48cdf912bc
8 changed files with 142 additions and 3 deletions

View File

@ -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
View File

@ -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();

View File

@ -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, '&quot;')}" 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");
}
};
})(); })();

View File

@ -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>

View File

@ -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; }

View File

@ -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");

View File

@ -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);

View File

@ -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]);