-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscanner.py
More file actions
179 lines (151 loc) · 5.42 KB
/
Copy pathscanner.py
File metadata and controls
179 lines (151 loc) · 5.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
import fnmatch
import time
from pathlib import Path
import config
def _get_size(path):
p = Path(path)
if p.is_symlink():
return p.lstat().st_size
if p.is_file():
return p.stat().st_size
total = 0
try:
for child in p.rglob("*"):
if not child.is_symlink() and child.is_file():
try:
total += child.stat().st_size
except OSError:
pass
except PermissionError:
pass
return total
def _format_size(size_bytes):
for unit in ("B", "KB", "MB", "GB", "TB"):
if size_bytes < 1024:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.1f} PB"
def _entry_info(path, downloads_root):
p = Path(path)
try:
rel = str(p.relative_to(downloads_root))
except ValueError:
rel = p.name
try:
st = p.lstat()
size = _get_size(path)
return {
"path": str(p),
"name": p.name,
"relative_path": rel,
"size": size,
"size_human": _format_size(size),
"modified": int(st.st_mtime),
"accessed": int(st.st_atime),
"is_dir": p.is_dir() and not p.is_symlink(),
}
except OSError as e:
return {
"path": str(p),
"name": p.name,
"relative_path": rel,
"size": 0,
"size_human": "0 B",
"modified": 0,
"accessed": 0,
"is_dir": False,
"error": str(e),
}
def _is_protected(entry, protected_paths):
candidates = {str(entry)}
if not entry.is_symlink():
try:
candidates.add(str(entry.resolve()))
except OSError:
pass
candidates.add(str(entry.absolute()))
return bool(candidates & protected_paths)
def _is_ignored(entry, ignore_paths):
if not ignore_paths:
return False
s = str(entry)
for ig in ignore_paths:
if ig.endswith('//'):
continue # folder-only patterns handled separately in _scan_dir
if any(c in ig for c in '*?['):
if fnmatch.fnmatchcase(s, ig):
return True
else:
if s == ig or s.startswith(ig.rstrip("/") + "/"):
return True
return False
def _is_folder_only_ignored(entry, ignore_paths):
"""Returns True for directories stored with // suffix — recurse in but don't add as orphan."""
if not ignore_paths:
return False
s = str(entry).rstrip('/')
return any(ig.endswith('//') and s == ig.rstrip('/') for ig in ignore_paths)
def _has_protected_descendant(directory, protected_paths):
prefix = str(directory).rstrip("/") + "/"
return any(p.startswith(prefix) for p in protected_paths)
def _has_folder_only_ignored_descendant(directory, ignore_paths):
"""Returns True if a folder-only ignore pattern lives inside this directory.
Used to ensure we recurse into a parent dir instead of treating it as a single orphan."""
if not ignore_paths:
return False
prefix = str(directory).rstrip("/") + "/"
return any(ig.endswith('//') and ig.startswith(prefix) for ig in ignore_paths)
def _scan_dir(directory, protected_paths, trash, downloads_root, results,
ignore_paths, min_age_seconds):
try:
entries = sorted(directory.iterdir(), key=lambda e: e.name.lower())
except PermissionError:
return
now = time.time()
for entry in entries:
if entry.name.startswith("."):
continue
try:
if entry.resolve() == trash:
continue
except OSError:
pass
if _is_protected(entry, protected_paths):
continue
if _is_ignored(entry, ignore_paths):
continue
# Skip items whose mtime is newer than the minimum age threshold
if min_age_seconds:
try:
if now - entry.lstat().st_mtime < min_age_seconds:
continue
except OSError:
pass
if entry.is_dir() and not entry.is_symlink():
if _is_folder_only_ignored(entry, ignore_paths):
_scan_dir(entry, protected_paths, trash, downloads_root, results,
ignore_paths, min_age_seconds)
elif (_has_protected_descendant(entry, protected_paths) or
_has_folder_only_ignored_descendant(entry, ignore_paths)):
_scan_dir(entry, protected_paths, trash, downloads_root, results,
ignore_paths, min_age_seconds)
else:
results.append(_entry_info(str(entry), downloads_root))
else:
results.append(_entry_info(str(entry), downloads_root))
def scan_orphans(protected_paths, ignore_paths=None, min_age_days=0):
"""
Recursively scan DOWNLOADS_DIR and return entries not claimed by any active torrent.
Skips items in ignore_paths and items newer than min_age_days.
"""
downloads = Path(config.DOWNLOADS_DIR)
trash = Path(config.TRASH_DIR).resolve()
if not downloads.exists():
return {"error": f"Downloads directory not found: {config.DOWNLOADS_DIR}"}
orphans = []
try:
_scan_dir(downloads, protected_paths, trash, downloads, orphans,
ignore_paths or set(), min_age_days * 86400)
except Exception as e:
return {"error": str(e)}
return orphans