Skip to content

Commit 0c30179

Browse files
committed
Add .lrrignore support for filtering archives
Implement .lrrignore support (plexignore based syntax) for filtering comic archive scanning with nested directory support, single-pass DFS traversal, and cross-platform path handling.
1 parent 7258395 commit 0c30179

4 files changed

Lines changed: 615 additions & 4 deletions

File tree

lib/LANraragi/Model/Archive.pm

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ sub remove_toc_entry {
372372
}
373373

374374
# Deletes the archive with the given id from redis, and the matching archive file/thumbnail.
375-
sub delete_archive ($id) {
375+
sub delete_archive ($id, $keep_file = 0) {
376376

377377
my $redis = LANraragi::Model::Config->get_redis;
378378
my $filename = get_archive_path( $redis, $id );
@@ -423,7 +423,7 @@ sub delete_archive ($id) {
423423

424424
LANraragi::Utils::Database::update_indexes( $id, $oldtags, "" );
425425

426-
if ( -e $filename ) {
426+
if ( !$keep_file && -e $filename ) {
427427
my $status = unlink_path($filename);
428428

429429
my $thumbdir = LANraragi::Model::Config->get_thumbdir;

lib/LANraragi/Utils/Ignore.pm

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package LANraragi::Utils::Ignore;
2+
3+
use strict;
4+
use warnings;
5+
use utf8;
6+
use feature qw(signatures);
7+
no warnings 'experimental::signatures';
8+
9+
use File::Spec;
10+
use File::Basename qw(dirname);
11+
use Config;
12+
13+
use LANraragi::Utils::Path qw(open_path);
14+
use LANraragi::Utils::String qw(trim);
15+
16+
use Exporter 'import';
17+
our @EXPORT_OK = qw(
18+
is_ignored
19+
build_ignore_rules
20+
update_ignore_rules
21+
);
22+
23+
use constant IS_UNIX => ( $Config{osname} ne 'MSWin32' );
24+
25+
# Check if $file should be ignored based on pre-built rules.
26+
sub is_ignored ($file, $rules) {
27+
my $entries = $rules->{entries};
28+
return 0 unless @$entries;
29+
return match_rules( $file, $entries );
30+
}
31+
32+
# Rules data structure:
33+
# $rules = {
34+
# content_dir => $path, # Root content directory for scanning
35+
# current_dir => $path, # Tracks the farthest directory reached
36+
# entries => [ # Ordered list of nested .lrrignore entries
37+
# {
38+
# dir => $path, # Directory where .lrrignore was loaded
39+
# patterns => [ # Parsed patterns from a single .lrrignore file
40+
# {
41+
# pattern => "*.tmp", # Original pattern string
42+
# regex => qr/.../i, # Compiled regex for matching
43+
# negation => 0|1, # 1 = ! re-include, 0 = ignore
44+
# },
45+
# ...
46+
# ],
47+
# },
48+
# ...
49+
# ],
50+
# }
51+
#
52+
sub build_ignore_rules ($content_dir, $file = undef) {
53+
$content_dir =~ s{\\}{/}g unless IS_UNIX;
54+
my $rules = {
55+
content_dir => $content_dir,
56+
current_dir => $content_dir,
57+
entries => [],
58+
};
59+
60+
load_ignore_file( $content_dir, $rules->{entries} );
61+
update_ignore_rules( dirname($file), $rules ) if defined $file;
62+
63+
return $rules;
64+
}
65+
66+
# Pops stale entries and auto-loads .lrrignore for newly entered directories.
67+
sub update_ignore_rules ($dst_dir, $rules) {
68+
$dst_dir =~ s{\\}{/}g unless IS_UNIX;
69+
return if $rules->{current_dir} && $rules->{current_dir} eq $dst_dir;
70+
71+
my $entries = $rules->{entries};
72+
73+
while ( !is_subpath( $rules->{current_dir}, $dst_dir ) ) {
74+
pop @$entries if @$entries && $rules->{current_dir} eq $entries->[-1]{dir};
75+
$rules->{current_dir} = dirname( $rules->{current_dir} );
76+
}
77+
78+
my @missing;
79+
my $cur = $dst_dir;
80+
while ( $cur && $cur ne $rules->{current_dir} ) {
81+
unshift @missing, $cur;
82+
$cur = dirname($cur);
83+
}
84+
85+
for my $d (@missing) {
86+
load_ignore_file( $d, $entries );
87+
}
88+
89+
$rules->{current_dir} = $dst_dir;
90+
}
91+
92+
sub match_rules ($file, $entries) {
93+
for my $entry ( reverse @$entries ) {
94+
my $rel = File::Spec->abs2rel( $file, $entry->{dir} );
95+
$rel =~ s{\\}{/}g unless IS_UNIX;
96+
utf8::decode($rel);
97+
my $result;
98+
for my $rule ( @{ $entry->{patterns} } ) {
99+
next unless $rel =~ $rule->{regex};
100+
$result = $rule->{negation} ? 0 : 1;
101+
}
102+
return $result if defined $result;
103+
}
104+
return 0;
105+
}
106+
107+
sub parse_ignore_file ($path) {
108+
my @patterns;
109+
110+
open_path( my $fh, '<:encoding(UTF-8)', $path ) or return @patterns;
111+
my @lines = <$fh>;
112+
close($fh);
113+
114+
for my $line (@lines) {
115+
$line = trim($line);
116+
next if $line eq '';
117+
next if $line =~ /^#/;
118+
119+
my $negation = 0;
120+
if ( $line =~ s/^!// ) { $negation = 1; }
121+
122+
my $regex = glob_to_regex($line);
123+
next unless defined $regex;
124+
125+
push @patterns, {
126+
pattern => $line,
127+
regex => $regex,
128+
negation => $negation,
129+
};
130+
}
131+
132+
return @patterns;
133+
}
134+
135+
sub glob_to_regex ($pattern_str) {
136+
137+
$pattern_str =~ s{/$}{/*};
138+
139+
return () if $pattern_str eq '';
140+
141+
my $anchored = 0;
142+
if ( $pattern_str =~ s{^/}{} ) { $anchored = 1; }
143+
144+
$pattern_str =~ s{^\./}{};
145+
146+
$pattern_str = join '[^/]*', map {
147+
quotemeta($_ =~ s/\\(.)/$1/gr);
148+
} split(/(?<!\\)\*/, $pattern_str, -1);
149+
150+
if (!$anchored) {
151+
$pattern_str = '(?:.+/)?' . $pattern_str;
152+
}
153+
154+
return ( qr{^$pattern_str$}i );
155+
}
156+
157+
sub load_ignore_file ($dir, $entries) {
158+
my $ignore = File::Spec->catfile( $dir, ".lrrignore" );
159+
return unless -f $ignore;
160+
my @parsed = parse_ignore_file($ignore);
161+
return unless @parsed;
162+
push @$entries, { dir => $dir, patterns => \@parsed };
163+
}
164+
165+
166+
sub is_subpath ($ancestor, $target) {
167+
return 1 if $ancestor eq $target;
168+
$ancestor =~ s{[\\/]+$}{};
169+
return index( $target, $ancestor . '/' ) == 0;
170+
}
171+
172+
173+
1;

lib/Shinobu.pm

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ use LANraragi::Utils::Logging qw(get_logger);
3636
use LANraragi::Utils::Generic qw(is_archive exec_with_lock_pure);
3737
use LANraragi::Utils::Redis qw(redis_encode);
3838
use LANraragi::Utils::Path qw(create_path open_path find_path get_archive_path);
39+
use LANraragi::Utils::Ignore qw(is_ignored build_ignore_rules update_ignore_rules);
40+
use LANraragi::Model::Archive;
3941

4042
use LANraragi::Model::Config;
4143
use LANraragi::Model::Plugins;
@@ -133,11 +135,20 @@ sub update_filemap {
133135
my $dirname = LANraragi::Model::Config->get_userdir;
134136
my @files;
135137

138+
my $ignore_rules = build_ignore_rules($dirname);
139+
136140
# Get all files in content directory and subdirectories.
137141
find_path(
138142
sub {
139143
$_ = create_path($_);
140144
return if -d $_; #Directories are excluded on the spot
145+
146+
update_ignore_rules( dirname($_), $ignore_rules );
147+
148+
if ( is_ignored( $_, $ignore_rules ) ) {
149+
return;
150+
}
151+
141152
return unless is_archive($_);
142153
push @files, $_; #Push files to array
143154
},
@@ -154,11 +165,12 @@ sub update_filemap {
154165
my @deletedfiles = grep { !$fshash{$_} } @filemapfiles;
155166

156167
$logger->info( "Found " . scalar @newfiles . " new files." );
157-
$logger->info( scalar @deletedfiles . " files were found on the filemap but not on the filesystem." );
168+
$logger->info( scalar @deletedfiles . " stale entries to remove from filemap." );
158169

159-
# Delete old files from filemap
170+
# Delete old files from filemap and cleanup ignored archives
160171
foreach my $deletedfile (@deletedfiles) {
161172
$logger->debug("Removing $deletedfile from filemap.");
173+
cleanup_ignored_archive( $deletedfile );
162174
$redis->hdel( "LRR_FILEMAP", $deletedfile ) || $logger->warn("Couldn't delete previous filemap data.");
163175
}
164176

@@ -329,6 +341,13 @@ sub new_file_callback ($name) {
329341
$logger->debug("New file detected: $name");
330342
unless ( -d $name ) {
331343

344+
my $dirname = LANraragi::Model::Config->get_userdir;
345+
my $ignore_rules = build_ignore_rules( $dirname, $name );
346+
if ( is_ignored( $name, $ignore_rules ) ) {
347+
$logger->debug("$name matches .lrrignore rules, skipping.");
348+
return;
349+
}
350+
332351
my $redis = LANraragi::Model::Config->get_redis_config;
333352
eval { add_to_filemap( $redis, $name ); };
334353
$redis->quit();
@@ -357,6 +376,15 @@ sub deleted_file_callback ($name) {
357376
}
358377
}
359378

379+
sub cleanup_ignored_archive ($file) {
380+
return unless -e $file;
381+
my $redis = LANraragi::Model::Config->get_redis_config;
382+
my $id = $redis->hget( "LRR_FILEMAP", $file );
383+
$logger->info("$file is no longer tracked by filemap, cleaning up archive entry $id");
384+
LANraragi::Model::Archive::delete_archive( $id, 1 );
385+
$redis->quit();
386+
}
387+
360388
sub add_new_files (@files) {
361389
my $redis = LANraragi::Model::Config->get_redis_config;
362390

0 commit comments

Comments
 (0)