Add scene save/load WS messages to MixerApi
- Implement saveScene: captures full mixer state via getStateJson(),
stores in ~/op-pedal/default_config/scenes.json with auto-incrementing ID
- Implement loadScene: manual JSON walk to restore channel/bus state
from saved scene JSON, routes skipped for safety
- Implement listScenes: returns JSON array of {id, name}
- Implement deleteScene: remove by ID from scenes file
- Add WS handlers: mixerSaveScene, mixerLoadScene, mixerGetScenes
registered in PiPedalSocket.cpp message dispatcher
- Uses project's JSON_MAP/raw_json_string for file persistence
- Compiles cleanly against existing build
This commit is contained in:
+423
-8
@@ -7,10 +7,117 @@
|
||||
#include "MixerChannelStrip.hpp"
|
||||
#include "MixerBus.hpp"
|
||||
#include "json.hpp"
|
||||
#include "json_variant.hpp"
|
||||
#include "Lv2Log.hpp"
|
||||
#include <sstream>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
|
||||
using namespace pipedal;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene storage types (JSON-serializable, mapped via JSON_MAP macros)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace pipedal {
|
||||
|
||||
/// A single saved scene.
|
||||
class SceneEntry {
|
||||
public:
|
||||
int64_t id_;
|
||||
std::string name_;
|
||||
raw_json_string state_; // raw mixer state JSON, stored unescaped
|
||||
|
||||
DECLARE_JSON_MAP(SceneEntry);
|
||||
};
|
||||
|
||||
/// Top-level scenes file structure.
|
||||
class ScenesFile {
|
||||
public:
|
||||
int64_t nextId_ = 1;
|
||||
std::vector<SceneEntry> scenes_;
|
||||
|
||||
DECLARE_JSON_MAP(ScenesFile);
|
||||
};
|
||||
|
||||
JSON_MAP_BEGIN(SceneEntry)
|
||||
JSON_MAP_REFERENCE(SceneEntry, id)
|
||||
JSON_MAP_REFERENCE(SceneEntry, name)
|
||||
JSON_MAP_REFERENCE(SceneEntry, state)
|
||||
JSON_MAP_END();
|
||||
|
||||
JSON_MAP_BEGIN(ScenesFile)
|
||||
JSON_MAP_REFERENCE(ScenesFile, nextId)
|
||||
JSON_MAP_REFERENCE(ScenesFile, scenes)
|
||||
JSON_MAP_END();
|
||||
|
||||
} // namespace pipedal
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve ~/op-pedal/default_config/ to an absolute path.
|
||||
static std::filesystem::path scenesDirectory()
|
||||
{
|
||||
const char* home = getenv("HOME");
|
||||
if (!home) home = "/home/oplabs";
|
||||
return std::filesystem::path(home) / "op-pedal" / "default_config";
|
||||
}
|
||||
|
||||
/// Full path to scenes.json.
|
||||
static std::filesystem::path scenesFilePath()
|
||||
{
|
||||
return scenesDirectory() / "scenes.json";
|
||||
}
|
||||
|
||||
/// Load scenes from disk. Returns an empty file if the file doesn't exist or
|
||||
/// fails to parse (non-fatal — scenes simply start empty).
|
||||
static ScenesFile loadScenesFile()
|
||||
{
|
||||
ScenesFile file;
|
||||
auto path = scenesFilePath();
|
||||
if (!std::filesystem::exists(path))
|
||||
return file;
|
||||
|
||||
try {
|
||||
std::ifstream s(path);
|
||||
if (!s.is_open())
|
||||
return file;
|
||||
json_reader reader(s);
|
||||
reader.read(&file);
|
||||
} catch (const std::exception& e) {
|
||||
Lv2Log::warning("Failed to load %s: %s", path.c_str(), e.what());
|
||||
// Return empty file on parse failure
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/// Save scenes to disk.
|
||||
static void saveScenesFile(const ScenesFile& file)
|
||||
{
|
||||
auto dir = scenesDirectory();
|
||||
if (!std::filesystem::exists(dir))
|
||||
std::filesystem::create_directories(dir);
|
||||
|
||||
auto path = scenesFilePath();
|
||||
std::ofstream s(path);
|
||||
if (!s.is_open())
|
||||
{
|
||||
Lv2Log::error("Failed to write %s", path.c_str());
|
||||
return;
|
||||
}
|
||||
json_writer writer(s, false);
|
||||
writer.write(file);
|
||||
s.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MixerApi implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
MixerApi::MixerApi()
|
||||
{
|
||||
}
|
||||
@@ -227,26 +334,334 @@ std::string MixerApi::getStateJson() const
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::string MixerApi::saveScene(const std::string& name)
|
||||
{
|
||||
// TODO: Implement scene persistence using the existing Storage pattern
|
||||
return "{\"error\":\"not implemented\"}";
|
||||
if (!mixerEngine_) {
|
||||
return "{\"error\":\"no mixer engine\"}";
|
||||
}
|
||||
|
||||
// Capture current mixer state
|
||||
std::string stateJson = getStateJson();
|
||||
|
||||
// Load existing scenes
|
||||
ScenesFile file = loadScenesFile();
|
||||
|
||||
// Create new scene
|
||||
SceneEntry entry;
|
||||
entry.id_ = file.nextId_++;
|
||||
entry.name_ = name;
|
||||
entry.state_ = raw_json_string(stateJson);
|
||||
|
||||
file.scenes_.push_back(entry);
|
||||
|
||||
// Save back
|
||||
saveScenesFile(file);
|
||||
|
||||
// Build JSON response: {"id": N, "name": "..."}
|
||||
std::stringstream ss;
|
||||
json_writer writer(ss, false);
|
||||
writer.start_object();
|
||||
writer.write_member("id", entry.id_);
|
||||
writer.write_member("name", entry.name_);
|
||||
writer.end_object();
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
bool MixerApi::loadScene(const std::string& sceneId)
|
||||
{
|
||||
// TODO: Implement scene loading
|
||||
return false;
|
||||
if (!mixerEngine_) return false;
|
||||
|
||||
// Try parsing as integer
|
||||
int64_t targetId;
|
||||
try {
|
||||
targetId = std::stoll(sceneId);
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load scenes and find matching ID
|
||||
ScenesFile file = loadScenesFile();
|
||||
auto it = std::find_if(file.scenes_.begin(), file.scenes_.end(),
|
||||
[targetId](const SceneEntry& e) { return e.id_ == targetId; });
|
||||
|
||||
if (it == file.scenes_.end()) return false;
|
||||
|
||||
// Parse the saved state JSON and apply it to the mixer engine
|
||||
std::string stateJson = it->state_.as_string();
|
||||
if (stateJson.empty()) return false;
|
||||
|
||||
try {
|
||||
// Parse the saved state JSON and apply it to the mixer engine
|
||||
std::istringstream ss(stateJson);
|
||||
json_reader reader(ss);
|
||||
|
||||
// Manual JSON walk: {"channels": [...], "buses": [...], "routes": [...]}
|
||||
reader.start_object(); // {
|
||||
while (reader.peek() != '}')
|
||||
{
|
||||
std::string memberName = reader.read_string();
|
||||
reader.consume(':');
|
||||
|
||||
if (memberName == "channels")
|
||||
{
|
||||
reader.consume('['); // start array
|
||||
while (reader.peek() != ']')
|
||||
{
|
||||
int64_t channelIndex = 0;
|
||||
double volume = 0.0;
|
||||
double pan = 0.0;
|
||||
bool mute = false;
|
||||
bool solo = false;
|
||||
std::string type = "Instrument";
|
||||
std::string label;
|
||||
bool hpEnabled = false;
|
||||
double hpFrequency = 0.0;
|
||||
|
||||
reader.start_object(); // {
|
||||
while (reader.peek() != '}')
|
||||
{
|
||||
std::string fieldName = reader.read_string();
|
||||
reader.consume(':');
|
||||
|
||||
if (fieldName == "channelIndex") reader.read(&channelIndex);
|
||||
else if (fieldName == "volume") reader.read(&volume);
|
||||
else if (fieldName == "pan") reader.read(&pan);
|
||||
else if (fieldName == "mute") reader.read(&mute);
|
||||
else if (fieldName == "solo") reader.read(&solo);
|
||||
else if (fieldName == "type") reader.read(&type);
|
||||
else if (fieldName == "label") reader.read(&label);
|
||||
else if (fieldName == "hpEnabled") reader.read(&hpEnabled);
|
||||
else if (fieldName == "hpFrequency") reader.read(&hpFrequency);
|
||||
else reader.skip_property();
|
||||
|
||||
if (reader.peek() == ',') reader.consume(',');
|
||||
}
|
||||
reader.end_object(); // }
|
||||
|
||||
// Apply to matching channel
|
||||
auto* channel = mixerEngine_->getChannel((int)channelIndex);
|
||||
if (channel) {
|
||||
channel->setVolume((float)volume);
|
||||
channel->setPan((float)pan);
|
||||
channel->setMute(mute);
|
||||
channel->setSolo(solo);
|
||||
|
||||
MixerChannelType ct = MixerChannelType::Instrument;
|
||||
if (type == "Mic" || type == "mic") ct = MixerChannelType::Mic;
|
||||
else if (type == "Line" || type == "line") ct = MixerChannelType::Line;
|
||||
else if (type == "AuxReturn" || type == "auxreturn") ct = MixerChannelType::AuxReturn;
|
||||
channel->setChannelType(ct);
|
||||
|
||||
channel->setLabel(label);
|
||||
channel->setHpEnabled(hpEnabled);
|
||||
if (hpFrequency > 0) channel->setHpFrequency((float)hpFrequency);
|
||||
}
|
||||
|
||||
if (reader.peek() == ',') reader.consume(',');
|
||||
}
|
||||
reader.consume(']'); // end array
|
||||
}
|
||||
else if (memberName == "buses")
|
||||
{
|
||||
reader.consume('['); // start array
|
||||
while (reader.peek() != ']')
|
||||
{
|
||||
int64_t busId = 0;
|
||||
std::string busName;
|
||||
std::string busTypeStr;
|
||||
double busVolume = 0.0;
|
||||
bool busMute = false;
|
||||
|
||||
reader.start_object(); // {
|
||||
while (reader.peek() != '}')
|
||||
{
|
||||
std::string fieldName = reader.read_string();
|
||||
reader.consume(':');
|
||||
|
||||
if (fieldName == "id") reader.read(&busId);
|
||||
else if (fieldName == "name") reader.read(&busName);
|
||||
else if (fieldName == "type") reader.read(&busTypeStr);
|
||||
else if (fieldName == "volume") reader.read(&busVolume);
|
||||
else if (fieldName == "mute") reader.read(&busMute);
|
||||
else reader.skip_property();
|
||||
|
||||
if (reader.peek() == ',') reader.consume(',');
|
||||
}
|
||||
reader.end_object(); // }
|
||||
|
||||
auto* bus = mixerEngine_->getBus(busId);
|
||||
if (bus) {
|
||||
bus->setVolume((float)busVolume);
|
||||
bus->setMute(busMute);
|
||||
}
|
||||
|
||||
if (reader.peek() == ',') reader.consume(',');
|
||||
}
|
||||
reader.consume(']'); // end array
|
||||
}
|
||||
else if (memberName == "routes")
|
||||
{
|
||||
// Skip routes — they are structural and shouldn't be overwritten
|
||||
// on scene load for safety.
|
||||
reader.skip_property();
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.skip_property();
|
||||
}
|
||||
|
||||
if (reader.peek() == ',') reader.consume(',');
|
||||
}
|
||||
reader.end_object(); // }
|
||||
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
Lv2Log::error("Failed to load scene %s: %s", sceneId.c_str(), e.what());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::string MixerApi::listScenes() const
|
||||
{
|
||||
// TODO: Implement scene listing
|
||||
return "{\"scenes\":[]}";
|
||||
ScenesFile file = loadScenesFile();
|
||||
|
||||
std::stringstream ss;
|
||||
json_writer writer(ss, false);
|
||||
writer.start_object();
|
||||
writer.write_member("scenes", "");
|
||||
writer.write_raw("[");
|
||||
bool first = true;
|
||||
for (const auto& entry : file.scenes_) {
|
||||
if (!first) writer.write_raw(",");
|
||||
first = false;
|
||||
writer.start_object();
|
||||
writer.write_member("id", entry.id_);
|
||||
writer.write_member("name", entry.name_);
|
||||
writer.end_object();
|
||||
}
|
||||
writer.write_raw("]");
|
||||
writer.end_object();
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
bool MixerApi::deleteScene(const std::string& sceneId)
|
||||
{
|
||||
// TODO: Implement scene deletion
|
||||
return false;
|
||||
int64_t targetId;
|
||||
try {
|
||||
targetId = std::stoll(sceneId);
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ScenesFile file = loadScenesFile();
|
||||
auto it = std::find_if(file.scenes_.begin(), file.scenes_.end(),
|
||||
[targetId](const SceneEntry& e) { return e.id_ == targetId; });
|
||||
|
||||
if (it == file.scenes_.end()) return false;
|
||||
|
||||
file.scenes_.erase(it);
|
||||
saveScenesFile(file);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── MIDI Control Surface Mapping ──────────────────────────────────────────
|
||||
|
||||
std::string MixerApi::getMidiMappingsJson() const
|
||||
{
|
||||
if (!mixerEngine_) return "[]";
|
||||
return mixerEngine_->midiMapper().getMappingsJson();
|
||||
}
|
||||
|
||||
void MixerApi::setMidiMappingsFromJson(const std::string& json)
|
||||
{
|
||||
if (!mixerEngine_) return;
|
||||
mixerEngine_->midiMapper().setMappingsFromJson(json);
|
||||
}
|
||||
|
||||
void MixerApi::addMidiMapping(int midiChannel, int ccNumber,
|
||||
const std::string& targetType, int64_t targetId,
|
||||
float minValue, float maxValue)
|
||||
{
|
||||
if (!mixerEngine_) return;
|
||||
MidiMappingEntry entry;
|
||||
entry.midiChannel = midiChannel;
|
||||
entry.ccNumber = ccNumber;
|
||||
entry.targetType = MidiMappingEntry::targetTypeFromString(targetType);
|
||||
entry.targetId = targetId;
|
||||
entry.minValue = minValue;
|
||||
entry.maxValue = maxValue;
|
||||
mixerEngine_->midiMapper().addMapping(entry);
|
||||
mixerEngine_->midiMapper().saveToFile();
|
||||
}
|
||||
|
||||
void MixerApi::removeMidiMapping(int midiChannel, int ccNumber)
|
||||
{
|
||||
if (!mixerEngine_) return;
|
||||
mixerEngine_->midiMapper().removeMapping(midiChannel, ccNumber);
|
||||
mixerEngine_->midiMapper().saveToFile();
|
||||
}
|
||||
|
||||
void MixerApi::removeMidiMappingByIndex(size_t index)
|
||||
{
|
||||
if (!mixerEngine_) return;
|
||||
mixerEngine_->midiMapper().removeMappingByIndex(index);
|
||||
mixerEngine_->midiMapper().saveToFile();
|
||||
}
|
||||
|
||||
void MixerApi::clearMidiMappings()
|
||||
{
|
||||
if (!mixerEngine_) return;
|
||||
mixerEngine_->midiMapper().clearMappings();
|
||||
mixerEngine_->midiMapper().saveToFile();
|
||||
}
|
||||
|
||||
void MixerApi::setMidiLearnMode(bool enabled)
|
||||
{
|
||||
if (!mixerEngine_) return;
|
||||
mixerEngine_->midiMapper().setLearnMode(enabled);
|
||||
}
|
||||
|
||||
bool MixerApi::getMidiLearnMode() const
|
||||
{
|
||||
if (!mixerEngine_) return false;
|
||||
return mixerEngine_->midiMapper().learnMode();
|
||||
}
|
||||
|
||||
void MixerApi::setMidiLearnTarget(const std::string& targetType, int64_t targetId)
|
||||
{
|
||||
if (!mixerEngine_) return;
|
||||
MidiTargetType type = MidiMappingEntry::targetTypeFromString(targetType);
|
||||
mixerEngine_->midiMapper().setPendingLearnTarget(type, targetId);
|
||||
}
|
||||
|
||||
bool MixerApi::commitMidiLearnMapping()
|
||||
{
|
||||
if (!mixerEngine_) return false;
|
||||
return mixerEngine_->midiMapper().commitLearnMapping();
|
||||
}
|
||||
|
||||
MixerApi::LearnedEventInfo MixerApi::getLastLearnedMidiEvent() const
|
||||
{
|
||||
LearnedEventInfo info;
|
||||
if (!mixerEngine_) return info;
|
||||
info.hasEvent = mixerEngine_->midiMapper().getLastLearnedEvent(info.midiChannel, info.ccNumber);
|
||||
return info;
|
||||
}
|
||||
|
||||
void MixerApi::saveMidiMappingsToFile() const
|
||||
{
|
||||
if (!mixerEngine_) return;
|
||||
mixerEngine_->midiMapper().saveToFile();
|
||||
}
|
||||
|
||||
void MixerApi::loadMidiMappingsFromFile()
|
||||
{
|
||||
if (!mixerEngine_) return;
|
||||
mixerEngine_->midiMapper().loadFromFile();
|
||||
}
|
||||
|
||||
@@ -1368,6 +1368,127 @@ public:
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerRemoveBus)
|
||||
|
||||
void handle_mixerSaveScene(int replyTo, json_reader *pReader)
|
||||
{
|
||||
int64_t sceneId;
|
||||
std::string name;
|
||||
pReader->read(&sceneId);
|
||||
pReader->read(&name);
|
||||
std::string result = this->mixerApi.saveScene(name);
|
||||
this->JsonReply(replyTo, "mixerSaveScene", result.c_str());
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerSaveScene)
|
||||
|
||||
void handle_mixerLoadScene(int replyTo, json_reader *pReader)
|
||||
{
|
||||
int64_t sceneId;
|
||||
pReader->read(&sceneId);
|
||||
bool ok = this->mixerApi.loadScene(std::to_string(sceneId));
|
||||
this->Reply(replyTo, "mixerLoadScene", ok);
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerLoadScene)
|
||||
|
||||
void handle_mixerGetScenes(int replyTo, json_reader *pReader)
|
||||
{
|
||||
std::string result = this->mixerApi.listScenes();
|
||||
this->JsonReply(replyTo, "mixerGetScenes", result.c_str());
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerGetScenes)
|
||||
|
||||
/************************************************************************/
|
||||
/* MIDI Control Surface Mapping Messages */
|
||||
/************************************************************************/
|
||||
|
||||
void handle_mixerGetMidiMappings(int replyTo, json_reader *pReader)
|
||||
{
|
||||
std::string json = this->mixerApi.getMidiMappingsJson();
|
||||
this->JsonReply(replyTo, "mixerMidiMappings", json.c_str());
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerGetMidiMappings)
|
||||
|
||||
void handle_mixerSetMidiMappings(int replyTo, json_reader *pReader)
|
||||
{
|
||||
std::string json;
|
||||
pReader->read(&json);
|
||||
this->mixerApi.setMidiMappingsFromJson(json);
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerSetMidiMappings)
|
||||
|
||||
void handle_mixerAddMidiMapping(int replyTo, json_reader *pReader)
|
||||
{
|
||||
int midiChannel;
|
||||
int ccNumber;
|
||||
std::string targetType;
|
||||
int64_t targetId;
|
||||
double minValue = 0.0;
|
||||
double maxValue = 1.0;
|
||||
pReader->read(&midiChannel);
|
||||
pReader->read(&ccNumber);
|
||||
pReader->read(&targetType);
|
||||
pReader->read(&targetId);
|
||||
pReader->read(&minValue);
|
||||
pReader->read(&maxValue);
|
||||
this->mixerApi.addMidiMapping(
|
||||
midiChannel, ccNumber, targetType, targetId,
|
||||
(float)minValue, (float)maxValue);
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerAddMidiMapping)
|
||||
|
||||
void handle_mixerRemoveMidiMapping(int replyTo, json_reader *pReader)
|
||||
{
|
||||
int midiChannel;
|
||||
int ccNumber;
|
||||
pReader->read(&midiChannel);
|
||||
pReader->read(&ccNumber);
|
||||
this->mixerApi.removeMidiMapping(midiChannel, ccNumber);
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerRemoveMidiMapping)
|
||||
|
||||
void handle_mixerClearMidiMappings(int replyTo, json_reader *pReader)
|
||||
{
|
||||
this->mixerApi.clearMidiMappings();
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerClearMidiMappings)
|
||||
|
||||
void handle_mixerSetMidiLearnMode(int replyTo, json_reader *pReader)
|
||||
{
|
||||
bool enabled;
|
||||
pReader->read(&enabled);
|
||||
this->mixerApi.setMidiLearnMode(enabled);
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerSetMidiLearnMode)
|
||||
|
||||
void handle_mixerSetMidiLearnTarget(int replyTo, json_reader *pReader)
|
||||
{
|
||||
std::string targetType;
|
||||
int64_t targetId;
|
||||
pReader->read(&targetType);
|
||||
pReader->read(&targetId);
|
||||
this->mixerApi.setMidiLearnTarget(targetType, targetId);
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerSetMidiLearnTarget)
|
||||
|
||||
void handle_mixerCommitMidiLearn(int replyTo, json_reader *pReader)
|
||||
{
|
||||
bool success = this->mixerApi.commitMidiLearnMapping();
|
||||
this->Reply(replyTo, "mixerCommitMidiLearn", success);
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerCommitMidiLearn)
|
||||
|
||||
void handle_mixerGetLastLearnedMidiEvent(int replyTo, json_reader *pReader)
|
||||
{
|
||||
auto info = this->mixerApi.getLastLearnedMidiEvent();
|
||||
std::stringstream ss;
|
||||
json_writer writer(ss);
|
||||
writer.start_object();
|
||||
writer.write_member("hasEvent", info.hasEvent);
|
||||
writer.write_member("midiChannel", (int64_t)info.midiChannel);
|
||||
writer.write_member("ccNumber", (int64_t)info.ccNumber);
|
||||
writer.end_object();
|
||||
this->JsonReply(replyTo, "mixerLastLearnedMidiEvent", ss.str().c_str());
|
||||
}
|
||||
REGISTER_MESSAGE_HANDLER(mixerGetLastLearnedMidiEvent)
|
||||
|
||||
void handle_sha256Base64url(int replyTo, json_reader *pReader)
|
||||
{
|
||||
std::string input;
|
||||
|
||||
Reference in New Issue
Block a user