diff --git a/approot/admin-scansettings.xml b/approot/admin-scansettings.xml index 17c244af9..645bd3383 100644 --- a/approot/admin-scansettings.xml +++ b/approot/admin-scansettings.xml @@ -4,7 +4,7 @@ - + ${tr:Lms.Admin.Database.scan-settings} @@ -24,15 +24,7 @@ ${update-start-time-info} - - - ${tr:Lms.Admin.Database.similarity-engine-type} - - ${similarity-engine-type class="form-control"} - - ${similarity-engine-type-info} - - + ${tr:Lms.Admin.Database.tag-parsing} ${tr:Lms.Admin.Database.extra-tags-to-scan} @@ -54,10 +46,32 @@ ${default-tag-delimiter-container class="row gy-3"} + ${tr:Lms.Admin.Database.misc} + + + ${skip-single-release-playlists class="form-check-input"} + + ${tr:Lms.Admin.Database.skip-single-release-playlists} + + + ${skip-single-release-playlists-info} + + + - ${save-btn class="btn btn-primary me-1"}${discard-btn class="btn btn-secondary"} + + ${tr:Lms.Admin.Database.similarity-engine-type} + + ${similarity-engine-type class="form-control"} + + ${similarity-engine-type-info} + + + + ${save-btn class="btn btn-primary me-1"}${discard-btn class="btn btn-secondary"} + diff --git a/approot/messages.xml b/approot/messages.xml index 514b5aa77..b913e32e1 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -78,6 +78,7 @@ Extra tags to scan Hourly Scan now! +Miscellaneous Monthly Never Scan aborted! @@ -87,7 +88,9 @@ Similarity engine Tag-based None +Skip playlists that contain tracks from the same album The tag delimiter must not consist solely of spaces +Tag parsing Update period Update start time Weekly diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index e9d8e380f..34bb5cdaa 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -78,6 +78,7 @@ Tags supplémentaires à scanner Toutes les heures Scanner maintenant ! +Divers Tous les mois Jamais Scan interrompu ! @@ -87,7 +88,9 @@ Moteur de similarité Basé sur les tags Aucun +Ignorer les playlists contenant des pistes d'un même album Le délimiteur de tag ne doit pas comporter uniquement des espaces +Analyse des tags Périodicité des mises à jour Heure de départ de la mise à jour Toutes les semaines diff --git a/approot/messages_it.xml b/approot/messages_it.xml index ab11f507d..6c18325b3 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -78,6 +78,7 @@ Tag aggiuntivi da scansionare Ogni ora Scansiona ora! +Varie Mensile Mai Scansione annullata! @@ -87,7 +88,9 @@ Motore di similarità Basato su tag Nessuno +Salta le playlist che contengono brani dello stesso album Il delimitatore del tag non deve consistere esclusivamente di spazi +Analisi dei tag Frequenza di aggiornamento Orario di aggiornamento Settimanale diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml index f699761d0..5ece9b624 100644 --- a/approot/messages_pl.xml +++ b/approot/messages_pl.xml @@ -79,6 +79,7 @@ Szukaj dodatkowych znaczników Co godzinę Skanuj teraz! +Różne Co miesiąc Nigdy Skanowanie przerwane! @@ -88,7 +89,9 @@ Metoda sprawdzania podobieństwa Oparta o znaczniki Żadna +Pomiń playlisty zawierające utwory z tego samego albumu Rozdzielacz nie może się składać z samych białych znaków +Analiza tagów Okres aktualizacji Czas startu aktualizacji Co tydzień diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index 70ca39165..de9ebbc1c 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -78,6 +78,7 @@ 每小时 立即扫描! + 每月 从不 @@ -88,6 +89,8 @@ + + 更新周期 更新开始时间 每周 diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index 4a83942d7..9efc07661 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -35,7 +35,7 @@ namespace lms::db { namespace { - static constexpr Version LMS_DATABASE_VERSION{ 77 }; + static constexpr Version LMS_DATABASE_VERSION{ 78 }; } VersionInfo::VersionInfo() @@ -1029,6 +1029,12 @@ FROM tracklist)"); utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } + void migrateFromV77(Session& session) + { + // added new scan settings: skip single release playlists (default value is conservative, no need to rescan) + utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD COLUMN skip_single_release_playlists BOOLEAN NOT NULL DEFAULT(FALSE)"); + } + bool doDbMigration(Session& session) { constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -1082,6 +1088,7 @@ FROM tracklist)"); { 74, migrateFromV74 }, { 75, migrateFromV75 }, { 76, migrateFromV76 }, + { 77, migrateFromV77 }, }; bool migrationPerformed{}; diff --git a/src/libs/database/impl/ScanSettings.cpp b/src/libs/database/impl/ScanSettings.cpp index 4168e402d..da2dc361e 100644 --- a/src/libs/database/impl/ScanSettings.cpp +++ b/src/libs/database/impl/ScanSettings.cpp @@ -94,6 +94,15 @@ namespace lms::db } } + void ScanSettings::setSkipSingleReleasePlayLists(bool value) + { + if (_skipSingleReleasePlayLists != value) + { + _skipSingleReleasePlayLists = value; + incScanVersion(); + } + } + void ScanSettings::incScanVersion() { _scanVersion += 1; diff --git a/src/libs/database/include/database/ScanSettings.hpp b/src/libs/database/include/database/ScanSettings.hpp index 11d7beab1..54ff5ec31 100644 --- a/src/libs/database/include/database/ScanSettings.hpp +++ b/src/libs/database/include/database/ScanSettings.hpp @@ -70,6 +70,7 @@ namespace lms::db SimilarityEngineType getSimilarityEngineType() const { return _similarityEngineType; } std::vector getArtistTagDelimiters() const; std::vector getDefaultTagDelimiters() const; + bool getSkipSingleReleasePlayLists() const { return _skipSingleReleasePlayLists; } // Setters void setUpdateStartTime(Wt::WTime t) { _startTime = t; } @@ -78,6 +79,7 @@ namespace lms::db void setSimilarityEngineType(SimilarityEngineType type) { _similarityEngineType = type; } void setArtistTagDelimiters(std::span delimiters); void setDefaultTagDelimiters(std::span delimiters); + void setSkipSingleReleasePlayLists(bool value); void incScanVersion(); template @@ -90,6 +92,7 @@ namespace lms::db Wt::Dbo::field(a, _extraTagsToScan, "extra_tags_to_scan"); Wt::Dbo::field(a, _artistTagDelimiters, "artist_tag_delimiters"); Wt::Dbo::field(a, _defaultTagDelimiters, "default_tag_delimiters"); + Wt::Dbo::field(a, _skipSingleReleasePlayLists, "skip_single_release_playlists"); } private: @@ -100,5 +103,6 @@ namespace lms::db std::string _extraTagsToScan; std::string _artistTagDelimiters; std::string _defaultTagDelimiters; + bool _skipSingleReleasePlayLists{ false }; }; } // namespace lms::db diff --git a/src/libs/database/include/database/Track.hpp b/src/libs/database/include/database/Track.hpp index 606977e48..409562d02 100644 --- a/src/libs/database/include/database/Track.hpp +++ b/src/libs/database/include/database/Track.hpp @@ -294,6 +294,7 @@ namespace lms::db std::vector> getArtists(core::EnumSet artistLinkTypes) const; // no type means all std::vector getArtistIds(core::EnumSet artistLinkTypes) const; // no type means all std::vector> getArtistLinks() const; + ReleaseId getReleaseId() const { return _release.id(); } ObjectPtr getRelease() const { return _release; } std::vector> getClusters() const; std::vector getClusterIds() const; diff --git a/src/libs/services/scanner/impl/ScannerService.cpp b/src/libs/services/scanner/impl/ScannerService.cpp index 8c3003b9d..fc4af8170 100644 --- a/src/libs/services/scanner/impl/ScannerService.cpp +++ b/src/libs/services/scanner/impl/ScannerService.cpp @@ -338,7 +338,6 @@ namespace lms::scanner return; LMS_LOG(DBUPDATER, DEBUG, "Scanner settings updated"); - LMS_LOG(DBUPDATER, DEBUG, "skipDuplicateMBID = " << newSettings.skipDuplicateMBID); LMS_LOG(DBUPDATER, DEBUG, "Using scan settings version " << newSettings.scanVersion); _settings = std::move(newSettings); @@ -406,6 +405,8 @@ namespace lms::scanner newSettings.artistTagDelimiters = scanSettings->getArtistTagDelimiters(); newSettings.defaultTagDelimiters = scanSettings->getDefaultTagDelimiters(); + + newSettings.skipSingleReleasePlayLists = scanSettings->getSkipSingleReleasePlayLists(); } return newSettings; diff --git a/src/libs/services/scanner/impl/ScannerSettings.hpp b/src/libs/services/scanner/impl/ScannerSettings.hpp index 9605003a6..03a83e3fa 100644 --- a/src/libs/services/scanner/impl/ScannerSettings.hpp +++ b/src/libs/services/scanner/impl/ScannerSettings.hpp @@ -42,6 +42,8 @@ namespace lms::scanner std::vector extraTags; std::vector artistTagDelimiters; std::vector defaultTagDelimiters; + bool skipSingleReleasePlayLists{}; + std::vector mediaLibraries; bool operator==(const ScannerSettings& rhs) const = default; diff --git a/src/libs/services/scanner/impl/scanners/PlayListFileScanner.cpp b/src/libs/services/scanner/impl/scanners/PlayListFileScanner.cpp index b93e1e1bd..4a7b8c1c7 100644 --- a/src/libs/services/scanner/impl/scanners/PlayListFileScanner.cpp +++ b/src/libs/services/scanner/impl/scanners/PlayListFileScanner.cpp @@ -100,6 +100,7 @@ namespace lms::scanner stats.deletions++; } context.stats.errors.emplace_back(_file, ScanErrorType::CannotReadPlayListFile); + LMS_LOG(DBUPDATER, DEBUG, "Removed playlist file " << _file); return; } diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListTracks.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListTracks.cpp index fd8297c83..b3852f9f2 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListTracks.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListTracks.cpp @@ -26,10 +26,13 @@ #include "database/Db.hpp" #include "database/Directory.hpp" #include "database/PlayListFile.hpp" +#include "database/ReleaseId.hpp" #include "database/Session.hpp" #include "database/Track.hpp" #include "database/TrackList.hpp" +#include "ScannerSettings.hpp" + namespace lms::scanner { namespace @@ -37,10 +40,17 @@ namespace lms::scanner constexpr std::size_t readBatchSize{ 20 }; constexpr std::size_t writeBatchSize{ 5 }; + struct TrackInfo + { + db::TrackId trackId; + db::ReleaseId releaseId; + }; + struct PlayListFileAssociation { db::PlayListFileId playListFileIdId; - std::vector trackIds; + + std::vector tracks; }; using PlayListFileAssociationContainer = std::deque; @@ -49,6 +59,7 @@ namespace lms::scanner db::Session& session; db::PlayListFileId lastRetrievedPlayListFileId; std::size_t processedPlayListFileCount{}; + const ScannerSettings& settings; }; db::Track::pointer getMatchingTrack(db::Session& session, const std::filesystem::path& filePath, const db::Directory::pointer& playListDirectory) @@ -67,7 +78,19 @@ namespace lms::scanner return matchingTrack; } - bool trackListNeedsUpdate(db::Session& session, std::string_view name, std::span trackIds, const db::TrackList::pointer& trackList) + bool isSingleReleasePlayList(std::span tracks) + { + if (tracks.empty()) + return true; + + const db::ReleaseId releaseId{ tracks.front().releaseId }; + if (std::all_of(std::cbegin(tracks) + 1, std::cend(tracks), [=](const TrackInfo& trackInfo) { return trackInfo.releaseId == releaseId; })) + return true; + + return false; + } + + bool trackListNeedsUpdate(db::Session& session, std::string_view name, std::span tracks, const db::TrackList::pointer& trackList) { if (trackList->getName() != name) return true; @@ -78,13 +101,13 @@ namespace lms::scanner bool needUpdate{}; std::size_t currentIndex{}; db::TrackListEntry::find(session, params, [&](const db::TrackListEntry::pointer& entry) { - if (currentIndex > trackIds.size() || trackIds[currentIndex] != entry->getTrackId()) + if (currentIndex > tracks.size() || tracks[currentIndex].trackId != entry->getTrackId()) needUpdate = true; currentIndex += 1; }); - if (currentIndex != trackIds.size()) + if (currentIndex != tracks.size()) needUpdate = true; return needUpdate; @@ -108,20 +131,27 @@ namespace lms::scanner // TODO optim: no need to fetch the whole track db::Track::pointer track{ getMatchingTrack(searchContext.session, file, playListFile->getDirectory()) }; if (track) - playListAssociation.trackIds.push_back(track->getId()); + playListAssociation.tracks.push_back(TrackInfo{ .trackId = track->getId(), .releaseId = track->getReleaseId() }); else - LMS_LOG(DBUPDATER, DEBUG, "Track '" << file.string() << "' not found in playlist '" << playListFile->getAbsoluteFilePath().string() << "'"); + LMS_LOG(DBUPDATER, DEBUG, "Track " << file << " not found in playlist " << playListFile->getAbsoluteFilePath()); + } + + if (playListAssociation.tracks.empty() + || (searchContext.settings.skipSingleReleasePlayLists && isSingleReleasePlayList(playListAssociation.tracks))) + { + playListAssociation.tracks.clear(); } bool needUpdate{ true }; if (const db::TrackList::pointer trackList{ playListFile->getTrackList() }) - needUpdate = trackListNeedsUpdate(searchContext.session, playListFile->getName(), playListAssociation.trackIds, trackList); + { + if (!playListAssociation.tracks.empty()) + needUpdate = trackListNeedsUpdate(searchContext.session, playListFile->getName(), playListAssociation.tracks, trackList); + } if (needUpdate) - { - LMS_LOG(DBUPDATER, DEBUG, "Updating PlayList '" << playListFile->getAbsoluteFilePath().string() << "' (" << playListAssociation.trackIds.size() << " files)"); playListFileAssociations.emplace_back(std::move(playListAssociation)); - } + searchContext.processedPlayListFileCount++; }); } @@ -135,7 +165,19 @@ namespace lms::scanner assert(playListFile); db::TrackList::pointer trackList{ playListFile->getTrackList() }; - if (!trackList) + if (playListFileAssociation.tracks.empty()) + { + if (trackList) + { + LMS_LOG(DBUPDATER, DEBUG, "Removed associated tracklist for " << playListFile->getAbsoluteFilePath() << ""); + trackList.remove(); + } + + return; + } + + const bool createTrackList{ !trackList }; + if (createTrackList) { trackList = session.create(playListFile->getName(), db::TrackListType::PlayList); playListFile.modify()->setTrackList(trackList); @@ -146,11 +188,13 @@ namespace lms::scanner trackList.modify()->setName(playListFile->getName()); trackList.modify()->clear(); - for (const db::TrackId trackId : playListFileAssociation.trackIds) + for (const TrackInfo trackInfo : playListFileAssociation.tracks) { - if (db::Track::pointer track{ db::Track::find(session, trackId) }) + if (db::Track::pointer track{ db::Track::find(session, trackInfo.trackId) }) session.create(track, trackList, playListFile->getLastWriteTime()); } + + LMS_LOG(DBUPDATER, DEBUG, std::string_view{ createTrackList ? "Created" : "Updated" } << " associated tracklist for " << playListFile->getAbsoluteFilePath() << " (" << playListFileAssociation.tracks.size() << " tracks)"); } void updatePlayListFiles(db::Session& session, PlayListFileAssociationContainer& playListFileAssociations) @@ -186,6 +230,7 @@ namespace lms::scanner SearchPlayListFileContext searchContext{ .session = session, .lastRetrievedPlayListFileId = {}, + .settings = _settings, }; PlayListFileAssociationContainer playListFileAssociations; diff --git a/src/lms/ui/admin/ScanSettingsView.cpp b/src/lms/ui/admin/ScanSettingsView.cpp index 872bd2c7a..100b04f64 100644 --- a/src/lms/ui/admin/ScanSettingsView.cpp +++ b/src/lms/ui/admin/ScanSettingsView.cpp @@ -19,6 +19,7 @@ #include "ScanSettingsView.hpp" +#include #include #include #include @@ -69,6 +70,7 @@ namespace lms::ui static inline constexpr Field UpdatePeriodField{ "update-period" }; static inline constexpr Field UpdateStartTimeField{ "update-start-time" }; static inline constexpr Field SimilarityEngineTypeField{ "similarity-engine-type" }; + static inline constexpr Field SkipSingleReleasePlayLists{ "skip-single-release-playlists" }; using UpdatePeriodModel = ValueStringModel; @@ -79,10 +81,12 @@ namespace lms::ui addField(UpdatePeriodField); addField(UpdateStartTimeField); addField(SimilarityEngineTypeField); + addField(SkipSingleReleasePlayLists); setValidator(UpdatePeriodField, createMandatoryValidator()); setValidator(UpdateStartTimeField, createMandatoryValidator()); setValidator(SimilarityEngineTypeField, createMandatoryValidator()); + setValidator(SkipSingleReleasePlayLists, createMandatoryValidator()); } std::shared_ptr updatePeriodModel() { return _updatePeriodModel; } @@ -109,6 +113,8 @@ namespace lms::ui setReadOnly(DatabaseSettingsModel::UpdateStartTimeField, true); } + setValue(SkipSingleReleasePlayLists, scanSettings->getSkipSingleReleasePlayLists()); + auto similarityEngineTypeRow{ _similarityEngineTypeModel->getRowFromValue(scanSettings->getSimilarityEngineType()) }; if (similarityEngineTypeRow) setValue(SimilarityEngineTypeField, _similarityEngineTypeModel->getString(*similarityEngineTypeRow)); @@ -126,17 +132,28 @@ namespace lms::ui ScanSettings::pointer scanSettings{ ScanSettings::get(LmsApp->getDbSession()) }; - auto updatePeriodRow{ _updatePeriodModel->getRowFromString(valueText(UpdatePeriodField)) }; - if (updatePeriodRow) - scanSettings.modify()->setUpdatePeriod(_updatePeriodModel->getValue(*updatePeriodRow)); + { + const auto updatePeriodRow{ _updatePeriodModel->getRowFromString(valueText(UpdatePeriodField)) }; + if (updatePeriodRow) + scanSettings.modify()->setUpdatePeriod(_updatePeriodModel->getValue(*updatePeriodRow)); + } - auto startTimeRow{ _updateStartTimeModel->getRowFromString(valueText(UpdateStartTimeField)) }; - if (startTimeRow) - scanSettings.modify()->setUpdateStartTime(_updateStartTimeModel->getValue(*startTimeRow)); + { + const auto startTimeRow{ _updateStartTimeModel->getRowFromString(valueText(UpdateStartTimeField)) }; + if (startTimeRow) + scanSettings.modify()->setUpdateStartTime(_updateStartTimeModel->getValue(*startTimeRow)); + } - auto similarityEngineTypeRow{ _similarityEngineTypeModel->getRowFromString(valueText(SimilarityEngineTypeField)) }; - if (similarityEngineTypeRow) - scanSettings.modify()->setSimilarityEngineType(_similarityEngineTypeModel->getValue(*similarityEngineTypeRow)); + { + const bool skipSingleReleasePlayLists{ Wt::asNumber(value(SkipSingleReleasePlayLists)) != 0 }; + scanSettings.modify()->setSkipSingleReleasePlayLists(skipSingleReleasePlayLists); + } + + { + const auto similarityEngineTypeRow{ _similarityEngineTypeModel->getRowFromString(valueText(SimilarityEngineTypeField)) }; + if (similarityEngineTypeRow) + scanSettings.modify()->setSimilarityEngineType(_similarityEngineTypeModel->getValue(*similarityEngineTypeRow)); + } scanSettings.modify()->setExtraTagsToScan(extraTagsToScan); scanSettings.modify()->setArtistTagDelimiters(artistDelimiters); @@ -318,6 +335,9 @@ namespace lms::ui updateStartTime->setModel(model->updateStartTimeModel()); t->setFormWidget(DatabaseSettingsModel::UpdateStartTimeField, std::move(updateStartTime)); + // Skip playlists + t->setFormWidget(DatabaseSettingsModel::SkipSingleReleasePlayLists, std::make_unique()); + // Similarity engine type auto similarityEngineType{ std::make_unique() }; similarityEngineType->setModel(model->similarityEngineTypeModel());