Restored recommendations based on acoustic similarities (using musicnn), fixes #301

This commit is contained in:
emeric
2026-06-02 08:32:43 +02:00
parent 1524106124
commit eb7f65878f
227 changed files with 10324 additions and 4673 deletions
@@ -49,6 +49,7 @@
#include "steps/ScanStepCheckForRemovedFiles.hpp"
#include "steps/ScanStepCompact.hpp"
#include "steps/ScanStepComputeClusterStats.hpp"
#include "steps/ScanStepExtractMusicNNEmbeddings.hpp"
#include "steps/ScanStepOptimize.hpp"
#include "steps/ScanStepRemoveOrphanedDbEntries.hpp"
#include "steps/ScanStepScanFiles.hpp"
@@ -123,6 +124,10 @@ namespace lms::scanner
settings->allowArtistMBIDFallback = scanSettings->getAllowMBIDArtistMerge();
settings->artistImageFallbackToRelease = scanSettings->getArtistImageFallbackToReleaseField();
settings->extractMusicNNEmbeddings = scanSettings->getRecommendationEngineType() == db::ScanSettings::RecommendationEngineType::AudioSimilarity;
settings->musicnnModelPath = core::Service<core::IConfig>::get()->getPath("musicnn-model-path", "/usr/share/lms/models/MSD_musicnn_embedding.onnx");
settings->musicnnMaxPatchCountPerTrack = core::Service<core::IConfig>::get()->getULong("musicnn-max-patch-count-per-track", 20);
// TODO, store this in DB + expose in UI
settings->skipDuplicateTrackMBID = core::Service<core::IConfig>::get()->getBool("scanner-skip-duplicate-mbid", false);
@@ -164,6 +169,8 @@ namespace lms::scanner
, _jobScheduler{ core::createJobScheduler("Scanner", getScannerThreadCount()) }
, _cachePath{ cachePath }
{
LMS_LOG(DBUPDATER, INFO, "Starting service...");
_ioService.setThreadCount(1);
LMS_LOG(DBUPDATER, INFO, "Using " << _jobScheduler->getThreadCount() << " thread(s) for jobs");
@@ -186,6 +193,8 @@ namespace lms::scanner
refreshScanSettings();
start();
LMS_LOG(DBUPDATER, INFO, "Service started!");
}
ScannerService::~ScannerService()
@@ -389,7 +398,7 @@ namespace lms::scanner
}
refreshTracingLoggerStats();
LMS_LOG(DBUPDATER, INFO, "Scan " << (_abortScan ? "aborted" : "complete") << ". Changes = " << stats.getChangesCount() << " (added = " << stats.additions << ", removed = " << stats.deletions << ", updated = " << stats.updates << ", failures = " << stats.failures << "), Not changed = " << stats.skips << ", Scanned = " << stats.scans << " (errors = " << stats.errorsCount << "), features fetched = " << stats.featuresFetched << ", duplicates = " << stats.duplicates.size());
LMS_LOG(DBUPDATER, INFO, "Scan " << (_abortScan ? "aborted" : "complete") << ". Changes = " << stats.getChangesCount() << " (added = " << stats.additions << ", removed = " << stats.deletions << ", updated = " << stats.updates << ", failures = " << stats.failures << "), Not changed = " << stats.skips << ", Scanned = " << stats.scans << " (errors = " << stats.errorsCount << "), audio features extracted = " << stats.featureExtractions << ", duplicates = " << stats.duplicates.size());
{
auto transaction{ _db.getTLSSession().createReadTransaction() };
@@ -518,6 +527,10 @@ namespace lms::scanner
_scanSteps.emplace_back(std::make_unique<ScanStepOptimize>(params));
_scanSteps.emplace_back(std::make_unique<ScanStepComputeClusterStats>(params));
_scanSteps.emplace_back(std::make_unique<ScanStepCheckForDuplicatedFiles>(params));
// Audio extraction scan step must be last as it is the most long running
if (_settings.extractMusicNNEmbeddings)
_scanSteps.emplace_back(std::make_unique<ScanStepExtractMusicNNEmbeddings>(params, _settings.musicnnModelPath, _settings.musicnnMaxPatchCountPerTrack));
}
void ScannerService::notifyInProgress(const ScanStepStats& stepStats)
@@ -47,6 +47,9 @@ namespace lms::scanner
bool skipSingleReleasePlayLists{};
bool allowArtistMBIDFallback{ true };
bool artistImageFallbackToRelease{};
bool extractMusicNNEmbeddings{};
std::filesystem::path musicnnModelPath;
std::size_t musicnnMaxPatchCountPerTrack{};
std::vector<MediaLibraryInfo> mediaLibraries;
@@ -34,7 +34,6 @@ namespace lms::scanner
unsigned ScanStepStats::progress() const
{
const unsigned res{ static_cast<unsigned>((processedElems / static_cast<float>(totalElems ? totalElems : 1)) * 100) };
// can technically be above 100% since we may add files while iterating the filesystem
return res;
}
} // namespace lms::scanner
@@ -43,8 +43,8 @@
#include "database/objects/TrackArtistLink.hpp"
#include "database/objects/TrackEmbeddedImage.hpp"
#include "database/objects/TrackEmbeddedImageLink.hpp"
#include "database/objects/TrackFeatures.hpp"
#include "database/objects/TrackLyrics.hpp"
#include "database/objects/TrackMusicNNEmbeddings.hpp"
#include "image/Exception.hpp"
#include "image/Image.hpp"
@@ -554,7 +554,7 @@ namespace lms::scanner
info.type = image.type;
{
LMS_SCOPED_TRACE_DETAILED("Scanner", "ImageHash");
info.hash = core::xxHash3_64(image.data);
info.hash = core::XxHash3_64::hash(image.data);
}
info.size = image.data.size();
info.mimeType = image.mimeType;
@@ -581,6 +581,28 @@ namespace lms::scanner
}
}
// Returns true if any value actually changed.
bool updateAudioProperties(db::Track::pointer& track, const audio::AudioProperties& props)
{
const bool changed{ track->getDuration() != props.duration
|| track->getContainer() != props.container
|| track->getCodec() != props.codec
|| track->getBitrate() != props.bitrate
|| track->getChannelCount() != props.channelCount
|| track->getSampleRate() != props.sampleRate
|| track->getBitsPerSample() != props.bitsPerSample };
track.modify()->setDuration(props.duration);
track.modify()->setContainer(props.container);
track.modify()->setCodec(props.codec);
track.modify()->setBitrate(props.bitrate);
track.modify()->setChannelCount(props.channelCount);
track.modify()->setSampleRate(props.sampleRate);
track.modify()->setBitsPerSample(props.bitsPerSample);
return changed;
}
AudioFileScanOperation::OperationResult AudioFileScanOperation::processResult()
{
LMS_SCOPED_TRACE_DETAILED("Scanner", "ProcessAudioScanData");
@@ -700,13 +722,7 @@ namespace lms::scanner
track.modify()->setScanVersion(getScannerSettings().audioScanVersion);
// Audio properties
track.modify()->setDuration(_file->audioProperties.duration);
track.modify()->setContainer(_file->audioProperties.container);
track.modify()->setCodec(_file->audioProperties.codec);
track.modify()->setBitrate(_file->audioProperties.bitrate);
track.modify()->setChannelCount(_file->audioProperties.channelCount);
track.modify()->setSampleRate(_file->audioProperties.sampleRate);
track.modify()->setBitsPerSample(_file->audioProperties.bitsPerSample);
const bool audioPropertiesChanged{ updateAudioProperties(track, _file->audioProperties) };
track.modify()->setFileSize(getFileSize());
track.modify()->setLastWriteTime(getLastWriteTime());
@@ -772,8 +788,11 @@ namespace lms::scanner
track.modify()->setRecordingMBID(_file->track.recordingMBID);
track.modify()->setTrackMBID(_file->track.mbid);
if (auto trackFeatures{ db::TrackFeatures::find(dbSession, track->getId()) })
trackFeatures.remove(); // TODO: only if MBID changed?
if (audioPropertiesChanged)
{
if (auto musicnnEmbedding{ db::TrackMusicNNEmbeddings::find(dbSession, track->getId()) })
musicnnEmbedding.remove();
}
track.modify()->setCopyright(_file->track.copyright);
track.modify()->setCopyrightURL(_file->track.copyrightURL);
track.modify()->setAdvisory(getAdvisory(_file->track.advisory));
@@ -90,4 +90,9 @@ namespace lms::scanner
{
LMS_LOG(DBUPDATER, ERROR, "Failed to parse playlist " << error.path << ": all entries are missing");
}
void ScanErrorLogger::visit(const MusicNNEmbeddingsExtractError& error)
{
LMS_LOG(DBUPDATER, ERROR, "Failed to extract MusicNN embeddings from " << error.path << ": " << error.errorMsg);
}
} // namespace lms::scanner
@@ -23,21 +23,22 @@
namespace lms::scanner
{
class ScanErrorLogger : public scanner::ScanErrorVisitor
class ScanErrorLogger : public ScanErrorVisitor
{
private:
void visit(const scanner::ScanError&) override;
void visit(const scanner::IOScanError& error) override;
void visit(const scanner::AudioFileScanError& error) override;
void visit(const scanner::EmbeddedImageScanError& error) override;
void visit(const scanner::NoAudioTrackFoundError& error) override;
void visit(const scanner::BadAudioDurationError& error) override;
void visit(const scanner::ArtistInfoFileScanError& error) override;
void visit(const scanner::MissingArtistNameError& error) override;
void visit(const scanner::ImageFileScanError& error) override;
void visit(const scanner::LyricsFileScanError& error) override;
void visit(const scanner::PlayListFileScanError& error) override;
void visit(const scanner::PlayListFilePathMissingError& error) override;
void visit(const scanner::PlayListFileAllPathesMissingError& error) override;
void visit(const ScanError& error) override;
void visit(const IOScanError& error) override;
void visit(const AudioFileScanError& error) override;
void visit(const EmbeddedImageScanError& error) override;
void visit(const NoAudioTrackFoundError& error) override;
void visit(const BadAudioDurationError& error) override;
void visit(const ArtistInfoFileScanError& error) override;
void visit(const MissingArtistNameError& error) override;
void visit(const ImageFileScanError& error) override;
void visit(const LyricsFileScanError& error) override;
void visit(const PlayListFileScanError& error) override;
void visit(const PlayListFilePathMissingError& error) override;
void visit(const PlayListFileAllPathesMissingError& error) override;
void visit(const MusicNNEmbeddingsExtractError& error) override;
};
} // namespace lms::scanner
@@ -229,7 +229,7 @@ namespace lms::scanner
{
constexpr std::size_t writeBatchSize{ 50 };
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || !imageAssociations.empty())
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || (!forceFullBatch && !imageAssociations.empty()))
{
auto transaction{ session.createWriteTransaction() };
@@ -158,7 +158,7 @@ namespace lms::scanner
{
constexpr std::size_t writeBatchSize{ 50 };
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || !imageAssociations.empty())
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || (!forceFullBatch && !imageAssociations.empty()))
{
auto transaction{ session.createWriteTransaction() };
@@ -147,7 +147,7 @@ namespace lms::scanner
{
constexpr std::size_t writeBatchSize{ 5 };
while ((forceFullBatch && playListFileAssociations.size() >= writeBatchSize) || !playListFileAssociations.empty())
while ((forceFullBatch && playListFileAssociations.size() >= writeBatchSize) || (!forceFullBatch && !playListFileAssociations.empty()))
{
auto transaction{ session.createWriteTransaction() };
@@ -187,7 +187,7 @@ namespace lms::scanner
{
constexpr std::size_t writeBatchSize{ 50 };
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || !imageAssociations.empty())
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || (!forceFullBatch && !imageAssociations.empty()))
{
auto transaction{ session.createWriteTransaction() };
@@ -128,7 +128,7 @@ namespace lms::scanner
{
constexpr std::size_t writeBatchSize{ 50 };
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || !imageAssociations.empty())
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || (!forceFullBatch && !imageAssociations.empty()))
{
auto transaction{ session.createWriteTransaction() };
@@ -135,7 +135,7 @@ namespace lms::scanner
constexpr std::size_t writeBatchSize{ 50 };
std::vector<typename Object::IdType> ids;
while ((forceFullBatch && objectIdsToRemove.size() >= writeBatchSize) || !objectIdsToRemove.empty())
while ((forceFullBatch && objectIdsToRemove.size() >= writeBatchSize) || (!forceFullBatch && !objectIdsToRemove.empty()))
{
for (std::size_t i{}; !objectIdsToRemove.empty() && i < writeBatchSize; ++i)
{
@@ -18,7 +18,9 @@
*/
#include "ScanStepComputeClusterStats.hpp"
#include "core/ILogger.hpp"
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/Cluster.hpp"
@@ -0,0 +1,227 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "ScanStepExtractMusicNNEmbeddings.hpp"
#include <deque>
#include <optional>
#include "core/IJob.hpp"
#include "core/IJobScheduler.hpp"
#include "core/ILogger.hpp"
#include "audio/Exception.hpp"
#include "audio/IMusicNNEmbeddingExtractor.hpp"
#include "audio/MusicNNEmbeddings.hpp"
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/ScanSettings.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/TrackMusicNNEmbeddings.hpp"
#include "services/scanner/ScanErrors.hpp"
#include "JobQueue.hpp"
#include "ScanContext.hpp"
#include "ScannerSettings.hpp"
#include "TrackLocation.hpp"
namespace lms::scanner
{
namespace
{
struct TrackEmbeddingAssociation
{
db::TrackId trackId;
std::optional<audio::TrackMusicNNEmbeddings> embeddings;
};
using TrackEmbeddingAssociationContainer = std::deque<TrackEmbeddingAssociation>;
db::Track::FindParameters createFindTrackParams(db::TrackId lastRetrievedTrackId = {})
{
db::Track::FindParameters params;
params.setHasMusicNNEmbeddings(false);
params.setSortMethod(db::TrackSortMethod::Id);
params.setLastTrackId(lastRetrievedTrackId);
params.setRange(db::Range{ .offset = 0, .size = 1 });
return params;
}
bool fetchNextTrackWithoutEmbeddings(db::Session& session, db::TrackId& lastRetrievedTrackId, TrackLocation& trackLocation)
{
auto transaction{ session.createReadTransaction() };
const db::Track::FindParameters params{ createFindTrackParams(lastRetrievedTrackId) };
trackLocation.track = db::TrackId{};
trackLocation.trackPath.clear();
db::Track::findAbsoluteFilePath(session, params, [&](db::TrackId trackId, const std::filesystem::path& absoluteFilePath) {
trackLocation.track = trackId;
trackLocation.trackPath = absoluteFilePath;
});
lastRetrievedTrackId = trackLocation.track;
return trackLocation.track.isValid();
}
class ExtractMusicNNEmbeddingsJob : public core::IJob
{
public:
ExtractMusicNNEmbeddingsJob(const audio::IMusicNNEmbeddingExtractor& extractor, const TrackLocation& trackLocation)
: _extractor{ extractor }
, _trackLocation{ trackLocation }
{
}
~ExtractMusicNNEmbeddingsJob() override = default;
ExtractMusicNNEmbeddingsJob(const ExtractMusicNNEmbeddingsJob&) = delete;
ExtractMusicNNEmbeddingsJob& operator=(const ExtractMusicNNEmbeddingsJob&) = delete;
const TrackLocation& getTrackLocation() const { return _trackLocation; }
const audio::TrackMusicNNEmbeddings* getEmbeddings() const { return _embeddings ? &_embeddings.value() : nullptr; }
std::string_view getErrorMessage() const { return _errorMessage; }
private:
core::LiteralString getName() const override { return "Extract MusicNN Embeddings"; }
void run() override
{
try
{
LMS_LOG(DBUPDATER, DEBUG, "Extracting MusicNN embeddings for " << _trackLocation.trackPath);
const auto result{ _extractor.extract(_trackLocation.trackPath) };
if (result.patchCount > 0)
_embeddings.emplace(result.embeddings);
LMS_LOG(DBUPDATER, DEBUG, "MusicNN extraction complete for " << _trackLocation.trackPath << " (" << result.patchCount << " patches)");
}
catch (const audio::Exception& e)
{
_errorMessage = e.what();
}
}
const audio::IMusicNNEmbeddingExtractor& _extractor;
const TrackLocation _trackLocation;
std::optional<audio::TrackMusicNNEmbeddings> _embeddings;
std::string _errorMessage;
};
void writeEmbedding(db::Session& session, const TrackEmbeddingAssociation& assoc)
{
db::Track::pointer track{ db::Track::find(session, assoc.trackId) };
assert(track);
std::vector<std::byte> blob(sizeof(audio::TrackMusicNNEmbeddings));
audio::trackMusicNNEmbeddingsToBlob(*assoc.embeddings, blob);
db::TrackMusicNNEmbeddings::pointer entry{ session.create<db::TrackMusicNNEmbeddings>(track) };
entry.modify()->setData(blob);
}
void writeEmbeddings(ScanContext& context, db::Session& session, TrackEmbeddingAssociationContainer& pendingAssocs, bool forceFullBatch)
{
constexpr std::size_t writeBatchSize{ 10 };
while ((forceFullBatch && pendingAssocs.size() >= writeBatchSize) || (!forceFullBatch && !pendingAssocs.empty()))
{
auto transaction{ session.createWriteTransaction() };
for (std::size_t i{}; !pendingAssocs.empty() && i < writeBatchSize; ++i)
{
writeEmbedding(session, pendingAssocs.front());
pendingAssocs.pop_front();
context.stats.featureExtractions += 1;
}
}
}
} // namespace
ScanStepExtractMusicNNEmbeddings::ScanStepExtractMusicNNEmbeddings(InitParams& initParams, const std::filesystem::path& modelPath, std::size_t musicnnMaxPatchCountPerTrack)
: ScanStepBase{ initParams }
, _embeddingExtractor{ audio::createMusicNNEmbeddingExtractor(modelPath, musicnnMaxPatchCountPerTrack) }
{
}
ScanStepExtractMusicNNEmbeddings::~ScanStepExtractMusicNNEmbeddings() = default;
bool ScanStepExtractMusicNNEmbeddings::needProcess([[maybe_unused]] const ScanContext& context) const
{
return true;
}
void ScanStepExtractMusicNNEmbeddings::process(ScanContext& context)
{
db::Session& dbSession{ _db.getTLSSession() };
{
const std::string fileIdentifier{ audio::getMusicNNModelIdentifier(_settings.musicnnModelPath) };
if (fileIdentifier.empty())
{
LMS_LOG(DBUPDATER, WARNING, "Cannot identify MusicNN model file, skipping embedding extraction");
return;
}
const std::string identifier{ fileIdentifier + "|" + std::to_string(_settings.musicnnMaxPatchCountPerTrack) };
auto transaction{ dbSession.createWriteTransaction() };
db::ScanSettings::pointer settings{ db::ScanSettings::find(dbSession) };
assert(settings);
if (settings->getMusicNNModelIdentifier() != identifier)
{
LMS_LOG(DBUPDATER, INFO, "MusicNN model changed, clearing embeddings");
db::TrackMusicNNEmbeddings::removeAll(dbSession);
settings.modify()->setMusicNNModelIdentifier(identifier);
}
}
{
db::Track::FindParameters params{ createFindTrackParams() };
auto transaction{ dbSession.createReadTransaction() };
context.currentStepStats.totalElems = db::Track::getCount(dbSession, params);
}
TrackEmbeddingAssociationContainer pendingAssocs;
auto processResults{ [&](std::span<std::unique_ptr<core::IJob>> jobs) {
if (_abortScan)
return;
for (const auto& job : jobs)
{
const auto& extractJob{ static_cast<const ExtractMusicNNEmbeddingsJob&>(*job) };
if (const audio::TrackMusicNNEmbeddings * embeddings{ extractJob.getEmbeddings() })
pendingAssocs.push_back(TrackEmbeddingAssociation{ .trackId = extractJob.getTrackLocation().track, .embeddings = *embeddings });
else
addError<MusicNNEmbeddingsExtractError>(context, extractJob.getTrackLocation().trackPath, extractJob.getErrorMessage());
}
context.currentStepStats.processedElems += jobs.size();
writeEmbeddings(context, dbSession, pendingAssocs, true);
_progressCallback(context.currentStepStats);
} };
{
JobQueue queue{ getJobScheduler(), 50, processResults, 1, 0.85F };
db::TrackId lastRetrievedTrackId;
TrackLocation trackLocation;
while (!_abortScan && fetchNextTrackWithoutEmbeddings(dbSession, lastRetrievedTrackId, trackLocation))
queue.push(std::make_unique<ExtractMusicNNEmbeddingsJob>(*_embeddingExtractor, trackLocation));
}
writeEmbeddings(context, dbSession, pendingAssocs, false);
}
} // namespace lms::scanner
@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "ScanStepBase.hpp"
namespace lms::audio
{
class IMusicNNEmbeddingExtractor;
}
namespace lms::scanner
{
class ScanStepExtractMusicNNEmbeddings : public ScanStepBase
{
public:
ScanStepExtractMusicNNEmbeddings(InitParams& initParams, const std::filesystem::path& modelPath, std::size_t maxPatchCountPerTrack);
~ScanStepExtractMusicNNEmbeddings() override;
ScanStepExtractMusicNNEmbeddings(const ScanStepExtractMusicNNEmbeddings&) = delete;
ScanStepExtractMusicNNEmbeddings& operator=(const ScanStepExtractMusicNNEmbeddings&) = delete;
private:
ScanStep getStep() const override { return ScanStep::ExtractMusicNNEmbeddings; }
core::LiteralString getStepName() const override { return "Extract MusicNN embeddings"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override;
std::unique_ptr<audio::IMusicNNEmbeddingExtractor> _embeddingExtractor;
};
} // namespace lms::scanner
@@ -181,7 +181,7 @@ namespace lms::scanner
constexpr std::size_t filesPerScanJob{ 10 };
constexpr std::size_t scanQueueMaxSize{ 50 };
constexpr std::size_t processFileResultsBatchSize{ 1 };
constexpr float drainRatio{ 0.85 };
constexpr float drainRatio{ 0.85F };
std::deque<std::unique_ptr<IFileScanOperation>> operations;
@@ -0,0 +1,33 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <filesystem>
#include "database/objects/TrackId.hpp"
namespace lms::scanner
{
struct TrackLocation
{
db::TrackId track;
std::filesystem::path trackPath;
};
} // namespace lms::scanner