This commit is contained in:
parent
9876641ff6
commit
9e985d05f1
11 changed files with 1011 additions and 70 deletions
|
|
@ -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<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)
|
||||
int getAudioDuration(const std::string& audioPath) {
|
||||
if (!isPathSafe(audioPath, "/app/uploads")) {
|
||||
|
|
@ -87,6 +228,7 @@ namespace {
|
|||
audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||
audio["filePath"] = row["file_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["format"] = row["format"].isNull() ? "" : row["format"].as<std::string>();
|
||||
audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>();
|
||||
|
|
@ -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<void(const HttpResponsePtr &)> &&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<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 "
|
||||
"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<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,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
|
|
|
|||
|
|
@ -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<void(const HttpResponsePtr &)> &&callback,
|
||||
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
|
||||
void getMyAudio(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
|
|
|||
|
|
@ -103,15 +103,15 @@ namespace {
|
|||
}
|
||||
}
|
||||
|
||||
// Helper to create a new viewer token
|
||||
void createNewViewerToken(std::function<void(const HttpResponsePtr &)> 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<void(const HttpResponsePtr &)> 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<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()) {
|
||||
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,10 +460,10 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
|
|||
void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2212,44 +2212,50 @@ void UserController::getTreasury(const HttpRequestPtr &,
|
|||
double estimatedShare = totalUsers > 0 ? balance / static_cast<double>(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;
|
||||
|
|
|
|||
140
frontend/src/lib/components/WaveformBackground.svelte
Normal file
140
frontend/src/lib/components/WaveformBackground.svelte
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script>
|
||||
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 */
|
||||
export let isActive = false;
|
||||
|
|
@ -25,12 +27,57 @@
|
|||
const volume = parseFloat(e.target.value);
|
||||
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>
|
||||
|
||||
<div class="audio-tab">
|
||||
<!-- Player Section (terminal/pixel style) -->
|
||||
<div class="player-section">
|
||||
{#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">
|
||||
<span class="player-label">NOW PLAYING</span>
|
||||
<span class="player-status">{$audioPlaylist.isPlaying ? '▶' : '■'}</span>
|
||||
|
|
@ -48,6 +95,9 @@
|
|||
<span class="player-title">{$currentTrack.title}</span>
|
||||
<span class="player-artist">{$currentTrack.username}</span>
|
||||
</div>
|
||||
{#if $isAuthenticated}
|
||||
<button class="dl-btn" on:click={(e) => downloadAudio(e, $currentTrack)} title="Download">↓</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Progress bar (pixel style) -->
|
||||
|
|
@ -126,6 +176,9 @@
|
|||
<span class="queue-index">{index + 1}</span>
|
||||
<span class="queue-title">{track.title}</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>
|
||||
</div>
|
||||
{/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;
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<span class="ebook-time">{timeAgo(ebook.createdAt)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="read-btn"
|
||||
on:click={() => openBook(ebook)}
|
||||
title="Open in reader"
|
||||
>Read</button>
|
||||
<div class="ebook-actions">
|
||||
<button
|
||||
class="read-btn"
|
||||
on:click={() => openBook(ebook)}
|
||||
title="Open in reader"
|
||||
>Read</button>
|
||||
{#if $isAuthenticated}
|
||||
<button
|
||||
class="dl-btn"
|
||||
on:click={(e) => downloadEbook(e, ebook)}
|
||||
title="Download"
|
||||
>↓</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
</script>
|
||||
|
|
@ -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 @@
|
|||
<div class="audio-list">
|
||||
{#each group.audio.slice(0, 5) as audio, index}
|
||||
<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>
|
||||
<div class="audio-thumbnail">
|
||||
{#if audio.thumbnailPath}
|
||||
|
|
@ -411,10 +492,11 @@
|
|||
<div class="audio-actions">
|
||||
<button
|
||||
class="action-btn play"
|
||||
on:click={() => playNow(audio, group.realmName)}
|
||||
title="Play now"
|
||||
class:playing={isCurrentlyPlaying(audio.id)}
|
||||
on:click={() => handlePlayClick(audio, group.realmName)}
|
||||
title={isCurrentlyPlaying(audio.id) ? 'Pause' : 'Play now'}
|
||||
>
|
||||
▶
|
||||
{isCurrentlyPlaying(audio.id) ? '▮▮' : '▶'}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
|
|
@ -424,6 +506,15 @@
|
|||
>
|
||||
{isInPlaylist(audio.id) ? '✓' : '+'}
|
||||
</button>
|
||||
{#if $isAuthenticated}
|
||||
<button
|
||||
class="action-btn download"
|
||||
on:click={(e) => downloadAudio(e, audio)}
|
||||
title="Download"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<div class="audio-list">
|
||||
{#each audioFiles as audio, index}
|
||||
<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>
|
||||
<div class="audio-thumbnail">
|
||||
{#if audio.thumbnailPath}
|
||||
|
|
@ -466,10 +548,11 @@
|
|||
<div class="audio-actions">
|
||||
<button
|
||||
class="action-btn play"
|
||||
on:click={() => playNow(audio)}
|
||||
title="Play now"
|
||||
class:playing={isCurrentlyPlaying(audio.id)}
|
||||
on:click={() => handlePlayClick(audio)}
|
||||
title={isCurrentlyPlaying(audio.id) ? 'Pause' : 'Play now'}
|
||||
>
|
||||
▶
|
||||
{isCurrentlyPlaying(audio.id) ? '▮▮' : '▶'}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
|
|
@ -479,6 +562,15 @@
|
|||
>
|
||||
{$audioPlaylist.queue.some(t => t.id === audio.id) ? '✓' : '+'}
|
||||
</button>
|
||||
{#if $isAuthenticated}
|
||||
<button
|
||||
class="action-btn download"
|
||||
on:click={(e) => downloadAudio(e, audio)}
|
||||
title="Download"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue