Further decoupled recommendation engine: it now has its own thread

This commit is contained in:
emeric
2020-02-16 12:39:27 +01:00
parent 6239064396
commit a414a80bbb
12 changed files with 280 additions and 184 deletions
+1
View File
@@ -16,6 +16,7 @@ target_include_directories(lmsrecommendation PRIVATE
target_link_libraries(lmsrecommendation PRIVATE
lmsdatabase
lmssom
wt
)
install(TARGETS lmsrecommendation DESTINATION lib)
+72 -31
View File
@@ -23,46 +23,51 @@
#include "recommendation/FeaturesClassifierCreator.hpp"
#include "database/ScanSettings.hpp"
#include "database/Session.hpp"
#include "database/TrackList.hpp"
#include "utils/Logger.hpp"
namespace Recommendation {
std::unique_ptr<IEngine>
createEngine(Database::Session& session)
createEngine(Database::Db& db)
{
return std::make_unique<Engine>(session);
return std::make_unique<Engine>(db);
}
Engine::Engine(Database::Session& session)
Engine::Engine(Database::Db& db)
: _dbSession {db}
{
reload(session);
}
void
Engine::reload(Database::Session& session)
Engine::start()
{
using namespace Database;
assert(!_running);
_running = true;
const ScanSettings::RecommendationEngineType engineType {[&]()
requestReload();
_ioService.start();
}
void
Engine::stop()
{
assert(_running);
_running = false;
_ioService.stop();
}
void
Engine::requestReload()
{
LMS_LOG(RECOMMENDATION, DEBUG) << "Reload requested...";
_ioService.post([&]()
{
auto transaction {session.createSharedTransaction()};
return ScanSettings::get(session)->getRecommendationEngineType();
}()};
// TODO: just replace the classifier once it is ready
clearClassifiers();
switch (engineType)
{
case ScanSettings::RecommendationEngineType::Features:
// addClassifier(createFeaturesClassifier(), 0); // higher priority
// [[fallthrough]];
case ScanSettings::RecommendationEngineType::Clusters:
addClassifier(createClustersClassifier(session), 1); // lower priority
break;
}
reload();
});
}
std::vector<Database::IdType>
@@ -85,7 +90,7 @@ Engine::getSimilarTracksFromTrackList(Database::Session& session, Database::IdTy
if (trackIds.empty())
return {};
std::shared_lock lock {_mutex};
std::shared_lock lock {_classifiersMutex};
for (const auto& [priority, classifier] : _classifiers)
{
@@ -99,7 +104,7 @@ Engine::getSimilarTracksFromTrackList(Database::Session& session, Database::IdTy
std::vector<Database::IdType>
Engine::getSimilarTracks(Database::Session& dbSession, const std::unordered_set<Database::IdType>& trackIds, std::size_t maxCount)
{
std::shared_lock lock {_mutex};
std::shared_lock lock {_classifiersMutex};
for (const auto& [priority, classifier] : _classifiers)
{
@@ -113,7 +118,7 @@ Engine::getSimilarTracks(Database::Session& dbSession, const std::unordered_set<
std::vector<Database::IdType>
Engine::getSimilarReleases(Database::Session& dbSession, Database::IdType releaseId, std::size_t maxCount)
{
std::shared_lock lock {_mutex};
std::shared_lock lock {_classifiersMutex};
for (const auto& [priority, classifier] : _classifiers)
{
@@ -127,7 +132,7 @@ Engine::getSimilarReleases(Database::Session& dbSession, Database::IdType releas
std::vector<Database::IdType>
Engine::getSimilarArtists(Database::Session& dbSession, Database::IdType artistId, std::size_t maxCount)
{
std::shared_lock lock {_mutex};
std::shared_lock lock {_classifiersMutex};
for (const auto& [priority, classifier] : _classifiers)
{
@@ -138,17 +143,53 @@ Engine::getSimilarArtists(Database::Session& dbSession, Database::IdType artistI
return {};
}
void
Engine::reload()
{
using namespace Database;
LMS_LOG(RECOMMENDATION, DEBUG) << "Reloading recommendation engines...";
const ScanSettings::RecommendationEngineType engineType {[&]()
{
auto transaction {_dbSession.createSharedTransaction()};
return ScanSettings::get(_dbSession)->getRecommendationEngineType();
}()};
// TODO: just replace the classifier once it is ready
clearClassifiers();
switch (engineType)
{
case ScanSettings::RecommendationEngineType::Features:
// addClassifier(createFeaturesClassifier(), 0); // higher priority
// [[fallthrough]];
case ScanSettings::RecommendationEngineType::Clusters:
addClassifier(createClustersClassifier(_dbSession), 1); // lower priority
break;
}
LMS_LOG(RECOMMENDATION, DEBUG) << "Recommendation engines reloaded!";
_sigReloaded.emit();
}
void
Engine::clearClassifiers()
{
std::unique_lock lock {_mutex};
std::unique_lock lock {_classifiersMutex};
_classifiers.clear();
}
void
Engine::addClassifier(std::unique_ptr<IClassifier> classifier, unsigned priority)
{
std::unique_lock lock {_mutex};
std::unique_lock lock {_classifiersMutex};
_classifiers.emplace(priority, std::move(classifier));
}
+17 -9
View File
@@ -22,35 +22,43 @@
#include <map>
#include <shared_mutex>
#include <Wt/WIOService.h>
#include "database/Session.hpp"
#include "recommendation/IEngine.hpp"
#include "recommendation/IClassifier.hpp"
namespace Database
{
class Session;
}
namespace Recommendation
{
class Engine : public IEngine
{
public:
Engine(Database::Session& session);
Engine(Database::Db& db);
private:
void reload(Database::Session& session) override;
void start() override;
void stop() override;
void requestReload() override;
Wt::Signal<>& reloaded() override { return _sigReloaded; }
// Closest results first
std::vector<Database::IdType> getSimilarTracksFromTrackList(Database::Session& session, Database::IdType tracklistId, std::size_t maxCount) override;
std::vector<Database::IdType> getSimilarTracks(Database::Session& session, const std::unordered_set<Database::IdType>& tracksId, std::size_t maxCount) override;
std::vector<Database::IdType> getSimilarReleases(Database::Session& session, Database::IdType releaseId, std::size_t maxCount) override;
std::vector<Database::IdType> getSimilarArtists(Database::Session& session, Database::IdType artistId, std::size_t maxCount) override;
void reload();
void clearClassifiers();
void addClassifier(std::unique_ptr<IClassifier> classifier, unsigned priority);
std::shared_mutex _mutex;
bool _running {};
Wt::WIOService _ioService;
Database::Session _dbSession;
Wt::Signal<> _sigReloaded;
std::shared_mutex _classifiersMutex;
std::map<unsigned, std::unique_ptr<IClassifier>> _classifiers;
};
@@ -19,13 +19,17 @@
#pragma once
#include <functional>
#include <vector>
#include <unordered_set>
#include <Wt/WSignal.h>
#include "database/Types.hpp"
namespace Database
{
class Db;
class Session;
}
@@ -36,7 +40,11 @@ namespace Recommendation
public:
virtual ~IEngine() = default;
virtual void reload(Database::Session& session) = 0;
virtual void start() = 0;
virtual void stop() = 0;
virtual void requestReload() = 0;
virtual Wt::Signal<>& reloaded() = 0;
// Closest results first
virtual std::vector<Database::IdType> getSimilarTracksFromTrackList(Database::Session& session, Database::IdType tracklistId, std::size_t maxCount) = 0;
@@ -45,7 +53,7 @@ namespace Recommendation
virtual std::vector<Database::IdType> getSimilarArtists(Database::Session& session, Database::IdType artistId, std::size_t maxCount) = 0;
};
std::unique_ptr<IEngine> createEngine(Database::Session& session);
std::unique_ptr<IEngine> createEngine(Database::Db& db);
} // ns Recommendation
+23 -32
View File
@@ -28,6 +28,7 @@
#include "database/Release.hpp"
#include "database/ScanSettings.hpp"
#include "database/Track.hpp"
#include "metadata/TagLibParser.hpp"
#include "utils/Exception.hpp"
#include "utils/Logger.hpp"
#include "utils/Path.hpp"
@@ -200,15 +201,18 @@ createMediaScanner(Database::Db& db)
MediaScanner::MediaScanner(Database::Db& db)
: _dbSession {db}
{
// For now, always use TagLib
_metadataParser = std::make_unique<MetaData::TagLibParser>();
_ioService.setThreadCount(1);
refreshScanSettings();
}
void
MediaScanner::setAddon(MediaScannerAddon& addon)
MediaScanner::~MediaScanner()
{
_addons.push_back(&addon);
if (_running)
stop();
}
void
@@ -233,9 +237,6 @@ MediaScanner::stop(void)
{
_running = false;
for (auto& addon : _addons)
addon->requestStop();
_scheduleTimer.cancel();
_ioService.stop();
@@ -281,7 +282,7 @@ MediaScanner::scheduleNextScan()
refreshScanSettings();
Wt::WDateTime now {Wt::WLocalDateTime::currentServerDateTime().toUTC()};
const Wt::WDateTime now {Wt::WLocalDateTime::currentServerDateTime().toUTC()};
Wt::WDate nextScanDate;
switch (_updatePeriod)
@@ -421,12 +422,6 @@ MediaScanner::scan(boost::system::error_code err)
LMS_LOG(DBUPDATER, INFO) << "Scan " << (_running ? "complete" : "aborted") << ". Changes = " << stats.nbChanges() << " (added = " << stats.additions << ", removed = " << stats.deletions << ", updated = " << stats.updates << "), Not changed = " << stats.skips << ", Scanned = " << stats.scans << " (errors = " << stats.errors.size() << "), duplicates = " << stats.duplicates.size();
if (_running)
{
for (auto& addon : _addons)
addon->preScanComplete();
}
LMS_LOG(DBUPDATER, INFO) << "Optimizing db...";
_dbSession.optimize();
LMS_LOG(DBUPDATER, INFO) << "Optimize db done!";
@@ -457,32 +452,28 @@ MediaScanner::scan(boost::system::error_code err)
void
MediaScanner::refreshScanSettings()
{
{
auto transaction {_dbSession.createSharedTransaction()};
auto transaction {_dbSession.createSharedTransaction()};
ScanSettings::pointer scanSettings {ScanSettings::get(_dbSession)};
ScanSettings::pointer scanSettings {ScanSettings::get(_dbSession)};
LMS_LOG(DBUPDATER, INFO) << "Using scan settings version " << scanSettings->getScanVersion();
LMS_LOG(DBUPDATER, INFO) << "Using scan settings version " << scanSettings->getScanVersion();
_scanVersion = scanSettings->getScanVersion();
_startTime = scanSettings->getUpdateStartTime();
_updatePeriod = scanSettings->getUpdatePeriod();
_scanVersion = scanSettings->getScanVersion();
_startTime = scanSettings->getUpdateStartTime();
_updatePeriod = scanSettings->getUpdatePeriod();
_fileExtensions = scanSettings->getAudioFileExtensions();
_mediaDirectory = scanSettings->getMediaDirectory();
_fileExtensions = scanSettings->getAudioFileExtensions();
_mediaDirectory = scanSettings->getMediaDirectory();
auto clusterTypes = scanSettings->getClusterTypes();
std::set<std::string> clusterTypeNames;
auto clusterTypes = scanSettings->getClusterTypes();
std::set<std::string> clusterTypeNames;
std::transform(std::cbegin(clusterTypes), std::cend(clusterTypes),
std::inserter(clusterTypeNames, clusterTypeNames.begin()),
[](ClusterType::pointer clusterType) { return clusterType->getName(); });
std::transform(std::cbegin(clusterTypes), std::cend(clusterTypes),
std::inserter(clusterTypeNames, clusterTypeNames.begin()),
[](ClusterType::pointer clusterType) { return clusterType->getName(); });
_metadataParser.setClusterTypeNames(clusterTypeNames);
}
_metadataParser->setClusterTypeNames(clusterTypeNames);
for (auto& addon : _addons)
addon->refreshSettings();
}
void
@@ -539,7 +530,7 @@ MediaScanner::scanAudioFile(const std::filesystem::path& file, bool forceScan, S
}
}
std::optional<MetaData::Track> trackInfo {_metadataParser.parse(file)};
std::optional<MetaData::Track> trackInfo {_metadataParser->parse(file)};
if (!trackInfo)
{
stats.errors.emplace_back(file, ScanErrorType::CannotParseFile);
+9 -5
View File
@@ -29,9 +29,10 @@
#include <boost/asio/system_timer.hpp>
#include "database/Types.hpp"
#include "database/ScanSettings.hpp"
#include "database/Session.hpp"
#include "metadata/TagLibParser.hpp"
#include "metadata/IParser.hpp"
#include "scanner/IMediaScanner.hpp"
@@ -41,8 +42,12 @@ class MediaScanner : public IMediaScanner
{
public:
MediaScanner(Database::Db& db);
~MediaScanner();
void setAddon(MediaScannerAddon& addon) override;
MediaScanner(const MediaScanner&) = delete;
MediaScanner(MediaScanner&&) = delete;
MediaScanner& operator=(const MediaScanner&) = delete;
MediaScanner& operator=(MediaScanner&&) = delete;
void start() override;
void stop() override;
@@ -80,7 +85,7 @@ class MediaScanner : public IMediaScanner
void notifyInProgressIfNeeded(const ScanStats& stats);
void notifyInProgress(const ScanStats& stats);
bool _running {false};
bool _running {};
Wt::WIOService _ioService;
boost::asio::system_timer _scheduleTimer {_ioService};
Wt::Signal<> _sigScanComplete;
@@ -88,8 +93,7 @@ class MediaScanner : public IMediaScanner
std::chrono::system_clock::time_point _lastScanInProgressEmit {};
Wt::Signal<Wt::WDateTime> _sigScheduled;
Database::Session _dbSession;
MetaData::TagLibParser _metadataParser;
std::vector<MediaScannerAddon*> _addons;
std::unique_ptr<MetaData::IParser> _metadataParser;
std::mutex _statusMutex;
State _curState {State::NotScheduled};
@@ -24,7 +24,6 @@
#include <Wt/WDateTime.h>
#include <Wt/WSignal.h>
#include "MediaScannerAddon.hpp"
#include "MediaScannerStats.hpp"
namespace Database
@@ -39,8 +38,6 @@ class IMediaScanner
public:
virtual ~IMediaScanner() = default;
virtual void setAddon(MediaScannerAddon& addon) = 0;
virtual void start() = 0;
virtual void stop() = 0;
virtual void restart() = 0;
+1 -1
View File
@@ -34,7 +34,7 @@ const char* getModuleName(Module mod)
case Module::METADATA: return "METADATA";
case Module::REMOTE: return "REMOTE";
case Module::SERVICE: return "SERVICE";
case Module::SIMILARITY: return "SIMILARITY";
case Module::RECOMMENDATION: return "RECOMMENDATION";
case Module::TRANSCODE: return "TRANSCODE";
case Module::UI: return "UI";
}
+1 -1
View File
@@ -46,7 +46,7 @@ enum class Module
METADATA,
REMOTE,
SERVICE,
SIMILARITY,
RECOMMENDATION,
TRANSCODE,
UI,
};
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2018 Emeric Poupon
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
@@ -19,23 +19,40 @@
#pragma once
#include "database/Types.hpp"
#include <mutex>
#include <condition_variable>
namespace Scanner {
class MediaScannerAddon
class Semaphore
{
public:
Semaphore() = default;
Semaphore(const Semaphore&) = delete;
Semaphore(Semaphore&&) = delete;
Semaphore& operator=(const Semaphore&) = delete;
Semaphore& operator=(Semaphore&&) = delete;
virtual void refreshSettings() = 0;
virtual void requestStop() = 0;
virtual void preScanComplete() = 0;
void notify()
{
std::unique_lock<std::mutex> lock {_mutex};
virtual void trackAdded(Database::IdType trackId) = 0;
virtual void trackToRemove(Database::IdType trackId) = 0;
virtual void trackUpdated(Database::IdType trackId) = 0;
_count++;
_cv.notify_one();
}
void wait()
{
std::unique_lock<std::mutex> lock(_mutex);
while (_count == 0)
_cv.wait(lock);
_count--;
}
private:
std::mutex _mutex;
std::condition_variable _cv;
unsigned _count {};
};
} // ns Scanner
+18 -17
View File
@@ -143,26 +143,21 @@ int main(int argc, char* argv[])
ServiceProvider<Auth::IPasswordService>::assign(Auth::createPasswordService(ServiceProvider<IConfig>::get()->getULong("login-throttler-max-entriees", 10000)));
Scanner::IMediaScanner& mediaScanner {ServiceProvider<Scanner::IMediaScanner>::assign(Scanner::createMediaScanner(database))};
Recommendation::IEngine& recommendationEngine {ServiceProvider<Recommendation::IEngine>::assign(Recommendation::createEngine(database))};
mediaScanner.scanComplete().connect([&]()
{
Database::Session session {database};
Recommendation::IEngine& recommendationEngine {ServiceProvider<Recommendation::IEngine>::assign(Recommendation::createEngine(session))};
auto status = mediaScanner.getStatus();
mediaScanner.scanComplete().connect([&]()
if (status.lastCompleteScanStats->nbChanges() > 0)
{
auto status = mediaScanner.getStatus();
if (status.lastCompleteScanStats->nbChanges() > 0)
{
LMS_LOG(MAIN, INFO) << "Scanner changed some files, reloading the recommendation engine...";
Database::Session session {database};
recommendationEngine.reload(session);
}
else
{
LMS_LOG(MAIN, INFO) << "Scanner did not change files, not reloading the recommendation engine...";
}
});
}
LMS_LOG(MAIN, INFO) << "Scanner changed some files, reloading the recommendation engine...";
recommendationEngine.requestReload();
}
else
{
LMS_LOG(MAIN, INFO) << "Scanner did not change files, not reloading the recommendation engine...";
}
});
CoverArt::IGrabber& coverArtGrabber {ServiceProvider<CoverArt::IGrabber>::assign(CoverArt::createGrabber(argv[0]))};
coverArtGrabber.setDefaultCover(server.appRoot() + "/images/unknown-cover.jpg");
@@ -179,6 +174,9 @@ int main(int argc, char* argv[])
std::placeholders::_1, std::ref(database), std::ref(appGroups)));
// Start
LMS_LOG(MAIN, INFO) << "Starting recommendation engine";
recommendationEngine.start();
LMS_LOG(MAIN, INFO) << "Starting media scanner...";
mediaScanner.start();
@@ -196,6 +194,9 @@ int main(int argc, char* argv[])
LMS_LOG(MAIN, INFO) << "Stopping media scanner...";
mediaScanner.stop();
LMS_LOG(MAIN, INFO) << "Stopping recommendation engine...";
recommendationEngine.stop();
LMS_LOG(MAIN, INFO) << "Clean stop!";
res = EXIT_SUCCESS;
}
+99 -71
View File
@@ -29,10 +29,97 @@
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "utils/IConfig.hpp"
#include "utils/Semaphore.hpp"
#include "utils/Service.hpp"
#include "utils/StreamLogger.hpp"
#include "recommendation/IEngine.hpp"
static
void
dumpRecommendation(Database::Session session, Recommendation::IEngine& engine)
{
const std::vector<Database::IdType> trackIds {[&]()
{
auto transaction {session.createSharedTransaction()};
return Database::Track::getAllIds(session);
}()};
std::cout << "*** Tracks (" << trackIds.size() << ") ***" << std::endl;
for (Database::IdType trackId : trackIds)
{
auto trackToString = [&](Database::IdType trackId)
{
std::string res;
auto transaction {session.createSharedTransaction()};
Database::Track::pointer track {Database::Track::getById(session, trackId)};
res += track->getName();
if (track->getRelease())
res += " [" + track->getRelease()->getName() + "]";
for (auto artist : track->getArtists())
res += " - " + artist->getName();
for (auto cluster : track->getClusters())
res += " {" + cluster->getType()->getName() + "-"+ cluster->getName() + "}";
return res;
};
std::cout << "Processing track '" << trackToString(trackId) << std::endl;
for (Database::IdType similarTrackId : engine.getSimilarTracks(session, {trackId}, 3))
std::cout << "\t- Similar track '" << trackToString(similarTrackId) << std::endl;
}
const std::vector<Database::IdType> releaseIds = std::invoke([&]()
{
auto transaction {session.createSharedTransaction()};
return Database::Release::getAllIds(session);
});
std::cout << "*** Releases ***" << std::endl;
for (Database::IdType releaseId : releaseIds)
{
auto releaseToString = [&](Database::IdType releaseId)
{
auto transaction {session.createSharedTransaction()};
Database::Release::pointer release {Database::Release::getById(session, releaseId)};
return release->getName();
};
std::cout << "Processing release '" << releaseToString(releaseId) << "'" << std::endl;
for (Database::IdType similarReleaseId : engine.getSimilarReleases(session, {releaseId}, 3))
std::cout << "\t- Similar release '" << releaseToString(similarReleaseId) << "'" << std::endl;
}
const std::vector<Database::IdType> artistIds = std::invoke([&]()
{
auto transaction {session.createSharedTransaction()};
return Database::Artist::getAllIds(session);
});
std::cout << "*** Artists ***" << std::endl;
for (Database::IdType artistId : artistIds)
{
auto artistToString = [&](Database::IdType artistId)
{
auto transaction {session.createSharedTransaction()};
Database::Artist::pointer artist {Database::Artist::getById(session, artistId)};
return artist->getName();
};
std::cout << "Processing artist '" << artistToString(artistId) << "'" << std::endl;
for (Database::IdType similarArtistId : engine.getSimilarArtists(session, {artistId}, 3))
std::cout << "\t- Similar artist '" << artistToString(similarArtistId) << "'" << std::endl;
}
}
int main(int argc, char *argv[])
{
try
@@ -50,84 +137,25 @@ int main(int argc, char *argv[])
Database::Session session {db};
std::cout << "Creating recommendation engine..." << std::endl;
const auto engine {Recommendation::createEngine(session)};
std::cout << "DONE!" << std::endl;
const auto engine {Recommendation::createEngine(db)};
std::cout << "Recommendation engine created!" << std::endl;
const std::vector<Database::IdType> trackIds {[&]()
{
auto transaction {session.createSharedTransaction()};
return Database::Track::getAllIds(session);
}()};
Semaphore sem;
std::cout << "*** Tracks (" << trackIds.size() << ") ***" << std::endl;
for (Database::IdType trackId : trackIds)
engine->reloaded().connect([&]()
{
auto trackToString = [&](Database::IdType trackId)
{
std::string res;
auto transaction {session.createSharedTransaction()};
Database::Track::pointer track {Database::Track::getById(session, trackId)};
sem.notify();
});
res += track->getName();
if (track->getRelease())
res += " [" + track->getRelease()->getName() + "]";
for (auto artist : track->getArtists())
res += " - " + artist->getName();
for (auto cluster : track->getClusters())
res += " {" + cluster->getType()->getName() + "-"+ cluster->getName() + "}";
engine->start();
return res;
};
std::cout << "Wating for the recommendation engine to be loaded..." << std::endl;
sem.wait();
std::cout << "Recommendation engine loaded!" << std::endl;
std::cout << "Processing track '" << trackToString(trackId) << std::endl;
for (Database::IdType similarTrackId : engine->getSimilarTracks(session, {trackId}, 3))
std::cout << "\t- Similar track '" << trackToString(similarTrackId) << std::endl;
}
const std::vector<Database::IdType> releaseIds = std::invoke([&]()
{
auto transaction {session.createSharedTransaction()};
return Database::Release::getAllIds(session);
});
std::cout << "*** Releases ***" << std::endl;
for (Database::IdType releaseId : releaseIds)
{
auto releaseToString = [&](Database::IdType releaseId)
{
auto transaction {session.createSharedTransaction()};
Database::Release::pointer release {Database::Release::getById(session, releaseId)};
return release->getName();
};
std::cout << "Processing release '" << releaseToString(releaseId) << "'" << std::endl;
for (Database::IdType similarReleaseId : engine->getSimilarReleases(session, {releaseId}, 3))
std::cout << "\t- Similar release '" << releaseToString(similarReleaseId) << "'" << std::endl;
}
const std::vector<Database::IdType> artistIds = std::invoke([&]()
{
auto transaction {session.createSharedTransaction()};
return Database::Artist::getAllIds(session);
});
std::cout << "*** Artists ***" << std::endl;
for (Database::IdType artistId : artistIds)
{
auto artistToString = [&](Database::IdType artistId)
{
auto transaction {session.createSharedTransaction()};
Database::Artist::pointer artist {Database::Artist::getById(session, artistId)};
return artist->getName();
};
std::cout << "Processing artist '" << artistToString(artistId) << "'" << std::endl;
for (Database::IdType similarArtistId : engine->getSimilarArtists(session, {artistId}, 3))
std::cout << "\t- Similar artist '" << artistToString(similarArtistId) << "'" << std::endl;
}
dumpRecommendation(db, *engine);
engine->stop();
}
catch( std::exception& e)
{