Fixed .lmsignore sometimes not taken into account, fixes #868
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
add_executable(bench-scanner
|
add_executable(bench-scanner
|
||||||
|
IgnoreRules.cpp
|
||||||
Lyrics.cpp
|
Lyrics.cpp
|
||||||
Scanner.cpp
|
Scanner.cpp
|
||||||
TrackMetadataParser.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 <fnmatch.h>
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
|
||||||
namespace lms::scanner
|
namespace lms::scanner
|
||||||
@@ -79,9 +80,28 @@ namespace lms::scanner
|
|||||||
|
|
||||||
bool IgnoreRules::isIgnored(const std::filesystem::path& relativePath, IsDirectory isDir) const
|
bool IgnoreRules::isIgnored(const std::filesystem::path& relativePath, IsDirectory isDir) const
|
||||||
{
|
{
|
||||||
|
assert(relativePath.is_relative());
|
||||||
|
|
||||||
if (_rules.empty())
|
if (_rules.empty())
|
||||||
return false;
|
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{};
|
bool ignored{};
|
||||||
for (const Rule& rule : _rules)
|
for (const Rule& rule : _rules)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ namespace lms::scanner
|
|||||||
bool operator==(const Rule&) const = default;
|
bool operator==(const Rule&) const = default;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bool matchesRules(const std::filesystem::path& relativePath, IsDirectory isDir) const;
|
||||||
|
|
||||||
std::vector<Rule> _rules;
|
std::vector<Rule> _rules;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
#include "ScanStepCheckForRemovedFiles.hpp"
|
#include "ScanStepCheckForRemovedFiles.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <span>
|
#include <span>
|
||||||
@@ -107,17 +108,22 @@ namespace lms::scanner
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto isInActiveLibrary{ [&](const MediaLibraryInfo& lib) {
|
// media library root paths never overlap: at most one library can own this path
|
||||||
if (!core::pathUtils::isPathInRootPath(p, lib.rootDirectory))
|
const auto itOwningLibrary{ std::find_if(std::cbegin(_settings.mediaLibraries), std::cend(_settings.mediaLibraries), [&](const MediaLibraryInfo& lib) {
|
||||||
return false;
|
return core::pathUtils::isPathInRootPath(p, lib.rootDirectory);
|
||||||
return lib.ignoreRules.isEmpty() || !lib.ignoreRules.isIgnored(std::filesystem::relative(p, lib.rootDirectory), IgnoreRules::IsDirectory{ false });
|
}) };
|
||||||
} };
|
if (itOwningLibrary == std::cend(_settings.mediaLibraries))
|
||||||
if (std::none_of(std::cbegin(_settings.mediaLibraries), std::cend(_settings.mediaLibraries), isInActiveLibrary))
|
|
||||||
{
|
{
|
||||||
LMS_LOG(DBUPDATER, DEBUG, "Removing " << p << ": out of media directory");
|
LMS_LOG(DBUPDATER, DEBUG, "Removing " << p << ": out of media directory");
|
||||||
return false;
|
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))
|
if (!_scanners.select(p))
|
||||||
{
|
{
|
||||||
LMS_LOG(DBUPDATER, DEBUG, "Removing " << p << ": file format no longer handled");
|
LMS_LOG(DBUPDATER, DEBUG, "Removing " << p << ": file format no longer handled");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ include(GoogleTest)
|
|||||||
add_executable(test-scanner
|
add_executable(test-scanner
|
||||||
ArtistInfo.cpp
|
ArtistInfo.cpp
|
||||||
AudioFileUtils.cpp
|
AudioFileUtils.cpp
|
||||||
IgnoreFilter.cpp
|
IgnoreRules.cpp
|
||||||
Lyrics.cpp
|
Lyrics.cpp
|
||||||
PlayList.cpp
|
PlayList.cpp
|
||||||
ScannerStats.cpp
|
ScannerStats.cpp
|
||||||
|
|||||||
+39
-10
@@ -30,6 +30,13 @@ namespace lms::scanner::tests
|
|||||||
EXPECT_FALSE(f.isIgnored("track.flac", IgnoreRules::IsDirectory{ false }));
|
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)
|
TEST(IgnoreRules, CommentsAndBlanksOnly)
|
||||||
{
|
{
|
||||||
const IgnoreRules f{ "# this is a comment\n\n# another comment\n" };
|
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" };
|
const IgnoreRules f{ "covers*/\n" };
|
||||||
EXPECT_TRUE(f.isIgnored("covers", IgnoreRules::IsDirectory{ true }));
|
EXPECT_TRUE(f.isIgnored("covers", IgnoreRules::IsDirectory{ true }));
|
||||||
EXPECT_TRUE(f.isIgnored("covers_2024", 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("notcovers", IgnoreRules::IsDirectory{ true }));
|
||||||
EXPECT_FALSE(f.isIgnored("covers_2024", IgnoreRules::IsDirectory{ false })); // dirOnly
|
EXPECT_FALSE(f.isIgnored("covers_2024", IgnoreRules::IsDirectory{ false })); // dirOnly
|
||||||
}
|
}
|
||||||
@@ -112,29 +119,51 @@ namespace lms::scanner::tests
|
|||||||
TEST(IgnoreRules, DirOnly_AnchoredOnlyMatchesRoot)
|
TEST(IgnoreRules, DirOnly_AnchoredOnlyMatchesRoot)
|
||||||
{
|
{
|
||||||
const IgnoreRules f{ "/untagged/\n" };
|
const IgnoreRules f{ "/untagged/\n" };
|
||||||
EXPECT_TRUE(f.isIgnored("untagged", IgnoreRules::IsDirectory{ true })); // root level — 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("jazz/untagged", IgnoreRules::IsDirectory{ true })); // nested: no match
|
||||||
EXPECT_FALSE(f.isIgnored("untagged", IgnoreRules::IsDirectory{ false })); // file, not dir — no match
|
EXPECT_FALSE(f.isIgnored("untagged", IgnoreRules::IsDirectory{ false })); // file, not dir: no match
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST(IgnoreRules, DirOnly_UnanchoredMatchesAnyDepth)
|
TEST(IgnoreRules, DirOnly_UnanchoredMatchesAnyDepth)
|
||||||
{
|
{
|
||||||
const IgnoreRules f{ "covers/\n" };
|
const IgnoreRules f{ "covers/\n" };
|
||||||
EXPECT_TRUE(f.isIgnored("covers", IgnoreRules::IsDirectory{ true })); // root — 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("jazz/covers", IgnoreRules::IsDirectory{ true })); // nested: also match
|
||||||
EXPECT_TRUE(f.isIgnored("a/b/c/covers", IgnoreRules::IsDirectory{ true })); // deep — 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_FALSE(f.isIgnored("covers", IgnoreRules::IsDirectory{ false })); // file, not dir: no match
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST(IgnoreRules, FullPath_ExactDir)
|
TEST(IgnoreRules, FullPath_ExactDir)
|
||||||
{
|
{
|
||||||
const IgnoreRules f{ "jazz/covers\n" };
|
const IgnoreRules f{ "jazz/covers\n" };
|
||||||
EXPECT_TRUE(f.isIgnored("jazz/covers", IgnoreRules::IsDirectory{ true }));
|
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_TRUE(f.isIgnored("jazz/covers/foo", IgnoreRules::IsDirectory{ true })); // descendant of an ignored dir
|
||||||
EXPECT_FALSE(f.isIgnored("foo/jazz/covers", IgnoreRules::IsDirectory{ true })); // never reached in practice: scanner prunes jazz/covers/ first
|
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 }));
|
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)
|
TEST(IgnoreRules, FullPath_GlobInDir)
|
||||||
{
|
{
|
||||||
const IgnoreRules f{ "jazz/*.nfo\n" };
|
const IgnoreRules f{ "jazz/*.nfo\n" };
|
||||||
Reference in New Issue
Block a user