whatsapp-sweeper 1.0.0__tar.gz

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,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Almog Baku
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.5
2
+ Name: whatsapp-sweeper
3
+ Version: 1.0.0
4
+ Summary: Find and delete WhatsApp Desktop's bloated media cache, fast.
5
+ Project-URL: Homepage, https://github.com/AlmogBaku/whatsapp-sweeper
6
+ Project-URL: Repository, https://github.com/AlmogBaku/whatsapp-sweeper
7
+ Project-URL: Issues, https://github.com/AlmogBaku/whatsapp-sweeper/issues
8
+ Author-email: Almog Baku <almog.baku@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cleanup,macos,media,storage,whatsapp
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Environment :: MacOS X
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: MacOS :: MacOS X
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Topic :: Desktop Environment :: File Managers
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: pillow>=9.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # ๐Ÿงน WhatsApp Sweeper
26
+
27
+ A local, fast tool for finding and deleting the media WhatsApp Desktop has quietly piled up on your Mac.
28
+
29
+ ## The problem
30
+
31
+ WhatsApp Desktop downloads every photo, video, and voice note you ever receive and keeps it forever in a local cache. It's not unusual for that folder to quietly grow to 20-30GB โ€” years of stickers, forwarded videos, and group-chat noise you'll never look at again.
32
+
33
+ WhatsApp's engineers clearly track exactly which file belongs to which chat and sender โ€” it's all sitting right there in their own local database. They just never shipped a way for you to see or clean it up. So here we are.
34
+
35
+ ## What it does
36
+
37
+ WhatsApp Sweeper scans WhatsApp Desktop's media cache, cross-references it against WhatsApp's own local chat database to figure out who sent each file and from which chat, and gives you a simple visual grid โ€” sorted by size, biggest offenders first โ€” so you can select what to delete and reclaim the space in a couple of minutes.
38
+
39
+ - **Sorted by size** โ€” see what's actually eating your disk first
40
+ - **Real thumbnails** โ€” generated from the source files, not WhatsApp's blurry tiny cache
41
+ - **Sender & group filters** โ€” include/exclude by person or by group, combinable
42
+ - **Finder-style selection** โ€” click, shift-click for ranges, drag to multi-select
43
+ - **Runs entirely on your machine** โ€” no network calls, no accounts, nothing uploaded
44
+
45
+ > [!NOTE]
46
+ > This only reads WhatsApp's local cache and database to *display* information. It never sends anything anywhere โ€” everything happens on `localhost`.
47
+
48
+ ## Requirements
49
+
50
+ - macOS (this reads WhatsApp Desktop's macOS-specific cache and database โ€” it won't work anywhere else)
51
+ - WhatsApp Desktop, installed and opened at least once
52
+ - Python 3.9+
53
+ - [ffmpeg](https://ffmpeg.org) (optional, for sharper video thumbnails โ€” falls back to WhatsApp's own cached preview frame if it's missing)
54
+
55
+ ## Quick start
56
+
57
+ No install step needed โ€” just run it:
58
+
59
+ ```bash
60
+ uvx whatsapp-sweeper
61
+ ```
62
+
63
+ or, with [pipx](https://pipx.pypa.io):
64
+
65
+ ```bash
66
+ pipx run whatsapp-sweeper
67
+ ```
68
+
69
+ Either one launches a local server and opens your browser to it automatically. Leave the terminal running while you use it; `Ctrl+C` to stop.
70
+
71
+ ## Using it
72
+
73
+ - Click a thumbnail to open the file in its default app; double-click also works.
74
+ - Click, `Shift`-click, `Cmd`-click, or drag across thumbnails to select multiple โ€” like Finder's icon view.
75
+ - Use the sidebar to filter by sender or by group/chat. Clicking a name cycles it through include-only โ†’ exclude โ†’ off, and both filters combine.
76
+ - **Select all** selects everything matching the current filter, not just what's scrolled into view.
77
+
78
+ > [!IMPORTANT]
79
+ > Deleting is permanent โ€” there is no trash or undo. Review your selection before confirming. WhatsApp will simply treat removed media as not-yet-downloaded if you ever need it again.
80
+
81
+ ## How sender resolution works
82
+
83
+ Media files on disk are just UUIDs sitting in per-chat folders. To label each one with a sender and date, WhatsApp Sweeper reads `ChatStorage.sqlite` โ€” the same database WhatsApp Desktop itself uses โ€” joining the media entry to its message, then to the group member and chat, to resolve a display name. Older files that have aged out of WhatsApp's own message history simply show up as "Unknown" โ€” that's WhatsApp's retention limit, not a bug here.
@@ -0,0 +1,59 @@
1
+ # ๐Ÿงน WhatsApp Sweeper
2
+
3
+ A local, fast tool for finding and deleting the media WhatsApp Desktop has quietly piled up on your Mac.
4
+
5
+ ## The problem
6
+
7
+ WhatsApp Desktop downloads every photo, video, and voice note you ever receive and keeps it forever in a local cache. It's not unusual for that folder to quietly grow to 20-30GB โ€” years of stickers, forwarded videos, and group-chat noise you'll never look at again.
8
+
9
+ WhatsApp's engineers clearly track exactly which file belongs to which chat and sender โ€” it's all sitting right there in their own local database. They just never shipped a way for you to see or clean it up. So here we are.
10
+
11
+ ## What it does
12
+
13
+ WhatsApp Sweeper scans WhatsApp Desktop's media cache, cross-references it against WhatsApp's own local chat database to figure out who sent each file and from which chat, and gives you a simple visual grid โ€” sorted by size, biggest offenders first โ€” so you can select what to delete and reclaim the space in a couple of minutes.
14
+
15
+ - **Sorted by size** โ€” see what's actually eating your disk first
16
+ - **Real thumbnails** โ€” generated from the source files, not WhatsApp's blurry tiny cache
17
+ - **Sender & group filters** โ€” include/exclude by person or by group, combinable
18
+ - **Finder-style selection** โ€” click, shift-click for ranges, drag to multi-select
19
+ - **Runs entirely on your machine** โ€” no network calls, no accounts, nothing uploaded
20
+
21
+ > [!NOTE]
22
+ > This only reads WhatsApp's local cache and database to *display* information. It never sends anything anywhere โ€” everything happens on `localhost`.
23
+
24
+ ## Requirements
25
+
26
+ - macOS (this reads WhatsApp Desktop's macOS-specific cache and database โ€” it won't work anywhere else)
27
+ - WhatsApp Desktop, installed and opened at least once
28
+ - Python 3.9+
29
+ - [ffmpeg](https://ffmpeg.org) (optional, for sharper video thumbnails โ€” falls back to WhatsApp's own cached preview frame if it's missing)
30
+
31
+ ## Quick start
32
+
33
+ No install step needed โ€” just run it:
34
+
35
+ ```bash
36
+ uvx whatsapp-sweeper
37
+ ```
38
+
39
+ or, with [pipx](https://pipx.pypa.io):
40
+
41
+ ```bash
42
+ pipx run whatsapp-sweeper
43
+ ```
44
+
45
+ Either one launches a local server and opens your browser to it automatically. Leave the terminal running while you use it; `Ctrl+C` to stop.
46
+
47
+ ## Using it
48
+
49
+ - Click a thumbnail to open the file in its default app; double-click also works.
50
+ - Click, `Shift`-click, `Cmd`-click, or drag across thumbnails to select multiple โ€” like Finder's icon view.
51
+ - Use the sidebar to filter by sender or by group/chat. Clicking a name cycles it through include-only โ†’ exclude โ†’ off, and both filters combine.
52
+ - **Select all** selects everything matching the current filter, not just what's scrolled into view.
53
+
54
+ > [!IMPORTANT]
55
+ > Deleting is permanent โ€” there is no trash or undo. Review your selection before confirming. WhatsApp will simply treat removed media as not-yet-downloaded if you ever need it again.
56
+
57
+ ## How sender resolution works
58
+
59
+ Media files on disk are just UUIDs sitting in per-chat folders. To label each one with a sender and date, WhatsApp Sweeper reads `ChatStorage.sqlite` โ€” the same database WhatsApp Desktop itself uses โ€” joining the media entry to its message, then to the group member and chat, to resolve a display name. Older files that have aged out of WhatsApp's own message history simply show up as "Unknown" โ€” that's WhatsApp's retention limit, not a bug here.
@@ -0,0 +1,78 @@
1
+ [project]
2
+ name = "whatsapp-sweeper"
3
+ version = "1.0.0"
4
+ description = "Find and delete WhatsApp Desktop's bloated media cache, fast."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.9"
8
+ authors = [
9
+ { name = "Almog Baku", email = "almog.baku@gmail.com" },
10
+ ]
11
+ keywords = ["whatsapp", "macos", "storage", "cleanup", "media"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Environment :: Console",
15
+ "Environment :: MacOS X",
16
+ "Intended Audience :: End Users/Desktop",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: MacOS :: MacOS X",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Desktop Environment :: File Managers",
21
+ "Topic :: Utilities",
22
+ ]
23
+ dependencies = [
24
+ "Pillow>=9.0",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/AlmogBaku/whatsapp-sweeper"
29
+ Repository = "https://github.com/AlmogBaku/whatsapp-sweeper"
30
+ Issues = "https://github.com/AlmogBaku/whatsapp-sweeper/issues"
31
+
32
+ [project.scripts]
33
+ whatsapp-sweeper = "whatsapp_sweeper.server:main"
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/whatsapp_sweeper"]
41
+
42
+ [tool.hatch.build.targets.wheel.force-include]
43
+ "src/whatsapp_sweeper/index.html" = "whatsapp_sweeper/index.html"
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = [
47
+ "src/whatsapp_sweeper",
48
+ "README.md",
49
+ "LICENSE",
50
+ ]
51
+
52
+ [tool.semantic_release]
53
+ version_toml = ["pyproject.toml:project.version"]
54
+ branch = "main"
55
+ upload_to_vcs_release = true
56
+
57
+ [tool.semantic_release.commit_parser_options]
58
+ allowed_tags = ["feat", "fix", "perf", "refactor", "breaking", "chore", "docs", "style", "ci"]
59
+ minor_tags = ["feat"]
60
+ patch_tags = ["fix", "perf", "refactor"]
61
+
62
+ [tool.semantic_release.changelog]
63
+ exclude_commit_patterns = [
64
+ "^chore",
65
+ "^docs",
66
+ "^style",
67
+ "^ci",
68
+ ]
69
+
70
+ [tool.semantic_release.branches.main]
71
+ match = "main"
72
+ prerelease = false
73
+
74
+ [tool.semantic_release.remote]
75
+ type = "github"
76
+
77
+ [tool.semantic_release.publish]
78
+ build_command = "uv build"
@@ -0,0 +1,3 @@
1
+ """WhatsApp Sweeper: find and delete WhatsApp Desktop's bloated media cache."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,622 @@
1
+ <title>WhatsApp Sweeper</title>
2
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%A7%B9%3C/text%3E%3C/svg%3E">
3
+ <style>
4
+ :root { color-scheme: dark; }
5
+ * { box-sizing: border-box; }
6
+ html, body { height: 100%; margin: 0; }
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
9
+ background: #121212;
10
+ color: #e8e8e8;
11
+ display: flex;
12
+ overflow: hidden;
13
+ }
14
+
15
+ /* ---- sidebar ---- */
16
+ #sidebar {
17
+ width: 260px;
18
+ flex-shrink: 0;
19
+ height: 100%;
20
+ min-height: 0;
21
+ background: #1a1a1a;
22
+ border-right: 1px solid #2a2a2a;
23
+ display: flex;
24
+ flex-direction: column;
25
+ overflow: hidden;
26
+ }
27
+ #brand {
28
+ display: flex; align-items: center; gap: 8px;
29
+ padding: 14px; border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
30
+ }
31
+ #brand .logo {
32
+ font-size: 26px; line-height: 1; display: flex; align-items: center; justify-content: center;
33
+ }
34
+ #brand .name { font-size: 15px; font-weight: 700; color: #eee; }
35
+ #sidebar h2 { font-size: 14px; margin: 14px 14px 6px; color: #999; text-transform: uppercase; letter-spacing: .05em; }
36
+ #sidebar .hint { font-size: 11px; color: #777; margin: 0 14px 8px; line-height: 1.4; }
37
+ .filter-section { flex: 1; min-height: 0; display: flex; flex-direction: column; }
38
+ .filter-section + .filter-section { border-top: 1px solid #2a2a2a; }
39
+ .filter-search {
40
+ width: calc(100% - 28px);
41
+ margin: 4px 14px 8px; padding: 6px 8px; border-radius: 6px; border: 1px solid #333;
42
+ background: #111; color: #eee; font-size: 12px; outline: none;
43
+ }
44
+ .filter-search:focus { border-color: #4a8fe0; }
45
+ .filter-list { flex: 1; min-height: 0; overflow-y: auto; padding: 0 6px 12px; }
46
+ .sender-row {
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 8px;
50
+ padding: 6px 8px;
51
+ border-radius: 6px;
52
+ cursor: pointer;
53
+ font-size: 13px;
54
+ }
55
+ .sender-row:hover { background: #232323; }
56
+ .sender-row .chip {
57
+ width: 20px; height: 20px; border-radius: 5px; flex-shrink: 0;
58
+ border: 1px solid #444; display: flex; align-items: center; justify-content: center;
59
+ font-size: 12px; font-weight: bold;
60
+ }
61
+ .sender-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
62
+ .sender-row .size { color: #888; font-size: 11px; }
63
+ .sender-row.include .chip { background: #1e5f2e; border-color: #2e8b40; color: #7ee08b; }
64
+ .sender-row.exclude .chip { background: #5f1e1e; border-color: #8b2e2e; color: #e07e7e; }
65
+ .sender-row.include { background: #14261a; }
66
+ .sender-row.exclude { background: #261414; }
67
+
68
+ /* ---- main ---- */
69
+ #main { flex: 1; height: 100%; min-height: 0; min-width: 0; display: flex; flex-direction: column; }
70
+ #toolbar {
71
+ display: flex; align-items: center; gap: 16px;
72
+ padding: 10px 16px; background: #1a1a1a; border-bottom: 1px solid #2a2a2a;
73
+ flex-shrink: 0;
74
+ }
75
+ #toolbar .stat { font-size: 13px; color: #aaa; }
76
+ #toolbar .spacer { flex: 1; }
77
+ #deleteBtn {
78
+ background: #7a1f1f; color: #fff; border: none; padding: 8px 16px;
79
+ border-radius: 6px; font-size: 13px; cursor: pointer;
80
+ }
81
+ #deleteBtn:disabled { background: #3a3a3a; color: #777; cursor: default; }
82
+ #deleteBtn:not(:disabled):hover { background: #9a2a2a; }
83
+ #selectAllBtn {
84
+ background: #2a2a2a; color: #ddd; border: 1px solid #3a3a3a; padding: 8px 16px;
85
+ border-radius: 6px; font-size: 13px; cursor: pointer;
86
+ }
87
+ #selectAllBtn:hover { background: #333; }
88
+
89
+ #grid {
90
+ flex: 1; min-height: 0; overflow-y: auto; padding: 16px;
91
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
92
+ gap: 12px; align-content: start;
93
+ }
94
+ .card {
95
+ position: relative; cursor: pointer; border: 2px solid transparent; border-radius: 8px;
96
+ aspect-ratio: 1 / 1;
97
+ }
98
+ /* overflow:hidden must live on this inner wrapper, not on .card itself:
99
+ overflow!=visible on a grid item zeroes its "automatic minimum size",
100
+ which makes the grid miscompute the row's auto track height. */
101
+ .card .frame {
102
+ position: absolute; inset: 0; overflow: hidden; border-radius: 8px; background: #1e1e1e;
103
+ }
104
+ .card.selected { border-color: #4a8fe0; }
105
+ .card img, .card .placeholder {
106
+ width: 100%; height: 100%; object-fit: cover; display: block;
107
+ -webkit-user-drag: none; user-select: none;
108
+ }
109
+ .card .placeholder {
110
+ display: flex; align-items: center; justify-content: center;
111
+ font-size: 12px; color: #888; background: #262626; text-transform: uppercase;
112
+ }
113
+ .card input[type=checkbox] {
114
+ position: absolute; top: 6px; left: 6px; width: 18px; height: 18px; z-index: 2;
115
+ cursor: pointer;
116
+ }
117
+ .card .overlay {
118
+ position: absolute; left: 0; right: 0; bottom: 0;
119
+ background: linear-gradient(transparent, rgba(0,0,0,.85));
120
+ padding: 18px 6px 5px; font-size: 10.5px; line-height: 1.35; color: #eee;
121
+ pointer-events: none;
122
+ }
123
+ .card .overlay .sender { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
124
+ .card .overlay .chat { color: #8fa8c9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
125
+ .card .overlay .meta { color: #bbb; display: flex; justify-content: space-between; gap: 4px; }
126
+ .card .duration {
127
+ position: absolute; top: 6px; right: 6px; background: rgba(0,0,0,.7);
128
+ padding: 2px 5px; border-radius: 4px; font-size: 10px;
129
+ }
130
+ #sentinel { height: 1px; grid-column: 1 / -1; }
131
+ #empty { color: #777; padding: 40px; text-align: center; grid-column: 1 / -1; }
132
+ #marquee {
133
+ position: fixed; border: 1px solid #4a8fe0; background: rgba(74,143,224,.15);
134
+ pointer-events: none; display: none; z-index: 50;
135
+ }
136
+ </style>
137
+
138
+ <div id="sidebar">
139
+ <div id="brand">
140
+ <span class="logo">๐Ÿงน</span>
141
+ <span class="name">WhatsApp Sweeper</span>
142
+ </div>
143
+ <div class="filter-section">
144
+ <h2>Filter by sender</h2>
145
+ <div class="hint">Click a name to cycle: include only โ†’ exclude โ†’ off. Both can be active together.</div>
146
+ <input type="text" class="filter-search" id="senderSearch" placeholder="Search sendersโ€ฆ">
147
+ <div class="filter-list" id="senderList"></div>
148
+ </div>
149
+ <div class="filter-section">
150
+ <h2>Filter by group / chat</h2>
151
+ <input type="text" class="filter-search" id="chatSearch" placeholder="Search groupsโ€ฆ">
152
+ <div class="filter-list" id="chatList"></div>
153
+ </div>
154
+ </div>
155
+
156
+ <div id="main">
157
+ <div id="toolbar">
158
+ <span class="stat" id="totalStat">Loadingโ€ฆ</span>
159
+ <span class="spacer"></span>
160
+ <span class="stat" id="selStat"></span>
161
+ <button id="selectAllBtn">Select all</button>
162
+ <button id="deleteBtn" disabled>Delete selected</button>
163
+ </div>
164
+ <div id="grid"></div>
165
+ </div>
166
+
167
+ <script>
168
+ const state = {
169
+ include: new Set(),
170
+ exclude: new Set(),
171
+ includeChat: new Set(),
172
+ excludeChat: new Set(),
173
+ selected: new Set(), // ids
174
+ selectedSizeOverride: null, // set by "select all" (exact server total); cleared on any manual toggle
175
+ itemsById: new Map(), // id -> item, for size lookups
176
+ lastClickedId: null, // shift-click range anchor
177
+ filteredTotal: 0, // total_count for the current filter (from last /api/items)
178
+ nextOffset: 0,
179
+ loading: false,
180
+ done: false,
181
+ };
182
+
183
+ function humanSize(bytes) {
184
+ if (bytes == null) return '';
185
+ const units = ['B','KB','MB','GB','TB'];
186
+ let i = 0, v = bytes;
187
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
188
+ return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
189
+ }
190
+
191
+ function humanDuration(sec) {
192
+ if (sec == null) return null;
193
+ sec = Math.round(sec);
194
+ const m = Math.floor(sec / 60), s = sec % 60;
195
+ return `${m}:${String(s).padStart(2, '0')}`;
196
+ }
197
+
198
+ function humanDate(epoch) {
199
+ if (!epoch) return '';
200
+ const d = new Date(epoch * 1000);
201
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
202
+ }
203
+
204
+ // Generic filter-section wiring: shared by "sender" and "chat" (group) filters.
205
+ const filterSections = {
206
+ sender: { endpoint: '/api/senders', listId: 'senderList', searchId: 'senderSearch', include: state.include, exclude: state.exclude },
207
+ chat: { endpoint: '/api/chats', listId: 'chatList', searchId: 'chatSearch', include: state.includeChat, exclude: state.excludeChat },
208
+ };
209
+
210
+ async function loadFilterSection(key) {
211
+ const cfg = filterSections[key];
212
+ const res = await fetch(cfg.endpoint);
213
+ const rows = await res.json();
214
+ const list = document.getElementById(cfg.listId);
215
+ list.innerHTML = '';
216
+ for (const s of rows) {
217
+ const row = document.createElement('div');
218
+ row.className = 'sender-row';
219
+ row.dataset.name = s.name.toLowerCase();
220
+ row.innerHTML = `<span class="chip"></span><span class="name">${escapeHtml(s.name)}</span><span class="size">${humanSize(s.total_size)}</span>`;
221
+ row.onclick = () => cycleFilter(key, s.name, row);
222
+ list.appendChild(row);
223
+ }
224
+ }
225
+
226
+ function cycleFilter(key, name, row) {
227
+ const { include, exclude } = filterSections[key];
228
+ if (include.has(name)) {
229
+ include.delete(name);
230
+ exclude.add(name);
231
+ row.className = 'sender-row exclude';
232
+ row.querySelector('.chip').textContent = 'โˆ’';
233
+ } else if (exclude.has(name)) {
234
+ exclude.delete(name);
235
+ row.className = 'sender-row';
236
+ row.querySelector('.chip').textContent = '';
237
+ } else {
238
+ include.add(name);
239
+ row.className = 'sender-row include';
240
+ row.querySelector('.chip').textContent = 'โœ“';
241
+ }
242
+ resetGrid();
243
+ }
244
+
245
+ function wireFilterSearch(key) {
246
+ const cfg = filterSections[key];
247
+ document.getElementById(cfg.searchId).addEventListener('input', (e) => {
248
+ const q = e.target.value.trim().toLowerCase();
249
+ for (const row of document.getElementById(cfg.listId).children) {
250
+ row.style.display = row.dataset.name.includes(q) ? '' : 'none';
251
+ }
252
+ });
253
+ }
254
+
255
+ function escapeHtml(s) {
256
+ const d = document.createElement('div');
257
+ d.textContent = s ?? '';
258
+ return d.innerHTML;
259
+ }
260
+
261
+ function resetGrid() {
262
+ document.getElementById('grid').innerHTML = '';
263
+ state.selected.clear();
264
+ state.selectedSizeOverride = null;
265
+ state.lastClickedId = null;
266
+ updateSelStat();
267
+ state.nextOffset = 0;
268
+ state.done = false;
269
+ loadMore();
270
+ }
271
+
272
+ async function loadMore() {
273
+ if (state.loading || state.done) return;
274
+ state.loading = true;
275
+ const params = new URLSearchParams({
276
+ offset: state.nextOffset,
277
+ limit: 60,
278
+ include: [...state.include].join(','),
279
+ exclude: [...state.exclude].join(','),
280
+ include_chat: [...state.includeChat].join(','),
281
+ exclude_chat: [...state.excludeChat].join(','),
282
+ });
283
+ const res = await fetch('/api/items?' + params);
284
+ const data = await res.json();
285
+ state.filteredTotal = data.total_count;
286
+ document.getElementById('totalStat').textContent =
287
+ `${data.total_count.toLocaleString()} files ยท ${humanSize(data.total_size)}`;
288
+ updateSelectAllLabel();
289
+
290
+ const grid = document.getElementById('grid');
291
+ const sentinel = document.getElementById('sentinel');
292
+ if (sentinel) sentinel.remove();
293
+
294
+ if (data.items.length === 0 && state.nextOffset === 0) {
295
+ const empty = document.createElement('div');
296
+ empty.id = 'empty';
297
+ empty.textContent = 'No files match this filter.';
298
+ grid.appendChild(empty);
299
+ }
300
+
301
+ for (const item of data.items) {
302
+ grid.appendChild(renderCard(item));
303
+ }
304
+
305
+ if (data.next_offset != null) {
306
+ state.nextOffset = data.next_offset;
307
+ const s = document.createElement('div');
308
+ s.id = 'sentinel';
309
+ grid.appendChild(s);
310
+ observer.observe(s);
311
+ } else {
312
+ state.done = true;
313
+ }
314
+ state.loading = false;
315
+ }
316
+
317
+ function renderCard(item) {
318
+ state.itemsById.set(item.id, item);
319
+
320
+ const card = document.createElement('div');
321
+ const preSelected = state.selected.has(item.id);
322
+ card.className = preSelected ? 'card selected' : 'card';
323
+ card.dataset.id = item.id;
324
+
325
+ const frame = document.createElement('div');
326
+ frame.className = 'frame';
327
+ card.appendChild(frame);
328
+
329
+ const cb = document.createElement('input');
330
+ cb.type = 'checkbox';
331
+ cb.checked = preSelected;
332
+ cb.onclick = (e) => {
333
+ e.stopPropagation();
334
+ setCardSelected(item.id, cb.checked);
335
+ state.lastClickedId = item.id;
336
+ updateSelStat();
337
+ };
338
+ card.appendChild(cb);
339
+
340
+ if (item.has_thumb) {
341
+ const img = document.createElement('img');
342
+ img.loading = 'lazy';
343
+ img.draggable = false;
344
+ img.src = `/thumb/${item.id}`;
345
+ img.onerror = () => { img.replaceWith(placeholder(item.ext)); };
346
+ frame.appendChild(img);
347
+ } else {
348
+ frame.appendChild(placeholder(item.ext));
349
+ }
350
+
351
+ const dur = humanDuration(item.duration);
352
+ if (dur) {
353
+ const d = document.createElement('div');
354
+ d.className = 'duration';
355
+ d.textContent = dur;
356
+ frame.appendChild(d);
357
+ }
358
+
359
+ const overlay = document.createElement('div');
360
+ overlay.className = 'overlay';
361
+ const showChat = item.chat && item.chat !== item.sender;
362
+ overlay.innerHTML = `
363
+ <div class="sender">${escapeHtml(item.sender)}</div>
364
+ ${showChat ? `<div class="chat">${escapeHtml(item.chat)}</div>` : ''}
365
+ <div class="meta"><span>${humanSize(item.size)}</span><span>${humanDate(item.date)}</span></div>
366
+ `;
367
+ frame.appendChild(overlay);
368
+
369
+ // Finder-like selection: click = select only, shift = range, cmd/ctrl = toggle.
370
+ // Double-click opens the file.
371
+ card.addEventListener('click', (e) => {
372
+ if (e.shiftKey && state.lastClickedId != null) {
373
+ selectRange(state.lastClickedId, item.id);
374
+ } else if (e.metaKey || e.ctrlKey) {
375
+ setCardSelected(item.id, !state.selected.has(item.id));
376
+ state.lastClickedId = item.id;
377
+ } else {
378
+ clearSelection();
379
+ setCardSelected(item.id, true);
380
+ state.lastClickedId = item.id;
381
+ }
382
+ updateSelStat();
383
+ });
384
+ card.addEventListener('dblclick', () => {
385
+ fetch('/api/open', {
386
+ method: 'POST',
387
+ headers: { 'Content-Type': 'application/json' },
388
+ body: JSON.stringify({ id: item.id }),
389
+ });
390
+ });
391
+
392
+ return card;
393
+ }
394
+
395
+ function placeholder(ext) {
396
+ const p = document.createElement('div');
397
+ p.className = 'placeholder';
398
+ p.textContent = ext || 'file';
399
+ return p;
400
+ }
401
+
402
+ function setCardSelected(id, selected) {
403
+ state.selectedSizeOverride = null; // manual change invalidates the "select all" exact total
404
+ const card = document.querySelector(`.card[data-id="${id}"]`);
405
+ if (selected) {
406
+ state.selected.add(id);
407
+ if (card) card.classList.add('selected');
408
+ } else {
409
+ state.selected.delete(id);
410
+ if (card) card.classList.remove('selected');
411
+ }
412
+ if (card) {
413
+ const cb = card.querySelector('input[type=checkbox]');
414
+ if (cb) cb.checked = selected;
415
+ }
416
+ }
417
+
418
+ function clearSelection() {
419
+ for (const id of [...state.selected]) setCardSelected(id, false);
420
+ }
421
+
422
+ function selectRange(fromId, toId) {
423
+ const ids = [...document.querySelectorAll('.card')].map(c => Number(c.dataset.id));
424
+ const i1 = ids.indexOf(fromId), i2 = ids.indexOf(toId);
425
+ if (i1 === -1 || i2 === -1) {
426
+ clearSelection();
427
+ setCardSelected(toId, true);
428
+ return;
429
+ }
430
+ const [lo, hi] = i1 < i2 ? [i1, i2] : [i2, i1];
431
+ clearSelection();
432
+ for (let i = lo; i <= hi; i++) setCardSelected(ids[i], true);
433
+ }
434
+
435
+ function updateSelStat() {
436
+ const n = state.selected.size;
437
+ let total = state.selectedSizeOverride;
438
+ if (total == null) {
439
+ total = 0;
440
+ for (const id of state.selected) {
441
+ const it = state.itemsById.get(id);
442
+ if (it) total += it.size;
443
+ }
444
+ }
445
+ document.getElementById('selStat').textContent = n ? `${n} selected ยท ${humanSize(total)}` : '';
446
+ document.getElementById('deleteBtn').disabled = n === 0;
447
+ updateSelectAllLabel();
448
+ }
449
+
450
+ function updateSelectAllLabel() {
451
+ const btn = document.getElementById('selectAllBtn');
452
+ const allSelected = state.filteredTotal > 0 && state.selected.size >= state.filteredTotal;
453
+ btn.textContent = allSelected ? 'Deselect all' : `Select all ${state.filteredTotal.toLocaleString()}`;
454
+ }
455
+
456
+ document.getElementById('selectAllBtn').onclick = async () => {
457
+ const allSelected = state.filteredTotal > 0 && state.selected.size >= state.filteredTotal;
458
+ if (allSelected) {
459
+ clearSelection();
460
+ updateSelStat();
461
+ return;
462
+ }
463
+ const params = new URLSearchParams({
464
+ include: [...state.include].join(','),
465
+ exclude: [...state.exclude].join(','),
466
+ include_chat: [...state.includeChat].join(','),
467
+ exclude_chat: [...state.excludeChat].join(','),
468
+ });
469
+ const res = await fetch('/api/select_all?' + params);
470
+ const data = await res.json();
471
+ state.selected = new Set(data.ids);
472
+ state.selectedSizeOverride = data.total_size;
473
+ // Reflect selection on whatever cards are already rendered.
474
+ for (const card of document.querySelectorAll('.card')) {
475
+ const id = Number(card.dataset.id);
476
+ const sel = state.selected.has(id);
477
+ card.classList.toggle('selected', sel);
478
+ const cb = card.querySelector('input[type=checkbox]');
479
+ if (cb) cb.checked = sel;
480
+ }
481
+ updateSelStat();
482
+ };
483
+
484
+ // ---- Finder-style rubber-band drag selection ----
485
+ const marquee = document.createElement('div');
486
+ marquee.id = 'marquee';
487
+ document.body.appendChild(marquee);
488
+
489
+ let dragStart = null;
490
+ let dragMoved = false;
491
+ let dragAdditive = false;
492
+ let dragSnapshot = null;
493
+ let lastMouse = { x: 0, y: 0 };
494
+ let autoScrollRAF = null;
495
+
496
+ const AUTOSCROLL_EDGE = 50; // px from top/bottom edge that triggers scrolling
497
+ const AUTOSCROLL_SPEED = 16; // max px scrolled per animation frame
498
+
499
+ document.getElementById('grid').addEventListener('mousedown', (e) => {
500
+ // Only the checkbox opts out of drag-select; dragging that starts on top of
501
+ // a thumbnail should still paint-select, like Finder icon view.
502
+ if (e.button !== 0 || e.target.closest('input[type=checkbox]')) return;
503
+ dragStart = { x: e.clientX, y: e.clientY };
504
+ dragMoved = false;
505
+ dragAdditive = e.shiftKey || e.metaKey || e.ctrlKey;
506
+ dragSnapshot = new Set(state.selected);
507
+ if (!dragAdditive) clearSelection();
508
+ e.preventDefault();
509
+ });
510
+
511
+ document.addEventListener('mousemove', (e) => {
512
+ lastMouse = { x: e.clientX, y: e.clientY };
513
+ if (!dragStart) return;
514
+ if (!dragMoved) {
515
+ // Don't show the marquee (or touch selection) for a plain click with no
516
+ // real movement โ€” let the card's own click handler own that case.
517
+ const moved = Math.abs(e.clientX - dragStart.x) > 3 || Math.abs(e.clientY - dragStart.y) > 3;
518
+ if (!moved) return;
519
+ dragMoved = true;
520
+ marquee.style.display = 'block';
521
+ if (!autoScrollRAF) autoScrollRAF = requestAnimationFrame(autoScrollTick);
522
+ }
523
+ updateMarquee(e.clientX, e.clientY);
524
+ applyMarqueeSelection();
525
+ });
526
+
527
+ document.addEventListener('mouseup', () => {
528
+ if (!dragStart) return;
529
+ dragStart = null;
530
+ dragMoved = false;
531
+ marquee.style.display = 'none';
532
+ updateSelStat();
533
+ });
534
+
535
+ // Autoscroll the grid while dragging near its top/bottom edge, like Finder.
536
+ // The marquee anchor moves with the content so it stays pinned to the same
537
+ // pixel as the grid scrolls underneath the still-held cursor.
538
+ function autoScrollTick() {
539
+ if (!dragStart) { autoScrollRAF = null; return; }
540
+ autoScrollRAF = requestAnimationFrame(autoScrollTick);
541
+ if (!dragMoved) return;
542
+
543
+ const grid = document.getElementById('grid');
544
+ const rect = grid.getBoundingClientRect();
545
+ const y = lastMouse.y;
546
+ let dy = 0;
547
+ if (y < rect.top + AUTOSCROLL_EDGE) {
548
+ dy = -AUTOSCROLL_SPEED * Math.min(1, (rect.top + AUTOSCROLL_EDGE - y) / AUTOSCROLL_EDGE);
549
+ } else if (y > rect.bottom - AUTOSCROLL_EDGE) {
550
+ dy = AUTOSCROLL_SPEED * Math.min(1, (y - (rect.bottom - AUTOSCROLL_EDGE)) / AUTOSCROLL_EDGE);
551
+ }
552
+ if (dy === 0) return;
553
+
554
+ const before = grid.scrollTop;
555
+ grid.scrollTop += dy;
556
+ const actualDy = grid.scrollTop - before;
557
+ if (actualDy === 0) return;
558
+
559
+ dragStart.y -= actualDy;
560
+ updateMarquee(lastMouse.x, lastMouse.y);
561
+ applyMarqueeSelection();
562
+ }
563
+
564
+ function updateMarquee(x, y) {
565
+ const left = Math.min(x, dragStart.x), top = Math.min(y, dragStart.y);
566
+ const width = Math.abs(x - dragStart.x), height = Math.abs(y - dragStart.y);
567
+ marquee.style.left = left + 'px';
568
+ marquee.style.top = top + 'px';
569
+ marquee.style.width = width + 'px';
570
+ marquee.style.height = height + 'px';
571
+ marquee._rect = { left, top, right: left + width, bottom: top + height };
572
+ }
573
+
574
+ function rectsIntersect(a, b) {
575
+ return !(b.left > a.right || b.right < a.left || b.top > a.bottom || b.bottom < a.top);
576
+ }
577
+
578
+ function applyMarqueeSelection() {
579
+ const mRect = marquee._rect;
580
+ for (const card of document.querySelectorAll('.card')) {
581
+ const id = Number(card.dataset.id);
582
+ const intersects = rectsIntersect(mRect, card.getBoundingClientRect());
583
+ setCardSelected(id, intersects || (dragAdditive && dragSnapshot.has(id)));
584
+ }
585
+ updateSelStat();
586
+ }
587
+
588
+ document.getElementById('deleteBtn').onclick = async () => {
589
+ const n = state.selected.size;
590
+ let total = 0;
591
+ for (const id of state.selected) {
592
+ const it = state.itemsById.get(id);
593
+ if (it) total += it.size;
594
+ }
595
+ if (!confirm(`Permanently delete ${n} file${n === 1 ? '' : 's'} (${humanSize(total)})? This cannot be undone.`)) {
596
+ return;
597
+ }
598
+ const ids = [...state.selected];
599
+ const res = await fetch('/api/delete', {
600
+ method: 'POST',
601
+ headers: { 'Content-Type': 'application/json' },
602
+ body: JSON.stringify({ ids }),
603
+ });
604
+ await res.json();
605
+ // Full refresh: re-pull the (now smaller) filtered list and both filter
606
+ // panels, rather than just plucking deleted cards out of the DOM.
607
+ state.itemsById.clear();
608
+ resetGrid();
609
+ loadFilterSection('sender');
610
+ loadFilterSection('chat');
611
+ };
612
+
613
+ const observer = new IntersectionObserver((entries) => {
614
+ for (const e of entries) if (e.isIntersecting) loadMore();
615
+ });
616
+
617
+ loadFilterSection('sender');
618
+ loadFilterSection('chat');
619
+ wireFilterSearch('sender');
620
+ wireFilterSearch('chat');
621
+ loadMore();
622
+ </script>
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env python3
2
+ """WhatsApp Sweeper: find and delete WhatsApp Desktop's bloated media cache.
3
+
4
+ Run: whatsapp-sweeper
5
+ Then open http://localhost:8765 (opened automatically).
6
+ """
7
+ import hashlib
8
+ import http.server
9
+ import json
10
+ import os
11
+ import socketserver
12
+ import sqlite3
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import threading
17
+ import urllib.parse
18
+ import webbrowser
19
+ from pathlib import Path
20
+
21
+ from PIL import Image
22
+
23
+ MEDIA_DIR = Path(os.path.expanduser(
24
+ "~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/Message/Media"
25
+ ))
26
+ DB_PATH = os.path.expanduser(
27
+ "~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlite"
28
+ )
29
+ STATIC_DIR = Path(__file__).parent
30
+ PORT = 8765
31
+ SIDECAR_EXTS = {".thumb", ".favicon", ".mmsthumb"}
32
+ APPLE_EPOCH_OFFSET = 978307200 # 2001-01-01 -> unix epoch
33
+
34
+ IMAGE_EXTS = {"jpg", "jpeg", "png", "webp", "gif", "bmp", "tiff"}
35
+ VIDEO_EXTS = {"mp4", "mov", "m4v", "3gp", "avi", "mkv"}
36
+ THUMB_SIZE = 320
37
+ THUMB_CACHE_DIR = Path(tempfile.gettempdir()) / "whatsapp-sweeper-thumbs"
38
+ THUMB_CACHE_DIR.mkdir(exist_ok=True)
39
+
40
+ RECORDS = {} # id -> record dict, mutated on delete
41
+ ORDER = [] # ids sorted by size desc, fixed at startup
42
+
43
+ DB_QUERY = """
44
+ SELECT
45
+ mi.ZMEDIALOCALPATH AS path,
46
+ mi.ZMOVIEDURATION AS duration,
47
+ m.ZMESSAGEDATE AS msg_date,
48
+ m.ZISFROMME AS is_from_me,
49
+ m.ZFROMJID AS from_jid,
50
+ gm.ZMEMBERJID AS member_jid,
51
+ pn_member.ZPUSHNAME AS member_pushname,
52
+ pn_direct.ZPUSHNAME AS direct_pushname,
53
+ cs.ZPARTNERNAME AS chat_name,
54
+ cs.ZSESSIONTYPE AS session_type
55
+ FROM ZWAMEDIAITEM mi
56
+ JOIN ZWAMESSAGE m ON m.Z_PK = mi.ZMESSAGE
57
+ LEFT JOIN ZWAGROUPMEMBER gm ON gm.Z_PK = m.ZGROUPMEMBER
58
+ LEFT JOIN ZWAPROFILEPUSHNAME pn_member ON pn_member.ZJID = gm.ZMEMBERJID
59
+ LEFT JOIN ZWAPROFILEPUSHNAME pn_direct ON pn_direct.ZJID = m.ZFROMJID
60
+ JOIN ZWACHATSESSION cs ON cs.Z_PK = m.ZCHATSESSION
61
+ WHERE mi.ZMEDIALOCALPATH IS NOT NULL
62
+ """
63
+
64
+
65
+ def load_db_index():
66
+ """rel path (inside MEDIA_DIR) -> {sender, chat, duration, date}"""
67
+ index = {}
68
+ conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
69
+ conn.row_factory = sqlite3.Row
70
+ for row in conn.execute(DB_QUERY):
71
+ rel = row["path"]
72
+ if rel.startswith("Media/"):
73
+ rel = rel[len("Media/"):]
74
+
75
+ if row["is_from_me"]:
76
+ sender = "Me"
77
+ elif row["member_pushname"]:
78
+ sender = row["member_pushname"]
79
+ elif row["direct_pushname"]:
80
+ sender = row["direct_pushname"]
81
+ elif row["session_type"] == 0 and row["chat_name"]:
82
+ sender = row["chat_name"]
83
+ else:
84
+ jid = row["member_jid"] or row["from_jid"] or ""
85
+ sender = jid.split("@")[0] if jid else "Unknown"
86
+
87
+ date = None
88
+ if row["msg_date"] is not None:
89
+ date = row["msg_date"] + APPLE_EPOCH_OFFSET
90
+
91
+ index[rel] = {
92
+ "sender": sender or "Unknown",
93
+ "chat": row["chat_name"] or sender or "Unknown",
94
+ "duration": row["duration"],
95
+ "date": date,
96
+ }
97
+ conn.close()
98
+ return index
99
+
100
+
101
+ def scan_media():
102
+ db_index = load_db_index()
103
+ next_id = 0
104
+ for root, _dirs, files in os.walk(MEDIA_DIR):
105
+ for name in files:
106
+ if name.startswith("."):
107
+ continue
108
+ ext = os.path.splitext(name)[1].lower()
109
+ if ext in SIDECAR_EXTS:
110
+ continue
111
+ full = Path(root) / name
112
+ rel = str(full.relative_to(MEDIA_DIR))
113
+ try:
114
+ st = full.stat()
115
+ except OSError:
116
+ continue
117
+ meta = db_index.get(rel)
118
+ ext_lower = ext.lstrip(".").lower()
119
+ is_video = ext_lower in VIDEO_EXTS
120
+ has_sidecar_thumb = os.path.exists(os.path.splitext(full)[0] + ".thumb")
121
+ RECORDS[next_id] = {
122
+ "id": next_id,
123
+ "path": str(full),
124
+ "name": name,
125
+ "ext": ext_lower,
126
+ "size": st.st_size,
127
+ "date": meta["date"] if meta and meta["date"] else st.st_mtime,
128
+ "duration": meta["duration"] if meta and is_video else None,
129
+ "sender": meta["sender"] if meta else "Unknown",
130
+ "chat": meta["chat"] if meta else "Unknown",
131
+ "has_thumb": ext_lower in IMAGE_EXTS or is_video or has_sidecar_thumb,
132
+ }
133
+ next_id += 1
134
+ ORDER[:] = sorted(RECORDS.keys(), key=lambda i: RECORDS[i]["size"], reverse=True)
135
+ total_size = sum(r["size"] for r in RECORDS.values())
136
+ print(f"Indexed {len(RECORDS)} files, {total_size / (1024**3):.1f} GB")
137
+
138
+
139
+ def thumb_cache_path(path):
140
+ key = hashlib.sha1(path.encode()).hexdigest()
141
+ return THUMB_CACHE_DIR / f"{key}.jpg"
142
+
143
+
144
+ def get_or_make_thumb(rec):
145
+ """Real thumbnail generated from the source file (cached to disk),
146
+ falling back to WhatsApp's own tiny cached .thumb sidecar on failure."""
147
+ cache_path = thumb_cache_path(rec["path"])
148
+ if cache_path.exists():
149
+ return cache_path
150
+
151
+ ext = rec["ext"]
152
+ try:
153
+ if ext in IMAGE_EXTS:
154
+ with Image.open(rec["path"]) as im:
155
+ im = im.convert("RGB")
156
+ im.thumbnail((THUMB_SIZE, THUMB_SIZE))
157
+ im.save(cache_path, "JPEG", quality=85)
158
+ return cache_path
159
+ elif ext in VIDEO_EXTS:
160
+ result = subprocess.run(
161
+ [
162
+ "ffmpeg", "-y", "-ss", "0.5", "-i", rec["path"],
163
+ "-frames:v", "1", "-vf", f"scale={THUMB_SIZE}:-1",
164
+ str(cache_path),
165
+ ],
166
+ capture_output=True, timeout=15,
167
+ )
168
+ if result.returncode == 0 and cache_path.exists():
169
+ return cache_path
170
+ except Exception as exc:
171
+ print(f"thumb generation failed for {rec['path']}: {exc}")
172
+
173
+ legacy = os.path.splitext(rec["path"])[0] + ".thumb"
174
+ return Path(legacy) if os.path.exists(legacy) else None
175
+
176
+
177
+ def compute_agg(key):
178
+ agg = {}
179
+ for r in RECORDS.values():
180
+ s = agg.setdefault(r[key], {"name": r[key], "count": 0, "total_size": 0})
181
+ s["count"] += 1
182
+ s["total_size"] += r["size"]
183
+ return sorted(agg.values(), key=lambda s: s["total_size"], reverse=True)
184
+
185
+
186
+ def compute_senders():
187
+ return compute_agg("sender")
188
+
189
+
190
+ def compute_chats():
191
+ return compute_agg("chat")
192
+
193
+
194
+ def filter_ids(include, exclude, include_chat, exclude_chat):
195
+ for i in ORDER:
196
+ r = RECORDS.get(i)
197
+ if r is None:
198
+ continue
199
+ if include and r["sender"] not in include:
200
+ continue
201
+ if r["sender"] in exclude:
202
+ continue
203
+ if include_chat and r["chat"] not in include_chat:
204
+ continue
205
+ if r["chat"] in exclude_chat:
206
+ continue
207
+ yield i, r
208
+
209
+
210
+ def item_json(i, r):
211
+ return {
212
+ "id": i,
213
+ "name": r["name"],
214
+ "ext": r["ext"],
215
+ "size": r["size"],
216
+ "date": r["date"],
217
+ "duration": r["duration"],
218
+ "sender": r["sender"],
219
+ "chat": r["chat"],
220
+ "has_thumb": r["has_thumb"],
221
+ }
222
+
223
+
224
+ class Handler(http.server.BaseHTTPRequestHandler):
225
+ def log_message(self, fmt, *args):
226
+ pass
227
+
228
+ def _send_json(self, obj, status=200):
229
+ body = json.dumps(obj).encode()
230
+ self.send_response(status)
231
+ self.send_header("Content-Type", "application/json")
232
+ self.send_header("Content-Length", str(len(body)))
233
+ self.end_headers()
234
+ self.wfile.write(body)
235
+
236
+ def _serve_bytes(self, data, content_type):
237
+ self.send_response(200)
238
+ self.send_header("Content-Type", content_type)
239
+ self.send_header("Content-Length", str(len(data)))
240
+ self.end_headers()
241
+ self.wfile.write(data)
242
+
243
+ def do_GET(self):
244
+ parsed = urllib.parse.urlparse(self.path)
245
+ if parsed.path == "/":
246
+ try:
247
+ data = (STATIC_DIR / "index.html").read_bytes()
248
+ except OSError:
249
+ self.send_error(404)
250
+ return
251
+ self._serve_bytes(data, "text/html; charset=utf-8")
252
+ elif parsed.path == "/api/items":
253
+ self._api_items(parsed)
254
+ elif parsed.path == "/api/senders":
255
+ self._send_json(compute_senders())
256
+ elif parsed.path == "/api/chats":
257
+ self._send_json(compute_chats())
258
+ elif parsed.path == "/api/select_all":
259
+ self._api_select_all(parsed)
260
+ elif parsed.path.startswith("/thumb/"):
261
+ self._serve_thumb(parsed.path[len("/thumb/"):])
262
+ else:
263
+ self.send_error(404)
264
+
265
+ @staticmethod
266
+ def _parse_filters(qs):
267
+ include = {s for s in qs.get("include", [""])[0].split(",") if s}
268
+ exclude = {s for s in qs.get("exclude", [""])[0].split(",") if s}
269
+ include_chat = {s for s in qs.get("include_chat", [""])[0].split(",") if s}
270
+ exclude_chat = {s for s in qs.get("exclude_chat", [""])[0].split(",") if s}
271
+ return include, exclude, include_chat, exclude_chat
272
+
273
+ def _api_items(self, parsed):
274
+ qs = urllib.parse.parse_qs(parsed.query)
275
+ try:
276
+ offset = int(qs.get("offset", ["0"])[0])
277
+ limit = min(int(qs.get("limit", ["60"])[0]), 200)
278
+ except ValueError:
279
+ offset, limit = 0, 60
280
+
281
+ matched = list(filter_ids(*self._parse_filters(qs)))
282
+ total_count = len(matched)
283
+ total_size = sum(r["size"] for _, r in matched)
284
+ page = matched[offset: offset + limit]
285
+ next_offset = offset + limit if offset + limit < total_count else None
286
+ self._send_json({
287
+ "items": [item_json(i, r) for i, r in page],
288
+ "total_count": total_count,
289
+ "total_size": total_size,
290
+ "next_offset": next_offset,
291
+ })
292
+
293
+ def _api_select_all(self, parsed):
294
+ qs = urllib.parse.parse_qs(parsed.query)
295
+ matched = list(filter_ids(*self._parse_filters(qs)))
296
+ self._send_json({
297
+ "ids": [i for i, _r in matched],
298
+ "total_count": len(matched),
299
+ "total_size": sum(r["size"] for _, r in matched),
300
+ })
301
+
302
+ def _serve_thumb(self, id_str):
303
+ try:
304
+ rec = RECORDS[int(id_str)]
305
+ except (KeyError, ValueError):
306
+ self.send_error(404)
307
+ return
308
+ thumb_path = get_or_make_thumb(rec)
309
+ if thumb_path is None:
310
+ self.send_error(404)
311
+ return
312
+ try:
313
+ data = thumb_path.read_bytes()
314
+ except OSError:
315
+ self.send_error(404)
316
+ return
317
+ self._serve_bytes(data, "image/jpeg")
318
+
319
+ def do_POST(self):
320
+ parsed = urllib.parse.urlparse(self.path)
321
+ length = int(self.headers.get("Content-Length", 0) or 0)
322
+ raw = self.rfile.read(length) if length else b"{}"
323
+ try:
324
+ payload = json.loads(raw or b"{}")
325
+ except json.JSONDecodeError:
326
+ payload = {}
327
+
328
+ if parsed.path == "/api/open":
329
+ self._api_open(payload)
330
+ elif parsed.path == "/api/delete":
331
+ self._api_delete(payload)
332
+ else:
333
+ self.send_error(404)
334
+
335
+ def _api_open(self, payload):
336
+ rec = RECORDS.get(payload.get("id"))
337
+ if rec is None:
338
+ self._send_json({"ok": False, "error": "not found"}, 404)
339
+ return
340
+ subprocess.run(["open", rec["path"]])
341
+ self._send_json({"ok": True})
342
+
343
+ def _api_delete(self, payload):
344
+ ids = payload.get("ids", [])
345
+ results = {}
346
+ for id_ in ids:
347
+ rec = RECORDS.get(id_)
348
+ if rec is None:
349
+ results[str(id_)] = False
350
+ continue
351
+ try:
352
+ os.remove(rec["path"])
353
+ except OSError as exc:
354
+ results[str(id_)] = False
355
+ print(f"delete failed for {rec['path']}: {exc}")
356
+ continue
357
+ base = os.path.splitext(rec["path"])[0]
358
+ for ext in SIDECAR_EXTS:
359
+ try:
360
+ os.remove(base + ext)
361
+ except OSError:
362
+ pass
363
+ del RECORDS[id_]
364
+ results[str(id_)] = True
365
+ self._send_json({"results": results})
366
+
367
+
368
+ def main():
369
+ if sys.platform != "darwin":
370
+ sys.exit(
371
+ "WhatsApp Sweeper only supports macOS (it reads WhatsApp Desktop's "
372
+ "local media cache and chat database, which only exist there)."
373
+ )
374
+ if not MEDIA_DIR.exists():
375
+ sys.exit(
376
+ f"Couldn't find WhatsApp Desktop's media folder at:\n {MEDIA_DIR}\n"
377
+ "Is WhatsApp Desktop installed, and have you opened it at least once?"
378
+ )
379
+ if not os.path.exists(DB_PATH):
380
+ sys.exit(f"Couldn't find WhatsApp's chat database at:\n {DB_PATH}")
381
+
382
+ scan_media()
383
+ with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), Handler) as httpd:
384
+ httpd.allow_reuse_address = True
385
+ url = f"http://localhost:{PORT}"
386
+ print(f"Serving on {url}")
387
+ threading.Timer(0.5, lambda: webbrowser.open(url)).start()
388
+ try:
389
+ httpd.serve_forever()
390
+ except KeyboardInterrupt:
391
+ pass
392
+
393
+
394
+ if __name__ == "__main__":
395
+ main()