fixes lol
All checks were successful
Build and Push / build-all (push) Successful in 8m29s

This commit is contained in:
doomtube 2026-01-10 19:25:42 -05:00
parent 9876641ff6
commit 9e985d05f1
11 changed files with 1011 additions and 70 deletions

View file

@ -22,6 +22,147 @@
using namespace drogon::orm; using namespace drogon::orm;
namespace { 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<std::string> 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<char*> argv;
for (auto& arg : args) {
argv.push_back(const_cast<char*>(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<float> 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<float> 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) // Get audio duration in seconds using ffprobe (safe version)
int getAudioDuration(const std::string& audioPath) { int getAudioDuration(const std::string& audioPath) {
if (!isPathSafe(audioPath, "/app/uploads")) { if (!isPathSafe(audioPath, "/app/uploads")) {
@ -87,6 +228,7 @@ namespace {
audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>(); audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
audio["filePath"] = row["file_path"].as<std::string>(); audio["filePath"] = row["file_path"].as<std::string>();
audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>(); audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
audio["waveformPath"] = row["waveform_path"].isNull() ? "" : row["waveform_path"].as<std::string>();
audio["durationSeconds"] = row["duration_seconds"].as<int>(); audio["durationSeconds"] = row["duration_seconds"].as<int>();
audio["format"] = row["format"].isNull() ? "" : row["format"].as<std::string>(); audio["format"] = row["format"].isNull() ? "" : row["format"].as<std::string>();
audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>(); audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>();
@ -120,16 +262,32 @@ namespace {
return; return;
} }
// Generate waveform data
std::string waveformPath = generateWaveform(audioFullPath, audioId);
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "UPDATE audio_files SET duration_seconds = $1, bitrate = $2, format = $3, status = 'ready' WHERE id = $4" if (!waveformPath.empty()) {
<< duration << bitrate << format << audioId *dbClient << "UPDATE audio_files SET duration_seconds = $1, bitrate = $2, format = $3, waveform_path = $4, status = 'ready' WHERE id = $5"
>> [audioId](const Result&) { << duration << bitrate << format << waveformPath << audioId
LOG_INFO << "Audio " << audioId << " metadata processed successfully"; >> [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(); >> [audioId](const DrogonDbException& e) {
markAudioFailed(audioId); 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) { } catch (const std::exception& e) {
LOG_ERROR << "Exception processing metadata for audio " << audioId << ": " << e.what(); LOG_ERROR << "Exception processing metadata for audio " << audioId << ": " << e.what();
markAudioFailed(audioId); markAudioFailed(audioId);
@ -143,7 +301,7 @@ void AudioController::getAllAudio(const HttpRequestPtr &req,
auto pagination = parsePagination(req, 20, 50); auto pagination = parsePagination(req, 20, 50);
auto dbClient = app().getDbClient(); 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, " "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, " "u.id as user_id, u.username, u.avatar_url, "
"r.name as realm_name " "r.name as realm_name "
@ -170,7 +328,7 @@ void AudioController::getAllAudio(const HttpRequestPtr &req,
void AudioController::getLatestAudio(const HttpRequestPtr &, void AudioController::getLatestAudio(const HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
auto dbClient = app().getDbClient(); 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, " "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, " "u.id as user_id, u.username, u.avatar_url, "
"r.name as realm_name " "r.name as realm_name "
@ -280,7 +438,7 @@ void AudioController::getRealmAudio(const HttpRequestPtr &req,
return; 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 " "a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at "
"FROM audio_files a " "FROM audio_files a "
"WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' " "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<int64_t>(); int64_t realmId = realmResult[0]["id"].as<int64_t>();
*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.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at "
"FROM audio_files a " "FROM audio_files a "
"WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' " "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<void(const HttpResponsePtr &)> &&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<std::string>();
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<void(const HttpResponsePtr &)> &&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>();
std::string filePath = r[0]["file_path"].as<std::string>();
std::string format = r[0]["format"].isNull() ? "mp3" : r[0]["format"].as<std::string>();
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, void AudioController::getMyAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) { std::function<void(const HttpResponsePtr &)> &&callback) {
UserInfo user = getUserFromRequest(req); UserInfo user = getUserFromRequest(req);

View file

@ -14,6 +14,8 @@ public:
ADD_METHOD_TO(AudioController::getRealmAudio, "/api/audio/realm/{1}", Get); 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::getRealmAudioByName, "/api/audio/realm/name/{1}", Get);
ADD_METHOD_TO(AudioController::incrementPlayCount, "/api/audio/{1}/play", Post); 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 // Authenticated endpoints
ADD_METHOD_TO(AudioController::getMyAudio, "/api/user/audio", Get); ADD_METHOD_TO(AudioController::getMyAudio, "/api/user/audio", Get);
@ -47,6 +49,14 @@ public:
std::function<void(const HttpResponsePtr &)> &&callback, std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId); const std::string &audioId);
void getWaveform(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId);
void downloadAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &audioId);
// Authenticated audio management // Authenticated audio management
void getMyAudio(const HttpRequestPtr &req, void getMyAudio(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback); std::function<void(const HttpResponsePtr &)> &&callback);

View file

@ -103,15 +103,15 @@ namespace {
} }
} }
// Helper to create a new viewer token // Helper to create a new viewer token (uses realm-specific cookies to support multi-stream viewing)
void createNewViewerToken(std::function<void(const HttpResponsePtr &)> callback, const std::string& streamKey) { void createNewViewerToken(std::function<void(const HttpResponsePtr &)> callback, const std::string& streamKey, const std::string& realmId) {
auto bytes = drogon::utils::genRandomString(32); auto bytes = drogon::utils::genRandomString(32);
std::string token = drogon::utils::base64Encode( std::string token = drogon::utils::base64Encode(
(const unsigned char*)bytes.data(), bytes.length() (const unsigned char*)bytes.data(), bytes.length()
); );
RedisHelper::storeKeyAsync("viewer_token:" + token, streamKey, 300, RedisHelper::storeKeyAsync("viewer_token:" + token, streamKey, 300,
[callback, token](bool stored) { [callback, token, realmId](bool stored) {
if (!stored) { if (!stored) {
auto resp = HttpResponse::newHttpResponse(); auto resp = HttpResponse::newHttpResponse();
resp->setStatusCode(k500InternalServerError); resp->setStatusCode(k500InternalServerError);
@ -121,7 +121,8 @@ namespace {
auto resp = HttpResponse::newHttpResponse(); 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.setPath("/");
cookie.setHttpOnly(true); cookie.setHttpOnly(true);
cookie.setSecure(false); cookie.setSecure(false);
@ -393,13 +394,13 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
const std::string &realmId) { const std::string &realmId) {
int64_t id = std::stoll(realmId); int64_t id = std::stoll(realmId);
// Check for existing viewer token to avoid creating duplicates on page refresh // Check for existing viewer token for THIS specific realm (supports multi-stream viewing)
auto existingToken = req->getCookie("viewer_token"); auto existingToken = req->getCookie("viewer_token_" + realmId);
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true" *dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
<< id << id
>> [callback, existingToken](const Result& r) { >> [callback, existingToken, realmId](const Result& r) {
if (r.empty()) { if (r.empty()) {
callback(jsonResp({}, k404NotFound)); callback(jsonResp({}, k404NotFound));
return; return;
@ -407,18 +408,18 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
std::string streamKey = r[0]["stream_key"].as<std::string>(); std::string streamKey = r[0]["stream_key"].as<std::string>();
// 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()) { if (!existingToken.empty()) {
RedisHelper::getKeyAsync("viewer_token:" + existingToken, RedisHelper::getKeyAsync("viewer_token:" + existingToken,
[callback, existingToken, streamKey](const std::string& storedKey) { [callback, existingToken, streamKey, realmId](const std::string& storedKey) {
if (storedKey == streamKey) { if (storedKey == streamKey) {
// Token is still valid for this stream - just refresh TTL and return it // Token is still valid for this stream - just refresh TTL and return it
RedisHelper::storeKeyAsync("viewer_token:" + existingToken, streamKey, 300, RedisHelper::storeKeyAsync("viewer_token:" + existingToken, streamKey, 300,
[callback, existingToken](bool stored) { [callback, existingToken, realmId](bool stored) {
auto resp = HttpResponse::newHttpResponse(); auto resp = HttpResponse::newHttpResponse();
// Refresh cookie // Refresh realm-specific cookie
Cookie cookie("viewer_token", existingToken); Cookie cookie("viewer_token_" + realmId, existingToken);
cookie.setPath("/"); cookie.setPath("/");
cookie.setHttpOnly(true); cookie.setHttpOnly(true);
cookie.setSecure(false); cookie.setSecure(false);
@ -441,13 +442,13 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
if (!storedKey.empty()) { if (!storedKey.empty()) {
RedisHelper::deleteKeyAsync("viewer_token:" + existingToken, [](bool){}); RedisHelper::deleteKeyAsync("viewer_token:" + existingToken, [](bool){});
} }
createNewViewerToken(callback, streamKey); createNewViewerToken(callback, streamKey, realmId);
} }
} }
); );
} else { } else {
// No existing token, create new one // No existing token for this realm, create new one
createNewViewerToken(callback, streamKey); createNewViewerToken(callback, streamKey, realmId);
} }
} }
>> [callback](const DrogonDbException& e) { >> [callback](const DrogonDbException& e) {
@ -459,15 +460,15 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
void RealmController::getRealmStreamKey(const HttpRequestPtr &req, void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback, std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId) { const std::string &realmId) {
// Check for viewer token // Check for realm-specific viewer token (supports multi-stream viewing)
auto token = req->getCookie("viewer_token"); auto token = req->getCookie("viewer_token_" + realmId);
if (token.empty()) { if (token.empty()) {
callback(jsonError("No viewer token", k403Forbidden)); callback(jsonError("No viewer token for this realm", k403Forbidden));
return; return;
} }
int64_t id = std::stoll(realmId); int64_t id = std::stoll(realmId);
// First get the stream key for this realm // First get the stream key for this realm
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true" *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)); callback(jsonError("Realm not found", k404NotFound));
return; return;
} }
std::string streamKey = r[0]["stream_key"].as<std::string>(); std::string streamKey = r[0]["stream_key"].as<std::string>();
// Verify the token is valid for this stream // Verify the token is valid for this stream
RedisHelper::getKeyAsync("viewer_token:" + token, RedisHelper::getKeyAsync("viewer_token:" + token,
[callback, streamKey](const std::string& storedStreamKey) { [callback, streamKey](const std::string& storedStreamKey) {
@ -487,7 +488,7 @@ void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
callback(jsonError("Invalid token for this realm", k403Forbidden)); callback(jsonError("Invalid token for this realm", k403Forbidden));
return; return;
} }
// Token is valid, return the stream key // Token is valid, return the stream key
Json::Value resp; Json::Value resp;
resp["success"] = true; resp["success"] = true;

View file

@ -2212,44 +2212,50 @@ void UserController::getTreasury(const HttpRequestPtr &,
double estimatedShare = totalUsers > 0 ? balance / static_cast<double>(totalUsers) : 0.0; double estimatedShare = totalUsers > 0 ? balance / static_cast<double>(totalUsers) : 0.0;
estimatedShare = std::ceil(estimatedShare * 1000.0) / 1000.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::time_t now = std::time(nullptr);
std::tm nowUtc; std::time_t nowEst = now + EST_OFFSET_SECONDS;
std::tm nowEstTm;
#ifdef _WIN32 #ifdef _WIN32
gmtime_s(&nowUtc, &now); gmtime_s(&nowEstTm, &nowEst);
#else #else
gmtime_r(&now, &nowUtc); gmtime_r(&nowEst, &nowEstTm);
#endif #endif
// Days until Sunday (0 = Sunday) // Days until Sunday (0 = Sunday) based on EST
// If today is Sunday, next distribution is next Sunday (7 days) int daysUntilSunday = (7 - nowEstTm.tm_wday) % 7;
int daysUntilSunday = (7 - nowUtc.tm_wday) % 7;
if (daysUntilSunday == 0) daysUntilSunday = 7; if (daysUntilSunday == 0) daysUntilSunday = 7;
// Calculate next Sunday at midnight UTC // Calculate next Sunday at midnight EST (which is 5 AM UTC)
std::tm nextSundayTm = nowUtc; std::tm nextSundayTm = nowEstTm;
nextSundayTm.tm_mday += daysUntilSunday; nextSundayTm.tm_mday += daysUntilSunday;
nextSundayTm.tm_hour = 0; nextSundayTm.tm_hour = 0;
nextSundayTm.tm_min = 0; nextSundayTm.tm_min = 0;
nextSundayTm.tm_sec = 0; nextSundayTm.tm_sec = 0;
// Normalize the tm struct (handles month overflow etc) // Convert to time_t (treating as UTC since we applied offset)
// Note: timegm is the UTC version of mktime
#ifdef _WIN32 #ifdef _WIN32
std::time_t nextSunday = _mkgmtime(&nextSundayTm); std::time_t nextSundayEst = _mkgmtime(&nextSundayTm);
#else #else
std::time_t nextSunday = timegm(&nextSundayTm); std::time_t nextSundayEst = timegm(&nextSundayTm);
#endif #endif
// Convert back to UTC for the response
std::time_t nextSundayUtc = nextSundayEst - EST_OFFSET_SECONDS;
// Format as ISO 8601 UTC // Format as ISO 8601 UTC
char nextDistBuffer[32]; char nextDistBuffer[32];
std::tm nextSundayUtc; std::tm nextSundayUtcTm;
#ifdef _WIN32 #ifdef _WIN32
gmtime_s(&nextSundayUtc, &nextSunday); gmtime_s(&nextSundayUtcTm, &nextSundayUtc);
#else #else
gmtime_r(&nextSunday, &nextSundayUtc); gmtime_r(&nextSundayUtc, &nextSundayUtcTm);
#endif #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; Json::Value resp;
resp["success"] = true; resp["success"] = true;

View file

@ -0,0 +1,140 @@
<script>
import { onMount } from 'svelte';
/** @type {string} Audio ID to fetch waveform for */
export let audioId = '';
/** @type {string} Direct waveform path (alternative to audioId) */
export let waveformPath = '';
/** @type {boolean} Whether audio is currently playing */
export let isPlaying = false;
/** @type {number} Current playback position in seconds */
export let currentTime = 0;
/** @type {number} Total duration in seconds */
export let duration = 0;
/** @type {string} Waveform color */
export let color = '#ec4899';
/** @type {number} Opacity of the waveform (0-1) */
export let opacity = 0.15;
/** @type {boolean} Whether this is the currently playing track */
export let isCurrentTrack = false;
// Waveform data cache (module-level for sharing across instances)
const waveformCache = new Map();
/** @type {number[]} */
let peaks = [];
let loading = true;
// Calculate scroll position based on playback
$: progress = duration > 0 ? currentTime / duration : 0;
$: translateX = isCurrentTrack && isPlaying ? -(progress * 50) : 0;
async function fetchWaveform() {
if (!audioId && !waveformPath) {
loading = false;
return;
}
const cacheKey = audioId || waveformPath;
// Check cache first
if (waveformCache.has(cacheKey)) {
peaks = waveformCache.get(cacheKey);
loading = false;
return;
}
try {
let url;
if (waveformPath) {
// Direct path to waveform JSON
url = waveformPath;
} else {
// Fetch via API endpoint
url = `/api/audio/${audioId}/waveform`;
}
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
peaks = data.peaks || [];
waveformCache.set(cacheKey, peaks);
}
} catch (e) {
console.error('Failed to load waveform:', e);
} finally {
loading = false;
}
}
onMount(() => {
fetchWaveform();
});
// Refetch if audioId or waveformPath changes
$: if (audioId || waveformPath) {
fetchWaveform();
}
</script>
{#if peaks.length > 0}
<div
class="waveform-container"
style="--waveform-color: {color}; --waveform-opacity: {opacity};"
>
<svg
class="waveform-svg"
viewBox="0 0 400 100"
preserveAspectRatio="none"
style="transform: translateX({translateX}%);"
>
<!-- Mirror waveform: bars extend up and down from center -->
{#each peaks as peak, i}
{@const barWidth = 400 / peaks.length}
{@const barHeight = peak * 45}
{@const x = i * barWidth}
<!-- Top bar (from center going up) -->
<rect
x={x}
y={50 - barHeight}
width={barWidth * 0.8}
height={barHeight}
fill={color}
rx="1"
/>
<!-- Bottom bar (from center going down) -->
<rect
x={x}
y={50}
width={barWidth * 0.8}
height={barHeight}
fill={color}
rx="1"
/>
{/each}
</svg>
</div>
{/if}
<style>
.waveform-container {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
opacity: var(--waveform-opacity, 0.15);
}
.waveform-svg {
width: 200%;
height: 100%;
transition: transform 0.1s linear;
}
</style>

View file

@ -451,6 +451,7 @@
left: 0; left: 0;
right: 0; right: 0;
border-bottom: 1px solid #333; border-bottom: 1px solid #333;
overflow-x: hidden;
} }
.terminal-container.undocked { .terminal-container.undocked {
@ -487,6 +488,8 @@
gap: 0.375rem; gap: 0.375rem;
user-select: none; user-select: none;
flex-shrink: 0; flex-shrink: 0;
min-width: 0;
overflow: hidden;
} }
.docked .terminal-header { .docked .terminal-header {
@ -502,6 +505,7 @@
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.375rem;
flex-shrink: 0; flex-shrink: 0;
min-width: 0;
} }
.status { .status {
@ -581,8 +585,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
overflow-x: hidden;
background: rgb(13, 17, 23); background: rgb(13, 17, 23);
opacity: 0.9; opacity: 0.9;
min-width: 0;
} }
.datetime-container { .datetime-container {

View file

@ -1,5 +1,7 @@
<script> <script>
import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist'; import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist';
import { isAuthenticated } from '$lib/stores/auth';
import WaveformBackground from '$lib/components/WaveformBackground.svelte';
/** @type {boolean} Whether the audio tab is currently active */ /** @type {boolean} Whether the audio tab is currently active */
export let isActive = false; export let isActive = false;
@ -25,12 +27,57 @@
const volume = parseFloat(e.target.value); const volume = parseFloat(e.target.value);
audioPlaylist.setVolume(volume); audioPlaylist.setVolume(volume);
} }
async function downloadAudio(e, track) {
e.stopPropagation();
if (!$isAuthenticated) {
alert('Please log in to download audio');
return;
}
try {
const response = await fetch(`/api/audio/${track.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 = track.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');
}
}
</script> </script>
<div class="audio-tab"> <div class="audio-tab">
<!-- Player Section (terminal/pixel style) --> <!-- Player Section (terminal/pixel style) -->
<div class="player-section"> <div class="player-section">
{#if $currentTrack} {#if $currentTrack}
{#if $currentTrack.waveformPath}
<WaveformBackground
waveformPath={$currentTrack.waveformPath}
isPlaying={$audioPlaylist.isPlaying}
currentTime={$audioPlaylist.currentTime}
duration={$audioPlaylist.duration}
isCurrentTrack={true}
opacity={0.1}
/>
{/if}
<div class="player-header"> <div class="player-header">
<span class="player-label">NOW PLAYING</span> <span class="player-label">NOW PLAYING</span>
<span class="player-status">{$audioPlaylist.isPlaying ? '▶' : '■'}</span> <span class="player-status">{$audioPlaylist.isPlaying ? '▶' : '■'}</span>
@ -48,6 +95,9 @@
<span class="player-title">{$currentTrack.title}</span> <span class="player-title">{$currentTrack.title}</span>
<span class="player-artist">{$currentTrack.username}</span> <span class="player-artist">{$currentTrack.username}</span>
</div> </div>
{#if $isAuthenticated}
<button class="dl-btn" on:click={(e) => downloadAudio(e, $currentTrack)} title="Download"></button>
{/if}
</div> </div>
<!-- Progress bar (pixel style) --> <!-- Progress bar (pixel style) -->
@ -126,6 +176,9 @@
<span class="queue-index">{index + 1}</span> <span class="queue-index">{index + 1}</span>
<span class="queue-title">{track.title}</span> <span class="queue-title">{track.title}</span>
<span class="queue-duration">{formatDuration(track.durationSeconds)}</span> <span class="queue-duration">{formatDuration(track.durationSeconds)}</span>
{#if $isAuthenticated}
<button class="queue-dl-btn" on:click|stopPropagation={(e) => downloadAudio(e, track)} title="Download"></button>
{/if}
<button class="remove-btn" on:click|stopPropagation={() => audioPlaylist.removeTrack(track.id)}>×</button> <button class="remove-btn" on:click|stopPropagation={() => audioPlaylist.removeTrack(track.id)}>×</button>
</div> </div>
{/each} {/each}
@ -154,6 +207,13 @@
padding: 0.75rem; padding: 0.75rem;
background: #0d1117; background: #0d1117;
border-bottom: 1px solid #21262d; border-bottom: 1px solid #21262d;
position: relative;
overflow: hidden;
}
.player-section > :not(:global(.waveform-container)) {
position: relative;
z-index: 1;
} }
.player-header { .player-header {
@ -238,6 +298,27 @@
filter: invert(1); 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) */ /* Progress bar (pixel style) */
.player-progress-wrap { .player-progress-wrap {
display: flex; display: flex;
@ -585,6 +666,29 @@
color: #f85149; 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 state */
.queue-empty { .queue-empty {
display: flex; display: flex;

View file

@ -2,6 +2,7 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { ebookReader } from '$lib/stores/ebookReader'; import { ebookReader } from '$lib/stores/ebookReader';
import { isAuthenticated } from '$lib/stores/auth';
/** @type {boolean} Whether the ebooks tab is currently active */ /** @type {boolean} Whether the ebooks tab is currently active */
export let isActive = false; 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) { function handleScroll(e) {
const target = e.target; const target = e.target;
const nearBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 100; const nearBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 100;
@ -152,11 +188,20 @@
<span class="ebook-time">{timeAgo(ebook.createdAt)}</span> <span class="ebook-time">{timeAgo(ebook.createdAt)}</span>
</span> </span>
</div> </div>
<button <div class="ebook-actions">
class="read-btn" <button
on:click={() => openBook(ebook)} class="read-btn"
title="Open in reader" on:click={() => openBook(ebook)}
>Read</button> title="Open in reader"
>Read</button>
{#if $isAuthenticated}
<button
class="dl-btn"
on:click={(e) => downloadEbook(e, ebook)}
title="Download"
>↓</button>
{/if}
</div>
</div> </div>
{/each} {/each}
{#if loadingMore} {#if loadingMore}
@ -375,4 +420,28 @@
.read-btn:active { .read-btn:active {
background: rgba(59, 130, 246, 0.35); 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;
}
</style> </style>

View file

@ -1,32 +1,59 @@
/** /**
* Holiday data for terminal calendar * 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 * 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 = [ export const fixedHolidays = [
// Indian National Holidays (Fixed) // Indian National Holidays (Fixed)
{ month: 0, day: 26, name: 'Republic Day', type: 'indian' }, { 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' }, { 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) // International Holidays (Fixed)
{ month: 0, day: 1, name: "New Year's Day", type: 'international' }, { month: 0, day: 1, name: "New Year's Day", type: 'international' },
{ month: 1, day: 14, name: "Valentine'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: 9, day: 31, name: 'Halloween', type: 'international' },
{ month: 11, day: 25, name: 'Christmas', 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' }, { 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 }> } * Format: { [year]: Array<{ month: 0-11, day: 1-31, name: string, type: string }> }
*/ */
export const variableHolidays = { export const variableHolidays = {
2024: [ 2024: [
// Indian
{ month: 2, day: 25, name: 'Holi', type: 'indian' }, { month: 2, day: 25, name: 'Holi', type: 'indian' },
{ month: 3, day: 10, name: 'Eid ul-Fitr', type: 'indian' }, { month: 3, day: 10, name: 'Eid ul-Fitr', type: 'indian' },
{ month: 5, day: 17, name: 'Eid ul-Adha', 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: 8, day: 7, name: 'Ganesh Chaturthi', type: 'indian' },
{ month: 9, day: 12, name: 'Dussehra', type: 'indian' }, { month: 9, day: 12, name: 'Dussehra', type: 'indian' },
{ month: 10, day: 1, name: 'Diwali', 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: [ 2025: [
// Indian
{ month: 2, day: 14, name: 'Holi', type: 'indian' }, { month: 2, day: 14, name: 'Holi', type: 'indian' },
{ month: 2, day: 31, name: 'Eid ul-Fitr', type: 'indian' }, { month: 2, day: 31, name: 'Eid ul-Fitr', type: 'indian' },
{ month: 5, day: 7, name: 'Eid ul-Adha', 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: 7, day: 27, name: 'Ganesh Chaturthi', type: 'indian' },
{ month: 9, day: 2, name: 'Dussehra', type: 'indian' }, { month: 9, day: 2, name: 'Dussehra', type: 'indian' },
{ month: 9, day: 20, name: 'Diwali', 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: [ 2026: [
// Indian
{ month: 2, day: 4, name: 'Holi', type: 'indian' }, { month: 2, day: 4, name: 'Holi', type: 'indian' },
{ month: 2, day: 20, name: 'Eid ul-Fitr', type: 'indian' }, { month: 2, day: 20, name: 'Eid ul-Fitr', type: 'indian' },
{ month: 4, day: 27, name: 'Eid ul-Adha', 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: 17, name: 'Ganesh Chaturthi', type: 'indian' },
{ month: 8, day: 20, name: 'Dussehra', type: 'indian' }, { month: 8, day: 20, name: 'Dussehra', type: 'indian' },
{ month: 10, day: 8, name: 'Diwali', 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: [ 2027: [
// Indian
{ month: 2, day: 22, name: 'Holi', type: 'indian' }, { month: 2, day: 22, name: 'Holi', type: 'indian' },
{ month: 2, day: 10, name: 'Eid ul-Fitr', type: 'indian' }, { month: 2, day: 10, name: 'Eid ul-Fitr', type: 'indian' },
{ month: 4, day: 17, name: 'Eid ul-Adha', 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: 8, day: 6, name: 'Ganesh Chaturthi', type: 'indian' },
{ month: 9, day: 9, name: 'Dussehra', type: 'indian' }, { month: 9, day: 9, name: 'Dussehra', type: 'indian' },
{ month: 9, day: 28, name: 'Diwali', 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' },
], ],
}; };

View file

@ -2,7 +2,9 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { siteSettings } from '$lib/stores/siteSettings'; 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 audioFiles = [];
let loading = true; let loading = true;
@ -55,6 +57,20 @@
return $audioPlaylist.queue.some(t => t.id === audioId); 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) { function togglePlaylist(audio, realmName) {
if (isInPlaylist(audio.id)) { if (isInPlaylist(audio.id)) {
audioPlaylist.removeTrack(audio.id); audioPlaylist.removeTrack(audio.id);
@ -65,6 +81,7 @@
username: audio.username, username: audio.username,
filePath: audio.filePath, filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '', thumbnailPath: audio.thumbnailPath || '',
waveformPath: audio.waveformPath || '',
durationSeconds: audio.durationSeconds, durationSeconds: audio.durationSeconds,
realmName: realmName realmName: realmName
}); });
@ -78,11 +95,47 @@
username: audio.username, username: audio.username,
filePath: audio.filePath, filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '', thumbnailPath: audio.thumbnailPath || '',
waveformPath: audio.waveformPath || '',
durationSeconds: audio.durationSeconds, durationSeconds: audio.durationSeconds,
realmName: realmName 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) { async function loadAudio(append = false) {
if (!browser) return; if (!browser) return;
@ -111,6 +164,7 @@
} }
onMount(() => { onMount(() => {
auth.init();
loadAudio(); loadAudio();
}); });
</script> </script>
@ -204,6 +258,13 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
transition: all 0.2s; transition: all 0.2s;
position: relative;
overflow: hidden;
}
.audio-item > :not(:global(.waveform-container)) {
position: relative;
z-index: 1;
} }
.audio-item:hover { .audio-item:hover {
@ -316,12 +377,23 @@
color: white; color: white;
} }
.action-btn.play.playing {
background: #ec4899;
color: white;
}
.action-btn.added { .action-btn.added {
background: rgba(126, 231, 135, 0.2); background: rgba(126, 231, 135, 0.2);
border-color: #7ee787; border-color: #7ee787;
color: #7ee787; color: #7ee787;
} }
.action-btn.download:hover {
background: rgba(59, 130, 246, 0.2);
border-color: #3b82f6;
color: #3b82f6;
}
.no-audio { .no-audio {
text-align: center; text-align: center;
padding: 4rem 0; padding: 4rem 0;
@ -387,6 +459,15 @@
<div class="audio-list"> <div class="audio-list">
{#each group.audio.slice(0, 5) as audio, index} {#each group.audio.slice(0, 5) as audio, index}
<div class="audio-item"> <div class="audio-item">
{#if audio.waveformPath}
<WaveformBackground
waveformPath={audio.waveformPath}
isPlaying={$audioPlaylist.isPlaying}
currentTime={$audioPlaylist.currentTime}
duration={$audioPlaylist.duration}
isCurrentTrack={$currentTrack?.id === audio.id}
/>
{/if}
<span class="audio-number">{index + 1}</span> <span class="audio-number">{index + 1}</span>
<div class="audio-thumbnail"> <div class="audio-thumbnail">
{#if audio.thumbnailPath} {#if audio.thumbnailPath}
@ -411,10 +492,11 @@
<div class="audio-actions"> <div class="audio-actions">
<button <button
class="action-btn play" class="action-btn play"
on:click={() => playNow(audio, group.realmName)} class:playing={isCurrentlyPlaying(audio.id)}
title="Play now" on:click={() => handlePlayClick(audio, group.realmName)}
title={isCurrentlyPlaying(audio.id) ? 'Pause' : 'Play now'}
> >
{isCurrentlyPlaying(audio.id) ? '▮▮' : '▶'}
</button> </button>
<button <button
class="action-btn" class="action-btn"
@ -424,6 +506,15 @@
> >
{isInPlaylist(audio.id) ? '✓' : '+'} {isInPlaylist(audio.id) ? '✓' : '+'}
</button> </button>
{#if $isAuthenticated}
<button
class="action-btn download"
on:click={(e) => downloadAudio(e, audio)}
title="Download"
>
</button>
{/if}
</div> </div>
</div> </div>
{/each} {/each}

View file

@ -2,7 +2,9 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { browser } from '$app/environment'; 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 realm = null;
let audioFiles = []; let audioFiles = [];
@ -41,6 +43,20 @@
return $audioPlaylist.queue.some(t => t.id === audioId); 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) { function togglePlaylist(audio) {
if (isInPlaylist(audio.id)) { if (isInPlaylist(audio.id)) {
audioPlaylist.removeTrack(audio.id); audioPlaylist.removeTrack(audio.id);
@ -51,6 +67,7 @@
username: audio.username || realm?.username, username: audio.username || realm?.username,
filePath: audio.filePath, filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '', thumbnailPath: audio.thumbnailPath || '',
waveformPath: audio.waveformPath || '',
durationSeconds: audio.durationSeconds, durationSeconds: audio.durationSeconds,
realmName: realm?.name realmName: realm?.name
}); });
@ -64,6 +81,7 @@
username: audio.username || realm?.username, username: audio.username || realm?.username,
filePath: audio.filePath, filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '', thumbnailPath: audio.thumbnailPath || '',
waveformPath: audio.waveformPath || '',
durationSeconds: audio.durationSeconds, durationSeconds: audio.durationSeconds,
realmName: realm?.name realmName: realm?.name
}); });
@ -76,6 +94,7 @@
username: audio.username || realm?.username, username: audio.username || realm?.username,
filePath: audio.filePath, filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '', thumbnailPath: audio.thumbnailPath || '',
waveformPath: audio.waveformPath || '',
durationSeconds: audio.durationSeconds, durationSeconds: audio.durationSeconds,
realmName: realm?.name 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() { async function loadRealmAudio() {
if (!browser || !realmName) return; if (!browser || !realmName) return;
@ -114,6 +168,7 @@
let prevRealmName = null; let prevRealmName = null;
onMount(() => { onMount(() => {
auth.init();
prevRealmName = realmName; prevRealmName = realmName;
loadRealmAudio(); loadRealmAudio();
}); });
@ -240,6 +295,13 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
transition: all 0.2s; transition: all 0.2s;
position: relative;
overflow: hidden;
}
.audio-item > :not(:global(.waveform-container)) {
position: relative;
z-index: 1;
} }
.audio-item:hover { .audio-item:hover {
@ -350,12 +412,23 @@
color: white; color: white;
} }
.action-btn.play.playing {
background: #ec4899;
color: white;
}
.action-btn.added { .action-btn.added {
background: rgba(126, 231, 135, 0.2); background: rgba(126, 231, 135, 0.2);
border-color: #7ee787; border-color: #7ee787;
color: #7ee787; color: #7ee787;
} }
.action-btn.download:hover {
background: rgba(59, 130, 246, 0.2);
border-color: #3b82f6;
color: #3b82f6;
}
.no-audio { .no-audio {
text-align: center; text-align: center;
padding: 4rem 0; padding: 4rem 0;
@ -444,6 +517,15 @@
<div class="audio-list"> <div class="audio-list">
{#each audioFiles as audio, index} {#each audioFiles as audio, index}
<div class="audio-item"> <div class="audio-item">
{#if audio.waveformPath}
<WaveformBackground
waveformPath={audio.waveformPath}
isPlaying={$audioPlaylist.isPlaying}
currentTime={$audioPlaylist.currentTime}
duration={$audioPlaylist.duration}
isCurrentTrack={$currentTrack?.id === audio.id}
/>
{/if}
<span class="audio-number">{index + 1}</span> <span class="audio-number">{index + 1}</span>
<div class="audio-thumbnail"> <div class="audio-thumbnail">
{#if audio.thumbnailPath} {#if audio.thumbnailPath}
@ -466,10 +548,11 @@
<div class="audio-actions"> <div class="audio-actions">
<button <button
class="action-btn play" class="action-btn play"
on:click={() => playNow(audio)} class:playing={isCurrentlyPlaying(audio.id)}
title="Play now" on:click={() => handlePlayClick(audio)}
title={isCurrentlyPlaying(audio.id) ? 'Pause' : 'Play now'}
> >
{isCurrentlyPlaying(audio.id) ? '▮▮' : '▶'}
</button> </button>
<button <button
class="action-btn" class="action-btn"
@ -479,6 +562,15 @@
> >
{$audioPlaylist.queue.some(t => t.id === audio.id) ? '✓' : '+'} {$audioPlaylist.queue.some(t => t.id === audio.id) ? '✓' : '+'}
</button> </button>
{#if $isAuthenticated}
<button
class="action-btn download"
on:click={(e) => downloadAudio(e, audio)}
title="Download"
>
</button>
{/if}
</div> </div>
</div> </div>
{/each} {/each}