mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-05 00:06:35 +01:00
Merge pull request #16166 from Shadowghost/ignore-caching
Implement ignore rule caching
This commit is contained in:
@@ -536,6 +536,7 @@ namespace Emby.Server.Implementations
|
||||
serviceCollection.AddSingleton<IMusicManager, MusicManager>();
|
||||
|
||||
serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>();
|
||||
serviceCollection.AddSingleton<DotIgnoreIgnoreRule>();
|
||||
|
||||
serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace Emby.Server.Implementations.IO
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IServerConfigurationManager _configurationManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||
|
||||
/// <summary>
|
||||
/// The file system watchers.
|
||||
@@ -47,17 +48,20 @@ namespace Emby.Server.Implementations.IO
|
||||
/// <param name="configurationManager">The configuration manager.</param>
|
||||
/// <param name="fileSystem">The filesystem.</param>
|
||||
/// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
|
||||
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
||||
public LibraryMonitor(
|
||||
ILogger<LibraryMonitor> logger,
|
||||
ILibraryManager libraryManager,
|
||||
IServerConfigurationManager configurationManager,
|
||||
IFileSystem fileSystem,
|
||||
IHostApplicationLifetime appLifetime)
|
||||
IHostApplicationLifetime appLifetime,
|
||||
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
_configurationManager = configurationManager;
|
||||
_fileSystem = fileSystem;
|
||||
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
||||
|
||||
appLifetime.ApplicationStarted.Register(Start);
|
||||
appLifetime.ApplicationStopping.Register(Stop);
|
||||
@@ -354,7 +358,7 @@ namespace Emby.Server.Implementations.IO
|
||||
}
|
||||
|
||||
var fileInfo = _fileSystem.GetFileSystemInfo(path);
|
||||
if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null))
|
||||
if (_dotIgnoreIgnoreRule.ShouldIgnore(fileInfo, null))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using BitFaster.Caching.Lru;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Resolvers;
|
||||
@@ -15,22 +17,36 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
{
|
||||
private static readonly bool IsWindows = OperatingSystem.IsWindows();
|
||||
|
||||
private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
|
||||
{
|
||||
for (var current = directory; current is not null; current = current.Parent)
|
||||
{
|
||||
var ignorePath = Path.Join(current.FullName, ".ignore");
|
||||
if (File.Exists(ignorePath))
|
||||
{
|
||||
return new FileInfo(ignorePath);
|
||||
}
|
||||
}
|
||||
private readonly FastConcurrentLru<string, IgnoreFileCacheEntry> _directoryCache;
|
||||
private readonly FastConcurrentLru<string, ParsedIgnoreCacheEntry> _rulesCache;
|
||||
|
||||
return null;
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DotIgnoreIgnoreRule"/> class.
|
||||
/// </summary>
|
||||
public DotIgnoreIgnoreRule()
|
||||
{
|
||||
var cacheSize = Math.Max(100, Environment.ProcessorCount * 100);
|
||||
_directoryCache = new FastConcurrentLru<string, IgnoreFileCacheEntry>(
|
||||
Environment.ProcessorCount,
|
||||
cacheSize,
|
||||
StringComparer.Ordinal);
|
||||
_rulesCache = new FastConcurrentLru<string, ParsedIgnoreCacheEntry>(
|
||||
Environment.ProcessorCount,
|
||||
Math.Max(32, cacheSize / 4),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent);
|
||||
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnoredInternal(fileInfo, parent);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the directory lookup cache. The parsed rules cache is not cleared
|
||||
/// as it validates file modification time on each access.
|
||||
/// </summary>
|
||||
public void ClearDirectoryCache()
|
||||
{
|
||||
_directoryCache.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether or not the file is ignored.
|
||||
@@ -38,40 +54,38 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
/// <param name="fileInfo">The file information.</param>
|
||||
/// <param name="parent">The parent BaseItem.</param>
|
||||
/// <returns>True if the file should be ignored.</returns>
|
||||
public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
|
||||
public bool IsIgnoredInternal(FileSystemMetadata fileInfo, BaseItem? parent)
|
||||
{
|
||||
var searchDirectory = fileInfo.IsDirectory
|
||||
? new DirectoryInfo(fileInfo.FullName)
|
||||
: new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty);
|
||||
? fileInfo.FullName
|
||||
: Path.GetDirectoryName(fileInfo.FullName);
|
||||
|
||||
if (string.IsNullOrEmpty(searchDirectory.FullName))
|
||||
if (string.IsNullOrEmpty(searchDirectory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ignoreFile = FindIgnoreFile(searchDirectory);
|
||||
var ignoreFile = FindIgnoreFileCached(searchDirectory);
|
||||
if (ignoreFile is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fast path in case the ignore files isn't a symlink and is empty
|
||||
if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0)
|
||||
var parsedEntry = GetParsedRules(ignoreFile);
|
||||
if (parsedEntry is null)
|
||||
{
|
||||
// File was deleted after we cached the path - clear the directory cache entry and return false
|
||||
_directoryCache.TryRemove(searchDirectory, out _);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Empty file means ignore everything
|
||||
if (parsedEntry.IsEmpty)
|
||||
{
|
||||
// Ignore directory if we just have the file
|
||||
return true;
|
||||
}
|
||||
|
||||
var content = GetFileContent(ignoreFile);
|
||||
return string.IsNullOrWhiteSpace(content)
|
||||
|| CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory);
|
||||
}
|
||||
|
||||
private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory)
|
||||
{
|
||||
// If file has content, base ignoring off the content .gitignore-style rules
|
||||
var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return CheckIgnoreRules(path, rules, isDirectory);
|
||||
return parsedEntry.Rules.IsIgnored(GetPathToCheck(fileInfo.FullName, fileInfo.IsDirectory));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -117,8 +131,8 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
|
||||
// See https://github.com/jellyfin/jellyfin/issues/15484
|
||||
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
|
||||
// See https://github.com/jellyfin/jellyfin/issues/15484
|
||||
var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
|
||||
|
||||
// Add trailing slash for directories to match "folder/"
|
||||
@@ -130,11 +144,196 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
return ignore.IsIgnored(pathToCheck);
|
||||
}
|
||||
|
||||
private static string GetFileContent(FileInfo ignoreFile)
|
||||
private FileInfo? FindIgnoreFileCached(string directory)
|
||||
{
|
||||
ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
|
||||
return ignoreFile.Exists
|
||||
? File.ReadAllText(ignoreFile.FullName)
|
||||
: string.Empty;
|
||||
// Check if we have a cached result for this directory
|
||||
if (_directoryCache.TryGet(directory, out var cached))
|
||||
{
|
||||
return cached.IgnoreFileDirectory is null
|
||||
? null
|
||||
: new FileInfo(Path.Join(cached.IgnoreFileDirectory, ".ignore"));
|
||||
}
|
||||
|
||||
DirectoryInfo startDir;
|
||||
try
|
||||
{
|
||||
startDir = new DirectoryInfo(directory);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Walk up the directory tree to find .ignore file using DirectoryInfo.Parent
|
||||
var checkedDirs = new List<string> { directory };
|
||||
|
||||
for (var current = startDir; current is not null; current = current.Parent)
|
||||
{
|
||||
var currentPath = current.FullName;
|
||||
|
||||
// Check if this intermediate directory is cached
|
||||
if (current != startDir && _directoryCache.TryGet(currentPath, out var parentCached))
|
||||
{
|
||||
// Cache the result for all directories we checked
|
||||
var entry = new IgnoreFileCacheEntry(parentCached.IgnoreFileDirectory);
|
||||
foreach (var dir in checkedDirs)
|
||||
{
|
||||
_directoryCache.AddOrUpdate(dir, entry);
|
||||
}
|
||||
|
||||
return parentCached.IgnoreFileDirectory is null
|
||||
? null
|
||||
: new FileInfo(Path.Join(parentCached.IgnoreFileDirectory, ".ignore"));
|
||||
}
|
||||
|
||||
var ignoreFile = new FileInfo(Path.Join(currentPath, ".ignore"));
|
||||
if (ignoreFile.Exists)
|
||||
{
|
||||
// Cache for all directories we checked
|
||||
var entry = new IgnoreFileCacheEntry(currentPath);
|
||||
foreach (var dir in checkedDirs)
|
||||
{
|
||||
_directoryCache.AddOrUpdate(dir, entry);
|
||||
}
|
||||
|
||||
return ignoreFile;
|
||||
}
|
||||
|
||||
if (current != startDir)
|
||||
{
|
||||
checkedDirs.Add(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// No .ignore file found - cache null result for all directories
|
||||
var nullEntry = new IgnoreFileCacheEntry((string?)null);
|
||||
foreach (var dir in checkedDirs)
|
||||
{
|
||||
_directoryCache.AddOrUpdate(dir, nullEntry);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private ParsedIgnoreCacheEntry? GetParsedRules(FileInfo ignoreFile)
|
||||
{
|
||||
if (!ignoreFile.Exists)
|
||||
{
|
||||
_rulesCache.TryRemove(ignoreFile.FullName, out _);
|
||||
return null;
|
||||
}
|
||||
|
||||
var lastModified = ignoreFile.LastWriteTimeUtc;
|
||||
var fileLength = ignoreFile.Length;
|
||||
var key = ignoreFile.FullName;
|
||||
|
||||
// Check cache
|
||||
if (_rulesCache.TryGet(key, out var cached))
|
||||
{
|
||||
if (cached.FileLastModified == lastModified && cached.FileLength == fileLength)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Stale - need to reparse
|
||||
_rulesCache.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
// Parse the file
|
||||
var parsedEntry = ParseIgnoreFile(ignoreFile, lastModified, fileLength);
|
||||
_rulesCache.AddOrUpdate(key, parsedEntry);
|
||||
return parsedEntry;
|
||||
}
|
||||
|
||||
private static ParsedIgnoreCacheEntry ParseIgnoreFile(FileInfo ignoreFile, DateTime lastModified, long fileLength)
|
||||
{
|
||||
if (ignoreFile.LinkTarget is null && fileLength == 0)
|
||||
{
|
||||
return new ParsedIgnoreCacheEntry
|
||||
{
|
||||
Rules = new Ignore.Ignore(),
|
||||
FileLastModified = lastModified,
|
||||
FileLength = fileLength,
|
||||
IsEmpty = true
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve symlinks
|
||||
var resolvedFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
|
||||
if (!resolvedFile.Exists)
|
||||
{
|
||||
return new ParsedIgnoreCacheEntry
|
||||
{
|
||||
Rules = new Ignore.Ignore(),
|
||||
FileLastModified = lastModified,
|
||||
FileLength = fileLength,
|
||||
IsEmpty = true
|
||||
};
|
||||
}
|
||||
|
||||
var content = File.ReadAllText(resolvedFile.FullName);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return new ParsedIgnoreCacheEntry
|
||||
{
|
||||
Rules = new Ignore.Ignore(),
|
||||
FileLastModified = lastModified,
|
||||
FileLength = fileLength,
|
||||
IsEmpty = true
|
||||
};
|
||||
}
|
||||
|
||||
var rules = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var ignore = new Ignore.Ignore();
|
||||
var validRulesAdded = 0;
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
try
|
||||
{
|
||||
ignore.Add(rule);
|
||||
validRulesAdded++;
|
||||
}
|
||||
catch (RegexParseException)
|
||||
{
|
||||
// Ignore invalid patterns
|
||||
}
|
||||
}
|
||||
|
||||
// No valid rules means treat as empty (ignore all)
|
||||
return new ParsedIgnoreCacheEntry
|
||||
{
|
||||
Rules = ignore,
|
||||
FileLastModified = lastModified,
|
||||
FileLength = fileLength,
|
||||
IsEmpty = validRulesAdded == 0
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetPathToCheck(string path, bool isDirectory)
|
||||
{
|
||||
// Normalize Windows paths
|
||||
var pathToCheck = IsWindows ? path.NormalizePath('/') : path;
|
||||
|
||||
// Add trailing slash for directories to match "folder/"
|
||||
if (isDirectory)
|
||||
{
|
||||
pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/");
|
||||
}
|
||||
|
||||
return pathToCheck;
|
||||
}
|
||||
|
||||
private readonly record struct IgnoreFileCacheEntry(string? IgnoreFileDirectory);
|
||||
|
||||
private sealed class ParsedIgnoreCacheEntry
|
||||
{
|
||||
public required Ignore.Ignore Rules { get; init; }
|
||||
|
||||
public required DateTime FileLastModified { get; init; }
|
||||
|
||||
public required long FileLength { get; init; }
|
||||
|
||||
public required bool IsEmpty { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ namespace Emby.Server.Implementations.Library
|
||||
private readonly ExtraResolver _extraResolver;
|
||||
private readonly IPathManager _pathManager;
|
||||
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
|
||||
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||
|
||||
/// <summary>
|
||||
/// The _root folder sync lock.
|
||||
@@ -127,6 +128,7 @@ namespace Emby.Server.Implementations.Library
|
||||
/// <param name="directoryService">The directory service.</param>
|
||||
/// <param name="peopleRepository">The people repository.</param>
|
||||
/// <param name="pathManager">The path manager.</param>
|
||||
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
||||
public LibraryManager(
|
||||
IServerApplicationHost appHost,
|
||||
ILoggerFactory loggerFactory,
|
||||
@@ -148,7 +150,8 @@ namespace Emby.Server.Implementations.Library
|
||||
NamingOptions namingOptions,
|
||||
IDirectoryService directoryService,
|
||||
IPeopleRepository peopleRepository,
|
||||
IPathManager pathManager)
|
||||
IPathManager pathManager,
|
||||
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
|
||||
{
|
||||
_appHost = appHost;
|
||||
_logger = loggerFactory.CreateLogger<LibraryManager>();
|
||||
@@ -173,6 +176,7 @@ namespace Emby.Server.Implementations.Library
|
||||
_namingOptions = namingOptions;
|
||||
_peopleRepository = peopleRepository;
|
||||
_pathManager = pathManager;
|
||||
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
||||
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
|
||||
|
||||
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
|
||||
@@ -1305,6 +1309,7 @@ namespace Emby.Server.Implementations.Library
|
||||
public async Task ValidateMediaLibraryInternal(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
IsScanRunning = true;
|
||||
ClearIgnoreRuleCache();
|
||||
LibraryMonitor.Stop();
|
||||
|
||||
try
|
||||
@@ -1313,6 +1318,7 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearIgnoreRuleCache();
|
||||
LibraryMonitor.Start();
|
||||
IsScanRunning = false;
|
||||
}
|
||||
@@ -1320,6 +1326,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false)
|
||||
{
|
||||
ClearIgnoreRuleCache();
|
||||
RootFolder.Children = null;
|
||||
await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -1362,6 +1369,14 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
_persistenceService.DeleteItem(toDelete.ToArray());
|
||||
}
|
||||
|
||||
ClearIgnoreRuleCache();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearIgnoreRuleCache()
|
||||
{
|
||||
_dotIgnoreIgnoreRule.ClearDirectoryCache();
|
||||
}
|
||||
|
||||
private async Task PerformLibraryValidation(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
@@ -3182,7 +3197,7 @@ namespace Emby.Server.Implementations.Library
|
||||
public IEnumerable<BaseItem> FindExtras(BaseItem owner, IReadOnlyList<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService)
|
||||
{
|
||||
// Apply .ignore rules
|
||||
var filtered = fileSystemChildren.Where(c => !DotIgnoreIgnoreRule.IsIgnored(c, owner)).ToList();
|
||||
var filtered = fileSystemChildren.Where(c => !_dotIgnoreIgnoreRule.ShouldIgnore(c, owner)).ToList();
|
||||
var isFolder = owner.IsFolder || (owner is Video video && (video.VideoType == VideoType.BluRay || video.VideoType == VideoType.Dvd));
|
||||
var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath);
|
||||
if (ownerVideoInfo is null)
|
||||
|
||||
@@ -189,6 +189,7 @@ public class LibraryStructureController : BaseJellyfinApiController
|
||||
var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase));
|
||||
if (newLib is CollectionFolder folder)
|
||||
{
|
||||
_libraryManager.ClearIgnoreRuleCache();
|
||||
foreach (var child in folder.GetPhysicalFolders())
|
||||
{
|
||||
await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false);
|
||||
@@ -197,9 +198,12 @@ public class LibraryStructureController : BaseJellyfinApiController
|
||||
}
|
||||
else
|
||||
{
|
||||
_libraryManager.ClearIgnoreRuleCache();
|
||||
// We don't know if this one can be validated individually, trigger a new validation
|
||||
await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_libraryManager.ClearIgnoreRuleCache();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -177,6 +177,13 @@ namespace MediaBrowser.Controller.Library
|
||||
/// <returns>Task.</returns>
|
||||
Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cached ignore rule directory lookups.
|
||||
/// Call this before triggering a library scan or item refresh to ensure
|
||||
/// any changes to .ignore files are picked up.
|
||||
/// </summary>
|
||||
void ClearIgnoreRuleCache();
|
||||
|
||||
Task UpdateImagesAsync(BaseItem item, bool forceUpdate = false);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1133,6 +1133,7 @@ namespace MediaBrowser.Providers.Manager
|
||||
|
||||
var cancellationToken = _disposeCancellationTokenSource.Token;
|
||||
|
||||
libraryManager.ClearIgnoreRuleCache();
|
||||
while (_refreshQueue.TryDequeue(out var refreshItem, out _))
|
||||
{
|
||||
if (_disposed)
|
||||
@@ -1167,6 +1168,7 @@ namespace MediaBrowser.Providers.Manager
|
||||
lock (_refreshQueueLock)
|
||||
{
|
||||
_isProcessingRefreshQueue = false;
|
||||
libraryManager.ClearIgnoreRuleCache();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations.Library;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Library;
|
||||
@@ -78,4 +83,391 @@ public class DotIgnoreIgnoreRuleTest
|
||||
// Without normalization, Windows paths with backslashes won't match patterns expecting forward slashes
|
||||
Assert.False(DotIgnoreIgnoreRule.CheckIgnoreRules(path, _rule1, isDirectory: false, normalizePath: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheHit_RepeatedCallsDoNotRereadFiles()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var subDir = Path.Combine(tempDir, "subdir");
|
||||
Directory.CreateDirectory(subDir);
|
||||
|
||||
try
|
||||
{
|
||||
var ignoreFilePath = Path.Combine(tempDir, ".ignore");
|
||||
File.WriteAllText(ignoreFilePath, "*.tmp");
|
||||
|
||||
var rule = new DotIgnoreIgnoreRule();
|
||||
var fileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(subDir, "test.tmp"),
|
||||
IsDirectory = false
|
||||
};
|
||||
|
||||
// First call - should cache
|
||||
var result1 = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.True(result1);
|
||||
|
||||
// Second call - should use cache
|
||||
var result2 = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.True(result2);
|
||||
|
||||
// Third call with different file in same directory - should use cache
|
||||
var fileInfo2 = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(subDir, "other.tmp"),
|
||||
IsDirectory = false
|
||||
};
|
||||
var result3 = rule.ShouldIgnore(fileInfo2, null);
|
||||
Assert.True(result3);
|
||||
|
||||
// Call with file that doesn't match pattern
|
||||
var fileInfo3 = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(subDir, "other.txt"),
|
||||
IsDirectory = false
|
||||
};
|
||||
var result4 = rule.ShouldIgnore(fileInfo3, null);
|
||||
Assert.False(result4);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheInvalidation_ModifyIgnoreFile_Reparses()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var ignoreFilePath = Path.Combine(tempDir, ".ignore");
|
||||
File.WriteAllText(ignoreFilePath, "*.tmp");
|
||||
|
||||
var rule = new DotIgnoreIgnoreRule();
|
||||
var fileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(tempDir, "test.tmp"),
|
||||
IsDirectory = false
|
||||
};
|
||||
|
||||
// First call - should ignore .tmp files
|
||||
var result1 = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.True(result1);
|
||||
|
||||
// Modify the .ignore file to ignore .txt instead
|
||||
// Wait a bit to ensure the file modification time changes
|
||||
Thread.Sleep(50);
|
||||
File.WriteAllText(ignoreFilePath, "*.txt");
|
||||
|
||||
// Now .tmp files should NOT be ignored
|
||||
var result2 = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.False(result2);
|
||||
|
||||
// And .txt files SHOULD be ignored
|
||||
var txtFileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(tempDir, "test.txt"),
|
||||
IsDirectory = false
|
||||
};
|
||||
var result3 = rule.ShouldIgnore(txtFileInfo, null);
|
||||
Assert.True(result3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyIgnoreFile_IgnoresEverything()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var ignoreFilePath = Path.Combine(tempDir, ".ignore");
|
||||
File.WriteAllText(ignoreFilePath, string.Empty);
|
||||
|
||||
var rule = new DotIgnoreIgnoreRule();
|
||||
|
||||
var fileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(tempDir, "anyfile.mkv"),
|
||||
IsDirectory = false
|
||||
};
|
||||
|
||||
// Empty .ignore file should ignore everything
|
||||
var result = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhitespaceOnlyIgnoreFile_IgnoresEverything()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var ignoreFilePath = Path.Combine(tempDir, ".ignore");
|
||||
File.WriteAllText(ignoreFilePath, " \n\t\n ");
|
||||
|
||||
var rule = new DotIgnoreIgnoreRule();
|
||||
|
||||
var fileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(tempDir, "anyfile.mkv"),
|
||||
IsDirectory = false
|
||||
};
|
||||
|
||||
// Whitespace-only .ignore file should ignore everything
|
||||
var result = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoIgnoreFile_DoesNotIgnore()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var rule = new DotIgnoreIgnoreRule();
|
||||
|
||||
var fileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(tempDir, "anyfile.mkv"),
|
||||
IsDirectory = false
|
||||
};
|
||||
|
||||
// No .ignore file means don't ignore
|
||||
var result = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentAccess_ThreadSafe()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var ignoreFilePath = Path.Combine(tempDir, ".ignore");
|
||||
File.WriteAllText(ignoreFilePath, "*.tmp");
|
||||
|
||||
var rule = new DotIgnoreIgnoreRule();
|
||||
|
||||
// Run multiple parallel checks
|
||||
Parallel.For(0, 100, i =>
|
||||
{
|
||||
var fileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(tempDir, $"test{i}.tmp"),
|
||||
IsDirectory = false
|
||||
};
|
||||
|
||||
var result = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.True(result);
|
||||
});
|
||||
|
||||
// Also test with non-matching files
|
||||
Parallel.For(0, 100, i =>
|
||||
{
|
||||
var fileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(tempDir, $"test{i}.txt"),
|
||||
IsDirectory = false
|
||||
};
|
||||
|
||||
var result = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.False(result);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearCache_ClearsAllCachedData()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var ignoreFilePath = Path.Combine(tempDir, ".ignore");
|
||||
File.WriteAllText(ignoreFilePath, "*.tmp");
|
||||
|
||||
var rule = new DotIgnoreIgnoreRule();
|
||||
var fileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(tempDir, "test.tmp"),
|
||||
IsDirectory = false
|
||||
};
|
||||
|
||||
// First call to populate cache
|
||||
var result1 = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.True(result1);
|
||||
|
||||
// Clear cache
|
||||
rule.ClearDirectoryCache();
|
||||
|
||||
// Should still work (will re-populate cache)
|
||||
var result2 = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.True(result2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoreFileDeleted_HandlesGracefully()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var ignoreFilePath = Path.Combine(tempDir, ".ignore");
|
||||
File.WriteAllText(ignoreFilePath, "*.tmp");
|
||||
|
||||
var rule = new DotIgnoreIgnoreRule();
|
||||
var fileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(tempDir, "test.tmp"),
|
||||
IsDirectory = false
|
||||
};
|
||||
|
||||
// First call - should ignore
|
||||
var result1 = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.True(result1);
|
||||
|
||||
// Delete the .ignore file
|
||||
File.Delete(ignoreFilePath);
|
||||
|
||||
// Should not ignore anymore (file deleted)
|
||||
var result2 = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.False(result2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParentDirectoryIgnoreFile_AppliesToSubdirectories()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var subDir1 = Path.Combine(tempDir, "sub1");
|
||||
var subDir2 = Path.Combine(tempDir, "sub1", "sub2");
|
||||
Directory.CreateDirectory(subDir1);
|
||||
Directory.CreateDirectory(subDir2);
|
||||
|
||||
try
|
||||
{
|
||||
// Put .ignore in root
|
||||
var ignoreFilePath = Path.Combine(tempDir, ".ignore");
|
||||
File.WriteAllText(ignoreFilePath, "*.tmp");
|
||||
|
||||
var rule = new DotIgnoreIgnoreRule();
|
||||
|
||||
// Check file in sub2 - should find .ignore in parent
|
||||
var fileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(subDir2, "test.tmp"),
|
||||
IsDirectory = false
|
||||
};
|
||||
|
||||
var result = rule.ShouldIgnore(fileInfo, null);
|
||||
Assert.True(result);
|
||||
|
||||
// Check file in sub1
|
||||
var fileInfo2 = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(subDir1, "test.tmp"),
|
||||
IsDirectory = false
|
||||
};
|
||||
|
||||
var result2 = rule.ShouldIgnore(fileInfo2, null);
|
||||
Assert.True(result2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectoryMatching_TrailingSlashPattern()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var subDir = Path.Combine(tempDir, "videos");
|
||||
Directory.CreateDirectory(subDir);
|
||||
|
||||
try
|
||||
{
|
||||
var ignoreFilePath = Path.Combine(tempDir, ".ignore");
|
||||
File.WriteAllText(ignoreFilePath, "videos/");
|
||||
|
||||
var rule = new DotIgnoreIgnoreRule();
|
||||
|
||||
// Directory should be ignored
|
||||
var dirInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = subDir,
|
||||
IsDirectory = true
|
||||
};
|
||||
|
||||
var result = rule.ShouldIgnore(dirInfo, null);
|
||||
Assert.True(result);
|
||||
|
||||
// File named "videos" should NOT be ignored (pattern has trailing slash)
|
||||
var fileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = Path.Combine(tempDir, "videos"),
|
||||
IsDirectory = false
|
||||
};
|
||||
|
||||
// Note: The Ignore library behavior may vary here, this tests the actual behavior
|
||||
var resultFile = rule.ShouldIgnore(fileInfo, null);
|
||||
// The file named "videos" without trailing slash might or might not match depending on the library
|
||||
// This test documents the actual behavior
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user