deepclean-mydisk 0.0.0-stage → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env python3
2
+ """相似照片扫描(dHash)——只读分析,供插件「相似照片」页面展示与清理。
3
+
4
+ 实现:系统解码器将图片缩成 9x8 → 灰度像素 → dHash64 → 汉明距离分组。
5
+ 不依赖第三方库;macOS 用 sips,Windows 用 PowerShell/.NET 系统图片解码器。
6
+ """
7
+ import argparse
8
+ import json
9
+ import os
10
+ import struct
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ from pathlib import Path
16
+
17
+ EXTS = {".jpg", ".jpeg", ".png", ".heic", ".heif", ".gif", ".webp", ".bmp", ".tiff"}
18
+ SKIP_DIRS = {"node_modules", ".git", ".Trash", "Library", "Photos Library.photoslibrary"}
19
+ PACKAGE_SUFFIXES = (
20
+ ".photoslibrary", ".photolibrary", ".sparsebundle", ".fcpbundle", ".logicx",
21
+ ".band", ".imovielibrary", ".tvlibrary", ".aplibrary",
22
+ )
23
+ CLOUD_ATTRS = 0x400000 | 0x40000 | 0x1000 # RECALL_ON_DATA_ACCESS | RECALL_ON_OPEN | OFFLINE
24
+ CLOUD_DIR_NAMES = ("onedrive", "dropbox", "google drive", "icloud drive")
25
+
26
+
27
+ def cloud_roots():
28
+ """云盘根:照搬 tree.py 的规则(平台固定位置 + 环境变量 + DEEPCLEAN_CLOUD_ROOTS)。"""
29
+ roots = []
30
+ home = Path.home()
31
+ if sys.platform == "darwin":
32
+ roots += [home / "Library" / "CloudStorage", home / "Library" / "Mobile Documents"]
33
+ else:
34
+ for name in ("OneDrive", "OneDriveConsumer", "OneDriveCommercial"):
35
+ value = os.environ.get(name)
36
+ if value:
37
+ roots.append(Path(value))
38
+ try:
39
+ for child in home.iterdir():
40
+ if child.name.lower().startswith(CLOUD_DIR_NAMES):
41
+ roots.append(child)
42
+ except OSError:
43
+ pass
44
+ extra = os.environ.get("DEEPCLEAN_CLOUD_ROOTS")
45
+ if extra:
46
+ try:
47
+ roots += [Path(p) for p in json.loads(extra) if isinstance(p, str)]
48
+ except (ValueError, TypeError):
49
+ pass
50
+ out = []
51
+ for root in roots:
52
+ try:
53
+ out.append(str(root.resolve()))
54
+ except OSError:
55
+ continue
56
+ return out
57
+
58
+
59
+ def is_cloud_path(path, roots):
60
+ try:
61
+ target = os.path.normcase(str(Path(path).resolve()))
62
+ except OSError:
63
+ return False
64
+ for root in roots:
65
+ base = os.path.normcase(str(Path(root).resolve()))
66
+ if target == base or target.startswith(base + os.sep):
67
+ return True
68
+ return False
69
+
70
+
71
+ def is_package_dir(name):
72
+ low = name.lower()
73
+ return any(low.endswith(suffix) for suffix in PACKAGE_SUFFIXES)
74
+
75
+
76
+ def _progress(pct, label):
77
+ try:
78
+ print(f"PROGRESS {pct} {label}", file=sys.stderr, flush=True)
79
+ except Exception:
80
+ pass
81
+
82
+
83
+ def bmp_gray(path):
84
+ """读 sips 产出的 9x8 BMP,返回 9x8 灰度(0-255)。"""
85
+ b = open(path, "rb").read()
86
+ if b[:2] != b"BM":
87
+ raise ValueError("not BMP")
88
+ off = struct.unpack_from("<I", b, 10)[0]
89
+ w, h = struct.unpack_from("<ii", b, 18)
90
+ bpp = struct.unpack_from("<H", b, 28)[0]
91
+ if w < 2 or abs(h) < 1 or bpp not in (24, 32):
92
+ raise ValueError(f"bad BMP w={w} h={h} bpp={bpp}")
93
+ top_down = h < 0
94
+ rows = abs(h)
95
+ step = bpp // 8
96
+ stride = ((w * bpp + 31) // 32) * 4
97
+ gray = []
98
+ for y in range(rows):
99
+ row = y if top_down else rows - 1 - y
100
+ base = off + row * stride
101
+ line = []
102
+ for x in range(w):
103
+ i = base + x * step
104
+ blue, green, red = b[i], b[i + 1], b[i + 2]
105
+ line.append(int(0.299 * red + 0.587 * green + 0.114 * blue))
106
+ gray.append(line)
107
+ return gray
108
+
109
+
110
+ def windows_command(mode):
111
+ if mode not in ("hash", "thumbnail"):
112
+ raise ValueError("Unknown image operation")
113
+ helper = Path(__file__).with_name("photos-windows.ps1")
114
+ command = "& {\n" + helper.read_text(encoding="utf-8") + "\n} -Mode " + mode
115
+ import base64
116
+ encoded = base64.b64encode(command.encode("utf-16-le")).decode("ascii")
117
+ return ["powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]
118
+
119
+
120
+ def windows_hashes(files):
121
+ """Decode the batch in one PowerShell process; paths travel as UTF-8 JSON, not shell code."""
122
+ result = subprocess.run(
123
+ windows_command("hash"),
124
+ input=json.dumps([f["path"] for f in files], ensure_ascii=False),
125
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=540)
126
+ if result.returncode:
127
+ raise RuntimeError("Windows image decoder failed: " + result.stderr.strip()[:500])
128
+ rows = json.loads(result.stdout.lstrip("\ufeff"))
129
+ return {r["path"]: int(r["hash"], 16) for r in rows if r.get("hash")}
130
+
131
+
132
+ def dhash(path):
133
+ with tempfile.TemporaryDirectory(prefix="dc-photo-") as td:
134
+ out = os.path.join(td, "x.bmp")
135
+ subprocess.run(["sips", "-z", "8", "9", "-s", "format", "bmp", str(path), "--out", out],
136
+ capture_output=True, timeout=30, check=True)
137
+ g = bmp_gray(out)
138
+ bits = 0
139
+ for y in range(8):
140
+ for x in range(8):
141
+ bits = (bits << 1) | (1 if g[y][x] > g[y][x + 1] else 0)
142
+ return bits
143
+
144
+
145
+ def hamming(a, b):
146
+ return bin(a ^ b).count("1")
147
+
148
+
149
+ def collect(dirs, min_kb, limit, stats=None):
150
+ """收集候选图片;云盘目录与 App 包目录跳过(云端去重=删云端文件,包目录移动会破坏 App)。"""
151
+ stats = stats if stats is not None else {"cloud": 0, "package": 0}
152
+ clouds = cloud_roots()
153
+ files = []
154
+ for root in dirs:
155
+ root = Path(root)
156
+ if not root.is_dir():
157
+ continue
158
+ if is_cloud_path(root, clouds):
159
+ stats["cloud"] += 1
160
+ continue
161
+ for dirpath, dirnames, filenames in os.walk(root):
162
+ kept = []
163
+ for d in dirnames:
164
+ if d in SKIP_DIRS or d.startswith("."):
165
+ continue
166
+ child = Path(dirpath) / d
167
+ if is_cloud_path(child, clouds):
168
+ stats["cloud"] += 1
169
+ continue
170
+ if is_package_dir(d):
171
+ stats["package"] += 1
172
+ continue
173
+ kept.append(d)
174
+ dirnames[:] = kept
175
+ for name in filenames:
176
+ if Path(name).suffix.lower() not in EXTS:
177
+ continue
178
+ p = Path(dirpath) / name
179
+ try:
180
+ st = p.stat()
181
+ except OSError:
182
+ continue
183
+ if getattr(st, "st_file_attributes", 0) & CLOUD_ATTRS:
184
+ stats["cloud"] += 1
185
+ continue
186
+ if st.st_size < min_kb * 1024:
187
+ continue
188
+ files.append({"path": str(p), "kb": st.st_size // 1024, "mtime": int(st.st_mtime), "size": st.st_size})
189
+ files.sort(key=lambda f: -f["size"])
190
+ return files[:limit], stats
191
+
192
+
193
+ def main():
194
+ ap = argparse.ArgumentParser()
195
+ ap.add_argument("--json", required=True)
196
+ ap.add_argument("--limit", type=int, default=600)
197
+ ap.add_argument("--min-kb", type=int, default=200)
198
+ ap.add_argument("--home")
199
+ args = ap.parse_args()
200
+ home = Path(args.home).expanduser() if args.home else Path.home()
201
+ dirs = [home / "Pictures", home / "Downloads", home / "Desktop", home / "Documents"]
202
+ t0 = time.time()
203
+ _progress(5, "查找图片")
204
+ files, stats = collect(dirs, args.min_kb, args.limit)
205
+ total = len(files)
206
+ _progress(15, f"计算指纹(0/{total})")
207
+ if sys.platform == "win32" and files:
208
+ hashes = windows_hashes(files)
209
+ for f in files:
210
+ f["hash"] = hashes.get(f["path"])
211
+ else:
212
+ for i, f in enumerate(files):
213
+ try:
214
+ f["hash"] = dhash(f["path"])
215
+ except (FileNotFoundError, PermissionError):
216
+ raise RuntimeError("Image decoder unavailable: sips")
217
+ except (OSError, ValueError, struct.error, subprocess.SubprocessError):
218
+ f["hash"] = None
219
+ if i % 25 == 0:
220
+ _progress(15 + int(70 * i / max(1, total)), f"计算指纹({i}/{total})")
221
+ good = [f for f in files if f["hash"] is not None]
222
+ if files and not good:
223
+ raise RuntimeError("No images could be decoded; check image formats and the system decoder")
224
+ _progress(88, "分组相似照片")
225
+ parent = list(range(len(good)))
226
+
227
+ def find(x):
228
+ while parent[x] != x:
229
+ parent[x] = parent[parent[x]]
230
+ x = parent[x]
231
+ return x
232
+
233
+ def union(a, b):
234
+ ra, rb = find(a), find(b)
235
+ if ra != rb:
236
+ parent[rb] = ra
237
+
238
+ for i in range(len(good)):
239
+ for j in range(i + 1, len(good)):
240
+ if hamming(good[i]["hash"], good[j]["hash"]) <= 6:
241
+ union(i, j)
242
+ groups = {}
243
+ for i, f in enumerate(good):
244
+ groups.setdefault(find(i), []).append(f)
245
+ result = []
246
+ for gi, items in groups.items():
247
+ if len(items) < 2:
248
+ continue
249
+ items.sort(key=lambda x: -x["size"])
250
+ total_kb = sum(x["kb"] for x in items)
251
+ waste = total_kb - items[0]["kb"]
252
+ result.append({
253
+ "id": f"g{gi}",
254
+ "items": [{k: it[k] for k in ("path", "kb", "mtime", "size")} for it in items],
255
+ "totalKb": total_kb,
256
+ "wastedKb": waste,
257
+ })
258
+ result.sort(key=lambda g: -g["wastedKb"])
259
+ out = {"generatedAt": int(time.time() * 1000), "scanned": total,
260
+ "compared": len(good), "skipped": total - len(good), "groups": result,
261
+ "skippedCloud": stats.get("cloud", 0), "skippedPackages": stats.get("package", 0),
262
+ "elapsed": round(time.time() - t0, 1)}
263
+ Path(args.json).write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
264
+ _progress(100, "完成")
265
+
266
+
267
+ if __name__ == "__main__":
268
+ main()