This commit is contained in:
peterino2 2026-02-03 01:10:20 -08:00
parent 55e4dd3947
commit d9ece80418
13 changed files with 490 additions and 116 deletions

1
.gitignore vendored
View File

@ -36,3 +36,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
tmp/ tmp/
library_cache.db library_cache.db
musicroom.db

View File

@ -7,7 +7,7 @@ export interface Track {
duration: number; duration: number;
} }
export type PlaybackMode = "repeat-all" | "repeat-one" | "shuffle"; export type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle";
export interface ChannelConfig { export interface ChannelConfig {
id: string; id: string;
@ -98,6 +98,15 @@ export class Channel {
if (this.queue.length === 0) return; if (this.queue.length === 0) return;
switch (this.playbackMode) { switch (this.playbackMode) {
case "once":
// Play through once, stop at end
if (this.currentIndex < this.queue.length - 1) {
this.currentIndex++;
} else {
// At end of playlist - pause
this.paused = true;
}
break;
case "repeat-one": case "repeat-one":
// Stay on same track, just reset timestamp // Stay on same track, just reset timestamp
break; break;

View File

@ -1,6 +1,6 @@
{ {
"port": 3001, "port": 3001,
"musicDir": "./music", "musicDir": "D:\\Music\\school_music",
"allowGuests": true, "allowGuests": true,
"defaultPermissions": { "defaultPermissions": {
"channel": ["listen", "control"] "channel": ["listen", "control"]

33
db.ts
View File

@ -55,19 +55,6 @@ db.run(`
) )
`); `);
// Content-addressed tracks table
db.run(`
CREATE TABLE IF NOT EXISTS tracks (
id TEXT PRIMARY KEY,
title TEXT,
artist TEXT,
album TEXT,
duration REAL NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER DEFAULT (unixepoch())
)
`);
// Types // Types
export interface User { export interface User {
id: number; id: number;
@ -274,26 +261,6 @@ export function getUserSessions(userId: number): Omit<Session, 'token'>[] {
// Cleanup expired sessions periodically // Cleanup expired sessions periodically
setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour
// Track functions
export function upsertTrack(track: Omit<Track, "created_at">): void {
db.query(`
INSERT INTO tracks (id, title, artist, album, duration, size)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = COALESCE(excluded.title, title),
artist = COALESCE(excluded.artist, artist),
album = COALESCE(excluded.album, album)
`).run(track.id, track.title, track.artist, track.album, track.duration, track.size);
}
export function getTrack(id: string): Track | null {
return db.query("SELECT * FROM tracks WHERE id = ?").get(id) as Track | null;
}
export function getAllTracks(): Track[] {
return db.query("SELECT * FROM tracks ORDER BY title").all() as Track[];
}
// Channel tables // Channel tables
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS channels ( CREATE TABLE IF NOT EXISTS channels (

View File

@ -4,7 +4,7 @@ import { watch, type FSWatcher } from "fs";
import { readdir, stat } from "fs/promises"; import { readdir, stat } from "fs/promises";
import { join, relative } from "path"; import { join, relative } from "path";
import { parseFile } from "music-metadata"; import { parseFile } from "music-metadata";
import { upsertTrack, type Track } from "./db"; import { type Track } from "./db";
const HASH_CHUNK_SIZE = 64 * 1024; // 64KB const HASH_CHUNK_SIZE = 64 * 1024; // 64KB
const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]); const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]);
@ -34,10 +34,19 @@ export class Library {
private watcher: FSWatcher | null = null; private watcher: FSWatcher | null = null;
private eventListeners = new Map<LibraryEventType, Set<LibraryEventCallback>>(); private eventListeners = new Map<LibraryEventType, Set<LibraryEventCallback>>();
private pendingFiles = new Map<string, NodeJS.Timeout>(); // filepath -> debounce timer private pendingFiles = new Map<string, NodeJS.Timeout>(); // filepath -> debounce timer
// Scan progress tracking
private _scanProgress = { scanning: false, processed: 0, total: 0 };
private scanCompleteCallbacks: Set<() => void> = new Set();
get scanProgress() {
return { ...this._scanProgress };
}
constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") { constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") {
this.musicDir = musicDir; this.musicDir = musicDir;
this.cacheDb = new Database(cacheDbPath); this.cacheDb = new Database(cacheDbPath);
this.cacheDb.run("PRAGMA journal_mode = WAL");
this.initCacheDb(); this.initCacheDb();
} }
@ -52,6 +61,56 @@ export class Library {
) )
`); `);
this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_file_cache_track ON file_cache(track_id)`); this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_file_cache_track ON file_cache(track_id)`);
// Tracks table - stores metadata for each unique track
this.cacheDb.run(`
CREATE TABLE IF NOT EXISTS tracks (
id TEXT PRIMARY KEY,
title TEXT,
artist TEXT,
album TEXT,
duration REAL NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER DEFAULT (unixepoch())
)
`);
// Library activity log
this.cacheDb.run(`
CREATE TABLE IF NOT EXISTS library_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER DEFAULT (unixepoch()),
action TEXT NOT NULL,
track_id TEXT,
filename TEXT,
title TEXT,
user_id INTEGER,
username TEXT
)
`);
this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_library_log_time ON library_log(timestamp DESC)`);
}
logActivity(action: string, track: { id?: string; filename?: string; title?: string | null }, user?: { id: number; username: string } | null): void {
this.cacheDb.query(`
INSERT INTO library_log (action, track_id, filename, title, user_id, username)
VALUES (?, ?, ?, ?, ?, ?)
`).run(action, track.id || null, track.filename || null, track.title || null, user?.id || null, user?.username || null);
}
getActivityLog(limit: number = 100): Array<{ id: number; timestamp: number; action: string; track_id: string | null; filename: string | null; title: string | null; user_id: number | null; username: string | null }> {
return this.cacheDb.query(`SELECT * FROM library_log ORDER BY timestamp DESC LIMIT ?`).all(limit) as any;
}
private upsertTrack(track: { id: string; title: string | null; artist: string | null; album: string | null; duration: number; size: number }): void {
this.cacheDb.query(`
INSERT INTO tracks (id, title, artist, album, duration, size)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = COALESCE(excluded.title, title),
artist = COALESCE(excluded.artist, artist),
album = COALESCE(excluded.album, album)
`).run(track.id, track.title, track.artist, track.album, track.duration, track.size);
} }
async computeTrackId(filePath: string): Promise<string> { async computeTrackId(filePath: string): Promise<string> {
@ -148,8 +207,8 @@ export class Library {
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
}; };
// Upsert to main database // Upsert to cache database
upsertTrack({ this.upsertTrack({
id: trackId, id: trackId,
title, title,
artist, artist,
@ -166,60 +225,136 @@ export class Library {
} }
async scan(): Promise<void> { async scan(): Promise<void> {
console.log(`[Library] Scanning ${this.musicDir}...`); console.log(`[Library] Quick loading from cache...`);
// Single query joining file_cache and tracks - all in cacheDb now
const cachedTracks = this.cacheDb.query(`
SELECT fc.path, fc.track_id, t.title, t.artist, t.album, t.duration
FROM file_cache fc
LEFT JOIN tracks t ON fc.track_id = t.id
`).all() as Array<{
path: string;
track_id: string;
title: string | null;
artist: string | null;
album: string | null;
duration: number | null;
}>;
for (const row of cachedTracks) {
const fullPath = join(this.musicDir, row.path);
const filename = row.path.split(/[/\\]/).pop() || row.path;
const track: LibraryTrack = {
id: row.track_id,
filename,
filepath: fullPath,
title: row.title || filename.replace(/\.[^.]+$/, ""),
artist: row.artist || null,
album: row.album || null,
duration: row.duration || 0,
available: true,
};
this.trackMap.set(track.id, fullPath);
this.trackInfo.set(track.id, track);
}
console.log(`[Library] Quick loaded ${cachedTracks.length} tracks from cache`);
// Start background scan for new/changed files
this.startBackgroundScan();
}
private async startBackgroundScan(): Promise<void> {
console.log(`[Library] Starting background scan...`);
const startTime = Date.now(); const startTime = Date.now();
let processed = 0; let processed = 0;
let cached = 0; let skipped = 0;
const BATCH_SIZE = 10;
const scanDir = async (dir: string) => { const BATCH_DELAY_MS = 50; // Pause between batches to not block
const entries = await readdir(dir, { withFileTypes: true });
const filesToProcess: string[] = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name); // Collect all files first (fast operation)
const collectFiles = async (dir: string) => {
if (entry.isDirectory()) { try {
await scanDir(fullPath); const entries = await readdir(dir, { withFileTypes: true });
} else if (entry.isFile() && this.isAudioFile(entry.name)) { for (const entry of entries) {
const relativePath = relative(this.musicDir, fullPath); const fullPath = join(dir, entry.name);
const cacheEntry = this.getCacheEntry(relativePath); if (entry.isDirectory()) {
await collectFiles(fullPath);
// Quick check if cache is valid } else if (entry.isFile() && this.isAudioFile(entry.name)) {
if (cacheEntry) { filesToProcess.push(fullPath);
try {
const fileStat = await stat(fullPath);
if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) {
// Cache hit - reuse existing data
const existingTrack = this.trackInfo.get(cacheEntry.track_id);
if (!existingTrack) {
// Need metadata but skip hashing
const track = await this.processFile(fullPath);
if (track) {
this.trackMap.set(track.id, fullPath);
this.trackInfo.set(track.id, track);
}
} else {
this.trackMap.set(cacheEntry.track_id, fullPath);
}
cached++;
continue;
}
} catch {}
}
const track = await this.processFile(fullPath);
if (track) {
this.trackMap.set(track.id, fullPath);
this.trackInfo.set(track.id, track);
processed++;
} }
} }
} catch (e) {
console.error(`[Library] Error reading directory ${dir}:`, e);
} }
}; };
await scanDir(this.musicDir); await collectFiles(this.musicDir);
console.log(`[Library] Found ${filesToProcess.length} audio files to check`);
// Initialize scan progress
this._scanProgress = { scanning: true, processed: 0, total: filesToProcess.length };
// Process in batches with delays
for (let i = 0; i < filesToProcess.length; i += BATCH_SIZE) {
const batch = filesToProcess.slice(i, i + BATCH_SIZE);
for (const fullPath of batch) {
const relativePath = relative(this.musicDir, fullPath);
const cacheEntry = this.getCacheEntry(relativePath);
// Check if already loaded and cache is valid
if (cacheEntry && this.trackInfo.has(cacheEntry.track_id)) {
try {
const fileStat = await stat(fullPath);
if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) {
skipped++;
this._scanProgress.processed++;
continue;
}
} catch {}
}
// Need to process this file
const track = await this.processFile(fullPath);
if (track) {
const isNew = !this.trackInfo.has(track.id);
this.trackMap.set(track.id, fullPath);
this.trackInfo.set(track.id, track);
processed++;
if (isNew) {
this.emit("added", track);
}
}
this._scanProgress.processed++;
}
// Yield to other operations
if (i + BATCH_SIZE < filesToProcess.length) {
await new Promise(resolve => setTimeout(resolve, BATCH_DELAY_MS));
}
// Progress log every 100 files
if ((i + BATCH_SIZE) % 100 === 0) {
console.log(`[Library] Background scan progress: ${Math.min(i + BATCH_SIZE, filesToProcess.length)}/${filesToProcess.length}`);
}
}
this._scanProgress = { scanning: false, processed: filesToProcess.length, total: filesToProcess.length };
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
console.log(`[Library] Scan complete: ${this.trackMap.size} tracks (${processed} processed, ${cached} cached) in ${elapsed}ms`); console.log(`[Library] Background scan complete: ${processed} new/updated, ${skipped} unchanged in ${elapsed}ms`);
// Notify listeners that scan is complete
this.scanCompleteCallbacks.forEach(cb => cb());
}
onScanComplete(callback: () => void): void {
this.scanCompleteCallbacks.add(callback);
} }
startWatching(): void { startWatching(): void {

View File

@ -209,6 +209,34 @@
} }
return; return;
} }
// Handle scan progress
if (data.type === "scan_progress") {
const el = M.$("#scan-progress");
const wasScanning = !el.classList.contains("complete") && !el.classList.contains("hidden");
if (data.scanning) {
el.innerHTML = `<span class="spinner"></span>Scanning: ${data.processed}/${data.total} files`;
el.classList.remove("hidden");
el.classList.remove("complete");
} else {
// Show track count when not scanning
const count = M.library.length;
el.innerHTML = `${count} song${count !== 1 ? 's' : ''} in library`;
el.classList.remove("hidden");
el.classList.add("complete");
// Show toast if scan just finished
if (wasScanning) {
M.showToast("Scanning complete!");
}
}
return;
}
// Handle server-sent toasts
if (data.type === "toast") {
M.showToast(data.message, data.toastType || "info");
return;
}
// Normal channel state update // Normal channel state update
M.handleUpdate(data); M.handleUpdate(data);
}; };

15
public/controls.js vendored
View File

@ -97,18 +97,19 @@
M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1); M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1);
// Playback mode button // Playback mode button
const modeIcons = { const modeLabels = {
"repeat-all": "🔁", "once": "once",
"repeat-one": "🔂", "repeat-all": "repeat",
"shuffle": "🔀" "repeat-one": "single",
"shuffle": "shuffle"
}; };
const modeOrder = ["repeat-all", "repeat-one", "shuffle"]; const modeOrder = ["once", "repeat-all", "repeat-one", "shuffle"];
M.updateModeButton = function() { M.updateModeButton = function() {
const btn = M.$("#btn-mode"); const btn = M.$("#btn-mode");
btn.textContent = modeIcons[M.playbackMode] || "🔁"; btn.textContent = modeLabels[M.playbackMode] || "repeat";
btn.title = `Playback: ${M.playbackMode}`; btn.title = `Playback: ${M.playbackMode}`;
btn.classList.toggle("active", M.playbackMode !== "repeat-all"); btn.className = "mode-" + M.playbackMode;
}; };
M.$("#btn-mode").onclick = async () => { M.$("#btn-mode").onclick = async () => {

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeoRose</title> <title>NeoRose</title>
<link rel="stylesheet" href="styles.css?v=4"> <link rel="stylesheet" href="styles.css?v=19">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@ -40,7 +40,7 @@
<div class="user-info"> <div class="user-info">
<span class="username" id="current-username"></span> <span class="username" id="current-username"></span>
<span class="admin-badge" id="admin-badge" style="display:none;">Admin</span> <span class="admin-badge" id="admin-badge" style="display:none;">Admin</span>
<button id="btn-kick-others" title="Disconnect all other devices">kick others</button> <button id="btn-kick-others" title="Disconnect all other devices">Disconnect all my devices</button>
<button id="btn-logout">logout</button> <button id="btn-logout">logout</button>
</div> </div>
</div> </div>
@ -56,14 +56,25 @@
<div id="channels-list"></div> <div id="channels-list"></div>
</div> </div>
<div id="library-panel"> <div id="library-panel">
<div id="scan-progress" class="scan-progress hidden"></div>
<div class="panel-header"> <div class="panel-header">
<h3>Library</h3> <h3>Library</h3>
<input type="text" id="library-search" placeholder="Search..." class="search-input"> <input type="text" id="library-search" placeholder="Search..." class="search-input">
<input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none">
</div> </div>
<div id="library"></div> <div id="library"></div>
<div id="upload-progress" class="upload-progress hidden">
<div class="upload-progress-bar"></div>
<span class="upload-progress-text"></span>
</div>
<button id="btn-upload" class="upload-btn">Upload files...</button>
<div id="upload-dropzone" class="upload-dropzone hidden">
<div class="dropzone-content">Drop audio files here</div>
</div>
</div> </div>
<div id="queue-panel"> <div id="queue-panel">
<h3 id="queue-title">Queue</h3> <h3 id="queue-title">Queue</h3>
<div id="now-playing-bar" class="now-playing-bar hidden" title="Click to scroll to current track"></div>
<div id="queue"></div> <div id="queue"></div>
</div> </div>
</div> </div>
@ -81,7 +92,7 @@
<span id="btn-prev" title="Previous track"></span> <span id="btn-prev" title="Previous track"></span>
<span id="status-icon"></span> <span id="status-icon"></span>
<span id="btn-next" title="Next track"></span> <span id="btn-next" title="Next track"></span>
<span id="btn-mode" title="Playback mode">🔁</span> <span id="btn-mode" class="mode-repeat-all" title="Playback mode">repeat</span>
<div id="progress-container"><div id="progress-bar"></div><div id="seek-tooltip"></div></div> <div id="progress-container"><div id="progress-bar"></div><div id="seek-tooltip"></div></div>
<div id="time"><span id="time-current">0:00</span>/<span id="time-total">0:00</span></div> <div id="time"><span id="time-current">0:00</span>/<span id="time-total">0:00</span></div>
</div> </div>
@ -96,6 +107,14 @@
<div id="status"></div> <div id="status"></div>
</div> </div>
<div id="toast-container"></div> <div id="toast-container"></div>
<button id="btn-history" title="Show notification history">recent notifications</button>
<div id="toast-history" class="hidden">
<div class="history-header">
<span>Notification History</span>
<button id="btn-close-history">×</button>
</div>
<div id="toast-history-list"></div>
</div>
</div> </div>
<script src="trackStorage.js"></script> <script src="trackStorage.js"></script>
<script src="core.js"></script> <script src="core.js"></script>
@ -106,6 +125,7 @@
<script src="queue.js"></script> <script src="queue.js"></script>
<script src="controls.js"></script> <script src="controls.js"></script>
<script src="auth.js"></script> <script src="auth.js"></script>
<script src="upload.js"></script>
<script src="init.js"></script> <script src="init.js"></script>
</body> </body>
</html> </html>

View File

@ -23,6 +23,19 @@
console.log(`TrackStorage: ${M.cachedTracks.size} tracks cached`); console.log(`TrackStorage: ${M.cachedTracks.size} tracks cached`);
} }
// Setup history panel handlers
document.addEventListener("DOMContentLoaded", () => {
const btnHistory = M.$("#btn-history");
const btnClose = M.$("#btn-close-history");
if (btnHistory) {
btnHistory.onclick = () => M.toggleToastHistory();
}
if (btnClose) {
btnClose.onclick = () => M.toggleToastHistory();
}
});
// Initialize the application // Initialize the application
Promise.all([initStorage(), loadServerStatus()]).then(async () => { Promise.all([initStorage(), loadServerStatus()]).then(async () => {
await M.loadLibrary(); await M.loadLibrary();

View File

@ -313,6 +313,7 @@
if (M.queue.length === 0) { if (M.queue.length === 0) {
container.innerHTML = '<div class="empty drop-zone">Queue empty - drag tracks here</div>'; container.innerHTML = '<div class="empty drop-zone">Queue empty - drag tracks here</div>';
M.updateNowPlayingBar();
return; return;
} }
@ -333,6 +334,7 @@
div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : ""); div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : "");
div.dataset.index = i; div.dataset.index = i;
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
div.title = title; // Tooltip for full name
const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : ''; const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
const trackNum = `<span class="track-number">${i + 1}.</span>`; const trackNum = `<span class="track-number">${i + 1}.</span>`;
@ -508,8 +510,46 @@
container.appendChild(div); container.appendChild(div);
}); });
M.updateNowPlayingBar();
}; };
// Update the now-playing bar above the queue
M.updateNowPlayingBar = function() {
const bar = M.$("#now-playing-bar");
if (!bar) return;
const track = M.queue[M.currentIndex];
if (!track) {
bar.classList.add("hidden");
return;
}
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
bar.innerHTML = `<span class="label">Now playing:</span> ${title}`;
bar.title = title;
bar.classList.remove("hidden");
};
// Scroll queue to current track
M.scrollToCurrentTrack = function() {
const container = M.$("#queue");
if (!container) return;
const activeTrack = container.querySelector(".track.active");
if (activeTrack) {
activeTrack.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
// Setup now-playing bar click handler
document.addEventListener("DOMContentLoaded", () => {
const bar = M.$("#now-playing-bar");
if (bar) {
bar.onclick = () => M.scrollToCurrentTrack();
}
});
// Library search state // Library search state
M.librarySearchQuery = ""; M.librarySearchQuery = "";
@ -545,6 +585,7 @@
const isSelected = M.selectedLibraryIds.has(track.id); const isSelected = M.selectedLibraryIds.has(track.id);
div.className = "track" + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : ""); div.className = "track" + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : "");
const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
div.title = title; // Tooltip for full name
const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : ''; const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
div.innerHTML = `${checkmark}<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`; div.innerHTML = `${checkmark}<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;

View File

@ -1,6 +1,6 @@
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; min-height: 100vh; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; min-height: 100vh; }
#app { width: 100%; max-width: 1200px; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; } #app { width: 100%; max-width: 1700px; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; }
h1 { font-size: 1rem; color: #888; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; } h1 { font-size: 1rem; color: #888; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.05em; } h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.05em; }
#sync-indicator { width: 8px; height: 8px; border-radius: 50%; background: #4e8; display: none; flex-shrink: 0; } #sync-indicator { width: 8px; height: 8px; border-radius: 50%; background: #4e8; display: none; flex-shrink: 0; }
@ -20,8 +20,8 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
#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 */
#main-content { display: flex; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.5rem; } #main-content { display: flex; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.5rem; max-width: 1600px; margin-left: auto; margin-right: auto; }
#channels-panel { flex: 0 0 140px; background: #1a1a1a; border-radius: 6px; padding: 0.4rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; } #channels-panel { flex: 0 0 160px; background: #1a1a1a; border-radius: 6px; padding: 0.4rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; }
#channels-list { flex: 1; overflow-y: auto; } #channels-list { flex: 1; overflow-y: auto; }
#channels-list .channel-item { padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; display: flex; flex-direction: column; gap: 0.1rem; } #channels-list .channel-item { padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; display: flex; flex-direction: column; gap: 0.1rem; }
#channels-list .channel-item.active { background: #2a4a3a; color: #4e8; } #channels-list .channel-item.active { background: #2a4a3a; color: #4e8; }
@ -33,8 +33,26 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
#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; }
#channels-list .listener-mult { color: #666; font-size: 0.55rem; } #channels-list .listener-mult { color: #666; font-size: 0.55rem; }
#library-panel, #queue-panel { flex: 2; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; } #library-panel, #queue-panel { flex: 0 0 700px; min-width: 0; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; }
.scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; }
.scan-progress.hidden { display: none; }
.scan-progress.complete { color: #4e8; background: #1a2a1a; }
.scan-progress .spinner { display: inline-block; width: 10px; height: 10px; border: 2px solid #ea4; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.upload-btn { width: 100%; padding: 0.5rem; background: #2a3a2a; border: 1px dashed #4e8; border-radius: 4px; color: #4e8; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; flex-shrink: 0; }
.upload-btn:hover { background: #3a4a3a; }
.upload-progress { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.5rem; background: #2a2a1a; border-radius: 4px; margin-top: 0.3rem; flex-shrink: 0; position: relative; overflow: hidden; }
.upload-progress.hidden { display: none; }
.upload-progress-bar { position: absolute; left: 0; top: 0; bottom: 0; background: #4a6a4a; transition: width 0.2s; }
.upload-progress-text { position: relative; z-index: 1; font-size: 0.75rem; color: #4e8; }
.upload-dropzone { position: absolute; inset: 0; background: rgba(42, 74, 58, 0.95); display: flex; align-items: center; justify-content: center; border-radius: 6px; border: 2px dashed #4e8; z-index: 10; }
.upload-dropzone.hidden { display: none; }
.dropzone-content { color: #4e8; font-size: 1.2rem; font-weight: 600; }
#queue-title { margin: 0 0 0.3rem 0; } #queue-title { margin: 0 0 0.3rem 0; }
.now-playing-bar { font-size: 0.75rem; color: #4e8; padding: 0.3rem 0.5rem; background: #1a2a1a; border: 1px solid #2a4a3a; border-radius: 4px; margin-bottom: 0.3rem; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.now-playing-bar:hover { background: #2a3a2a; }
.now-playing-bar.hidden { display: none; }
.now-playing-bar .label { color: #666; }
.panel-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; } .panel-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; }
.panel-header h3 { margin: 0; flex-shrink: 0; } .panel-header h3 { margin: 0; flex-shrink: 0; }
.panel-header select { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } .panel-header select { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
@ -45,15 +63,16 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.btn-submit-channel:hover { background: #3a5a4a; } .btn-submit-channel:hover { background: #3a5a4a; }
.search-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } .search-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
.search-input::placeholder { color: #666; } .search-input::placeholder { color: #666; }
#library, #queue { flex: 1; overflow-y: auto; } #library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
#library .track, #queue .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; } #library .track, #queue .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; }
#library .track[title], #queue .track[title] { cursor: pointer; }
#library .track:hover, #queue .track:hover { background: #222; } #library .track:hover, #queue .track:hover { background: #222; }
#queue .track.active { background: #2a4a3a; color: #4e8; } #queue .track.active { background: #2a4a3a; color: #4e8; }
.cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; } .cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; }
.track.cached .cache-indicator { background: #4e8; } .track.cached .cache-indicator { background: #4e8; }
.track.not-cached .cache-indicator { background: #ea4; } .track.not-cached .cache-indicator { background: #ea4; }
.track-number { color: #555; font-size: 0.7rem; min-width: 1.3rem; margin-right: 0.2rem; } .track-number { color: #555; font-size: 0.7rem; min-width: 1.3rem; margin-right: 0.2rem; }
.track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } .track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
.track-actions .duration { color: #666; font-size: 0.75rem; } .track-actions .duration { color: #666; font-size: 0.75rem; }
.track-actions .track-add, .track-actions .track-remove { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.85rem; color: #ccc; cursor: pointer; opacity: 0; transition: opacity 0.2s; } .track-actions .track-add, .track-actions .track-remove { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.85rem; color: #ccc; cursor: pointer; opacity: 0; transition: opacity 0.2s; }
@ -96,9 +115,12 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
#btn-sync.synced.connected { color: #4e8; text-shadow: 0 0 8px #4e8, 0 0 12px #4e8; } #btn-sync.synced.connected { color: #4e8; text-shadow: 0 0 8px #4e8, 0 0 12px #4e8; }
#btn-prev, #btn-next { font-size: 0.75rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; } #btn-prev, #btn-next { font-size: 0.75rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }
#btn-prev:hover, #btn-next:hover { opacity: 1; } #btn-prev:hover, #btn-next:hover { opacity: 1; }
#btn-mode { font-size: 0.85rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; } #btn-mode { font-size: 0.7rem; cursor: pointer; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; padding: 0.1rem 0.3rem; border-radius: 3px; }
#btn-mode:hover { opacity: 1; } #btn-mode.mode-once { color: #888; text-shadow: none; }
#btn-mode.active { opacity: 1; color: #4e8; } #btn-mode.mode-repeat-all { color: #4e8; text-shadow: 0 0 6px #4e8; }
#btn-mode.mode-repeat-one { color: #eb0; text-shadow: 0 0 6px #eb0; }
#btn-mode.mode-shuffle { color: #c4f; text-shadow: 0 0 6px #c4f; }
#btn-mode:hover { opacity: 0.8; }
#status-icon { font-size: 0.85rem; width: 1rem; text-align: center; cursor: pointer; } #status-icon { font-size: 0.85rem; width: 1rem; text-align: center; cursor: pointer; }
#progress-container { background: #222; border-radius: 4px; height: 5px; cursor: pointer; position: relative; flex: 1; } #progress-container { background: #222; border-radius: 4px; height: 5px; cursor: pointer; position: relative; flex: 1; }
#progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; pointer-events: none; } #progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; pointer-events: none; }
@ -116,7 +138,7 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
#volume-controls { display: flex; gap: 0.4rem; align-items: center; } #volume-controls { display: flex; gap: 0.4rem; align-items: center; }
#btn-mute { font-size: 1rem; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; } #btn-mute { font-size: 1rem; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; }
#btn-mute:hover { opacity: 1; } #btn-mute:hover { opacity: 1; }
#volume { width: 70px; accent-color: #4e8; } #volume { width: 120px; accent-color: #4e8; }
/* Common */ /* Common */
button { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; } button { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
@ -153,8 +175,24 @@ button:hover { background: #333; }
::-webkit-scrollbar-thumb:hover { background-color: #555; } ::-webkit-scrollbar-thumb:hover { background-color: #555; }
/* Toast notifications */ /* Toast notifications */
#toast-container { position: fixed; top: 0.5rem; left: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; z-index: 1000; pointer-events: none; } #toast-container { position: fixed; top: 0.5rem; left: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; z-index: 1000; pointer-events: none; max-height: 80vh; overflow-y: auto; }
.toast { background: #1a3a2a; color: #4e8; padding: 0.5rem 0.75rem; border-radius: 5px; border: 1px solid #4e8; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font-size: 0.8rem; animation: toast-in 0.3s ease-out; max-width: 280px; } .toast { background: #1a3a2a; color: #4e8; padding: 0.5rem 0.75rem; border-radius: 5px; border: 1px solid #4e8; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font-size: 0.8rem; animation: toast-in 0.3s ease-out; max-width: 280px; }
.toast.toast-warning { background: #3a3a1a; color: #ea4; border-color: #ea4; }
.toast.toast-error { background: #3a1a1a; color: #e44; border-color: #e44; }
.toast.fade-out { animation: toast-out 0.3s ease-in forwards; } .toast.fade-out { animation: toast-out 0.3s ease-in forwards; }
@keyframes toast-in { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } } @keyframes toast-in { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } }
@keyframes toast-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-20px); } } @keyframes toast-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-20px); } }
/* Toast history panel */
#btn-history { position: fixed; bottom: 0.5rem; left: 0.5rem; background: #222; border: 1px solid #333; color: #888; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.7rem; z-index: 999; }
#btn-history:hover { background: #333; color: #ccc; }
#toast-history { position: fixed; bottom: 3rem; left: 0.5rem; width: 320px; max-height: 400px; background: #1a1a1a; border: 1px solid #333; border-radius: 6px; z-index: 1001; display: flex; flex-direction: column; }
#toast-history.hidden { display: none; }
.history-header { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px solid #333; color: #888; font-size: 0.8rem; }
.history-header button { background: none; border: none; color: #888; cursor: pointer; font-size: 1rem; padding: 0; width: 20px; height: 20px; }
.history-header button:hover { color: #ccc; }
#toast-history-list { flex: 1; overflow-y: auto; padding: 0.3rem; max-height: 350px; }
.history-item { padding: 0.3rem 0.5rem; font-size: 0.75rem; border-radius: 3px; margin-bottom: 0.2rem; color: #4e8; background: #1a2a1a; }
.history-item.history-warning { color: #ea4; background: #2a2a1a; }
.history-item.history-error { color: #e44; background: #2a1a1a; }
.history-time { color: #666; margin-right: 0.4rem; }

View File

@ -15,17 +15,56 @@
return m + ":" + String(s).padStart(2, "0"); return m + ":" + String(s).padStart(2, "0");
}; };
// Toast notifications // Toast history
M.showToast = function(message, duration = 4000) { M.toastHistory = [];
// Toast notifications (log style - multiple visible)
M.showToast = function(message, type = "info", duration = 5000) {
const container = M.$("#toast-container"); const container = M.$("#toast-container");
const toast = document.createElement("div"); const toast = document.createElement("div");
toast.className = "toast"; toast.className = "toast toast-" + type;
toast.textContent = message; toast.textContent = message;
container.appendChild(toast); container.appendChild(toast);
setTimeout(() => { setTimeout(() => {
toast.classList.add("fade-out"); toast.classList.add("fade-out");
setTimeout(() => toast.remove(), 300); setTimeout(() => toast.remove(), 300);
}, duration); }, duration);
// Add to history
M.toastHistory.push({
message,
type,
time: new Date()
});
M.updateToastHistory();
};
// Update toast history panel
M.updateToastHistory = function() {
const list = M.$("#toast-history-list");
if (!list) return;
list.innerHTML = "";
// Show newest first
const items = [...M.toastHistory].reverse().slice(0, 50);
for (const item of items) {
const div = document.createElement("div");
div.className = "history-item history-" + item.type;
const time = item.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
div.innerHTML = `<span class="history-time">${time}</span> ${item.message}`;
list.appendChild(div);
}
};
// Toggle toast history panel
M.toggleToastHistory = function() {
const panel = M.$("#toast-history");
if (panel) {
panel.classList.toggle("hidden");
if (!panel.classList.contains("hidden")) {
M.updateToastHistory();
}
}
}; };
// Flash permission denied animation // Flash permission denied animation
@ -40,23 +79,25 @@
// Set track title (UI and document title) // Set track title (UI and document title)
M.setTrackTitle = function(title) { M.setTrackTitle = function(title) {
M.currentTitle = title; M.currentTitle = title;
const titleEl = M.$("#track-title");
const containerEl = M.$("#track-name"); const containerEl = M.$("#track-name");
const marqueeEl = containerEl.querySelector(".marquee-inner"); const marqueeEl = containerEl?.querySelector(".marquee-inner");
if (!containerEl || !marqueeEl) return;
titleEl.textContent = title;
document.title = title ? `${title} - MusicRoom` : "MusicRoom"; document.title = title ? `${title} - MusicRoom` : "MusicRoom";
// First set simple content to measure
marqueeEl.innerHTML = `<span id="track-title">${title}</span>`;
// Check if title overflows and needs scrolling // Check if title overflows and needs scrolling
requestAnimationFrame(() => { requestAnimationFrame(() => {
const needsScroll = titleEl.scrollWidth > containerEl.clientWidth; const titleEl = M.$("#track-title");
const needsScroll = titleEl && titleEl.scrollWidth > containerEl.clientWidth;
containerEl.classList.toggle("scrolling", needsScroll); containerEl.classList.toggle("scrolling", needsScroll);
// Duplicate text for seamless wrap-around scrolling // Duplicate text for seamless wrap-around scrolling
if (needsScroll) { if (needsScroll) {
marqueeEl.innerHTML = `<span id="track-title">${title}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span><span>${title}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span>`; marqueeEl.innerHTML = `<span id="track-title">${title}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span><span>${title}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span>`;
} else {
marqueeEl.innerHTML = `<span id="track-title">${title}</span>`;
} }
}); });
}; };

View File

@ -124,6 +124,21 @@ async function init(): Promise<void> {
// Scan library first // Scan library first
await library.scan(); await library.scan();
library.startWatching(); library.startWatching();
// Broadcast when scan completes
library.onScanComplete(() => {
broadcastToAll({ type: "scan_progress", scanning: false });
});
// Broadcast when tracks are added/updated
library.on("added", (track) => {
broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" });
library.logActivity("scan_added", { id: track.id, filename: track.filename, title: track.title });
});
library.on("changed", (track) => {
broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" });
library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title });
});
// Load channels from database // Load channels from database
const savedChannels = loadAllChannels(); const savedChannels = loadAllChannels();
@ -222,6 +237,7 @@ function broadcastChannelList() {
// Listen for library changes and notify clients // Listen for library changes and notify clients
library.on("added", (track) => { library.on("added", (track) => {
console.log(`New track detected: ${track.title}`); console.log(`New track detected: ${track.title}`);
const allTracks = library.getAllTracks().map(t => ({ const allTracks = library.getAllTracks().map(t => ({
id: t.id, id: t.id,
title: t.title, title: t.title,
@ -266,6 +282,20 @@ setInterval(() => {
channel.broadcast(); channel.broadcast();
} }
} }
// Broadcast scan progress every 2 seconds while scanning
const scanProgress = library.scanProgress;
if (scanProgress.scanning && tickCount % 2 === 0) {
broadcastToAll({
type: "scan_progress",
scanning: true,
processed: scanProgress.processed,
total: scanProgress.total
});
} else if (!scanProgress.scanning && tickCount % 30 === 0) {
// Periodically send "not scanning" to clear any stale UI
broadcastToAll({ type: "scan_progress", scanning: false });
}
}, 1000); }, 1000);
// Helper to get or create guest session // Helper to get or create guest session
@ -467,6 +497,55 @@ serve({
return Response.json(tracks, { headers }); return Response.json(tracks, { headers });
} }
// API: upload audio file
if (path === "/api/upload" && req.method === "POST") {
const { user, headers } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
try {
const formData = await req.formData();
const uploadedFile = formData.get("file");
if (!uploadedFile || !(uploadedFile instanceof File)) {
library.logActivity("upload_failed", { filename: "unknown" }, { id: user.id, username: user.username });
return Response.json({ error: "No file provided" }, { status: 400 });
}
// Validate extension
const validExts = [".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"];
const ext = uploadedFile.name.toLowerCase().match(/\.[^.]+$/)?.[0];
if (!ext || !validExts.includes(ext)) {
library.logActivity("upload_rejected", { filename: uploadedFile.name }, { id: user.id, username: user.username });
return Response.json({ error: "Invalid audio format" }, { status: 400 });
}
// Sanitize filename
const safeName = uploadedFile.name.replace(/[^a-zA-Z0-9._-]/g, "_");
const destPath = join(config.musicDir, safeName);
// Check if file already exists
const existingFile = Bun.file(destPath);
if (await existingFile.exists()) {
library.logActivity("upload_duplicate", { filename: safeName }, { id: user.id, username: user.username });
return Response.json({ error: "File already exists" }, { status: 409 });
}
// Write file
const arrayBuffer = await uploadedFile.arrayBuffer();
await Bun.write(destPath, arrayBuffer);
console.log(`[Upload] ${user.username} uploaded: ${safeName}`);
library.logActivity("upload", { filename: safeName }, { id: user.id, username: user.username });
return Response.json({ success: true, filename: safeName }, { headers });
} catch (e) {
console.error("[Upload] Error:", e);
library.logActivity("upload_error", { filename: "unknown" }, { id: user.id, username: user.username });
return Response.json({ error: "Upload failed" }, { status: 500 });
}
}
// Auth: signup // Auth: signup
if (path === "/api/auth/signup" && req.method === "POST") { if (path === "/api/auth/signup" && req.method === "POST") {
try { try {
@ -489,6 +568,7 @@ serve({
?? undefined; ?? undefined;
const token = createSession(user.id, userAgent, ipAddress); const token = createSession(user.id, userAgent, ipAddress);
console.log(`[AUTH] Signup: user="${username}" id=${user.id} admin=${user.is_admin} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`); console.log(`[AUTH] Signup: user="${username}" id=${user.id} admin=${user.is_admin} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`);
library.logActivity("account_created", { title: user.is_admin ? "admin" : "user" }, { id: user.id, username: user.username });
return Response.json( return Response.json(
{ user: { id: user.id, username: user.username, isAdmin: user.is_admin } }, { user: { id: user.id, username: user.username, isAdmin: user.is_admin } },
{ headers: { "Set-Cookie": setSessionCookie(token) } } { headers: { "Set-Cookie": setSessionCookie(token) } }