From 35ba66099fd795ca8191fcc57264c2c73645840e Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 5 Aug 2025 22:33:03 +0200 Subject: [PATCH] Allow wildcards in file names to search for images, fixes #697 --- conf/lms.conf | 6 +- src/libs/database/impl/Utils.cpp | 2 +- src/libs/database/impl/Utils.hpp | 2 +- src/libs/database/impl/objects/Artist.cpp | 4 +- src/libs/database/impl/objects/Directory.cpp | 2 +- src/libs/database/impl/objects/Image.cpp | 15 ++- src/libs/database/impl/objects/Listen.cpp | 8 +- src/libs/database/impl/objects/Release.cpp | 2 +- src/libs/database/impl/objects/Track.cpp | 2 +- src/libs/database/impl/objects/TrackList.cpp | 2 +- .../include/database/objects/Directory.hpp | 2 +- .../include/database/objects/Image.hpp | 11 ++- src/libs/database/test/Image.cpp | 98 +++++++++++++++++++ .../steps/ScanStepAssociateArtistImages.cpp | 2 +- .../steps/ScanStepAssociateMediumImages.cpp | 8 +- .../steps/ScanStepAssociateReleaseImages.cpp | 2 +- 16 files changed, 142 insertions(+), 26 deletions(-) diff --git a/conf/lms.conf b/conf/lms.conf index 2d0ceb7c..d7049da1 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -99,14 +99,14 @@ cover-max-cache-size = 30; # JPEG quality for covers (range is 1-100) cover-jpeg-quality = 75; -# Preferred file names for covers (order is important) +# Preferred file names for covers (order is important, accept wildcards) cover-preferred-file-names = ("cover", "front", "folder", "default"); -# File names for artist images (order is important) +# File names for artist images (order is important, accept wildcards) # Note: files whose name is the artist's MBID are always searched before the names in this list. You can place the MBID files anywhere in your libraries. artist-image-file-names = ("artist"); -# File names for medium images (order is important) +# File names for medium images (order is important, accept wildcards) # Note: files named after the disc itself are always searched before the names in this list. medium-image-file-names = ("discsubtitle"); diff --git a/src/libs/database/impl/Utils.cpp b/src/libs/database/impl/Utils.cpp index c3648b43..924d7e00 100644 --- a/src/libs/database/impl/Utils.cpp +++ b/src/libs/database/impl/Utils.cpp @@ -23,7 +23,7 @@ namespace lms::db::utils { - std::string escapeLikeKeyword(std::string_view keyword) + std::string escapeForLikeKeyword(std::string_view keyword) { return core::stringUtils::escapeString(keyword, "%_", escapeChar); } diff --git a/src/libs/database/impl/Utils.hpp b/src/libs/database/impl/Utils.hpp index bb377756..2292f596 100644 --- a/src/libs/database/impl/Utils.hpp +++ b/src/libs/database/impl/Utils.hpp @@ -38,7 +38,7 @@ namespace lms::db::utils { #define ESCAPE_CHAR_STR "\\" static inline constexpr char escapeChar{ '\\' }; - std::string escapeLikeKeyword(std::string_view keywords); + std::string escapeForLikeKeyword(std::string_view keywords); Wt::WDateTime normalizeDateTime(const Wt::WDateTime& dateTime); diff --git a/src/libs/database/impl/objects/Artist.cpp b/src/libs/database/impl/objects/Artist.cpp index 436f6735..3b0053ec 100644 --- a/src/libs/database/impl/objects/Artist.cpp +++ b/src/libs/database/impl/objects/Artist.cpp @@ -106,13 +106,13 @@ namespace lms::db for (const std::string_view keyword : params.keywords) { clauses.push_back("a.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'"); - query.bind("%" + utils::escapeLikeKeyword(keyword) + "%"); + query.bind("%" + utils::escapeForLikeKeyword(keyword) + "%"); } for (const std::string_view keyword : params.keywords) { sortClauses.push_back("a.sort_name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'"); - query.bind("%" + utils::escapeLikeKeyword(keyword) + "%"); + query.bind("%" + utils::escapeForLikeKeyword(keyword) + "%"); } query.where("(" + core::stringUtils::joinStrings(clauses, " AND ") + ") OR (" + core::stringUtils::joinStrings(sortClauses, " AND ") + ")"); diff --git a/src/libs/database/impl/objects/Directory.cpp b/src/libs/database/impl/objects/Directory.cpp index 286c07b5..293728c2 100644 --- a/src/libs/database/impl/objects/Directory.cpp +++ b/src/libs/database/impl/objects/Directory.cpp @@ -40,7 +40,7 @@ namespace lms::db auto query{ session.getDboSession()->query>("SELECT d FROM directory d") }; for (std::string_view keyword : params.keywords) - query.where("d.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeLikeKeyword(keyword) + "%"); + query.where("d.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeForLikeKeyword(keyword) + "%"); if (params.artist.isValid() || params.release.isValid() diff --git a/src/libs/database/impl/objects/Image.cpp b/src/libs/database/impl/objects/Image.cpp index ec63a4fd..152b076a 100644 --- a/src/libs/database/impl/objects/Image.cpp +++ b/src/libs/database/impl/objects/Image.cpp @@ -43,7 +43,20 @@ namespace lms::db if (params.directory.isValid()) query.where("i.directory_id = ?").bind(params.directory); if (!params.fileStem.empty()) - query.where("i.stem = ? COLLATE NOCASE").bind(params.fileStem); + { + // if contains a wildcard, replace by % for LIKE + if (params.processWildcardsInFileStem.value() && params.fileStem.find('*') != std::string::npos) + { + std::string fileStem{ params.fileStem }; + utils::escapeForLikeKeyword(fileStem); + std::replace(std::begin(fileStem), std::end(fileStem), '*', '%'); + query.where("i.stem LIKE ? COLLATE NOCASE ESCAPE '" ESCAPE_CHAR_STR "'").bind(fileStem); + } + else + { + query.where("i.stem = ? COLLATE NOCASE").bind(params.fileStem); + } + } return query; } diff --git a/src/libs/database/impl/objects/Listen.cpp b/src/libs/database/impl/objects/Listen.cpp index 08874598..8fd3ac05 100644 --- a/src/libs/database/impl/objects/Listen.cpp +++ b/src/libs/database/impl/objects/Listen.cpp @@ -100,13 +100,13 @@ namespace lms::db for (const std::string_view keyword : params.keywords) { clauses.push_back("a.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'"); - query.bind("%" + utils::escapeLikeKeyword(keyword) + "%"); + query.bind("%" + utils::escapeForLikeKeyword(keyword) + "%"); } for (const std::string_view keyword : params.keywords) { sortClauses.push_back("a.sort_name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'"); - query.bind("%" + utils::escapeLikeKeyword(keyword) + "%"); + query.bind("%" + utils::escapeForLikeKeyword(keyword) + "%"); } query.where("(" + core::stringUtils::joinStrings(clauses, " AND ") + ") OR (" + core::stringUtils::joinStrings(sortClauses, " AND ") + ")"); @@ -169,7 +169,7 @@ namespace lms::db } for (std::string_view keyword : params.keywords) - query.where("r.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeLikeKeyword(keyword) + "%"); + query.where("r.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeForLikeKeyword(keyword) + "%"); return query; } @@ -227,7 +227,7 @@ namespace lms::db } for (std::string_view keyword : params.keywords) - query.where("t.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeLikeKeyword(keyword) + "%"); + query.where("t.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeForLikeKeyword(keyword) + "%"); return query; } diff --git a/src/libs/database/impl/objects/Release.cpp b/src/libs/database/impl/objects/Release.cpp index 147e3c98..472f423f 100644 --- a/src/libs/database/impl/objects/Release.cpp +++ b/src/libs/database/impl/objects/Release.cpp @@ -125,7 +125,7 @@ namespace lms::db query.where("r.name = ?").bind(params.name); for (std::string_view keyword : params.keywords) - query.where("r.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeLikeKeyword(keyword) + "%"); + query.where("r.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeForLikeKeyword(keyword) + "%"); if (params.starringUser.isValid()) { diff --git a/src/libs/database/impl/objects/Track.cpp b/src/libs/database/impl/objects/Track.cpp index d51dd4c8..119f9644 100644 --- a/src/libs/database/impl/objects/Track.cpp +++ b/src/libs/database/impl/objects/Track.cpp @@ -61,7 +61,7 @@ namespace lms::db assert(params.keywords.empty() || params.name.empty()); for (std::string_view keyword : params.keywords) - query.where("t.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeLikeKeyword(keyword) + "%"); + query.where("t.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeForLikeKeyword(keyword) + "%"); if (!params.name.empty()) query.where("t.name = ?").bind(params.name); diff --git a/src/libs/database/impl/objects/TrackList.cpp b/src/libs/database/impl/objects/TrackList.cpp index de77c518..a32efa7a 100644 --- a/src/libs/database/impl/objects/TrackList.cpp +++ b/src/libs/database/impl/objects/TrackList.cpp @@ -57,7 +57,7 @@ namespace lms::db } for (std::string_view keyword : params.keywords) - query.where("t_l.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeLikeKeyword(keyword) + "%"); + query.where("t_l.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeForLikeKeyword(keyword) + "%"); if (params.filters.mediaLibrary.isValid() || params.filters.label.isValid() diff --git a/src/libs/database/include/database/objects/Directory.hpp b/src/libs/database/include/database/objects/Directory.hpp index 566b3745..3174f235 100644 --- a/src/libs/database/include/database/objects/Directory.hpp +++ b/src/libs/database/include/database/objects/Directory.hpp @@ -114,7 +114,7 @@ namespace lms::db static pointer find(Session& session, const std::filesystem::path& path); static void find(Session& session, DirectoryId& lastRetrievedDirectory, std::size_t count, const std::function& func); static RangeResults find(Session& session, const FindParameters& params); - static void find(Session& session, const FindParameters& parameters, const std::function& func); + static void find(Session& session, const FindParameters& params, const std::function& func); static RangeResults findOrphanIds(Session& session, std::optional range = std::nullopt); static RangeResults findMismatchedLibrary(Session& session, std::optional range, const std::filesystem::path& rootPath, MediaLibraryId expectedLibraryId); static RangeResults findRootDirectories(Session& session, std::optional range = std::nullopt); diff --git a/src/libs/database/include/database/objects/Image.hpp b/src/libs/database/include/database/objects/Image.hpp index da4c48bf..8050c33c 100644 --- a/src/libs/database/include/database/objects/Image.hpp +++ b/src/libs/database/include/database/objects/Image.hpp @@ -26,6 +26,7 @@ #include #include +#include "core/TaggedType.hpp" #include "database/Object.hpp" #include "database/Types.hpp" #include "database/objects/DirectoryId.hpp" @@ -43,18 +44,22 @@ namespace lms::db struct FindParameters { + using ProcessWildcards = core::TaggedBool; + std::optional range; - std::string fileStem; // if set, images with this file stem - DirectoryId directory; // if set, images in this directory + std::string fileStem; // if set, images with this file stem + ProcessWildcards processWildcardsInFileStem{ false }; // if true, replace '*' by '%' for SQL LIKE + DirectoryId directory; // if set, images in this directory FindParameters& setRange(std::optional _range) { range = _range; return *this; } - FindParameters& setFileStem(std::string_view _fileStem) + FindParameters& setFileStem(std::string_view _fileStem, ProcessWildcards processWildcards = ProcessWildcards{ false }) { fileStem = _fileStem; + processWildcardsInFileStem = processWildcards; return *this; } FindParameters& setDirectory(DirectoryId _directory) diff --git a/src/libs/database/test/Image.cpp b/src/libs/database/test/Image.cpp index a26340ad..129e1dc2 100644 --- a/src/libs/database/test/Image.cpp +++ b/src/libs/database/test/Image.cpp @@ -122,4 +122,102 @@ namespace lms::db::tests } } + TEST_F(DatabaseFixture, Image_findByFileStem) + { + ScopedDirectory directory{ session, "/path/to" }; + ScopedImage image{ session, "/path/to/image" }; + + const std::string fileStem{ "image" }; + { + auto transaction{ session.createWriteTransaction() }; + image.get().modify()->setDirectory(directory.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + Image::FindParameters params; + params.setFileStem(fileStem); + const auto results{ Image::find(session, params) }; + ASSERT_EQ(results.results.size(), 1); + EXPECT_EQ(results.results[0]->getId(), image.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + Image::FindParameters params; + params.setFileStem(fileStem, Image::FindParameters::ProcessWildcards{ true }); + const auto results{ Image::find(session, params) }; + ASSERT_EQ(results.results.size(), 1); + EXPECT_EQ(results.results[0]->getId(), image.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + Image::FindParameters params; + params.setFileStem("nonexistent"); + const auto results{ Image::find(session, params) }; + EXPECT_EQ(results.results.size(), 0); + } + + { + auto transaction{ session.createReadTransaction() }; + Image::FindParameters params; + params.setFileStem("ima*"); + const auto results{ Image::find(session, params) }; + ASSERT_EQ(results.results.size(), 0); + } + + { + auto transaction{ session.createReadTransaction() }; + Image::FindParameters params; + params.setFileStem("ima*", Image::FindParameters::ProcessWildcards{ true }); + const auto results{ Image::find(session, params) }; + ASSERT_EQ(results.results.size(), 1); + EXPECT_EQ(results.results[0]->getId(), image.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + Image::FindParameters params; + params.setFileStem("*ge", Image::FindParameters::ProcessWildcards{ true }); + const auto results{ Image::find(session, params) }; + ASSERT_EQ(results.results.size(), 1); + EXPECT_EQ(results.results[0]->getId(), image.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + Image::FindParameters params; + params.setFileStem("*g*", Image::FindParameters::ProcessWildcards{ true }); + const auto results{ Image::find(session, params) }; + ASSERT_EQ(results.results.size(), 1); + EXPECT_EQ(results.results[0]->getId(), image.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + Image::FindParameters params; + params.setFileStem("*", Image::FindParameters::ProcessWildcards{ true }); + const auto results{ Image::find(session, params) }; + ASSERT_EQ(results.results.size(), 1); + EXPECT_EQ(results.results[0]->getId(), image.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + Image::FindParameters params; + params.setFileStem("ima%"); + const auto results{ Image::find(session, params) }; + ASSERT_EQ(results.results.size(), 0); + } + + { + auto transaction{ session.createReadTransaction() }; + Image::FindParameters params; + params.setFileStem("ima%", Image::FindParameters::ProcessWildcards{ true }); + const auto results{ Image::find(session, params) }; + ASSERT_EQ(results.results.size(), 0); + } + } + } // namespace lms::db::tests \ No newline at end of file diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateArtistImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateArtistImages.cpp index be63bc24..2a3a321c 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateArtistImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateArtistImages.cpp @@ -73,7 +73,7 @@ namespace lms::scanner { db::Image::FindParameters params; params.setDirectory(directory->getId()); - params.setFileStem(fileStem); // no need to sanitize here, user is responsible for providing sanitized file stems in conf file + params.setFileStem(fileStem, db::Image::FindParameters::ProcessWildcards{ true }); // no need to sanitize here, user is responsible for providing sanitized file stems in conf file db::Image::find(session, params, [&](const db::Image::pointer foundImg) { if (!image) diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp index fc0157e2..1971f844 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp @@ -62,7 +62,7 @@ namespace lms::scanner std::span mediumFileNames; }; - db::Image::pointer findImageInDirectory(db::Session& session, const db::Directory::pointer& directory, std::span fileStemsToSearch) + db::Image::pointer findImageInDirectory(db::Session& session, const db::Directory::pointer& directory, std::span fileStemsToSearch, db::Image::FindParameters::ProcessWildcards processWildcards) { db::Image::pointer image; @@ -70,7 +70,7 @@ namespace lms::scanner { db::Image::FindParameters params; params.setDirectory(directory->getId()); - params.setFileStem(fileStem); + params.setFileStem(fileStem, processWildcards); db::Image::find(session, params, [&](const db::Image::pointer foundImg) { if (!image) @@ -109,11 +109,11 @@ namespace lms::scanner if (const std::string mediumName{ core::pathUtils::sanitizeFileStem(medium->getName()) }; !mediumName.empty()) { std::string_view mediumNameView{ mediumName }; - image = findImageInDirectory(session, directory, std::span{ &mediumNameView, 1 }); + image = findImageInDirectory(session, directory, std::span{ &mediumNameView, 1 }, db::Image::FindParameters::ProcessWildcards{ false }); } if (!image) - image = findImageInDirectory(session, directory, searchParams.mediumFileNames); + image = findImageInDirectory(session, directory, searchParams.mediumFileNames, db::Image::FindParameters::ProcessWildcards{ true }); }); return image; diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp index 2488a139..158fba59 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp @@ -67,7 +67,7 @@ namespace lms::scanner { db::Image::FindParameters params; params.setDirectory(directory->getId()); - params.setFileStem(fileStem); + params.setFileStem(fileStem, db::Image::FindParameters::ProcessWildcards{ true }); // no need to sanitize here, user is responsible for providing sanitized file stems in conf file db::Image::find(session, params, [&](const db::Image::pointer& image) { if (!artwork)