diff --git a/backend/src/controllers/AudioController.cpp b/backend/src/controllers/AudioController.cpp index 5a69289..4f56788 100644 --- a/backend/src/controllers/AudioController.cpp +++ b/backend/src/controllers/AudioController.cpp @@ -22,6 +22,147 @@ using namespace drogon::orm; namespace { + // Generate waveform data using ffmpeg (safe version) + // Returns path to waveform JSON file, or empty string on failure + std::string generateWaveform(const std::string& audioPath, int64_t audioId) { + if (!isPathSafe(audioPath, "/app/uploads")) { + LOG_ERROR << "Unsafe audio path rejected for waveform: " << audioPath; + return ""; + } + + // Ensure waveforms directory exists + const std::string waveformDir = "/app/uploads/audio/waveforms"; + if (!ensureDirectoryExists(waveformDir)) { + LOG_ERROR << "Failed to create waveforms directory"; + return ""; + } + + std::string waveformPath = waveformDir + "/" + std::to_string(audioId) + ".json"; + + // Use ffmpeg to extract raw audio samples (mono, downsampled to 8000Hz) + // Then we'll compute peaks from the samples + std::vector args = { + "/usr/bin/ffmpeg", "-i", audioPath, + "-ac", "1", // mono + "-ar", "8000", // 8kHz sample rate + "-f", "f32le", // 32-bit float, little endian + "-acodec", "pcm_f32le", + "-v", "error", + "-y", // overwrite + "pipe:1" // output to stdout + }; + + // Execute ffmpeg and capture raw samples + int pipefd[2]; + if (pipe(pipefd) == -1) { + LOG_ERROR << "Failed to create pipe for waveform generation"; + return ""; + } + + pid_t pid = fork(); + if (pid == -1) { + LOG_ERROR << "Failed to fork for waveform generation"; + close(pipefd[0]); + close(pipefd[1]); + return ""; + } + + if (pid == 0) { + // Child process + close(pipefd[0]); // Close read end + dup2(pipefd[1], STDOUT_FILENO); + close(pipefd[1]); + + // Redirect stderr to /dev/null + int devNull = open("/dev/null", O_WRONLY); + if (devNull >= 0) { + dup2(devNull, STDERR_FILENO); + close(devNull); + } + + std::vector argv; + for (auto& arg : args) { + argv.push_back(const_cast(arg.c_str())); + } + argv.push_back(nullptr); + + execv("/usr/bin/ffmpeg", argv.data()); + _exit(1); + } + + // Parent process + close(pipefd[1]); // Close write end + + // Read raw samples (limit to reasonable amount - ~30 seconds of audio at 8kHz = 240KB) + const size_t maxSamples = 8000 * 30; // 30 seconds worth + std::vector samples; + samples.reserve(maxSamples); + + float sample; + ssize_t bytesRead; + while (samples.size() < maxSamples && + (bytesRead = read(pipefd[0], &sample, sizeof(float))) == sizeof(float)) { + samples.push_back(std::abs(sample)); // Store absolute value + } + close(pipefd[0]); + + // Wait for child process + int status; + waitpid(pid, &status, 0); + + if (samples.empty()) { + LOG_WARN << "No samples extracted for waveform generation"; + return ""; + } + + // Compute 200 peaks from samples + const int numPeaks = 200; + std::vector peaks(numPeaks, 0.0f); + size_t samplesPerPeak = samples.size() / numPeaks; + if (samplesPerPeak == 0) samplesPerPeak = 1; + + for (int i = 0; i < numPeaks; i++) { + size_t start = i * samplesPerPeak; + size_t end = std::min(start + samplesPerPeak, samples.size()); + float maxVal = 0.0f; + for (size_t j = start; j < end; j++) { + if (samples[j] > maxVal) maxVal = samples[j]; + } + peaks[i] = maxVal; + } + + // Normalize peaks to 0-1 range + float maxPeak = *std::max_element(peaks.begin(), peaks.end()); + if (maxPeak > 0.0f) { + for (auto& p : peaks) { + p /= maxPeak; + } + } + + // Write JSON file + try { + std::ofstream ofs(waveformPath); + if (!ofs) { + LOG_ERROR << "Failed to create waveform file: " << waveformPath; + return ""; + } + + ofs << "{\"peaks\":["; + for (size_t i = 0; i < peaks.size(); i++) { + if (i > 0) ofs << ","; + ofs << std::fixed << std::setprecision(3) << peaks[i]; + } + ofs << "]}"; + ofs.close(); + + LOG_INFO << "Waveform generated for audio " << audioId << ": " << peaks.size() << " peaks"; + return "/uploads/audio/waveforms/" + std::to_string(audioId) + ".json"; + } catch (const std::exception& e) { + LOG_ERROR << "Exception writing waveform file: " << e.what(); + return ""; + } + } + // Get audio duration in seconds using ffprobe (safe version) int getAudioDuration(const std::string& audioPath) { if (!isPathSafe(audioPath, "/app/uploads")) { @@ -87,6 +228,7 @@ namespace { audio["description"] = row["description"].isNull() ? "" : row["description"].as(); audio["filePath"] = row["file_path"].as(); audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as(); + audio["waveformPath"] = row["waveform_path"].isNull() ? "" : row["waveform_path"].as(); audio["durationSeconds"] = row["duration_seconds"].as(); audio["format"] = row["format"].isNull() ? "" : row["format"].as(); audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as(); @@ -120,16 +262,32 @@ namespace { return; } + // Generate waveform data + std::string waveformPath = generateWaveform(audioFullPath, audioId); + auto dbClient = app().getDbClient(); - *dbClient << "UPDATE audio_files SET duration_seconds = $1, bitrate = $2, format = $3, status = 'ready' WHERE id = $4" - << duration << bitrate << format << audioId - >> [audioId](const Result&) { - LOG_INFO << "Audio " << audioId << " metadata processed successfully"; - } - >> [audioId](const DrogonDbException& e) { - LOG_ERROR << "Failed to update audio " << audioId << " metadata: " << e.base().what(); - markAudioFailed(audioId); - }; + if (!waveformPath.empty()) { + *dbClient << "UPDATE audio_files SET duration_seconds = $1, bitrate = $2, format = $3, waveform_path = $4, status = 'ready' WHERE id = $5" + << duration << bitrate << format << waveformPath << audioId + >> [audioId](const Result&) { + LOG_INFO << "Audio " << audioId << " metadata and waveform processed successfully"; + } + >> [audioId](const DrogonDbException& e) { + LOG_ERROR << "Failed to update audio " << audioId << " metadata: " << e.base().what(); + markAudioFailed(audioId); + }; + } else { + // Still mark as ready even without waveform + *dbClient << "UPDATE audio_files SET duration_seconds = $1, bitrate = $2, format = $3, status = 'ready' WHERE id = $4" + << duration << bitrate << format << audioId + >> [audioId](const Result&) { + LOG_INFO << "Audio " << audioId << " metadata processed (no waveform)"; + } + >> [audioId](const DrogonDbException& e) { + LOG_ERROR << "Failed to update audio " << audioId << " metadata: " << e.base().what(); + markAudioFailed(audioId); + }; + } } catch (const std::exception& e) { LOG_ERROR << "Exception processing metadata for audio " << audioId << ": " << e.what(); markAudioFailed(audioId); @@ -143,7 +301,7 @@ void AudioController::getAllAudio(const HttpRequestPtr &req, auto pagination = parsePagination(req, 20, 50); auto dbClient = app().getDbClient(); - *dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, " + *dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, a.waveform_path, " "a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at, a.realm_id, " "u.id as user_id, u.username, u.avatar_url, " "r.name as realm_name " @@ -170,7 +328,7 @@ void AudioController::getAllAudio(const HttpRequestPtr &req, void AudioController::getLatestAudio(const HttpRequestPtr &, std::function &&callback) { auto dbClient = app().getDbClient(); - *dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, " + *dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, a.waveform_path, " "a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at, a.realm_id, " "u.id as user_id, u.username, u.avatar_url, " "r.name as realm_name " @@ -280,7 +438,7 @@ void AudioController::getRealmAudio(const HttpRequestPtr &req, return; } - *dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, " + *dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, a.waveform_path, " "a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at " "FROM audio_files a " "WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' " @@ -339,7 +497,7 @@ void AudioController::getRealmAudioByName(const HttpRequestPtr &req, int64_t realmId = realmResult[0]["id"].as(); - *dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, " + *dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, a.waveform_path, " "a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at " "FROM audio_files a " "WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' " @@ -422,6 +580,168 @@ void AudioController::incrementPlayCount(const HttpRequestPtr &req, }); } +void AudioController::getWaveform(const HttpRequestPtr &, + std::function &&callback, + const std::string &audioId) { + int64_t id; + try { + id = std::stoll(audioId); + } catch (...) { + callback(jsonError("Invalid audio ID", k400BadRequest)); + return; + } + + auto dbClient = app().getDbClient(); + *dbClient << "SELECT waveform_path FROM audio_files WHERE id = $1 AND is_public = true AND status = 'ready'" + << id + >> [callback, id](const Result& r) { + if (r.empty()) { + callback(jsonError("Audio not found", k404NotFound)); + return; + } + + std::string waveformPath = r[0]["waveform_path"].isNull() ? "" : r[0]["waveform_path"].as(); + + if (waveformPath.empty()) { + // Return empty peaks if no waveform available + Json::Value resp; + resp["peaks"] = Json::arrayValue; + callback(jsonResp(resp)); + return; + } + + std::string fullPath = "/app" + waveformPath; + + // Validate path is within allowed directory + if (!isPathSafe(fullPath, "/app/uploads/audio/waveforms")) { + LOG_WARN << "Blocked access to waveform file outside uploads: " << fullPath; + Json::Value resp; + resp["peaks"] = Json::arrayValue; + callback(jsonResp(resp)); + return; + } + + // Read and return the JSON file + try { + std::ifstream ifs(fullPath); + if (!ifs) { + Json::Value resp; + resp["peaks"] = Json::arrayValue; + callback(jsonResp(resp)); + return; + } + + std::stringstream buffer; + buffer << ifs.rdbuf(); + + Json::Value resp; + Json::CharReaderBuilder builder; + std::string errors; + std::istringstream stream(buffer.str()); + if (Json::parseFromStream(builder, stream, &resp, &errors)) { + // Add cache headers for waveforms (they don't change) + auto response = jsonResp(resp); + response->addHeader("Cache-Control", "public, max-age=31536000"); // 1 year + callback(response); + } else { + Json::Value errResp; + errResp["peaks"] = Json::arrayValue; + callback(jsonResp(errResp)); + } + } catch (const std::exception& e) { + LOG_ERROR << "Error reading waveform file: " << e.what(); + Json::Value resp; + resp["peaks"] = Json::arrayValue; + callback(jsonResp(resp)); + } + } + >> DB_ERROR(callback, "get waveform"); +} + +void AudioController::downloadAudio(const HttpRequestPtr &req, + std::function &&callback, + const std::string &audioId) { + // Require authentication for downloads + UserInfo user = getUserFromRequest(req); + if (user.id == 0) { + callback(jsonError("Please log in to download audio", k401Unauthorized)); + return; + } + + int64_t id; + try { + id = std::stoll(audioId); + } catch (...) { + callback(jsonError("Invalid audio ID", k400BadRequest)); + return; + } + + auto dbClient = app().getDbClient(); + + // Get audio info - only allow download of public, ready audio + *dbClient << "SELECT title, file_path, format FROM audio_files WHERE id = $1 AND is_public = true AND status = 'ready'" + << id + >> [callback, id](const Result& r) { + if (r.empty()) { + callback(jsonError("Audio not found", k404NotFound)); + return; + } + + std::string title = r[0]["title"].as(); + std::string filePath = r[0]["file_path"].as(); + std::string format = r[0]["format"].isNull() ? "mp3" : r[0]["format"].as(); + std::string fullPath = "/app" + filePath; + + // Validate path is within allowed directory + if (!isPathSafe(fullPath, "/app/uploads/audio")) { + LOG_WARN << "Blocked access to file outside uploads: " << fullPath; + callback(jsonError("Audio file not found", k404NotFound)); + return; + } + + // Check file exists + if (!std::filesystem::exists(fullPath)) { + LOG_ERROR << "Audio file not found: " << fullPath; + callback(jsonError("Audio file not found", k404NotFound)); + return; + } + + // Sanitize title for filename (remove special chars) + std::string safeTitle; + for (char c : title) { + if (std::isalnum(c) || c == ' ' || c == '-' || c == '_') { + safeTitle += c; + } + } + if (safeTitle.empty()) safeTitle = "audio"; + if (safeTitle.length() > 100) safeTitle = safeTitle.substr(0, 100); + + // Determine content type based on format + std::string contentType = "audio/mpeg"; // default for mp3 + std::string extension = "mp3"; + if (format == "wav") { + contentType = "audio/wav"; + extension = "wav"; + } else if (format == "flac") { + contentType = "audio/flac"; + extension = "flac"; + } else if (format == "ogg") { + contentType = "audio/ogg"; + extension = "ogg"; + } else if (format == "aac" || format == "m4a") { + contentType = "audio/mp4"; + extension = "m4a"; + } + + // Use Drogon's file response for efficient streaming + auto resp = HttpResponse::newFileResponse(fullPath, "", CT_CUSTOM); + resp->addHeader("Content-Type", contentType); + resp->addHeader("Content-Disposition", "attachment; filename=\"" + safeTitle + "." + extension + "\""); + callback(resp); + } + >> DB_ERROR(callback, "download audio"); +} + void AudioController::getMyAudio(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); diff --git a/backend/src/controllers/AudioController.h b/backend/src/controllers/AudioController.h index 550aae3..f95b7b3 100644 --- a/backend/src/controllers/AudioController.h +++ b/backend/src/controllers/AudioController.h @@ -14,6 +14,8 @@ public: ADD_METHOD_TO(AudioController::getRealmAudio, "/api/audio/realm/{1}", Get); ADD_METHOD_TO(AudioController::getRealmAudioByName, "/api/audio/realm/name/{1}", Get); ADD_METHOD_TO(AudioController::incrementPlayCount, "/api/audio/{1}/play", Post); + ADD_METHOD_TO(AudioController::getWaveform, "/api/audio/{1}/waveform", Get); + ADD_METHOD_TO(AudioController::downloadAudio, "/api/audio/{1}/download", Get); // Authenticated endpoints ADD_METHOD_TO(AudioController::getMyAudio, "/api/user/audio", Get); @@ -47,6 +49,14 @@ public: std::function &&callback, const std::string &audioId); + void getWaveform(const HttpRequestPtr &req, + std::function &&callback, + const std::string &audioId); + + void downloadAudio(const HttpRequestPtr &req, + std::function &&callback, + const std::string &audioId); + // Authenticated audio management void getMyAudio(const HttpRequestPtr &req, std::function &&callback); diff --git a/backend/src/controllers/RealmController.cpp b/backend/src/controllers/RealmController.cpp index 05b7119..6ff0477 100644 --- a/backend/src/controllers/RealmController.cpp +++ b/backend/src/controllers/RealmController.cpp @@ -103,15 +103,15 @@ namespace { } } - // Helper to create a new viewer token - void createNewViewerToken(std::function callback, const std::string& streamKey) { + // Helper to create a new viewer token (uses realm-specific cookies to support multi-stream viewing) + void createNewViewerToken(std::function callback, const std::string& streamKey, const std::string& realmId) { auto bytes = drogon::utils::genRandomString(32); std::string token = drogon::utils::base64Encode( (const unsigned char*)bytes.data(), bytes.length() ); RedisHelper::storeKeyAsync("viewer_token:" + token, streamKey, 300, - [callback, token](bool stored) { + [callback, token, realmId](bool stored) { if (!stored) { auto resp = HttpResponse::newHttpResponse(); resp->setStatusCode(k500InternalServerError); @@ -121,7 +121,8 @@ namespace { auto resp = HttpResponse::newHttpResponse(); - Cookie cookie("viewer_token", token); + // Use realm-specific cookie name to support multiple streams simultaneously + Cookie cookie("viewer_token_" + realmId, token); cookie.setPath("/"); cookie.setHttpOnly(true); cookie.setSecure(false); @@ -393,13 +394,13 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req, const std::string &realmId) { int64_t id = std::stoll(realmId); - // Check for existing viewer token to avoid creating duplicates on page refresh - auto existingToken = req->getCookie("viewer_token"); + // Check for existing viewer token for THIS specific realm (supports multi-stream viewing) + auto existingToken = req->getCookie("viewer_token_" + realmId); auto dbClient = app().getDbClient(); *dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true" << id - >> [callback, existingToken](const Result& r) { + >> [callback, existingToken, realmId](const Result& r) { if (r.empty()) { callback(jsonResp({}, k404NotFound)); return; @@ -407,18 +408,18 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req, std::string streamKey = r[0]["stream_key"].as(); - // If user has existing token, check if it's still valid for this stream + // If user has existing token for this realm, check if it's still valid if (!existingToken.empty()) { RedisHelper::getKeyAsync("viewer_token:" + existingToken, - [callback, existingToken, streamKey](const std::string& storedKey) { + [callback, existingToken, streamKey, realmId](const std::string& storedKey) { if (storedKey == streamKey) { // Token is still valid for this stream - just refresh TTL and return it RedisHelper::storeKeyAsync("viewer_token:" + existingToken, streamKey, 300, - [callback, existingToken](bool stored) { + [callback, existingToken, realmId](bool stored) { auto resp = HttpResponse::newHttpResponse(); - // Refresh cookie - Cookie cookie("viewer_token", existingToken); + // Refresh realm-specific cookie + Cookie cookie("viewer_token_" + realmId, existingToken); cookie.setPath("/"); cookie.setHttpOnly(true); cookie.setSecure(false); @@ -441,13 +442,13 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req, if (!storedKey.empty()) { RedisHelper::deleteKeyAsync("viewer_token:" + existingToken, [](bool){}); } - createNewViewerToken(callback, streamKey); + createNewViewerToken(callback, streamKey, realmId); } } ); } else { - // No existing token, create new one - createNewViewerToken(callback, streamKey); + // No existing token for this realm, create new one + createNewViewerToken(callback, streamKey, realmId); } } >> [callback](const DrogonDbException& e) { @@ -459,15 +460,15 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req, void RealmController::getRealmStreamKey(const HttpRequestPtr &req, std::function &&callback, const std::string &realmId) { - // Check for viewer token - auto token = req->getCookie("viewer_token"); + // Check for realm-specific viewer token (supports multi-stream viewing) + auto token = req->getCookie("viewer_token_" + realmId); if (token.empty()) { - callback(jsonError("No viewer token", k403Forbidden)); + callback(jsonError("No viewer token for this realm", k403Forbidden)); return; } - + int64_t id = std::stoll(realmId); - + // First get the stream key for this realm auto dbClient = app().getDbClient(); *dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true" @@ -477,9 +478,9 @@ void RealmController::getRealmStreamKey(const HttpRequestPtr &req, callback(jsonError("Realm not found", k404NotFound)); return; } - + std::string streamKey = r[0]["stream_key"].as(); - + // Verify the token is valid for this stream RedisHelper::getKeyAsync("viewer_token:" + token, [callback, streamKey](const std::string& storedStreamKey) { @@ -487,7 +488,7 @@ void RealmController::getRealmStreamKey(const HttpRequestPtr &req, callback(jsonError("Invalid token for this realm", k403Forbidden)); return; } - + // Token is valid, return the stream key Json::Value resp; resp["success"] = true; diff --git a/backend/src/controllers/UserController.cpp b/backend/src/controllers/UserController.cpp index 692ed52..c2c9fe5 100644 --- a/backend/src/controllers/UserController.cpp +++ b/backend/src/controllers/UserController.cpp @@ -2212,44 +2212,50 @@ void UserController::getTreasury(const HttpRequestPtr &, double estimatedShare = totalUsers > 0 ? balance / static_cast(totalUsers) : 0.0; estimatedShare = std::ceil(estimatedShare * 1000.0) / 1000.0; - // Calculate next Sunday (next distribution) in UTC + // Calculate next Sunday (next distribution) in EST (UTC-5) + // EST offset: -5 hours = -18000 seconds + const int EST_OFFSET_SECONDS = -5 * 3600; + std::time_t now = std::time(nullptr); - std::tm nowUtc; + std::time_t nowEst = now + EST_OFFSET_SECONDS; + + std::tm nowEstTm; #ifdef _WIN32 - gmtime_s(&nowUtc, &now); + gmtime_s(&nowEstTm, &nowEst); #else - gmtime_r(&now, &nowUtc); + gmtime_r(&nowEst, &nowEstTm); #endif - // Days until Sunday (0 = Sunday) - // If today is Sunday, next distribution is next Sunday (7 days) - int daysUntilSunday = (7 - nowUtc.tm_wday) % 7; + // Days until Sunday (0 = Sunday) based on EST + int daysUntilSunday = (7 - nowEstTm.tm_wday) % 7; if (daysUntilSunday == 0) daysUntilSunday = 7; - // Calculate next Sunday at midnight UTC - std::tm nextSundayTm = nowUtc; + // Calculate next Sunday at midnight EST (which is 5 AM UTC) + std::tm nextSundayTm = nowEstTm; nextSundayTm.tm_mday += daysUntilSunday; nextSundayTm.tm_hour = 0; nextSundayTm.tm_min = 0; nextSundayTm.tm_sec = 0; - // Normalize the tm struct (handles month overflow etc) - // Note: timegm is the UTC version of mktime + // Convert to time_t (treating as UTC since we applied offset) #ifdef _WIN32 - std::time_t nextSunday = _mkgmtime(&nextSundayTm); + std::time_t nextSundayEst = _mkgmtime(&nextSundayTm); #else - std::time_t nextSunday = timegm(&nextSundayTm); + std::time_t nextSundayEst = timegm(&nextSundayTm); #endif + // Convert back to UTC for the response + std::time_t nextSundayUtc = nextSundayEst - EST_OFFSET_SECONDS; + // Format as ISO 8601 UTC char nextDistBuffer[32]; - std::tm nextSundayUtc; + std::tm nextSundayUtcTm; #ifdef _WIN32 - gmtime_s(&nextSundayUtc, &nextSunday); + gmtime_s(&nextSundayUtcTm, &nextSundayUtc); #else - gmtime_r(&nextSunday, &nextSundayUtc); + gmtime_r(&nextSundayUtc, &nextSundayUtcTm); #endif - std::strftime(nextDistBuffer, sizeof(nextDistBuffer), "%Y-%m-%dT%H:%M:%SZ", &nextSundayUtc); + std::strftime(nextDistBuffer, sizeof(nextDistBuffer), "%Y-%m-%dT%H:%M:%SZ", &nextSundayUtcTm); Json::Value resp; resp["success"] = true; diff --git a/frontend/src/lib/components/WaveformBackground.svelte b/frontend/src/lib/components/WaveformBackground.svelte new file mode 100644 index 0000000..25f1b69 --- /dev/null +++ b/frontend/src/lib/components/WaveformBackground.svelte @@ -0,0 +1,140 @@ + + +{#if peaks.length > 0} +
+ + + {#each peaks as peak, i} + {@const barWidth = 400 / peaks.length} + {@const barHeight = peak * 45} + {@const x = i * barWidth} + + + + + {/each} + +
+{/if} + + diff --git a/frontend/src/lib/components/chat/ChatTerminal.svelte b/frontend/src/lib/components/chat/ChatTerminal.svelte index b5e6eef..0dd99cd 100644 --- a/frontend/src/lib/components/chat/ChatTerminal.svelte +++ b/frontend/src/lib/components/chat/ChatTerminal.svelte @@ -451,6 +451,7 @@ left: 0; right: 0; border-bottom: 1px solid #333; + overflow-x: hidden; } .terminal-container.undocked { @@ -487,6 +488,8 @@ gap: 0.375rem; user-select: none; flex-shrink: 0; + min-width: 0; + overflow: hidden; } .docked .terminal-header { @@ -502,6 +505,7 @@ align-items: center; gap: 0.375rem; flex-shrink: 0; + min-width: 0; } .status { @@ -581,8 +585,10 @@ display: flex; flex-direction: column; overflow: hidden; + overflow-x: hidden; background: rgb(13, 17, 23); opacity: 0.9; + min-width: 0; } .datetime-container { diff --git a/frontend/src/lib/components/terminal/AudioBrowser.svelte b/frontend/src/lib/components/terminal/AudioBrowser.svelte index 4d6eaf4..63645ac 100644 --- a/frontend/src/lib/components/terminal/AudioBrowser.svelte +++ b/frontend/src/lib/components/terminal/AudioBrowser.svelte @@ -1,5 +1,7 @@
{#if $currentTrack} + {#if $currentTrack.waveformPath} + + {/if}
NOW PLAYING {$audioPlaylist.isPlaying ? '▶' : '■'} @@ -48,6 +95,9 @@ {$currentTrack.title} {$currentTrack.username}
+ {#if $isAuthenticated} + + {/if}
@@ -126,6 +176,9 @@ {index + 1} {track.title} {formatDuration(track.durationSeconds)} + {#if $isAuthenticated} + + {/if}
{/each} @@ -154,6 +207,13 @@ padding: 0.75rem; background: #0d1117; border-bottom: 1px solid #21262d; + position: relative; + overflow: hidden; + } + + .player-section > :not(:global(.waveform-container)) { + position: relative; + z-index: 1; } .player-header { @@ -238,6 +298,27 @@ filter: invert(1); } + .dl-btn { + width: 24px; + height: 24px; + border: 1px solid #30363d; + background: #161b22; + color: #8b949e; + font-size: 0.7rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + } + + .dl-btn:hover { + background: rgba(59, 130, 246, 0.2); + border-color: #3b82f6; + color: #3b82f6; + } + /* Progress bar (pixel style) */ .player-progress-wrap { display: flex; @@ -585,6 +666,29 @@ color: #f85149; } + .queue-dl-btn { + width: 16px; + height: 16px; + border: none; + background: transparent; + color: #484f58; + font-size: 0.7rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.1s ease; + } + + .queue-item:hover .queue-dl-btn { + opacity: 1; + } + + .queue-dl-btn:hover { + color: #3b82f6; + } + /* Queue empty state */ .queue-empty { display: flex; diff --git a/frontend/src/lib/components/terminal/EbookBrowser.svelte b/frontend/src/lib/components/terminal/EbookBrowser.svelte index 76c030c..f664d42 100644 --- a/frontend/src/lib/components/terminal/EbookBrowser.svelte +++ b/frontend/src/lib/components/terminal/EbookBrowser.svelte @@ -2,6 +2,7 @@ import { onDestroy } from 'svelte'; import { browser } from '$app/environment'; import { ebookReader } from '$lib/stores/ebookReader'; + import { isAuthenticated } from '$lib/stores/auth'; /** @type {boolean} Whether the ebooks tab is currently active */ export let isActive = false; @@ -99,6 +100,41 @@ }); } + async function downloadEbook(e, ebook) { + e.stopPropagation(); + + if (!$isAuthenticated) { + alert('Please log in to download ebooks'); + return; + } + + try { + const response = await fetch(`/api/ebooks/${ebook.id}/download`, { + credentials: 'include' + }); + + if (response.ok) { + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const safeTitle = ebook.title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100); + a.download = `${safeTitle || 'ebook'}.epub`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } else if (response.status === 401) { + alert('Please log in to download ebooks'); + } else { + alert('Download failed'); + } + } catch (err) { + console.error('Download error:', err); + alert('Download failed'); + } + } + function handleScroll(e) { const target = e.target; const nearBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 100; @@ -152,11 +188,20 @@ {timeAgo(ebook.createdAt)} - +
+ + {#if $isAuthenticated} + + {/if} +
{/each} {#if loadingMore} @@ -375,4 +420,28 @@ .read-btn:active { background: rgba(59, 130, 246, 0.35); } + + .ebook-actions { + display: flex; + gap: 0.3rem; + flex-shrink: 0; + } + + .dl-btn { + padding: 0.3rem 0.5rem; + background: rgba(107, 114, 128, 0.15); + border: 1px solid rgba(107, 114, 128, 0.3); + border-radius: 4px; + color: #6b7280; + font-size: 0.65rem; + font-family: inherit; + cursor: pointer; + transition: all 0.15s ease; + } + + .dl-btn:hover { + background: rgba(59, 130, 246, 0.25); + border-color: #3b82f6; + color: #3b82f6; + } diff --git a/frontend/src/lib/data/holidays.js b/frontend/src/lib/data/holidays.js index 579e773..d5e6314 100644 --- a/frontend/src/lib/data/holidays.js +++ b/frontend/src/lib/data/holidays.js @@ -1,32 +1,59 @@ /** * Holiday data for terminal calendar - * Includes both Indian national holidays and international holidays + * Includes Indian, American, Canadian, Australian, UK, Russian, and international holidays */ /** * Fixed holidays - same date every year - * Format: { month: 0-11, day: 1-31, name: string, type: 'indian' | 'international' } + * Format: { month: 0-11, day: 1-31, name: string, type: string } */ export const fixedHolidays = [ // Indian National Holidays (Fixed) { month: 0, day: 26, name: 'Republic Day', type: 'indian' }, - { month: 7, day: 15, name: 'Independence Day', type: 'indian' }, + { month: 7, day: 15, name: 'Independence Day (India)', type: 'indian' }, { month: 9, day: 2, name: 'Gandhi Jayanti', type: 'indian' }, + // American Holidays (Fixed) + { month: 6, day: 4, name: 'Independence Day (USA)', type: 'american' }, + { month: 10, day: 11, name: 'Veterans Day', type: 'american' }, + + // Canadian Holidays (Fixed) + { month: 6, day: 1, name: 'Canada Day', type: 'canadian' }, + { month: 10, day: 11, name: 'Remembrance Day', type: 'canadian' }, + + // Australian Holidays (Fixed) + { month: 0, day: 26, name: 'Australia Day', type: 'australian' }, + { month: 3, day: 25, name: 'ANZAC Day', type: 'australian' }, + + // UK Holidays (Fixed) + { month: 10, day: 5, name: 'Guy Fawkes Night', type: 'uk' }, + + // Russian Holidays (Fixed) + { month: 0, day: 7, name: 'Orthodox Christmas', type: 'russian' }, + { month: 1, day: 23, name: 'Defender of the Fatherland Day', type: 'russian' }, + { month: 2, day: 8, name: "International Women's Day", type: 'russian' }, + { month: 4, day: 1, name: 'Spring and Labour Day', type: 'russian' }, + { month: 4, day: 9, name: 'Victory Day', type: 'russian' }, + { month: 5, day: 12, name: 'Russia Day', type: 'russian' }, + { month: 10, day: 4, name: 'Unity Day', type: 'russian' }, + // International Holidays (Fixed) { month: 0, day: 1, name: "New Year's Day", type: 'international' }, { month: 1, day: 14, name: "Valentine's Day", type: 'international' }, + { month: 2, day: 17, name: "St. Patrick's Day", type: 'international' }, { month: 9, day: 31, name: 'Halloween', type: 'international' }, { month: 11, day: 25, name: 'Christmas', type: 'international' }, + { month: 11, day: 26, name: 'Boxing Day', type: 'international' }, { month: 11, day: 31, name: "New Year's Eve", type: 'international' }, ]; /** - * Variable holidays - different date each year (lunar calendar based) + * Variable holidays - different date each year (based on lunar calendar or day of week) * Format: { [year]: Array<{ month: 0-11, day: 1-31, name: string, type: string }> } */ export const variableHolidays = { 2024: [ + // Indian { month: 2, day: 25, name: 'Holi', type: 'indian' }, { month: 3, day: 10, name: 'Eid ul-Fitr', type: 'indian' }, { month: 5, day: 17, name: 'Eid ul-Adha', type: 'indian' }, @@ -35,8 +62,27 @@ export const variableHolidays = { { month: 8, day: 7, name: 'Ganesh Chaturthi', type: 'indian' }, { month: 9, day: 12, name: 'Dussehra', type: 'indian' }, { month: 10, day: 1, name: 'Diwali', type: 'indian' }, + // American + { month: 0, day: 15, name: 'MLK Day', type: 'american' }, + { month: 1, day: 19, name: "Presidents' Day", type: 'american' }, + { month: 4, day: 27, name: 'Memorial Day', type: 'american' }, + { month: 8, day: 2, name: 'Labor Day', type: 'american' }, + { month: 9, day: 14, name: 'Columbus Day', type: 'american' }, + { month: 10, day: 28, name: 'Thanksgiving (USA)', type: 'american' }, + // Canadian + { month: 4, day: 20, name: 'Victoria Day', type: 'canadian' }, + { month: 9, day: 14, name: 'Thanksgiving (Canada)', type: 'canadian' }, + // Australian + { month: 5, day: 10, name: "Queen's Birthday", type: 'australian' }, + // UK + { month: 2, day: 29, name: 'Good Friday', type: 'uk' }, + { month: 3, day: 1, name: 'Easter Monday', type: 'uk' }, + { month: 4, day: 6, name: 'Early May Bank Holiday', type: 'uk' }, + { month: 4, day: 27, name: 'Spring Bank Holiday', type: 'uk' }, + { month: 7, day: 26, name: 'Summer Bank Holiday', type: 'uk' }, ], 2025: [ + // Indian { month: 2, day: 14, name: 'Holi', type: 'indian' }, { month: 2, day: 31, name: 'Eid ul-Fitr', type: 'indian' }, { month: 5, day: 7, name: 'Eid ul-Adha', type: 'indian' }, @@ -45,8 +91,27 @@ export const variableHolidays = { { month: 7, day: 27, name: 'Ganesh Chaturthi', type: 'indian' }, { month: 9, day: 2, name: 'Dussehra', type: 'indian' }, { month: 9, day: 20, name: 'Diwali', type: 'indian' }, + // American + { month: 0, day: 20, name: 'MLK Day', type: 'american' }, + { month: 1, day: 17, name: "Presidents' Day", type: 'american' }, + { month: 4, day: 26, name: 'Memorial Day', type: 'american' }, + { month: 8, day: 1, name: 'Labor Day', type: 'american' }, + { month: 9, day: 13, name: 'Columbus Day', type: 'american' }, + { month: 10, day: 27, name: 'Thanksgiving (USA)', type: 'american' }, + // Canadian + { month: 4, day: 19, name: 'Victoria Day', type: 'canadian' }, + { month: 9, day: 13, name: 'Thanksgiving (Canada)', type: 'canadian' }, + // Australian + { month: 5, day: 9, name: "Queen's Birthday", type: 'australian' }, + // UK + { month: 3, day: 18, name: 'Good Friday', type: 'uk' }, + { month: 3, day: 21, name: 'Easter Monday', type: 'uk' }, + { month: 4, day: 5, name: 'Early May Bank Holiday', type: 'uk' }, + { month: 4, day: 26, name: 'Spring Bank Holiday', type: 'uk' }, + { month: 7, day: 25, name: 'Summer Bank Holiday', type: 'uk' }, ], 2026: [ + // Indian { month: 2, day: 4, name: 'Holi', type: 'indian' }, { month: 2, day: 20, name: 'Eid ul-Fitr', type: 'indian' }, { month: 4, day: 27, name: 'Eid ul-Adha', type: 'indian' }, @@ -55,8 +120,27 @@ export const variableHolidays = { { month: 8, day: 17, name: 'Ganesh Chaturthi', type: 'indian' }, { month: 8, day: 20, name: 'Dussehra', type: 'indian' }, { month: 10, day: 8, name: 'Diwali', type: 'indian' }, + // American + { month: 0, day: 19, name: 'MLK Day', type: 'american' }, + { month: 1, day: 16, name: "Presidents' Day", type: 'american' }, + { month: 4, day: 25, name: 'Memorial Day', type: 'american' }, + { month: 8, day: 7, name: 'Labor Day', type: 'american' }, + { month: 9, day: 12, name: 'Columbus Day', type: 'american' }, + { month: 10, day: 26, name: 'Thanksgiving (USA)', type: 'american' }, + // Canadian + { month: 4, day: 18, name: 'Victoria Day', type: 'canadian' }, + { month: 9, day: 12, name: 'Thanksgiving (Canada)', type: 'canadian' }, + // Australian + { month: 5, day: 8, name: "Queen's Birthday", type: 'australian' }, + // UK + { month: 3, day: 3, name: 'Good Friday', type: 'uk' }, + { month: 3, day: 6, name: 'Easter Monday', type: 'uk' }, + { month: 4, day: 4, name: 'Early May Bank Holiday', type: 'uk' }, + { month: 4, day: 25, name: 'Spring Bank Holiday', type: 'uk' }, + { month: 7, day: 31, name: 'Summer Bank Holiday', type: 'uk' }, ], 2027: [ + // Indian { month: 2, day: 22, name: 'Holi', type: 'indian' }, { month: 2, day: 10, name: 'Eid ul-Fitr', type: 'indian' }, { month: 4, day: 17, name: 'Eid ul-Adha', type: 'indian' }, @@ -65,6 +149,24 @@ export const variableHolidays = { { month: 8, day: 6, name: 'Ganesh Chaturthi', type: 'indian' }, { month: 9, day: 9, name: 'Dussehra', type: 'indian' }, { month: 9, day: 28, name: 'Diwali', type: 'indian' }, + // American + { month: 0, day: 18, name: 'MLK Day', type: 'american' }, + { month: 1, day: 15, name: "Presidents' Day", type: 'american' }, + { month: 4, day: 31, name: 'Memorial Day', type: 'american' }, + { month: 8, day: 6, name: 'Labor Day', type: 'american' }, + { month: 9, day: 11, name: 'Columbus Day', type: 'american' }, + { month: 10, day: 25, name: 'Thanksgiving (USA)', type: 'american' }, + // Canadian + { month: 4, day: 24, name: 'Victoria Day', type: 'canadian' }, + { month: 9, day: 11, name: 'Thanksgiving (Canada)', type: 'canadian' }, + // Australian + { month: 5, day: 14, name: "Queen's Birthday", type: 'australian' }, + // UK + { month: 2, day: 26, name: 'Good Friday', type: 'uk' }, + { month: 2, day: 29, name: 'Easter Monday', type: 'uk' }, + { month: 4, day: 3, name: 'Early May Bank Holiday', type: 'uk' }, + { month: 4, day: 31, name: 'Spring Bank Holiday', type: 'uk' }, + { month: 7, day: 30, name: 'Summer Bank Holiday', type: 'uk' }, ], }; diff --git a/frontend/src/routes/audio/+page.svelte b/frontend/src/routes/audio/+page.svelte index 25fe8fb..cd80935 100644 --- a/frontend/src/routes/audio/+page.svelte +++ b/frontend/src/routes/audio/+page.svelte @@ -2,7 +2,9 @@ import { onMount } from 'svelte'; import { browser } from '$app/environment'; import { siteSettings } from '$lib/stores/siteSettings'; - import { audioPlaylist } from '$lib/stores/audioPlaylist'; + import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist'; + import { auth, isAuthenticated } from '$lib/stores/auth'; + import WaveformBackground from '$lib/components/WaveformBackground.svelte'; let audioFiles = []; let loading = true; @@ -55,6 +57,20 @@ return $audioPlaylist.queue.some(t => t.id === audioId); } + function isCurrentlyPlaying(audioId) { + return $currentTrack?.id === audioId && $audioPlaylist.isPlaying; + } + + function handlePlayClick(audio, realmName) { + if ($currentTrack?.id === audio.id) { + // Toggle play/pause if this is the current track + audioPlaylist.togglePlay(); + } else { + // Play this track + playNow(audio, realmName); + } + } + function togglePlaylist(audio, realmName) { if (isInPlaylist(audio.id)) { audioPlaylist.removeTrack(audio.id); @@ -65,6 +81,7 @@ username: audio.username, filePath: audio.filePath, thumbnailPath: audio.thumbnailPath || '', + waveformPath: audio.waveformPath || '', durationSeconds: audio.durationSeconds, realmName: realmName }); @@ -78,11 +95,47 @@ username: audio.username, filePath: audio.filePath, thumbnailPath: audio.thumbnailPath || '', + waveformPath: audio.waveformPath || '', durationSeconds: audio.durationSeconds, realmName: realmName }); } + async function downloadAudio(e, audio) { + e.stopPropagation(); + + if (!$isAuthenticated) { + alert('Please log in to download audio'); + return; + } + + try { + const response = await fetch(`/api/audio/${audio.id}/download`, { + credentials: 'include' + }); + + if (response.ok) { + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const safeTitle = audio.title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100); + a.download = safeTitle || 'audio'; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } else if (response.status === 401) { + alert('Please log in to download audio'); + } else { + alert('Download failed'); + } + } catch (err) { + console.error('Download error:', err); + alert('Download failed'); + } + } + async function loadAudio(append = false) { if (!browser) return; @@ -111,6 +164,7 @@ } onMount(() => { + auth.init(); loadAudio(); }); @@ -204,6 +258,13 @@ border: 1px solid var(--border); border-radius: 8px; transition: all 0.2s; + position: relative; + overflow: hidden; + } + + .audio-item > :not(:global(.waveform-container)) { + position: relative; + z-index: 1; } .audio-item:hover { @@ -316,12 +377,23 @@ color: white; } + .action-btn.play.playing { + background: #ec4899; + color: white; + } + .action-btn.added { background: rgba(126, 231, 135, 0.2); border-color: #7ee787; color: #7ee787; } + .action-btn.download:hover { + background: rgba(59, 130, 246, 0.2); + border-color: #3b82f6; + color: #3b82f6; + } + .no-audio { text-align: center; padding: 4rem 0; @@ -387,6 +459,15 @@
{#each group.audio.slice(0, 5) as audio, index}
+ {#if audio.waveformPath} + + {/if} {index + 1}
{#if audio.thumbnailPath} @@ -411,10 +492,11 @@
+ {#if $isAuthenticated} + + {/if}
{/each} diff --git a/frontend/src/routes/audio/[name]/+page.svelte b/frontend/src/routes/audio/[name]/+page.svelte index d3d6aac..f99d2a5 100644 --- a/frontend/src/routes/audio/[name]/+page.svelte +++ b/frontend/src/routes/audio/[name]/+page.svelte @@ -2,7 +2,9 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; import { browser } from '$app/environment'; - import { audioPlaylist } from '$lib/stores/audioPlaylist'; + import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist'; + import { auth, isAuthenticated } from '$lib/stores/auth'; + import WaveformBackground from '$lib/components/WaveformBackground.svelte'; let realm = null; let audioFiles = []; @@ -41,6 +43,20 @@ return $audioPlaylist.queue.some(t => t.id === audioId); } + function isCurrentlyPlaying(audioId) { + return $currentTrack?.id === audioId && $audioPlaylist.isPlaying; + } + + function handlePlayClick(audio) { + if ($currentTrack?.id === audio.id) { + // Toggle play/pause if this is the current track + audioPlaylist.togglePlay(); + } else { + // Play this track + playNow(audio); + } + } + function togglePlaylist(audio) { if (isInPlaylist(audio.id)) { audioPlaylist.removeTrack(audio.id); @@ -51,6 +67,7 @@ username: audio.username || realm?.username, filePath: audio.filePath, thumbnailPath: audio.thumbnailPath || '', + waveformPath: audio.waveformPath || '', durationSeconds: audio.durationSeconds, realmName: realm?.name }); @@ -64,6 +81,7 @@ username: audio.username || realm?.username, filePath: audio.filePath, thumbnailPath: audio.thumbnailPath || '', + waveformPath: audio.waveformPath || '', durationSeconds: audio.durationSeconds, realmName: realm?.name }); @@ -76,6 +94,7 @@ username: audio.username || realm?.username, filePath: audio.filePath, thumbnailPath: audio.thumbnailPath || '', + waveformPath: audio.waveformPath || '', durationSeconds: audio.durationSeconds, realmName: realm?.name }); @@ -89,6 +108,41 @@ }); } + async function downloadAudio(e, audio) { + e.stopPropagation(); + + if (!$isAuthenticated) { + alert('Please log in to download audio'); + return; + } + + try { + const response = await fetch(`/api/audio/${audio.id}/download`, { + credentials: 'include' + }); + + if (response.ok) { + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const safeTitle = audio.title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100); + a.download = safeTitle || 'audio'; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } else if (response.status === 401) { + alert('Please log in to download audio'); + } else { + alert('Download failed'); + } + } catch (err) { + console.error('Download error:', err); + alert('Download failed'); + } + } + async function loadRealmAudio() { if (!browser || !realmName) return; @@ -114,6 +168,7 @@ let prevRealmName = null; onMount(() => { + auth.init(); prevRealmName = realmName; loadRealmAudio(); }); @@ -240,6 +295,13 @@ border: 1px solid var(--border); border-radius: 8px; transition: all 0.2s; + position: relative; + overflow: hidden; + } + + .audio-item > :not(:global(.waveform-container)) { + position: relative; + z-index: 1; } .audio-item:hover { @@ -350,12 +412,23 @@ color: white; } + .action-btn.play.playing { + background: #ec4899; + color: white; + } + .action-btn.added { background: rgba(126, 231, 135, 0.2); border-color: #7ee787; color: #7ee787; } + .action-btn.download:hover { + background: rgba(59, 130, 246, 0.2); + border-color: #3b82f6; + color: #3b82f6; + } + .no-audio { text-align: center; padding: 4rem 0; @@ -444,6 +517,15 @@
{#each audioFiles as audio, index}
+ {#if audio.waveformPath} + + {/if} {index + 1}
{#if audio.thumbnailPath} @@ -466,10 +548,11 @@
+ {#if $isAuthenticated} + + {/if}
{/each}