diff --git a/approot/messages.xml b/approot/messages.xml
index 1f6add56..a91e9681 100644
--- a/approot/messages.xml
+++ b/approot/messages.xml
@@ -83,6 +83,7 @@
Not scheduled
Scheduled on {1}
Scanning: step {1}/{2}
+Checking for duplicate files... {1} files
Checking files... {1}%
Discovering files: {1} files
Fetching track features from AcousticBrainz: {1}/{2} tracks ({3}%)...
diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml
index 95afa35c..eef64859 100644
--- a/approot/messages_fr.xml
+++ b/approot/messages_fr.xml
@@ -83,6 +83,7 @@
Non planifié
Planifié le {1}
En cours de scan : étape {1}/{2}
+Vérification des fichiers dupliqués... {1} fichiers
Vérification des fichiers... {1}%
Découverte des fichiers : {1} fichiers
Récupération des métadonnées AcousticBrainz : {1}/{2} fichiers ({3}%)...
diff --git a/approot/messages_it.xml b/approot/messages_it.xml
index 5c21c420..aa6a37b0 100644
--- a/approot/messages_it.xml
+++ b/approot/messages_it.xml
@@ -83,6 +83,7 @@
Non pianificato
Pianificato il {1}
Scansione: passo {1}/{2}
+
Controllo file... {1}%
File trovati: {1} files
Recupero metadati da AcousticBrainz: {1}/{2} tracce ({3}%)...
diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml
index e685897f..ea1c2b4c 100644
--- a/approot/messages_zh.xml
+++ b/approot/messages_zh.xml
@@ -83,6 +83,7 @@
无计划
计划于 {1}
扫描中: 阶段 {1}/{2}
+
检查文件中... {1}%
检索文件中: {1} 文件
从 AcousticBrainz 获取音轨特征: {1}/{2} 音轨 ({3}%)...
diff --git a/src/libs/services/scanner/CMakeLists.txt b/src/libs/services/scanner/CMakeLists.txt
index 53f65df3..e913996f 100644
--- a/src/libs/services/scanner/CMakeLists.txt
+++ b/src/libs/services/scanner/CMakeLists.txt
@@ -2,6 +2,10 @@
add_library(lmsscanner SHARED
impl/ScannerService.cpp
impl/ScannerStats.cpp
+ impl/ScanStepCheckDuplicatedDbFiles.cpp
+ impl/ScanStepDiscoverFiles.cpp
+ impl/ScanStepRemoveOrphanDbFiles.cpp
+ impl/ScanStepScanFiles.cpp
)
target_include_directories(lmsscanner INTERFACE
diff --git a/src/libs/services/scanner/impl/IScanStep.hpp b/src/libs/services/scanner/impl/IScanStep.hpp
new file mode 100644
index 00000000..2eeb7b98
--- /dev/null
+++ b/src/libs/services/scanner/impl/IScanStep.hpp
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2023 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 .
+ */
+
+#pragma once
+
+#include
+
+#include "services/scanner/ScannerStats.hpp"
+
+namespace Scanner
+{
+ class IScanStep
+ {
+ public:
+ virtual ~IScanStep() = default;
+
+ virtual ScanStep getStep() const = 0;
+ virtual std::string_view getStepName() const = 0;
+
+ struct ScanContext
+ {
+ const std::filesystem::path directory;
+ const bool forceScan;
+ ScanStats stats;
+ ScanStepStats currentStepStats;
+ };
+ virtual void process(ScanContext& context) = 0;
+ };
+}
diff --git a/src/libs/services/scanner/impl/ScanStepBase.hpp b/src/libs/services/scanner/impl/ScanStepBase.hpp
new file mode 100644
index 00000000..6e774e60
--- /dev/null
+++ b/src/libs/services/scanner/impl/ScanStepBase.hpp
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2023 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 .
+ */
+
+#pragma once
+
+#include
+
+#include "services/scanner/ScannerStats.hpp"
+#include "IScanStep.hpp"
+#include "ScannerSettings.hpp"
+
+namespace Database
+{
+ class Db;
+}
+
+namespace Scanner
+{
+ class ScanStepBase : public IScanStep
+ {
+ public:
+ static inline const std::filesystem::path excludeDirFileName {".lmsignore"};
+ using ProgressCallback = std::function;
+
+ struct InitParams
+ {
+ const ScannerSettings& settings;
+ ProgressCallback progressCallback;
+ bool& abortScan;
+ Database::Db& db;
+ };
+ ScanStepBase(InitParams& initParams)
+ : _settings {initParams.settings}
+ , _progressCallback {initParams.progressCallback}
+ , _abortScan {initParams.abortScan}
+ , _db {initParams.db}
+ {}
+
+ protected:
+ const ScannerSettings& _settings;
+ ProgressCallback _progressCallback;
+ bool& _abortScan;
+ Database::Db& _db;
+ };
+}
diff --git a/src/libs/services/scanner/impl/ScanStepCheckDuplicatedDbFiles.cpp b/src/libs/services/scanner/impl/ScanStepCheckDuplicatedDbFiles.cpp
new file mode 100644
index 00000000..8d1a0773
--- /dev/null
+++ b/src/libs/services/scanner/impl/ScanStepCheckDuplicatedDbFiles.cpp
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2023 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 .
+ */
+
+#include "ScanStepCheckDuplicatedDbFiles.hpp"
+
+#include "services/database/Db.hpp"
+#include "services/database/Session.hpp"
+#include "services/database/Track.hpp"
+#include "utils/Logger.hpp"
+
+namespace Scanner
+{
+ void
+ ScanStepCheckDuplicatedDbFiles::process(ScanContext& context)
+ {
+ using namespace Database;
+
+ if (_abortScan)
+ return;
+
+ Session& session {_db.getTLSSession()};
+ auto transaction {session.createSharedTransaction()};
+
+ const RangeResults tracks = Track::findTrackMBIDDuplicates(session, Range {});
+ for (const TrackId trackId : tracks.results)
+ {
+ const Track::pointer track {Track::find(session, trackId)};
+ if (auto trackMBID {track->getTrackMBID()})
+ {
+ LMS_LOG(DBUPDATER, INFO) << "Found duplicated track MBID [" << trackMBID->getAsString() << "], file: " << track->getPath().string() << " - " << track->getName();
+ context.stats.duplicates.emplace_back(ScanDuplicate {track->getId(), DuplicateReason::SameTrackMBID});
+ context.currentStepStats.processedElems++;
+ _progressCallback(context.currentStepStats);
+ }
+ }
+
+ LMS_LOG(DBUPDATER, DEBUG) << "Found " << context.currentStepStats.processedElems << " duplicated audio files";
+ }
+}
diff --git a/src/libs/services/scanner/impl/ScanStepCheckDuplicatedDbFiles.hpp b/src/libs/services/scanner/impl/ScanStepCheckDuplicatedDbFiles.hpp
new file mode 100644
index 00000000..655ed863
--- /dev/null
+++ b/src/libs/services/scanner/impl/ScanStepCheckDuplicatedDbFiles.hpp
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2023 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 .
+ */
+
+#pragma once
+
+#include "ScanStepBase.hpp"
+
+namespace Scanner
+{
+ class ScanStepCheckDuplicatedDbFiles : public ScanStepBase
+ {
+ public:
+ using ScanStepBase::ScanStepBase;
+
+ private:
+ std::string_view getStepName() const override { return "Checking for duplicated files"; }
+ ScanStep getStep() const override { return ScanStep::CheckingForDuplicateFiles; }
+ void process(ScanContext& context) override;
+ };
+}
diff --git a/src/libs/services/scanner/impl/ScanStepDiscoverFiles.cpp b/src/libs/services/scanner/impl/ScanStepDiscoverFiles.cpp
new file mode 100644
index 00000000..02f3dcfa
--- /dev/null
+++ b/src/libs/services/scanner/impl/ScanStepDiscoverFiles.cpp
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2023 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 .
+ */
+
+#include "ScanStepDiscoverFiles.hpp"
+#include "utils/Logger.hpp"
+#include "utils/Path.hpp"
+
+namespace Scanner
+{
+ void
+ ScanStepDiscoverFiles::process(ScanContext& context)
+ {
+ context.stats.filesScanned = 0;
+ PathUtils::exploreFilesRecursive(context.directory, [&](std::error_code ec, const std::filesystem::path& path)
+ {
+ if (_abortScan)
+ return false;
+
+ if (!ec && PathUtils::hasFileAnyExtension(path, _settings.supportedExtensions))
+ {
+ context.currentStepStats.processedElems++;
+ _progressCallback(context.currentStepStats);
+ }
+
+ return true;
+ }, &excludeDirFileName);
+
+ context.stats.filesScanned = context.currentStepStats.processedElems;
+
+ LMS_LOG(DBUPDATER, DEBUG) << "Discovered " << context.stats.filesScanned << " files in '" << context.directory << "'";
+ }
+}
diff --git a/src/libs/services/scanner/impl/ScanStepDiscoverFiles.hpp b/src/libs/services/scanner/impl/ScanStepDiscoverFiles.hpp
new file mode 100644
index 00000000..1e17ca34
--- /dev/null
+++ b/src/libs/services/scanner/impl/ScanStepDiscoverFiles.hpp
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2023 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 .
+ */
+
+#pragma once
+
+#include "ScanStepBase.hpp"
+
+namespace Scanner
+{
+ class ScanStepDiscoverFiles : public ScanStepBase
+ {
+ public:
+ using ScanStepBase::ScanStepBase;
+
+ private:
+ ScanStep getStep() const override { return ScanStep::DiscoveringFiles; }
+ std::string_view getStepName() const override { return "DiscoveringFiles"; }
+ void process(ScanContext& context) override;
+ };
+}
diff --git a/src/libs/services/scanner/impl/ScanStepRemoveOrphanDbFiles.cpp b/src/libs/services/scanner/impl/ScanStepRemoveOrphanDbFiles.cpp
new file mode 100644
index 00000000..7e9eebc1
--- /dev/null
+++ b/src/libs/services/scanner/impl/ScanStepRemoveOrphanDbFiles.cpp
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2023 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 .
+ */
+
+#include "ScanStepRemoveOrphanDbFiles.hpp"
+
+#include "services/database/Artist.hpp"
+#include "services/database/Cluster.hpp"
+#include "services/database/Db.hpp"
+#include "services/database/Release.hpp"
+#include "services/database/Session.hpp"
+#include "services/database/Track.hpp"
+#include "utils/Logger.hpp"
+#include "utils/Path.hpp"
+
+namespace Scanner
+{
+ void
+ ScanStepRemoveOrphanDbFiles::process(ScanContext& context)
+ {
+ removeOrphanTracks(context);
+ removeOrphanClusters();
+ removeOrphanArtists();
+ removeOrphanReleases();
+ }
+
+ void ScanStepRemoveOrphanDbFiles::removeOrphanTracks(ScanContext& context)
+ {
+ using namespace Database;
+
+ if (_abortScan)
+ return;
+
+ static constexpr std::size_t batchSize {50};
+ Session& session {_db.getTLSSession()};
+
+ LMS_LOG(DBUPDATER, DEBUG) << "Checking tracks to be removed...";
+ std::size_t trackCount {};
+
+ {
+ auto transaction {session.createSharedTransaction()};
+ trackCount = Track::getCount(session);
+ }
+ LMS_LOG(DBUPDATER, DEBUG) << trackCount << " tracks to be checked...";
+
+ context.currentStepStats.totalElems = trackCount;
+
+ RangeResults trackPaths;
+ std::vector tracksToRemove;
+
+ // TODO handle only files in context.directory
+ for (std::size_t i {trackCount < batchSize ? 0 : trackCount - batchSize}; ; i -= (i > batchSize ? batchSize : i))
+ {
+ tracksToRemove.clear();
+
+ {
+ auto transaction {session.createSharedTransaction()};
+ trackPaths = Track::findPaths(session, Range {i, batchSize});
+ }
+
+ for (const Track::PathResult& trackPath : trackPaths.results)
+ {
+ if (_abortScan)
+ return;
+
+ if (!checkFile(trackPath.path, _settings.mediaDirectory))
+ tracksToRemove.push_back(trackPath.trackId);
+
+ context.currentStepStats.processedElems++;
+ }
+
+ if (!tracksToRemove.empty())
+ {
+ auto transaction {session.createSharedTransaction()};
+
+ for (const TrackId trackId : tracksToRemove)
+ {
+ Track::pointer track {Track::find(session, trackId)};
+ if (track)
+ {
+ track.remove();
+ context.stats.deletions++;
+ }
+ }
+ }
+
+ _progressCallback(context.currentStepStats);
+
+ if (i == 0)
+ break;
+ }
+
+ LMS_LOG(DBUPDATER, DEBUG) << trackCount << " tracks checked!";
+ }
+
+ void
+ ScanStepRemoveOrphanDbFiles::removeOrphanClusters()
+ {
+ using namespace Database;
+
+ LMS_LOG(DBUPDATER, DEBUG) << "Checking orphan clusters...";
+ Session& session {_db.getTLSSession()};
+ auto transaction {session.createUniqueTransaction()};
+
+ // Now process orphan Cluster (no track)
+ auto clusterIds {Cluster::findOrphans(session, Range {})};
+ for (ClusterId clusterId : clusterIds.results)
+ {
+ Cluster::pointer cluster {Cluster::find(session, clusterId)};
+ LMS_LOG(DBUPDATER, DEBUG) << "Removing orphan cluster '" << cluster->getName() << "'";
+ cluster.remove();
+ }
+ }
+
+ void
+ ScanStepRemoveOrphanDbFiles::removeOrphanArtists()
+ {
+ using namespace Database;
+
+ LMS_LOG(DBUPDATER, DEBUG) << "Checking orphan artists...";
+
+ Session& session {_db.getTLSSession()};
+ auto transaction {session.createUniqueTransaction()};
+
+ auto artistIds {Artist::findAllOrphans(session, Range {})};
+ for (const ArtistId artistId : artistIds.results)
+ {
+ Artist::pointer artist {Artist::find(session, artistId)};
+ LMS_LOG(DBUPDATER, DEBUG) << "Removing orphan artist '" << artist->getName() << "'";
+ artist.remove();
+ }
+ }
+
+ void
+ ScanStepRemoveOrphanDbFiles::removeOrphanReleases()
+ {
+ using namespace Database;
+
+ LMS_LOG(DBUPDATER, DEBUG) << "Checking orphan releases...";
+
+ Session& session {_db.getTLSSession()};
+ auto transaction {session.createUniqueTransaction()};
+
+ auto releases {Release::findOrphans(session, Range {})};
+ for (const ReleaseId releaseId : releases.results)
+ {
+ Release::pointer release {Release::find(session, releaseId)};
+ LMS_LOG(DBUPDATER, DEBUG) << "Removing orphan release '" << release->getName() << "'";
+ release.remove();
+ }
+ }
+
+ bool
+ ScanStepRemoveOrphanDbFiles::checkFile(const std::filesystem::path& p, const std::filesystem::path& mediaDirectory)
+ {
+ try
+ {
+ // For each track, make sure the the file still exists
+ // and still belongs to a media directory
+ if (!std::filesystem::exists( p )
+ || !std::filesystem::is_regular_file( p ) )
+ {
+ LMS_LOG(DBUPDATER, INFO) << "Removing '" << p.string() << "': missing";
+ return false;
+ }
+
+ if (!PathUtils::isPathInRootPath(p, mediaDirectory, &excludeDirFileName))
+ {
+ LMS_LOG(DBUPDATER, INFO) << "Removing '" << p.string() << "': out of media directory";
+ return false;
+ }
+
+ if (!PathUtils::hasFileAnyExtension(p, _settings.supportedExtensions))
+ {
+ LMS_LOG(DBUPDATER, INFO) << "Removing '" << p.string() << "': file format no longer handled";
+ return false;
+ }
+
+ return true;
+ }
+ catch (std::filesystem::filesystem_error& e)
+ {
+ LMS_LOG(DBUPDATER, ERROR) << "Caught exception while checking file '" << p.string() << "': " << e.what();
+ return false;
+ }
+ }
+}
diff --git a/src/libs/services/scanner/impl/ScanStepRemoveOrphanDbFiles.hpp b/src/libs/services/scanner/impl/ScanStepRemoveOrphanDbFiles.hpp
new file mode 100644
index 00000000..460cdb84
--- /dev/null
+++ b/src/libs/services/scanner/impl/ScanStepRemoveOrphanDbFiles.hpp
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2023 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 .
+ */
+
+#pragma once
+
+#include
+
+#include "ScanStepBase.hpp"
+
+namespace Scanner
+{
+ class ScanStepRemoveOrphanDbFiles : public ScanStepBase
+ {
+ public:
+ using ScanStepBase::ScanStepBase;
+
+ private:
+ std::string_view getStepName() const override { return "Checking orphaned entries"; }
+ ScanStep getStep() const override { return ScanStep::ChekingForMissingFiles; }
+ void process(ScanContext& context) override;
+
+ void removeOrphanTracks(ScanContext& context);
+ void removeOrphanClusters();
+ void removeOrphanArtists();
+ void removeOrphanReleases();
+ bool checkFile(const std::filesystem::path& p, const std::filesystem::path& mediaDirectory);
+ };
+}
diff --git a/src/libs/services/scanner/impl/ScanStepScanFiles.cpp b/src/libs/services/scanner/impl/ScanStepScanFiles.cpp
new file mode 100644
index 00000000..82cca76e
--- /dev/null
+++ b/src/libs/services/scanner/impl/ScanStepScanFiles.cpp
@@ -0,0 +1,430 @@
+/*
+ * Copyright (C) 2023 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 .
+ */
+
+#include "ScanStepScanFiles.hpp"
+
+#include "metadata/IParser.hpp"
+#include "services/database/Artist.hpp"
+#include "services/database/Cluster.hpp"
+#include "services/database/Db.hpp"
+#include "services/database/Release.hpp"
+#include "services/database/Session.hpp"
+#include "services/database/Track.hpp"
+#include "services/database/TrackFeatures.hpp"
+#include "services/database/TrackArtistLink.hpp"
+#include "utils/Exception.hpp"
+#include "utils/IConfig.hpp"
+#include "utils/Logger.hpp"
+#include "utils/Path.hpp"
+
+using namespace Database;
+
+namespace
+{
+ Artist::pointer
+ createArtist(Session& session, const MetaData::Artist& artistInfo)
+ {
+ Artist::pointer artist {session.create(artistInfo.name)};
+
+ if (artistInfo.musicBrainzArtistID)
+ artist.modify()->setMBID(*artistInfo.musicBrainzArtistID);
+ if (artistInfo.sortName)
+ artist.modify()->setSortName(*artistInfo.sortName);
+
+ return artist;
+ }
+
+ void
+ updateArtistIfNeeded(Artist::pointer artist, const MetaData::Artist& artistInfo)
+ {
+ // Name may have been updated
+ if (artist->getName() != artistInfo.name)
+ {
+ artist.modify()->setName(artistInfo.name);
+ }
+
+ // Sortname may have been updated
+ if (artistInfo.sortName && *artistInfo.sortName != artist->getSortName() )
+ {
+ artist.modify()->setSortName(*artistInfo.sortName);
+ }
+ }
+
+ std::vector
+ getOrCreateArtists(Session& session, const std::vector& artistsInfo, bool allowFallbackOnMBIDEntries)
+ {
+ std::vector artists;
+
+ for (const MetaData::Artist& artistInfo : artistsInfo)
+ {
+ Artist::pointer artist;
+
+ // First try to get by MBID
+ if (artistInfo.musicBrainzArtistID)
+ {
+ artist = Artist::find(session, *artistInfo.musicBrainzArtistID);
+ if (!artist)
+ artist = createArtist(session, artistInfo);
+ else
+ updateArtistIfNeeded(artist, artistInfo);
+
+ artists.emplace_back(std::move(artist));
+ continue;
+ }
+
+ // Fall back on artist name (collisions may occur)
+ if (!artistInfo.name.empty())
+ {
+ for (const Artist::pointer& sameNamedArtist : Artist::find(session, artistInfo.name))
+ {
+ // Do not fallback on artist that is correctly tagged
+ if (!allowFallbackOnMBIDEntries && sameNamedArtist->getMBID())
+ continue;
+
+ artist = sameNamedArtist;
+ break;
+ }
+
+ // No Artist found with the same name and without MBID -> creating
+ if (!artist)
+ artist = createArtist(session, artistInfo);
+ else
+ updateArtistIfNeeded(artist, artistInfo);
+
+ artists.emplace_back(std::move(artist));
+ continue;
+ }
+ }
+
+ return artists;
+ }
+
+ Release::pointer
+ getOrCreateRelease(Session& session, const MetaData::Album& album)
+ {
+ Release::pointer release;
+
+ // First try to get by MBID
+ if (album.musicBrainzAlbumID)
+ {
+ release = Release::find(session, *album.musicBrainzAlbumID);
+ if (!release)
+ {
+ release = session.create(album.name, album.musicBrainzAlbumID);
+ }
+ else if (release->getName() != album.name)
+ {
+ // Name may have been updated
+ release.modify()->setName(album.name);
+ }
+
+ return release;
+ }
+
+ // Fall back on release name (collisions may occur)
+ if (!album.name.empty())
+ {
+ for (const Release::pointer& sameNamedRelease : Release::find(session, album.name))
+ {
+ // do not fallback on properly tagged releases
+ if (!sameNamedRelease->getMBID())
+ {
+ release = sameNamedRelease;
+ break;
+ }
+ }
+
+ // No release found with the same name and without MBID -> creating
+ if (!release)
+ release = session.create(album.name);
+
+ return release;
+ }
+
+ return Release::pointer{};
+ }
+
+ std::vector
+ getOrCreateClusters(Session& session, const MetaData::Clusters& clustersNames)
+ {
+ std::vector< Cluster::pointer > clusters;
+
+ for (auto clusterNames : clustersNames)
+ {
+ auto clusterType = ClusterType::find(session, clusterNames.first);
+ if (!clusterType)
+ continue;
+
+ for (auto clusterName : clusterNames.second)
+ {
+ auto cluster = clusterType->getCluster(clusterName);
+ if (!cluster)
+ cluster = session.create(clusterType, clusterName);
+
+ clusters.push_back(cluster);
+ }
+ }
+
+ return clusters;
+ }
+
+ MetaData::ParserReadStyle
+ getParserReadStyle()
+ {
+ std::string_view readStyle {Service::get()->getString("scanner-parser-read-style", "accurate")};
+
+ if (readStyle == "fast")
+ return MetaData::ParserReadStyle::Fast;
+ else if (readStyle == "average")
+ return MetaData::ParserReadStyle::Average;
+ else if (readStyle == "accurate")
+ return MetaData::ParserReadStyle::Accurate;
+
+ throw LmsException {"Invalid value for 'scanner-parser-read-style'"};
+ }
+} // namespace
+
+namespace Scanner
+{
+ ScanStepScanFiles::ScanStepScanFiles(InitParams& initParams)
+ : ScanStepBase {initParams}
+ , _metadataParser {MetaData::createParser(MetaData::ParserType::TagLib, getParserReadStyle())} // For now, always use TagLib
+ {
+ }
+
+ void
+ ScanStepScanFiles::process(ScanContext& context)
+ {
+ _metadataParser->setClusterTypeNames(_settings.clusterTypeNames);
+
+ context.currentStepStats.totalElems = context.stats.filesScanned;
+
+ PathUtils::exploreFilesRecursive(context.directory, [&](std::error_code ec, const std::filesystem::path& path)
+ {
+ if (_abortScan)
+ return false;
+
+ if (ec)
+ {
+ LMS_LOG(DBUPDATER, ERROR) << "Cannot process entry '" << path.string() << "': " << ec.message();
+ context.stats.errors.emplace_back(ScanError {path, ScanErrorType::CannotReadFile, ec.message()});
+ }
+ else if (PathUtils::hasFileAnyExtension(path, _settings.supportedExtensions))
+ {
+ scanAudioFile(path, context);
+
+ context.currentStepStats.processedElems++;
+ _progressCallback(context.currentStepStats);
+ }
+
+ return true;
+ }, &excludeDirFileName);
+ }
+
+ void
+ ScanStepScanFiles::scanAudioFile(const std::filesystem::path& file, ScanContext& context)
+ {
+ ScanStats& stats {context.stats};
+ Wt::WDateTime lastWriteTime;
+ try
+ {
+ lastWriteTime = PathUtils::getLastWriteTime(file);
+ }
+ catch (LmsException& e)
+ {
+ LMS_LOG(DBUPDATER, ERROR) << e.what();
+ stats.skips++;
+ return;
+ }
+
+ if (!context.forceScan)
+ {
+ // Skip file if last write is the same
+ Database::Session& dbSession {_db.getTLSSession()};
+ auto transaction {_db.getTLSSession().createSharedTransaction()};
+
+ const Track::pointer track {Track::findByPath(dbSession, file)};
+
+ if (track && track->getLastWriteTime().toTime_t() == lastWriteTime.toTime_t()
+ && track->getScanVersion() == _settings.scanVersion)
+ {
+ stats.skips++;
+ return;
+ }
+ }
+
+ std::optional trackInfo {_metadataParser->parse(file)};
+ if (!trackInfo)
+ {
+ context.stats.errors.emplace_back(file, ScanErrorType::CannotParseFile);
+ return;
+ }
+
+ stats.scans++;
+
+ Database::Session& dbSession {_db.getTLSSession()};
+ auto uniqueTransaction {dbSession.createUniqueTransaction()};
+
+ Track::pointer track {Track::findByPath(dbSession, file) };
+
+ // Skip duplicate recording MBID
+ if (trackInfo->recordingMBID && _settings.skipDuplicateRecordingMBID)
+ {
+ for (Track::pointer otherTrack : Track::findByRecordingMBID(dbSession, *trackInfo->recordingMBID))
+ {
+ if (track && track->getId() == otherTrack->getId())
+ continue;
+
+ LMS_LOG(DBUPDATER, DEBUG) << "Skipped '" << file.string() << "' (similar recording MBID in '" << otherTrack->getPath().string() << "')";
+ // This recording MBID already exists, just remove what we just scanned
+ if (track)
+ {
+ track.remove();
+ stats.deletions++;
+ }
+ return;
+ }
+ }
+
+ // We estimate this is an audio file if:
+ // - we found a least one audio stream
+ // - the duration is not null
+ if (trackInfo->audioStreams.empty())
+ {
+ LMS_LOG(DBUPDATER, INFO) << "Skipped '" << file.string() << "' (no audio stream found)";
+
+ // If Track exists here, delete it!
+ if (track)
+ {
+ track.remove();
+ stats.deletions++;
+ }
+ stats.errors.emplace_back(ScanError {file, ScanErrorType::NoAudioTrack});
+ return;
+ }
+ if (trackInfo->duration == std::chrono::milliseconds::zero())
+ {
+ LMS_LOG(DBUPDATER, INFO) << "Skipped '" << file.string() << "' (duration is 0)";
+
+ // If Track exists here, delete it!
+ if (track)
+ {
+ track.remove();
+ stats.deletions++;
+ }
+ stats.errors.emplace_back(ScanError {file, ScanErrorType::BadDuration});
+ return;
+ }
+
+ // ***** Title
+ std::string title;
+ if (!trackInfo->title.empty())
+ title = trackInfo->title;
+ else
+ {
+ // TODO parse file name guess track etc.
+ // For now juste use file name as title
+ title = file.filename().string();
+ }
+
+ // If file already exists, update its data
+ // Otherwise, create it
+ if (!track)
+ {
+ track = dbSession.create