From 9ae425a800fb04b13b31dfbefd3161dc458eb2eb Mon Sep 17 00:00:00 2001 From: emeric Date: Mon, 4 Aug 2025 22:35:26 +0200 Subject: [PATCH] Updated readme + sanitize subtitle file names before matching, ref #699 --- README.md | 3 ++ src/libs/core/impl/Path.cpp | 20 +++++++++++++ src/libs/core/include/core/Path.hpp | 5 ++++ src/libs/core/test/Path.cpp | 30 +++++++++++++++++++ .../steps/ScanStepAssociateArtistImages.cpp | 8 ++--- .../steps/ScanStepAssociateMediumImages.cpp | 8 +++-- 6 files changed, 67 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7141ba87..a327bd39 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,9 @@ If neither exists, it will then search for a file named `artist` (or another nam 2. Scan for the image: the directory is scanned starting from this common path, moving upwards if needed, until the artist image file is found. 3. Fallback search: if no image is found, _LMS_ will then search within each individual album folder. +## Disc image lookup +_LMS_ automatically associates images with each disc in your collection. Name the image file after the disc's subtitle or another identifier configured in `lms.conf` (see the `medium-image-file-names` setting), and place it in the same directory as the disc's tracks. If no suitable image is found, LMS will also look for embedded images within the tracks of the disc. + ## Playlist support _LMS_ supports playlist files in `m3u` and `m3u8` formats. These playlists are synced during the scan process and are available as public shared playlists. diff --git a/src/libs/core/impl/Path.cpp b/src/libs/core/impl/Path.cpp index ff7436a4..d17c5bc3 100644 --- a/src/libs/core/impl/Path.cpp +++ b/src/libs/core/impl/Path.cpp @@ -86,4 +86,24 @@ namespace lms::core::pathUtils return longestCommonPath; } + + std::string sanitizeFileStem(const std::string_view fileStem) + { + // Keep UTF8-encoded characters, but skip illegal ASCII characters + constexpr std::array illegalChars{ '/', '\\', ':', '*', '?', '"', '<', '>', '|' }; + static_assert(std::all_of(std::begin(illegalChars), std::end(illegalChars), [](unsigned char c) { return c < 128; }), "Illegal characters must be ASCII"); + + std::string sanitized; + sanitized.reserve(fileStem.size()); + + for (const char c : fileStem) + { + if (std::any_of(std::begin(illegalChars), std::end(illegalChars), [c](char illegalChar) { return c == illegalChar; })) + continue; + + sanitized.push_back(c); + } + + return sanitized; + } } // namespace lms::core::pathUtils diff --git a/src/libs/core/include/core/Path.hpp b/src/libs/core/include/core/Path.hpp index 2d8fece0..38ab0790 100644 --- a/src/libs/core/include/core/Path.hpp +++ b/src/libs/core/include/core/Path.hpp @@ -21,6 +21,8 @@ #include #include +#include +#include #include @@ -54,4 +56,7 @@ namespace lms::core::pathUtils return longestCommonPath; } + + // A method that sanitize a file stem, removing any illegal chars + std::string sanitizeFileStem(std::string_view fileStem); } // namespace lms::core::pathUtils diff --git a/src/libs/core/test/Path.cpp b/src/libs/core/test/Path.cpp index 1e525b06..7aa52907 100644 --- a/src/libs/core/test/Path.cpp +++ b/src/libs/core/test/Path.cpp @@ -103,4 +103,34 @@ namespace lms::core::pathUtils::tests EXPECT_EQ(core::pathUtils::isPathInRootPath(test.path, test.rootPath), test.expectedResult) << "Failed: path = " << test.path << ", rootPath = " << test.rootPath; } } + + TEST(Path, sanitizeFileStem) + { + struct TestCase + { + std::string input; + std::string_view expectedOutput; + }; + + TestCase tests[]{ + { "", "" }, // empty input + { "valid_file_name", "valid_file_name" }, + { "invalid:file*name?", "invalidfilename" }, + { "another|invalid", "anotherinvalidname" }, + { "/leading/slash", "leadingslash" }, + { "\\backslash\\file", "backslashfile" }, + { "file_with_äöüß", "file_with_äöüß" }, // keep German umlauts + { "file_with_éèêë", "file_with_éèêë" }, // keep French accents + { "héllo 漢字", "héllo 漢字" }, // keep UTF8 characters + { "file_with_üñîçødë", "file_with_üñîçødë" }, // keep special characters + { "file_with_!@#$%^&*()_+", "file_with_!@#$%^&()_+" }, // remove special characters + { "file_with_", "file_with_" }, // handle double dots + { "file.with.extension", "file.with.extension" }, // keep extensions + }; + + for (const TestCase& test : tests) + { + EXPECT_EQ(core::pathUtils::sanitizeFileStem(test.input), test.expectedOutput) << "Failed: input = " << test.input; + } + } } // namespace lms::core::pathUtils::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 c8209f9a..be63bc24 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); + params.setFileStem(fileStem); // 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) @@ -135,13 +135,11 @@ namespace lms::scanner { // Expect layout like this: // ReleaseArtist/Release/Tracks' - // /artist.jpg - // /someOtherUserConfiguredArtistFile.jpg + // /someUserConfiguredArtistFile.jpg // // Or: // ReleaseArtist/SomeGrouping/Release/Tracks' - // /artist.jpg - // /someOtherUserConfiguredArtistFile.jpg + // /someUserConfiguredArtistFile.jpg // std::filesystem::path directoryToInspect{ core::pathUtils::getLongestCommonPath(std::cbegin(releasePaths), std::cend(releasePaths)) }; while (true) diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp index c741dfd9..fc0157e2 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp @@ -28,6 +28,7 @@ #include "core/IConfig.hpp" #include "core/IJob.hpp" #include "core/ILogger.hpp" +#include "core/Path.hpp" #include "database/IDb.hpp" #include "database/Session.hpp" #include "database/Types.hpp" @@ -105,8 +106,11 @@ namespace lms::scanner if (image) return; - if (const std::string_view mediumName{ medium->getName() }; !mediumName.empty()) - image = findImageInDirectory(session, directory, std::span{ &mediumName, 1 }); + 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 }); + } if (!image) image = findImageInDirectory(session, directory, searchParams.mediumFileNames);