Fixed .lmsignore sometimes not taken into account, fixes #868

This commit is contained in:
emeric
2026-07-17 23:57:12 +02:00
parent b6df7ae120
commit 7cb02828a9
7 changed files with 179 additions and 17 deletions
@@ -1,5 +1,6 @@
add_executable(bench-scanner
IgnoreRules.cpp
Lyrics.cpp
Scanner.cpp
TrackMetadataParser.cpp
@@ -0,0 +1,104 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include <string>
#include <benchmark/benchmark.h>
#include "IgnoreRules.hpp"
namespace lms::scanner::benchmarks
{
namespace
{
std::filesystem::path makeDeepPath(std::size_t depth)
{
std::filesystem::path path;
for (std::size_t i{}; i < depth; ++i)
path /= "artist_" + std::to_string(i);
path /= "track.flac";
return path;
}
std::string makeManyRules(std::size_t count)
{
std::string content;
for (std::size_t i{}; i < count; ++i)
content += "*.rule_" + std::to_string(i) + "\n";
return content;
}
void BM_IgnoreRules_isIgnored_ShallowNoMatch(benchmark::State& state)
{
const IgnoreRules rules{ "*.nfo\ncovers/\n" };
for (auto _ : state)
benchmark::DoNotOptimize(rules.isIgnored("track.flac", IgnoreRules::IsDirectory{ false }));
}
void BM_IgnoreRules_isIgnored_ShallowMatch(benchmark::State& state)
{
const IgnoreRules rules{ "*.nfo\ncovers/\n" };
for (auto _ : state)
benchmark::DoNotOptimize(rules.isIgnored("track.nfo", IgnoreRules::IsDirectory{ false }));
}
// Worst case for the ancestor walk: no rule ever matches, so every
// ancestor of a deep path gets fully evaluated on every call.
void BM_IgnoreRules_isIgnored_DeepPath_NoMatch(benchmark::State& state)
{
const IgnoreRules rules{ "*.nfo\ncovers/\n" };
const std::filesystem::path path{ makeDeepPath(static_cast<std::size_t>(state.range(0))) };
for (auto _ : state)
benchmark::DoNotOptimize(rules.isIgnored(path, IgnoreRules::IsDirectory{ false }));
}
// Best case: the leaf itself matches, so the ancestor walk exits on its first iteration.
void BM_IgnoreRules_isIgnored_DeepPath_LeafMatch(benchmark::State& state)
{
const IgnoreRules rules{ "*.flac\n" };
const std::filesystem::path path{ makeDeepPath(static_cast<std::size_t>(state.range(0))) };
for (auto _ : state)
benchmark::DoNotOptimize(rules.isIgnored(path, IgnoreRules::IsDirectory{ false }));
}
// Combines a deep path with a large rule set: every level of the
// ancestor walk pays the full per-rule fnmatch fold.
void BM_IgnoreRules_isIgnored_DeepPath_ManyRules_NoMatch(benchmark::State& state)
{
const IgnoreRules rules{ makeManyRules(static_cast<std::size_t>(state.range(1))) };
const std::filesystem::path path{ makeDeepPath(static_cast<std::size_t>(state.range(0))) };
for (auto _ : state)
benchmark::DoNotOptimize(rules.isIgnored(path, IgnoreRules::IsDirectory{ false }));
}
} // namespace
BENCHMARK(BM_IgnoreRules_isIgnored_ShallowNoMatch);
BENCHMARK(BM_IgnoreRules_isIgnored_ShallowMatch);
BENCHMARK(BM_IgnoreRules_isIgnored_DeepPath_NoMatch)->Arg(4)->Arg(8);
BENCHMARK(BM_IgnoreRules_isIgnored_DeepPath_LeafMatch)->Arg(4)->Arg(8);
BENCHMARK(BM_IgnoreRules_isIgnored_DeepPath_ManyRules_NoMatch)->Args({ 4, 10 })->Args({ 8, 20 });
} // namespace lms::scanner::benchmarks
@@ -21,6 +21,7 @@
#include <fnmatch.h>
#include <cassert>
#include <fstream>
namespace lms::scanner
@@ -79,9 +80,28 @@ namespace lms::scanner
bool IgnoreRules::isIgnored(const std::filesystem::path& relativePath, IsDirectory isDir) const
{
assert(relativePath.is_relative());
if (_rules.empty())
return false;
std::filesystem::path currentPath{ relativePath };
IsDirectory currentIsDir{ isDir };
while (!currentPath.empty())
{
if (matchesRules(currentPath, currentIsDir))
return true;
currentPath = currentPath.parent_path();
currentIsDir = IsDirectory{ true };
}
return false;
}
bool IgnoreRules::matchesRules(const std::filesystem::path& relativePath, IsDirectory isDir) const
{
bool ignored{};
for (const Rule& rule : _rules)
{
@@ -56,6 +56,8 @@ namespace lms::scanner
bool operator==(const Rule&) const = default;
};
bool matchesRules(const std::filesystem::path& relativePath, IsDirectory isDir) const;
std::vector<Rule> _rules;
public:
@@ -19,6 +19,7 @@
#include "ScanStepCheckForRemovedFiles.hpp"
#include <algorithm>
#include <deque>
#include <filesystem>
#include <span>
@@ -107,17 +108,22 @@ namespace lms::scanner
return false;
}
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))
// media library root paths never overlap: at most one library can own this path
const auto itOwningLibrary{ std::find_if(std::cbegin(_settings.mediaLibraries), std::cend(_settings.mediaLibraries), [&](const MediaLibraryInfo& lib) {
return core::pathUtils::isPathInRootPath(p, lib.rootDirectory);
}) };
if (itOwningLibrary == std::cend(_settings.mediaLibraries))
{
LMS_LOG(DBUPDATER, DEBUG, "Removing " << p << ": out of media directory");
return false;
}
if (itOwningLibrary->ignoreRules.isIgnored(std::filesystem::relative(p, itOwningLibrary->rootDirectory), IgnoreRules::IsDirectory{ false }))
{
LMS_LOG(DBUPDATER, DEBUG, "Removing " << p << ": ignored by .lmsignore rules");
return false;
}
if (!_scanners.select(p))
{
LMS_LOG(DBUPDATER, DEBUG, "Removing " << p << ": file format no longer handled");
@@ -3,7 +3,7 @@ include(GoogleTest)
add_executable(test-scanner
ArtistInfo.cpp
AudioFileUtils.cpp
IgnoreFilter.cpp
IgnoreRules.cpp
Lyrics.cpp
PlayList.cpp
ScannerStats.cpp
@@ -30,6 +30,13 @@ namespace lms::scanner::tests
EXPECT_FALSE(f.isIgnored("track.flac", IgnoreRules::IsDirectory{ false }));
}
TEST(IgnoreRules, EmptyPath_NeverIgnored)
{
const IgnoreRules f{ "*.mp3\ncovers/\n" };
EXPECT_FALSE(f.isIgnored("", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("", IgnoreRules::IsDirectory{ true }));
}
TEST(IgnoreRules, CommentsAndBlanksOnly)
{
const IgnoreRules f{ "# this is a comment\n\n# another comment\n" };
@@ -79,7 +86,7 @@ namespace lms::scanner::tests
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_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
}
@@ -112,29 +119,51 @@ namespace lms::scanner::tests
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
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
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_TRUE(f.isIgnored("jazz/covers/foo", IgnoreRules::IsDirectory{ true })); // descendant of an ignored dir
EXPECT_FALSE(f.isIgnored("foo/jazz/covers", IgnoreRules::IsDirectory{ true })); // unrelated path structure, no prefix matches
EXPECT_FALSE(f.isIgnored("rock/covers", IgnoreRules::IsDirectory{ true }));
}
TEST(IgnoreRules, AncestorDirIgnored_MatchesDescendantFile)
{
const IgnoreRules f{ "import\n" };
EXPECT_TRUE(f.isIgnored("import/song.mp3", IgnoreRules::IsDirectory{ false }));
EXPECT_TRUE(f.isIgnored("import/sub/song.mp3", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("other/song.mp3", IgnoreRules::IsDirectory{ false }));
}
TEST(IgnoreRules, AncestorDirIgnored_AnchoredRuleMatchesNestedFile)
{
const IgnoreRules f{ "/import/\n" };
EXPECT_TRUE(f.isIgnored("import/song.mp3", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("jazz/import/song.mp3", IgnoreRules::IsDirectory{ false })); // anchored to root only
}
TEST(IgnoreRules, AncestorDirIgnored_NegationDoesNotReinclude)
{
const IgnoreRules f{ "jazz/\n!jazz/keep.flac\n" };
EXPECT_TRUE(f.isIgnored("jazz/keep.flac", IgnoreRules::IsDirectory{ false })); // ancestor dir ignored: negation deeper inside has no effect
EXPECT_TRUE(f.isIgnored("jazz", IgnoreRules::IsDirectory{ true }));
}
TEST(IgnoreRules, FullPath_GlobInDir)
{
const IgnoreRules f{ "jazz/*.nfo\n" };