Made .lmsignore more like .gitignore, fixes #486
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <unistd.h>
|
||||
|
||||
#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;
|
||||
|
||||
|
||||
@@ -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<const std::filesystem::path> 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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "IgnoreRules.hpp"
|
||||
|
||||
#include <fnmatch.h>
|
||||
|
||||
#include <fstream>
|
||||
|
||||
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<char>{ file }, std::istreambuf_iterator<char>{} } };
|
||||
}
|
||||
} // namespace lms::scanner
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#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<struct IsDirectoryTag>;
|
||||
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<Rule> _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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -31,8 +31,6 @@
|
||||
|
||||
namespace lms::scanner
|
||||
{
|
||||
static inline const std::filesystem::path excludeDirFileName{ ".lmsignore" };
|
||||
|
||||
struct ScannerSettings
|
||||
{
|
||||
std::size_t audioScanVersion{};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(std::error_code, const std::filesystem::path& path, const std::filesystem::directory_entry*)>;
|
||||
bool exploreFilesRecursive(const std::filesystem::path& directory, ExploreFileCallback cb, const std::filesystem::path* excludeDirFileName)
|
||||
using ShouldIgnoreCallback = std::function<bool(const std::filesystem::path& absPath, IgnoreRules::IsDirectory)>;
|
||||
|
||||
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<std::filesystem::directory_entry> 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<FileScanJob>(getFileScanners(), mediaLibrary, context.scanOptions.fullScan, filesToScan));
|
||||
|
||||
@@ -3,6 +3,7 @@ include(GoogleTest)
|
||||
add_executable(test-scanner
|
||||
ArtistInfo.cpp
|
||||
AudioFileUtils.cpp
|
||||
IgnoreFilter.cpp
|
||||
Lyrics.cpp
|
||||
PlayList.cpp
|
||||
ScannerStats.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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#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
|
||||
Reference in New Issue
Block a user