diff --git a/README.md b/README.md index f4899705..0af4e3dd 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,21 @@ The folder must follow a structure defined by Kodi, as detailed [here](https://k The canonical artist name used by _LMS_ is the one specified in the `artist.nfo` file; If an `artist.nfo` exists but does not provide a name, the name of the containing folder is used. If no artist info file is provided, _LMS_ will pick the artist name found on the latest release. +## Excluding files from scan +Place a `.lmsignore` file at the root of a media library to exclude files or directories from scanning. + +The file uses a gitignore-inspired reduced syntax: +* `*.jpg`: ignore all `.jpg` files at any depth +* `/Unsorted/`: ignore the top-level `Unsorted/` directory only +* `extras/`: ignore any `extras/` directory at any depth +* `!cover.jpg`: re-include a file previously matched by a broader rule +* `?`: matches any single character except `/` +* `[abc]`: character class + +Lines starting with `#` are comments. An empty file has no effect. + +__Note__: only one `.lmsignore` file per library root is supported; files placed in subdirectories are ignored. + ### Filtering It is possible to apply global filters on your collection using `genre`, `mood`, `grouping`, `language`, `codec`, and by music library. More tags, including custom ones, can be added in the database administration settings. diff --git a/src/libs/core/impl/Path.cpp b/src/libs/core/impl/Path.cpp index 886cc3dc..71732293 100644 --- a/src/libs/core/impl/Path.cpp +++ b/src/libs/core/impl/Path.cpp @@ -21,8 +21,6 @@ #include #include -#include -#include #include "core/String.hpp" @@ -35,22 +33,13 @@ namespace lms::core::pathUtils return (std::find(std::cbegin(supportedExtensions), std::cend(supportedExtensions), extension) != std::cend(supportedExtensions)); } - bool isPathInRootPath(const std::filesystem::path& path, const std::filesystem::path& rootPathArg, const std::filesystem::path* excludeDirFileName) + bool isPathInRootPath(const std::filesystem::path& path, const std::filesystem::path& rootPathArg) { std::filesystem::path curPath{ path }; std::filesystem::path rootPath{ rootPathArg.has_filename() ? rootPathArg : rootPathArg.parent_path() }; while (true) { - if (excludeDirFileName && !excludeDirFileName->empty()) - { - assert(!excludeDirFileName->has_parent_path()); - - std::error_code ec; - if (std::filesystem::exists(curPath / *excludeDirFileName, ec)) - return false; - } - if (curPath == rootPath) return true; diff --git a/src/libs/core/include/core/Path.hpp b/src/libs/core/include/core/Path.hpp index e748cb36..e46f4926 100644 --- a/src/libs/core/include/core/Path.hpp +++ b/src/libs/core/include/core/Path.hpp @@ -29,9 +29,8 @@ namespace lms::core::pathUtils // Check if file's extension is one of provided extensions bool hasFileAnyExtension(const std::filesystem::path& file, std::span extensions); - // Check if a path is within a directory (excludeDirFileName is a relative can be used to exclude a whole directory and its subdirectory, must not have parent_path) - // Caller responsibility to call with normalized paths - bool isPathInRootPath(const std::filesystem::path& path, const std::filesystem::path& rootPath, const std::filesystem::path* excludeDirFileName = {}); + // Check if a path is within a directory. Caller responsibility to call with normalized paths. + bool isPathInRootPath(const std::filesystem::path& path, const std::filesystem::path& rootPath); std::filesystem::path getLongestCommonPath(const std::filesystem::path& path1, const std::filesystem::path& path2); diff --git a/src/libs/services/scanner/CMakeLists.txt b/src/libs/services/scanner/CMakeLists.txt index fe5a8bd0..1abea10a 100644 --- a/src/libs/services/scanner/CMakeLists.txt +++ b/src/libs/services/scanner/CMakeLists.txt @@ -35,6 +35,7 @@ add_library(lmsscanner STATIC impl/steps/ScanStepScanFiles.cpp impl/steps/ScanStepUpdateLibraryFields.cpp impl/FileScanners.cpp + impl/IgnoreRules.cpp impl/ScannerService.cpp impl/ScannerServiceTraceLogger.cpp impl/ScannerStats.cpp diff --git a/src/libs/services/scanner/impl/IgnoreRules.cpp b/src/libs/services/scanner/impl/IgnoreRules.cpp new file mode 100644 index 00000000..b0d7d9d2 --- /dev/null +++ b/src/libs/services/scanner/impl/IgnoreRules.cpp @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2025 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 "IgnoreRules.hpp" + +#include + +#include + +namespace lms::scanner +{ + IgnoreRules::IgnoreRules(std::string_view content) + { + std::string_view remaining{ content }; + while (!remaining.empty()) + { + const std::string_view::size_type newlinePos{ remaining.find('\n') }; + std::string_view line{ remaining.substr(0, newlinePos) }; + remaining = (newlinePos != std::string_view::npos) ? remaining.substr(newlinePos + 1) : std::string_view{}; + + if (!line.empty() && line.back() == '\r') + line.remove_suffix(1); + + if (line.empty() || line.front() == '#') + continue; + + Rule rule{}; + + if (line.front() == '!') + { + rule.negate = true; + line.remove_prefix(1); + } + + if (!line.empty() && line.back() == '/') + { + rule.dirOnly = true; + line.remove_suffix(1); + } + + // A leading / anchors the pattern to the root. + bool anchored{}; + if (!line.empty() && line.front() == '/') + { + anchored = true; + line.remove_prefix(1); + } + + if (line.empty()) + continue; + + rule.pattern = std::string{ line }; + rule.mode = (anchored || rule.pattern.find('/') != std::string::npos) ? MatchMode::FullPath : MatchMode::BasenameOnly; + + _rules.push_back(std::move(rule)); + } + } + + bool IgnoreRules::isEmpty() const + { + return _rules.empty(); + } + + bool IgnoreRules::isIgnored(const std::filesystem::path& relativePath, IsDirectory isDir) const + { + if (_rules.empty()) + return false; + + bool ignored{}; + for (const Rule& rule : _rules) + { + if (rule.dirOnly && !isDir.value()) + continue; + + bool matched{}; + switch (rule.mode) + { + case MatchMode::BasenameOnly: + { + const std::filesystem::path basename{ relativePath.filename() }; + matched = (::fnmatch(rule.pattern.c_str(), basename.c_str(), 0) == 0); + } + break; + case MatchMode::FullPath: + matched = (::fnmatch(rule.pattern.c_str(), relativePath.c_str(), FNM_PATHNAME) == 0); + break; + } + + if (matched) + ignored = !rule.negate; + } + + return ignored; + } + + IgnoreRules loadIgnoreRules(const std::filesystem::path& path) + { + std::ifstream file{ path }; + if (!file.is_open()) + return IgnoreRules{ {} }; + + return IgnoreRules{ std::string{ std::istreambuf_iterator{ file }, std::istreambuf_iterator{} } }; + } +} // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/IgnoreRules.hpp b/src/libs/services/scanner/impl/IgnoreRules.hpp new file mode 100644 index 00000000..1cef472a --- /dev/null +++ b/src/libs/services/scanner/impl/IgnoreRules.hpp @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2025 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 +#include +#include + +#include "core/TaggedType.hpp" + +namespace lms::scanner +{ + // Parsed representation of a .lmsignore file. See README.md for supported syntax. + class IgnoreRules + { + public: + explicit IgnoreRules(std::string_view content); + + bool isEmpty() const; + + using IsDirectory = core::TaggedBool; + bool isIgnored(const std::filesystem::path& relativePath, IsDirectory isDir) const; + + private: + enum class MatchMode + { + BasenameOnly, // no '/' in pattern: matched against basename only + FullPath, // has '/': matched against full relative path + }; + + struct Rule + { + bool negate; + bool dirOnly; + MatchMode mode; + std::string pattern; + + bool operator==(const Rule&) const = default; + }; + + std::vector _rules; + + public: + bool operator==(const IgnoreRules&) const = default; + }; + + // returns an empty IgnoreRules if the file does not exist or cannot be opened. + IgnoreRules loadIgnoreRules(const std::filesystem::path& path); +} // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/MediaLibraryInfo.hpp b/src/libs/services/scanner/impl/MediaLibraryInfo.hpp index 116aa748..01af4247 100644 --- a/src/libs/services/scanner/impl/MediaLibraryInfo.hpp +++ b/src/libs/services/scanner/impl/MediaLibraryInfo.hpp @@ -23,6 +23,8 @@ #include "database/objects/MediaLibraryId.hpp" +#include "IgnoreRules.hpp" + namespace lms::scanner { struct MediaLibraryInfo @@ -30,7 +32,8 @@ namespace lms::scanner db::MediaLibraryId id; std::filesystem::path rootDirectory; bool firstScan{}; + IgnoreRules ignoreRules{ {} }; - auto operator<=>(const MediaLibraryInfo& other) const = default; + bool operator==(const MediaLibraryInfo&) const = default; }; } // namespace lms::scanner \ No newline at end of file diff --git a/src/libs/services/scanner/impl/ScannerService.cpp b/src/libs/services/scanner/impl/ScannerService.cpp index ba513cc0..d608336f 100644 --- a/src/libs/services/scanner/impl/ScannerService.cpp +++ b/src/libs/services/scanner/impl/ScannerService.cpp @@ -108,6 +108,7 @@ namespace lms::scanner info.firstScan = mediaLibrary->isEmpty(); info.id = mediaLibrary->getId(); info.rootDirectory = mediaLibrary->getPath().lexically_normal(); + info.ignoreRules = loadIgnoreRules(info.rootDirectory / ".lmsignore"); settings->mediaLibraries.push_back(info); }); diff --git a/src/libs/services/scanner/impl/ScannerSettings.hpp b/src/libs/services/scanner/impl/ScannerSettings.hpp index 562dedd7..f7fbab40 100644 --- a/src/libs/services/scanner/impl/ScannerSettings.hpp +++ b/src/libs/services/scanner/impl/ScannerSettings.hpp @@ -31,8 +31,6 @@ namespace lms::scanner { - static inline const std::filesystem::path excludeDirFileName{ ".lmsignore" }; - struct ScannerSettings { std::size_t audioScanVersion{}; diff --git a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp index 8aabf041..14a8827b 100644 --- a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp +++ b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp @@ -50,6 +50,7 @@ #include "services/scanner/ScanErrors.hpp" +#include "IgnoreRules.hpp" #include "ScannerSettings.hpp" #include "helpers/ArtistHelpers.hpp" #include "scanners/IFileScanOperation.hpp" @@ -645,15 +646,17 @@ namespace lms::scanner if (track && track->getId() == otherTrack->getId()) continue; - // Skip if duplicate files no longer in media root: as it will be removed later, we will end up with no file - auto& mediaLibraries{ getScannerSettings().mediaLibraries }; - if (std::none_of(std::cbegin(mediaLibraries), std::cend(mediaLibraries), - [&](const MediaLibraryInfo& libraryInfo) { - return core::pathUtils::isPathInRootPath(getFilePath(), libraryInfo.rootDirectory, &excludeDirFileName); - })) - { + // If the other file is no longer in an active library it will be removed later: + // do not treat the current file as a duplicate, otherwise no file will remain for this MBID + const std::filesystem::path otherFilePath{ otherTrack->getAbsoluteFilePath() }; + const auto isInActiveLibrary{ [&](const MediaLibraryInfo& libraryInfo) { + if (!core::pathUtils::isPathInRootPath(otherFilePath, libraryInfo.rootDirectory)) + return false; + return libraryInfo.ignoreRules.isEmpty() || !libraryInfo.ignoreRules.isIgnored(std::filesystem::relative(otherFilePath, libraryInfo.rootDirectory), IgnoreRules::IsDirectory{ false }); + } }; + const auto& mediaLibraries{ getScannerSettings().mediaLibraries }; + if (!std::any_of(std::cbegin(mediaLibraries), std::cend(mediaLibraries), isInActiveLibrary)) continue; - } LMS_LOG(DBUPDATER, DEBUG, "Skipped " << getFilePath() << ": same MBID already found in " << otherTrack->getAbsoluteFilePath()); // As this MBID already exists, just remove what we just scanned diff --git a/src/libs/services/scanner/impl/steps/ScanStepCheckForRemovedFiles.cpp b/src/libs/services/scanner/impl/steps/ScanStepCheckForRemovedFiles.cpp index df2e93dd..1984ed67 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepCheckForRemovedFiles.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepCheckForRemovedFiles.cpp @@ -36,6 +36,7 @@ #include "database/objects/TrackLyrics.hpp" #include "FileScanners.hpp" +#include "IgnoreRules.hpp" #include "JobQueue.hpp" #include "ScanContext.hpp" #include "ScannerSettings.hpp" @@ -103,10 +104,12 @@ namespace lms::scanner return false; } - if (std::none_of(std::cbegin(_settings.mediaLibraries), std::cend(_settings.mediaLibraries), - [&](const MediaLibraryInfo& libraryInfo) { - return core::pathUtils::isPathInRootPath(p, libraryInfo.rootDirectory, &excludeDirFileName); - })) + const auto isInActiveLibrary{ [&](const MediaLibraryInfo& lib) { + if (!core::pathUtils::isPathInRootPath(p, lib.rootDirectory)) + return false; + return lib.ignoreRules.isEmpty() || !lib.ignoreRules.isIgnored(std::filesystem::relative(p, lib.rootDirectory), IgnoreRules::IsDirectory{ false }); + } }; + if (std::none_of(std::cbegin(_settings.mediaLibraries), std::cend(_settings.mediaLibraries), isInActiveLibrary)) { LMS_LOG(DBUPDATER, DEBUG, "Removing " << p << ": out of media directory"); return false; diff --git a/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp b/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp index 7d1c690f..e011f643 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp @@ -33,6 +33,7 @@ #include "scanners/IFileScanner.hpp" #include "FileScanners.hpp" +#include "IgnoreRules.hpp" #include "JobQueue.hpp" #include "ScanContext.hpp" @@ -41,7 +42,9 @@ namespace lms::scanner namespace { using ExploreFileCallback = std::function; - bool exploreFilesRecursive(const std::filesystem::path& directory, ExploreFileCallback cb, const std::filesystem::path* excludeDirFileName) + using ShouldIgnoreCallback = std::function; + + bool exploreFilesRecursive(const std::filesystem::path& directory, const ExploreFileCallback& cb, const ShouldIgnoreCallback& shouldIgnore) { std::error_code ec; std::filesystem::directory_iterator itPath{ directory, std::filesystem::directory_options::follow_directory_symlink, ec }; @@ -52,16 +55,6 @@ namespace lms::scanner return true; // try to continue exploring anyway } - if (excludeDirFileName && !excludeDirFileName->empty()) - { - const std::filesystem::path excludePath{ directory / *excludeDirFileName }; - if (std::filesystem::exists(excludePath, ec)) - { - LMS_LOG(DBUPDATER, DEBUG, "Found " << excludePath << ": skipping directory"); - return true; - } - } - std::filesystem::directory_iterator itEnd; while (itPath != itEnd) { @@ -74,12 +67,25 @@ namespace lms::scanner { continueExploring = cb(ec, path, nullptr); } - else + else if (entry.is_regular_file()) { - if (entry.is_regular_file()) - continueExploring = cb(ec, path, &entry); - else if (entry.is_directory()) - continueExploring = exploreFilesRecursive(path, cb, excludeDirFileName); + if (shouldIgnore(path, IgnoreRules::IsDirectory{ false })) + { + LMS_LOG(DBUPDATER, DEBUG, "Ignoring file " << path << " (matched .lmsignore rule)"); + itPath.increment(ec); + continue; + } + continueExploring = cb(ec, path, &entry); + } + else if (entry.is_directory()) + { + if (shouldIgnore(path, IgnoreRules::IsDirectory{ true })) + { + LMS_LOG(DBUPDATER, DEBUG, "Ignoring directory " << path << " (matched .lmsignore rule)"); + itPath.increment(ec); + continue; + } + continueExploring = exploreFilesRecursive(path, cb, shouldIgnore); } if (!continueExploring) @@ -208,7 +214,8 @@ namespace lms::scanner std::vector filesToScan; exploreFilesRecursive( - mediaLibrary.rootDirectory, [&](std::error_code ec, const std::filesystem::path& path, const std::filesystem::directory_entry* fileEntry) { + mediaLibrary.rootDirectory, + [&](std::error_code ec, const std::filesystem::path& path, const std::filesystem::directory_entry* fileEntry) { LMS_SCOPED_TRACE_DETAILED("Scanner", "OnExploreFile"); assert((ec && !fileEntry) || (!ec && fileEntry)); @@ -234,7 +241,9 @@ namespace lms::scanner return true; }, - &excludeDirFileName); + [&](const std::filesystem::path& path, IgnoreRules::IsDirectory isDir) { + return !mediaLibrary.ignoreRules.isEmpty() && mediaLibrary.ignoreRules.isIgnored(std::filesystem::relative(path, mediaLibrary.rootDirectory), isDir); + }); if (!filesToScan.empty()) queue.push(std::make_unique(getFileScanners(), mediaLibrary, context.scanOptions.fullScan, filesToScan)); diff --git a/src/libs/services/scanner/test/CMakeLists.txt b/src/libs/services/scanner/test/CMakeLists.txt index bcc6ebc3..b603b4ab 100644 --- a/src/libs/services/scanner/test/CMakeLists.txt +++ b/src/libs/services/scanner/test/CMakeLists.txt @@ -3,6 +3,7 @@ include(GoogleTest) add_executable(test-scanner ArtistInfo.cpp AudioFileUtils.cpp + IgnoreFilter.cpp Lyrics.cpp PlayList.cpp ScannerStats.cpp diff --git a/src/libs/services/scanner/test/IgnoreFilter.cpp b/src/libs/services/scanner/test/IgnoreFilter.cpp new file mode 100644 index 00000000..a245f421 --- /dev/null +++ b/src/libs/services/scanner/test/IgnoreFilter.cpp @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2025 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 + +#include "IgnoreRules.hpp" + +namespace lms::scanner::tests +{ + TEST(IgnoreRules, EmptyContent) + { + const IgnoreRules f{ "" }; + EXPECT_TRUE(f.isEmpty()); + EXPECT_FALSE(f.isIgnored("track.flac", IgnoreRules::IsDirectory{ false })); + } + + TEST(IgnoreRules, CommentsAndBlanksOnly) + { + const IgnoreRules f{ "# this is a comment\n\n# another comment\n" }; + EXPECT_TRUE(f.isEmpty()); + } + + TEST(IgnoreRules, Basename_MatchesAtRoot) + { + const IgnoreRules f{ "*.nfo\n" }; + EXPECT_TRUE(f.isIgnored("track.nfo", IgnoreRules::IsDirectory{ false })); + EXPECT_FALSE(f.isIgnored("track.flac", IgnoreRules::IsDirectory{ false })); + } + + TEST(IgnoreRules, Basename_MatchesInSubdir) + { + const IgnoreRules f{ "*.nfo\n" }; + EXPECT_TRUE(f.isIgnored("jazz/miles/track.nfo", IgnoreRules::IsDirectory{ false })); + EXPECT_FALSE(f.isIgnored("jazz/miles/track.flac", IgnoreRules::IsDirectory{ false })); + } + + TEST(IgnoreRules, Basename_QuestionMark) + { + const IgnoreRules f{ "?.nfo\n" }; + EXPECT_TRUE(f.isIgnored("a.nfo", IgnoreRules::IsDirectory{ false })); + EXPECT_FALSE(f.isIgnored("ab.nfo", IgnoreRules::IsDirectory{ false })); + } + + TEST(IgnoreRules, Basename_ExactName) + { + const IgnoreRules f{ "Thumbs.db\n" }; + EXPECT_TRUE(f.isIgnored("Thumbs.db", IgnoreRules::IsDirectory{ false })); + EXPECT_FALSE(f.isIgnored("thumbs.db", IgnoreRules::IsDirectory{ false })); // case-sensitive + } + + TEST(IgnoreRules, Basename_MultipleWildcards) + { + const IgnoreRules f{ "cover*.*\n" }; + EXPECT_TRUE(f.isIgnored("cover.jpg", IgnoreRules::IsDirectory{ false })); + EXPECT_TRUE(f.isIgnored("cover_front.jpg", IgnoreRules::IsDirectory{ false })); + EXPECT_TRUE(f.isIgnored("cover.png", IgnoreRules::IsDirectory{ false })); + EXPECT_FALSE(f.isIgnored("notcover.jpg", IgnoreRules::IsDirectory{ false })); + EXPECT_FALSE(f.isIgnored("coverjpg", IgnoreRules::IsDirectory{ false })); // no dot + } + + TEST(IgnoreRules, Basename_WildcardInDirName) + { + const IgnoreRules f{ "covers*/\n" }; + EXPECT_TRUE(f.isIgnored("covers", IgnoreRules::IsDirectory{ true })); + EXPECT_TRUE(f.isIgnored("covers_2024", IgnoreRules::IsDirectory{ true })); + EXPECT_TRUE(f.isIgnored("jazz/covers_hq", IgnoreRules::IsDirectory{ true })); // unanchored — matches at any depth + EXPECT_FALSE(f.isIgnored("notcovers", IgnoreRules::IsDirectory{ true })); + EXPECT_FALSE(f.isIgnored("covers_2024", IgnoreRules::IsDirectory{ false })); // dirOnly + } + + TEST(IgnoreRules, FullPath_WildcardInPathComponent) + { + const IgnoreRules f{ "jazz/*/liner.nfo\n" }; + EXPECT_TRUE(f.isIgnored("jazz/miles/liner.nfo", IgnoreRules::IsDirectory{ false })); + EXPECT_TRUE(f.isIgnored("jazz/coltrane/liner.nfo", IgnoreRules::IsDirectory{ false })); + EXPECT_FALSE(f.isIgnored("jazz/liner.nfo", IgnoreRules::IsDirectory{ false })); // no intermediate component + EXPECT_FALSE(f.isIgnored("jazz/miles/davis/liner.nfo", IgnoreRules::IsDirectory{ false })); // * doesn't cross / + EXPECT_FALSE(f.isIgnored("rock/miles/liner.nfo", IgnoreRules::IsDirectory{ false })); + } + + TEST(IgnoreRules, Basename_MatchesDirectoryToo) + { + // patterns without trailing / apply to both files and directories + const IgnoreRules f{ "*.nfo\n" }; + EXPECT_TRUE(f.isIgnored("track.nfo", IgnoreRules::IsDirectory{ false })); + EXPECT_TRUE(f.isIgnored("track.nfo", IgnoreRules::IsDirectory{ true })); + } + + TEST(IgnoreRules, DirOnly_IgnoresDirectory) + { + const IgnoreRules f{ "covers/\n" }; + EXPECT_TRUE(f.isIgnored("covers", IgnoreRules::IsDirectory{ true })); + EXPECT_FALSE(f.isIgnored("covers", IgnoreRules::IsDirectory{ false })); // not a dir + } + + TEST(IgnoreRules, DirOnly_AnchoredOnlyMatchesRoot) + { + const IgnoreRules f{ "/untagged/\n" }; + EXPECT_TRUE(f.isIgnored("untagged", IgnoreRules::IsDirectory{ true })); // root level — match + EXPECT_FALSE(f.isIgnored("jazz/untagged", IgnoreRules::IsDirectory{ true })); // nested — no match + EXPECT_FALSE(f.isIgnored("untagged", IgnoreRules::IsDirectory{ false })); // file, not dir — no match + } + + TEST(IgnoreRules, DirOnly_UnanchoredMatchesAnyDepth) + { + const IgnoreRules f{ "covers/\n" }; + EXPECT_TRUE(f.isIgnored("covers", IgnoreRules::IsDirectory{ true })); // root — match + EXPECT_TRUE(f.isIgnored("jazz/covers", IgnoreRules::IsDirectory{ true })); // nested — also match + EXPECT_TRUE(f.isIgnored("a/b/c/covers", IgnoreRules::IsDirectory{ true })); // deep — also match + EXPECT_FALSE(f.isIgnored("covers", IgnoreRules::IsDirectory{ false })); // file, not dir — no match + } + + TEST(IgnoreRules, FullPath_ExactDir) + { + const IgnoreRules f{ "jazz/covers\n" }; + EXPECT_TRUE(f.isIgnored("jazz/covers", IgnoreRules::IsDirectory{ true })); + EXPECT_FALSE(f.isIgnored("jazz/covers/foo", IgnoreRules::IsDirectory{ true })); // never reached in practice: scanner prunes jazz/covers/ first + EXPECT_FALSE(f.isIgnored("foo/jazz/covers", IgnoreRules::IsDirectory{ true })); // never reached in practice: scanner prunes jazz/covers/ first + EXPECT_FALSE(f.isIgnored("rock/covers", IgnoreRules::IsDirectory{ true })); + } + + TEST(IgnoreRules, FullPath_GlobInDir) + { + const IgnoreRules f{ "jazz/*.nfo\n" }; + EXPECT_TRUE(f.isIgnored("jazz/liner.nfo", IgnoreRules::IsDirectory{ false })); + EXPECT_FALSE(f.isIgnored("liner.nfo", IgnoreRules::IsDirectory{ false })); + EXPECT_FALSE(f.isIgnored("rock/liner.nfo", IgnoreRules::IsDirectory{ false })); + } + + TEST(IgnoreRules, Negation_ReIncludesAfterBroadMatch) + { + const IgnoreRules f{ "*.nfo\n!important.nfo\n" }; + EXPECT_TRUE(f.isIgnored("track.nfo", IgnoreRules::IsDirectory{ false })); + EXPECT_FALSE(f.isIgnored("important.nfo", IgnoreRules::IsDirectory{ false })); + } + + TEST(IgnoreRules, Negation_NoEffectIfNoPriorMatch) + { + const IgnoreRules f{ "!track.nfo\n" }; + EXPECT_FALSE(f.isIgnored("track.nfo", IgnoreRules::IsDirectory{ false })); + } + + TEST(IgnoreRules, LastRuleWins) + { + const IgnoreRules f{ "*.flac\n!keep.flac\n*.flac\n" }; + EXPECT_TRUE(f.isIgnored("keep.flac", IgnoreRules::IsDirectory{ false })); + } + + TEST(IgnoreRules, WindowsLineEndings) + { + const IgnoreRules f{ "*.nfo\r\n*.jpg\r\n" }; + EXPECT_TRUE(f.isIgnored("track.nfo", IgnoreRules::IsDirectory{ false })); + EXPECT_TRUE(f.isIgnored("cover.jpg", IgnoreRules::IsDirectory{ false })); + EXPECT_FALSE(f.isIgnored("track.flac", IgnoreRules::IsDirectory{ false })); + } +} // namespace lms::scanner::tests