Beware under-testing and bugs. Just getting something barebones in so that I can do rN+1 bringupcustom
parent
b6bc6b9e47
commit
7197da21f6
@ -0,0 +1,10 @@ |
||||
# Copyright 2023 jacqueline <me@jacqueline.id.au> |
||||
# |
||||
# SPDX-License-Identifier: GPL-3.0-only |
||||
|
||||
idf_component_register( |
||||
SRCS "source.cpp" "shuffler.cpp" |
||||
INCLUDE_DIRS "include" |
||||
REQUIRES "database" "util") |
||||
|
||||
target_compile_options(${COMPONENT_LIB} PRIVATE ${EXTRA_WARNINGS}) |
@ -0,0 +1,68 @@ |
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au> |
||||
* |
||||
* SPDX-License-Identifier: GPL-3.0-only |
||||
*/ |
||||
|
||||
#pragma once |
||||
|
||||
#include <deque> |
||||
#include <memory> |
||||
#include <mutex> |
||||
#include <variant> |
||||
#include <vector> |
||||
|
||||
#include "bloom_filter.hpp" |
||||
#include "database.hpp" |
||||
#include "future_fetcher.hpp" |
||||
#include "random.hpp" |
||||
#include "source.hpp" |
||||
#include "track.hpp" |
||||
|
||||
namespace playlist { |
||||
|
||||
/*
|
||||
* A source composes of other sources and/or specific extra tracks. Supports |
||||
* iteration over its contents in a random order. |
||||
*/ |
||||
class Shuffler : public ISource { |
||||
public: |
||||
static auto Create() -> Shuffler*; |
||||
|
||||
explicit Shuffler( |
||||
util::IRandom* random, |
||||
std::unique_ptr<util::BloomFilter<database::TrackId>> filter); |
||||
|
||||
auto Current() -> std::optional<database::TrackId> override; |
||||
auto Advance() -> std::optional<database::TrackId> override; |
||||
auto Peek(std::size_t, std::vector<database::TrackId>*) |
||||
-> std::size_t override; |
||||
|
||||
typedef std::variant<database::TrackId, std::shared_ptr<IResetableSource>> |
||||
Item; |
||||
auto Add(Item) -> void; |
||||
|
||||
/*
|
||||
* Returns the enqueued items, starting from the current item, in their |
||||
* original insertion order. |
||||
*/ |
||||
auto Unshuffle() -> std::vector<Item>; |
||||
|
||||
// Not copyable or movable.
|
||||
|
||||
Shuffler(const Shuffler&) = delete; |
||||
Shuffler& operator=(const Shuffler&) = delete; |
||||
|
||||
private: |
||||
auto RefillBuffer() -> void; |
||||
|
||||
util::IRandom* random_; |
||||
|
||||
std::unique_ptr<util::BloomFilter<database::TrackId>> already_played_; |
||||
bool out_of_items_; |
||||
|
||||
std::deque<Item> ordered_items_; |
||||
std::deque<database::TrackId> shuffled_items_buffer_; |
||||
}; |
||||
|
||||
} // namespace playlist
|
@ -0,0 +1,105 @@ |
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au> |
||||
* |
||||
* SPDX-License-Identifier: GPL-3.0-only |
||||
*/ |
||||
|
||||
#pragma once |
||||
|
||||
#include <deque> |
||||
#include <memory> |
||||
#include <mutex> |
||||
#include <variant> |
||||
#include <vector> |
||||
|
||||
#include "bloom_filter.hpp" |
||||
#include "database.hpp" |
||||
#include "future_fetcher.hpp" |
||||
#include "random.hpp" |
||||
#include "track.hpp" |
||||
|
||||
namespace playlist { |
||||
|
||||
/*
|
||||
* Stateful interface for iterating over a collection of tracks by id. |
||||
*/ |
||||
class ISource { |
||||
public: |
||||
virtual ~ISource() {} |
||||
|
||||
virtual auto Current() -> std::optional<database::TrackId> = 0; |
||||
|
||||
/*
|
||||
* Discards the current track id and continues to the next in this source. |
||||
* Returns the new current track id. |
||||
*/ |
||||
virtual auto Advance() -> std::optional<database::TrackId> = 0; |
||||
|
||||
/*
|
||||
* Repeatedly advances until a track with the given id is the current track. |
||||
* Returns false if this source ran out of tracks before the requested id |
||||
* was encounted, true otherwise. |
||||
*/ |
||||
virtual auto AdvanceTo(database::TrackId id) -> bool { |
||||
for (auto t = Current(); t.has_value(); t = Advance()) { |
||||
if (*t == id) { |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
/*
|
||||
* Places the next n tracks into the given vector, in order. Does not change |
||||
* the value returned by Current(). |
||||
*/ |
||||
virtual auto Peek(std::size_t n, std::vector<database::TrackId>*) |
||||
-> std::size_t = 0; |
||||
}; |
||||
|
||||
/*
|
||||
* A Source that supports restarting iteration from its original initial |
||||
* value. |
||||
*/ |
||||
class IResetableSource : public ISource { |
||||
public: |
||||
virtual ~IResetableSource() {} |
||||
|
||||
virtual auto Previous() -> std::optional<database::TrackId> = 0; |
||||
|
||||
/*
|
||||
* Restarts iteration from this source's initial value. |
||||
*/ |
||||
virtual auto Reset() -> void = 0; |
||||
}; |
||||
|
||||
class IndexRecordSource : public IResetableSource { |
||||
public: |
||||
IndexRecordSource(std::weak_ptr<database::Database> db, |
||||
std::shared_ptr<database::Result<database::IndexRecord>>); |
||||
|
||||
IndexRecordSource(std::weak_ptr<database::Database> db, |
||||
std::shared_ptr<database::Result<database::IndexRecord>>, |
||||
std::size_t, |
||||
std::shared_ptr<database::Result<database::IndexRecord>>, |
||||
std::size_t); |
||||
|
||||
auto Current() -> std::optional<database::TrackId> override; |
||||
auto Advance() -> std::optional<database::TrackId> override; |
||||
auto Peek(std::size_t n, std::vector<database::TrackId>*) |
||||
-> std::size_t override; |
||||
|
||||
auto Previous() -> std::optional<database::TrackId> override; |
||||
auto Reset() -> void override; |
||||
|
||||
private: |
||||
std::weak_ptr<database::Database> db_; |
||||
|
||||
std::shared_ptr<database::Result<database::IndexRecord>> initial_page_; |
||||
ssize_t initial_item_; |
||||
|
||||
std::shared_ptr<database::Result<database::IndexRecord>> current_page_; |
||||
ssize_t current_item_; |
||||
}; |
||||
|
||||
} // namespace playlist
|
@ -0,0 +1,166 @@ |
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au> |
||||
* |
||||
* SPDX-License-Identifier: GPL-3.0-only |
||||
*/ |
||||
|
||||
#include "shuffler.hpp" |
||||
|
||||
#include <algorithm> |
||||
#include <functional> |
||||
#include <memory> |
||||
#include <set> |
||||
#include <variant> |
||||
|
||||
#include "bloom_filter.hpp" |
||||
#include "database.hpp" |
||||
#include "komihash.h" |
||||
#include "random.hpp" |
||||
#include "track.hpp" |
||||
|
||||
static constexpr std::size_t kShufflerBufferSize = 32; |
||||
|
||||
namespace playlist { |
||||
|
||||
auto Shuffler::Create() -> Shuffler* { |
||||
return new Shuffler(util::sRandom, |
||||
std::make_unique<util::BloomFilter<database::TrackId>>( |
||||
[](database::TrackId id) { |
||||
return komihash(&id, sizeof(database::TrackId), 0); |
||||
})); |
||||
} |
||||
|
||||
Shuffler::Shuffler(util::IRandom* random, |
||||
std::unique_ptr<util::BloomFilter<database::TrackId>> filter) |
||||
: random_(random), already_played_(std::move(filter)) {} |
||||
|
||||
auto Shuffler::Current() -> std::optional<database::TrackId> { |
||||
if (shuffled_items_buffer_.empty()) { |
||||
return {}; |
||||
} |
||||
return shuffled_items_buffer_.front(); |
||||
} |
||||
|
||||
auto Shuffler::Advance() -> std::optional<database::TrackId> { |
||||
if (shuffled_items_buffer_.empty() && !out_of_items_) { |
||||
RefillBuffer(); |
||||
} |
||||
|
||||
auto res = Current(); |
||||
if (res) { |
||||
// Mark tracks off in the bloom filter only *after* they've been advanced
|
||||
// past. This gives us the most flexibility for reshuffling when adding new
|
||||
// items.
|
||||
already_played_->Insert(*res); |
||||
shuffled_items_buffer_.pop_front(); |
||||
} |
||||
return res; |
||||
} |
||||
|
||||
auto Shuffler::Peek(std::size_t num, std::vector<database::TrackId>* out) |
||||
-> std::size_t { |
||||
if (shuffled_items_buffer_.size() < num) { |
||||
RefillBuffer(); |
||||
} |
||||
for (int i = 0; i < num; i++) { |
||||
if (i >= shuffled_items_buffer_.size()) { |
||||
// We must be out of data, since the buffer didn't fill up.
|
||||
return i; |
||||
} |
||||
out->push_back(shuffled_items_buffer_.at(i)); |
||||
} |
||||
return num; |
||||
} |
||||
|
||||
auto Shuffler::Add(Item item) -> void { |
||||
ordered_items_.push_back(item); |
||||
out_of_items_ = false; |
||||
|
||||
// Empty out the buffer of already shuffled items, since we will need to
|
||||
// shuffle again in order to incorporate the newly added item(s). We keep the
|
||||
// current item however because we wouldn't want Add() to change the value of
|
||||
// Current() unless we're completely out of items.
|
||||
if (shuffled_items_buffer_.size() > 1) { |
||||
shuffled_items_buffer_.erase(shuffled_items_buffer_.begin() + 1, |
||||
shuffled_items_buffer_.end()); |
||||
} |
||||
RefillBuffer(); |
||||
} |
||||
|
||||
auto Shuffler::Unshuffle() -> std::vector<Item> { |
||||
std::vector<Item> ret; |
||||
database::TrackId current = shuffled_items_buffer_.front(); |
||||
bool has_found_current = false; |
||||
|
||||
for (const Item& item : ordered_items_) { |
||||
if (!has_found_current) { |
||||
// TODO(jacqueline): *Should* this include previous items? What is the
|
||||
// 'previous' button meant to do after unshuffling?
|
||||
if (std::holds_alternative<database::TrackId>(item)) { |
||||
has_found_current = current == std::get<database::TrackId>(item); |
||||
} else { |
||||
auto source = std::get<std::shared_ptr<IResetableSource>>(item); |
||||
source->Reset(); |
||||
has_found_current = |
||||
std::get<std::shared_ptr<IResetableSource>>(item)->AdvanceTo( |
||||
current); |
||||
} |
||||
} else { |
||||
ret.push_back(item); |
||||
} |
||||
} |
||||
|
||||
return ret; |
||||
} |
||||
|
||||
auto Shuffler::RefillBuffer() -> void { |
||||
// Don't waste time iterating if we know there's nothing new.
|
||||
if (out_of_items_) { |
||||
return; |
||||
} |
||||
|
||||
int num_to_sample = kShufflerBufferSize - shuffled_items_buffer_.size(); |
||||
int resovoir_offset = shuffled_items_buffer_.size(); |
||||
|
||||
std::set<database::TrackId> in_buffer; |
||||
for (const database::TrackId& id : shuffled_items_buffer_) { |
||||
in_buffer.insert(id); |
||||
} |
||||
|
||||
uint32_t i = 0; |
||||
auto consider_item = [&, this](const database::TrackId& item) { |
||||
if (already_played_->Contains(item) || in_buffer.contains(item)) { |
||||
return; |
||||
} |
||||
if (i < num_to_sample) { |
||||
shuffled_items_buffer_.push_back(item); |
||||
} else { |
||||
uint32_t index_to_replace = random_->RangeInclusive(0, i); |
||||
if (index_to_replace < num_to_sample) { |
||||
shuffled_items_buffer_[resovoir_offset + index_to_replace] = item; |
||||
} |
||||
} |
||||
i++; |
||||
}; |
||||
|
||||
for (const Item& item : ordered_items_) { |
||||
if (std::holds_alternative<database::TrackId>(item)) { |
||||
std::invoke(consider_item, std::get<database::TrackId>(item)); |
||||
} else { |
||||
auto source = std::get<std::shared_ptr<IResetableSource>>(item); |
||||
source->Reset(); |
||||
while (source->Advance()) { |
||||
std::invoke(consider_item, *source->Current()); |
||||
} |
||||
} |
||||
} |
||||
|
||||
out_of_items_ = i > num_to_sample; |
||||
// We've now got a random *selection*, but the order might be predictable
|
||||
// (e.g. if there were only `num_to_sample` new items). Do a final in-memory
|
||||
// shuffle.
|
||||
std::random_shuffle(shuffled_items_buffer_.begin() + resovoir_offset, |
||||
shuffled_items_buffer_.end()); |
||||
} |
||||
|
||||
} // namespace playlist
|
@ -0,0 +1,145 @@ |
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au> |
||||
* |
||||
* SPDX-License-Identifier: GPL-3.0-only |
||||
*/ |
||||
|
||||
#include "source.hpp" |
||||
|
||||
#include <algorithm> |
||||
#include <functional> |
||||
#include <memory> |
||||
#include <set> |
||||
#include <variant> |
||||
|
||||
#include "esp_log.h" |
||||
|
||||
#include "bloom_filter.hpp" |
||||
#include "database.hpp" |
||||
#include "komihash.h" |
||||
#include "random.hpp" |
||||
#include "track.hpp" |
||||
|
||||
namespace playlist { |
||||
|
||||
IndexRecordSource::IndexRecordSource( |
||||
std::weak_ptr<database::Database> db, |
||||
std::shared_ptr<database::Result<database::IndexRecord>> initial) |
||||
: db_(db), |
||||
initial_page_(initial), |
||||
initial_item_(0), |
||||
current_page_(initial_page_), |
||||
current_item_(initial_item_) {} |
||||
|
||||
IndexRecordSource::IndexRecordSource( |
||||
std::weak_ptr<database::Database> db, |
||||
std::shared_ptr<database::Result<database::IndexRecord>> initial, |
||||
std::size_t initial_index, |
||||
std::shared_ptr<database::Result<database::IndexRecord>> current, |
||||
std::size_t current_index) |
||||
: db_(db), |
||||
initial_page_(initial), |
||||
initial_item_(initial_index), |
||||
current_page_(current), |
||||
current_item_(current_index) {} |
||||
|
||||
auto IndexRecordSource::Current() -> std::optional<database::TrackId> { |
||||
if (current_page_->values().size() <= current_item_) { |
||||
return {}; |
||||
} |
||||
if (current_page_ == initial_page_ && current_item_ < initial_item_) { |
||||
return {}; |
||||
} |
||||
|
||||
return current_page_->values().at(current_item_).track(); |
||||
} |
||||
|
||||
auto IndexRecordSource::Advance() -> std::optional<database::TrackId> { |
||||
current_item_++; |
||||
if (current_item_ >= current_page_->values().size()) { |
||||
auto next_page = current_page_->next_page(); |
||||
if (!next_page) { |
||||
current_item_--; |
||||
return {}; |
||||
} |
||||
|
||||
auto db = db_.lock(); |
||||
if (!db) { |
||||
return {}; |
||||
} |
||||
|
||||
current_page_.reset(db->GetPage(&*next_page).get()); |
||||
current_item_ = 0; |
||||
} |
||||
|
||||
return Current(); |
||||
} |
||||
|
||||
auto IndexRecordSource::Previous() -> std::optional<database::TrackId> { |
||||
if (current_page_ == initial_page_ && current_item_ <= initial_item_) { |
||||
return {}; |
||||
} |
||||
|
||||
current_item_--; |
||||
if (current_item_ < 0) { |
||||
auto prev_page = current_page_->prev_page(); |
||||
if (!prev_page) { |
||||
return {}; |
||||
} |
||||
|
||||
auto db = db_.lock(); |
||||
if (!db) { |
||||
return {}; |
||||
} |
||||
|
||||
current_page_.reset(db->GetPage(&*prev_page).get()); |
||||
current_item_ = current_page_->values().size() - 1; |
||||
} |
||||
|
||||
return Current(); |
||||
} |
||||
|
||||
auto IndexRecordSource::Peek(std::size_t n, std::vector<database::TrackId>* out) |
||||
-> std::size_t { |
||||
if (current_page_->values().size() <= current_item_) { |
||||
return {}; |
||||
} |
||||
|
||||
auto db = db_.lock(); |
||||
if (!db) { |
||||
return 0; |
||||
} |
||||
|
||||
std::size_t items_added = 0; |
||||
|
||||
std::shared_ptr<database::Result<database::IndexRecord>> working_page = |
||||
current_page_; |
||||
std::size_t working_item = current_item_ + 1; |
||||
|
||||
while (n > 0) { |
||||
if (working_item >= working_page->values().size()) { |
||||
auto next_page = current_page_->next_page(); |
||||
if (!next_page) { |
||||
break; |
||||
} |
||||
// TODO(jacqueline): It would probably be a good idea to hold onto these
|
||||
// peeked pages, to avoid needing to look them up again later.
|
||||
working_page.reset(db->GetPage(&*next_page).get()); |
||||
working_item = 0; |
||||
} |
||||
|
||||
out->push_back(working_page->values().at(working_item).track().value()); |
||||
n--; |
||||
items_added++; |
||||
working_item++; |
||||
} |
||||
|
||||
return items_added; |
||||
} |
||||
|
||||
auto IndexRecordSource::Reset() -> void { |
||||
current_page_ = initial_page_; |
||||
current_item_ = initial_item_; |
||||
} |
||||
|
||||
} // namespace playlist
|
@ -0,0 +1,7 @@ |
||||
# Copyright 2023 jacqueline <me@jacqueline.id.au> |
||||
# |
||||
# SPDX-License-Identifier: GPL-3.0-only |
||||
|
||||
idf_component_register( |
||||
INCLUDE_DIRS "include" |
||||
REQUIRES "database") |
@ -0,0 +1,40 @@ |
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au> |
||||
* |
||||
* SPDX-License-Identifier: GPL-3.0-only |
||||
*/ |
||||
|
||||
#pragma once |
||||
|
||||
#include <bitset> |
||||
#include <cstdint> |
||||
#include <functional> |
||||
|
||||
namespace util { |
||||
|
||||
template <typename T> |
||||
class BloomFilter { |
||||
public: |
||||
explicit BloomFilter(std::function<uint64_t(T)> hasher) |
||||
: hasher_(hasher), bits_() {} |
||||
|
||||
auto Insert(T val) -> void { |
||||
uint64_t hash = std::invoke(hasher_, val); |
||||
bits_[hash & 0xFFFF] = 1; |
||||
bits_[(hash >> 16) & 0xFFFF] = 1; |
||||
bits_[(hash >> 32) & 0xFFFF] = 1; |
||||
bits_[(hash >> 48) & 0xFFFF] = 1; |
||||
} |
||||
|
||||
auto Contains(T val) -> bool { |
||||
uint64_t hash = std::invoke(hasher_, val); |
||||
return bits_[hash & 0xFFFF] && bits_[(hash >> 16) & 0xFFFF] && |
||||
bits_[(hash >> 32) & 0xFFFF] && bits_[(hash >> 48) & 0xFFFF]; |
||||
} |
||||
|
||||
private: |
||||
std::function<uint64_t(T)> hasher_; |
||||
std::bitset<(1 << 16)> bits_; |
||||
}; |
||||
|
||||
} // namespace util
|
@ -0,0 +1,69 @@ |
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au> |
||||
* |
||||
* SPDX-License-Identifier: GPL-3.0-only |
||||
*/ |
||||
|
||||
#pragma once |
||||
|
||||
#include <algorithm> |
||||
#include <bitset> |
||||
#include <cstdint> |
||||
#include <list> |
||||
#include <optional> |
||||
#include <unordered_map> |
||||
#include <utility> |
||||
|
||||
namespace util { |
||||
|
||||
/*
|
||||
* Basic least recently used cache. Stores the `Size` most recently accessed |
||||
* entries in memory. |
||||
* |
||||
* Not safe for use from multiple tasks, but all operations are constant time. |
||||
*/ |
||||
template <int Size, typename K, typename V> |
||||
class LruCache { |
||||
public: |
||||
LruCache() : entries_(), key_to_it_() {} |
||||
|
||||
auto Put(K key, V val) -> void { |
||||
if (key_to_it_.contains(key)) { |
||||
// This key was already present. Overwrite by removing the previous
|
||||
// value.
|
||||
entries_.erase(key_to_it_[key]); |
||||
key_to_it_.erase(key); |
||||
} else if (entries_.size() >= Size) { |
||||
// Cache is full. Evict the last entry.
|
||||
key_to_it_.erase(entries_.back().first); |
||||
entries_.pop_back(); |
||||
} |
||||
|
||||
// Add the new value.
|
||||
entries_.push_front({key, val}); |
||||
key_to_it_[key] = entries_.begin(); |
||||
} |
||||
|
||||
auto Get(K key) -> std::optional<V> { |
||||
if (!key_to_it_.contains(key)) { |
||||
return {}; |
||||
} |
||||
// Use splice() to move the entry to the front of the list. This approach
|
||||
// doesn't invalidate any of the iterators in key_to_it_, and is constant
|
||||
// time.
|
||||
auto it = key_to_it_[key]; |
||||
entries_.splice(entries_.begin(), entries_, it); |
||||
return it->second; |
||||
} |
||||
|
||||
auto Clear() -> void { |
||||
entries_.clear(); |
||||
key_to_it_.clear(); |
||||
} |
||||
|
||||
private: |
||||
std::list<std::pair<K, V>> entries_; |
||||
std::unordered_map<K, decltype(entries_.begin())> key_to_it_; |
||||
}; |
||||
|
||||
} // namespace util
|
@ -0,0 +1,39 @@ |
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au> |
||||
* |
||||
* SPDX-License-Identifier: GPL-3.0-only |
||||
*/ |
||||
|
||||
#pragma once |
||||
|
||||
#include <cstdint> |
||||
|
||||
#include "komihash.h" |
||||
|
||||
namespace util { |
||||
|
||||
class IRandom { |
||||
public: |
||||
virtual ~IRandom() {} |
||||
|
||||
virtual auto Next() -> std::uint64_t = 0; |
||||
virtual auto RangeInclusive(std::uint64_t lower, std::uint64_t upper) |
||||
-> std::uint64_t = 0; |
||||
}; |
||||
|
||||
extern IRandom* sRandom; |
||||
|
||||
class Random : public IRandom { |
||||
public: |
||||
Random(); |
||||
|
||||
auto Next() -> std::uint64_t override; |
||||
auto RangeInclusive(std::uint64_t lower, std::uint64_t upper) |
||||
-> std::uint64_t override; |
||||
|
||||
private: |
||||
std::uint64_t seed1_; |
||||
std::uint64_t seed2_; |
||||
}; |
||||
|
||||
} // namespace util
|
@ -0,0 +1,37 @@ |
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au> |
||||
* |
||||
* SPDX-License-Identifier: GPL-3.0-only |
||||
*/ |
||||
|
||||
#include "random.hpp" |
||||
|
||||
#include <cstdint> |
||||
|
||||
#include "esp_random.h" |
||||
#include "komihash.h" |
||||
|
||||
namespace util { |
||||
|
||||
IRandom* sRandom = new Random(); |
||||
|
||||
Random::Random() { |
||||
esp_fill_random(&seed1_, sizeof(seed1_)); |
||||
seed2_ = seed1_; |
||||
|
||||
// komirand needs four iterations to properly self-start.
|
||||
for (int i = 0; i < 4; i++) { |
||||
Next(); |
||||
} |
||||
} |
||||
|
||||
auto Random::Next() -> std::uint64_t { |
||||
return komirand(&seed1_, &seed2_); |
||||
} |
||||
|
||||
auto Random::RangeInclusive(std::uint64_t lower, std::uint64_t upper) |
||||
-> std::uint64_t { |
||||
return (Next() % (upper - lower + 1)) + lower; |
||||
} |
||||
|
||||
} // namespace util
|
Loading…
Reference in new issue