Repository navigation
Expand file tree
/
Copy pathwatcher.ts
More file actions
458 lines (405 loc) · 19.1 KB
/
Copy pathwatcher.ts
File metadata and controls
458 lines (405 loc) · 19.1 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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
import chokidar, { type FSWatcher } from 'chokidar'
import { basename, dirname, extname } from 'path'
import { stat, readFile, readdir, access, rm, unlink, open } from 'fs/promises'
import { existsSync } from 'fs'
import xxhash from 'xxhash-wasm'
import type { DatabaseInstance } from './db'
import type { Indexer } from './indexer'
import type { MediaKind, TaskOrigin } from '../shared/types'
import { supportedDesignExts } from './image/design'
import { detectDataset, shouldIgnorePath } from './skipRules'
import { isCloudStub } from './cloudFiles'
import { detectScreenshot } from './screenshot'
// xxHash 实例(懒初始化)
let hashFn: ((input: Uint8Array) => string) | null = null
async function getHasher(): Promise<(input: Uint8Array) => string> {
if (!hashFn) {
const hasher = await xxhash()
// h64Raw 接受 Uint8Array 返回 bigint,转 16 位 hex
hashFn = (data: Uint8Array) => {
const raw = hasher.h64Raw(data)
return raw.toString(16).padStart(16, '0')
}
}
return hashFn
}
/** 图片内容 hash(与入库时的 photos.file_hash 完全一致):收集箱写盘前用它判重 */
export async function hashImageContent(data: Uint8Array): Promise<string> {
return (await getHasher())(data)
}
// 支持的图片格式
const SUPPORTED_IMAGE_EXTENSIONS = new Set([
'.jpg', '.jpeg', '.png', '.heic', '.heif', '.webp', '.gif', '.bmp', '.tiff', '.tif',
'.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.avif',
])
// 设计格式按栅格预览走图片管线;平台支持矩阵见 image/design.ts(SVG/PSD/PSB/PDF/AI
// 全平台支持:PDF/AI 在 macOS 走 sips,Windows/Linux 走 pdf.js 隐藏窗口服务)。
// 懒合并:服务器版启动时可能先 restrictDesignExts 收窄列表(无 Electron 时 Linux 没有 PDF 渲染器)
let imageExts: Set<string> | null = null
function supportedImageExts(): Set<string> {
if (!imageExts) {
imageExts = new Set(SUPPORTED_IMAGE_EXTENSIONS)
for (const ext of supportedDesignExts()) imageExts.add(ext)
}
return imageExts
}
// 支持的视频格式(实际能不能解码取决于 ffmpeg-static;这些是最常见的)
const SUPPORTED_VIDEO_EXTENSIONS = new Set([
'.mp4', '.mov', '.m4v', '.webm', '.mkv', '.avi',
])
// 纯音频:和视频共用 videos 表 + extract_frames 任务,indexer 按 media_kind 分支
const SUPPORTED_AUDIO_EXTENSIONS = new Set([
'.mp3', '.m4a', '.aac', '.wav', '.flac', '.ogg', '.opus',
])
const VIDEO_HASH_SAMPLE_BYTES = 64 * 1024
const IO_CONCURRENCY = 4
/**
* 计算视频文件的"轻量内容哈希":size + mtime + 头 N 字节 + 尾 N 字节。
* 对个人相册体量足够区分;不读全文件(多 GB 视频几秒钟卡住 watcher 不可接受)。
*
* 路径**不参与**哈希 —— 这是有意为之:搬动视频后内容不变,dedup 应该
* 仍然把它识别为同一个素材。
*/
async function computeVideoLightHash(
filePath: string,
size: number,
mtimeMs: number,
hasher: (input: Uint8Array) => string
): Promise<string> {
const handle = await open(filePath, 'r')
try {
const headLen = Math.min(VIDEO_HASH_SAMPLE_BYTES, size)
const head = Buffer.alloc(headLen)
if (headLen > 0) await handle.read(head, 0, headLen, 0)
let tail = Buffer.alloc(0)
// 文件足够大才采尾部,否则头部已经覆盖整个文件
if (size > VIDEO_HASH_SAMPLE_BYTES * 2) {
tail = Buffer.alloc(VIDEO_HASH_SAMPLE_BYTES)
await handle.read(tail, 0, VIDEO_HASH_SAMPLE_BYTES, size - VIDEO_HASH_SAMPLE_BYTES)
}
const meta = new TextEncoder().encode(`v1|${size}|${mtimeMs}|`)
const combined = Buffer.concat([Buffer.from(meta), head, tail])
return hasher(new Uint8Array(combined.buffer, combined.byteOffset, combined.byteLength))
} finally {
await handle.close()
}
}
export function classifyMedia(filePath: string): MediaKind | null {
const ext = extname(filePath).toLowerCase()
if (supportedImageExts().has(ext)) return 'image'
if (SUPPORTED_VIDEO_EXTENSIONS.has(ext)) return 'video'
if (SUPPORTED_AUDIO_EXTENSIONS.has(ext)) return 'audio'
return null
}
export class FileWatcher {
private watchers: Map<number, FSWatcher> = new Map()
private db: DatabaseInstance
private indexer: Indexer
/** 启动对账(chokidar 初扫)已完成的文件夹:之后的事件算 'live'(电源策略等待时仍放行) */
private readyFolders = new Set<number>()
/** 数据集判定缓存:目录路径 → 是否跳过(判定规则见 skipRules.detectDataset) */
private datasetDecisions = new Map<string, Promise<boolean>>()
constructor(db: DatabaseInstance, indexer: Indexer) {
this.db = db
this.indexer = indexer
}
/** 并发闸:同时最多 IO_CONCURRENCY 个整文件读取 / stat */
private active = 0
private waiting: Array<() => void> = []
private async ioLimit<T>(fn: () => Promise<T>): Promise<T> {
// 名额直接交接给下一个等待者,active 不回落,避免新来的插队超额
if (this.active >= IO_CONCURRENCY) await new Promise<void>((r) => this.waiting.push(r))
else this.active++
try {
return await fn()
} finally {
const next = this.waiting.shift()
if (next) next()
else this.active--
}
}
watchFolder(folderId: number, folderPath: string): void {
if (this.watchers.has(folderId)) {
return
}
console.log(`Starting to watch folder: ${folderPath}`)
const watcher = chokidar.watch(folderPath, {
// 跳过规则(core/skipRules.ts):隐藏目录、node_modules/.git、应用与图库包
// (.app / .photoslibrary …)、密钥 / 凭据文件、iCloud 占位。根目录本身永不忽略。
ignored: (path: string) => shouldIgnorePath(folderPath, path),
persistent: true,
ignoreInitial: false,
awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 },
depth: 99,
})
watcher
.on('add', (filePath) => this.handleAdd(folderId, folderPath, filePath))
.on('change', (filePath) => this.handleChange(filePath))
.on('unlink', (filePath) => this.handleRemove(filePath))
.on('ready', () => {
this.readyFolders.add(folderId)
this.db.updateFolderScanTime(folderId)
// 清理离线期间被删除的文件
void this.cleanupStale(folderId)
})
.on('error', (error) => {
console.error(`Watcher error for folder ${folderId}:`, error)
})
this.watchers.set(folderId, watcher)
}
unwatchFolder(folderId: number): void {
const watcher = this.watchers.get(folderId)
if (watcher) {
watcher.close()
this.watchers.delete(folderId)
this.readyFolders.delete(folderId)
}
}
/**
* 用户对某个被跳过的数据集目录点了"仍然索引":清掉判定缓存并整夹重扫
* (chokidar 对未变化的文件触发的 add 会被启动对账跳过,代价很小)。
*/
rescanAfterOverride(folderId: number): void {
this.datasetDecisions.clear()
const folder = this.db.getFolder(folderId)
if (!folder) return
this.unwatchFolder(folderId)
this.watchFolder(folderId, folder.path)
}
stopAll(): void {
for (const [folderId] of this.watchers) {
this.unwatchFolder(folderId)
}
}
/**
* 启动时清理离线期间被删除的文件:照片软删,音视频走完整级联(片段 / 帧目录 / 缩略图)。
* 一条查询拿路径,access 检查限并发,不再逐张 getPhoto。
*/
private async cleanupStale(folderId: number): Promise<void> {
try {
const { photos, videos } = this.db.getLiveSourcePaths(folderId)
const missing = async (paths: string[]): Promise<string[]> => {
const gone: string[] = []
await Promise.all(paths.map((p) => this.ioLimit(() => access(p).catch(() => { gone.push(p) }))))
return gone
}
for (const p of await missing(photos)) this.db.softDeletePhoto(p)
for (const p of await missing(videos)) await this.removeVideo(p)
} catch (error) {
console.error(`Error cleaning stale media for folder ${folderId}:`, error)
}
}
/** 任务来源:初扫(启动对账 / 新加文件夹)= 回填,之后的事件 = live(电源等待时仍放行) */
private originFor(folderId: number): TaskOrigin {
return this.readyFolders.has(folderId) ? 'live' : 'backfill'
}
/**
* 数据集目录判定(skipRules.detectDataset):文件的直接父目录里同类媒体 ≥ 200
* 且名字大多是机器生成(frame_0042 / UUID;相机命名豁免)→ 整目录跳过并记进
* skipped_folders(设置 › 文件夹 里可"仍然索引")。根目录本身永不判定。
* 每个目录只 readdir 一次,结果进程内缓存。
*/
private isSkippedDataset(folderId: number, rootPath: string, filePath: string): Promise<boolean> {
const dir = dirname(filePath)
if (dir === rootPath) return Promise.resolve(false)
// 缓存的是判定的 Promise:一个目录的几百个 add 事件同时到达时共用一次 readdir,
// 只判定、只记录一次(缓存结果值的话,第一次 await 返回前其余事件都会各判一遍)
let decision = this.datasetDecisions.get(dir)
if (!decision) {
decision = this.decideDataset(folderId, dir)
this.datasetDecisions.set(dir, decision)
}
return decision
}
private async decideDataset(folderId: number, dir: string): Promise<boolean> {
const persisted = this.db.listSkippedFolders(folderId).find((r) => r.path === dir)
if (persisted) return !persisted.override
try {
const names = await this.ioLimit(() => readdir(dir))
const verdict = detectDataset(names.map((name) => ({ name, kind: classifyMedia(name) })))
if (!verdict.isDataset) return false
this.db.addSkippedFolder(folderId, dir, verdict.fileCount)
console.log(`[skip] dataset-like folder (${verdict.fileCount} files, ${Math.round(verdict.machineRatio * 100)}% machine-named): ${dir}`)
return true
} catch {
// readdir 失败(目录中途被删):不跳过,后续 stat 自会失败
return false
}
}
private async handleAdd(folderId: number, rootPath: string, filePath: string): Promise<void> {
const kind = classifyMedia(filePath)
if (kind === null) return
if (await this.isSkippedDataset(folderId, rootPath, filePath)) return
const origin = this.originFor(folderId)
if (kind !== 'image') {
await this.handleAddVideo(folderId, filePath, kind, origin)
return
}
try {
const stats = await stat(filePath)
// 云端占位(iCloud dataless / OneDrive 按需文件):读取会触发下载,跳过。
// 文件真正落盘时会再来一次事件(内容变化 → size/mtime 变)
if (isCloudStub(filePath, stats)) return
const fileName = basename(filePath)
// 启动时 chokidar 会对已有文件逐个触发 add。大小 / mtime 没变、缩略图也在 → 什么都不用做;
// 否则每次启动都要把整个图库读一遍算 hash。漏掉的 embedding 由 requeueMissingEmbeddings 补排
// 缩略图缺失但任务失败过(坏图)同样跳过:重试走任务面板,不每次启动重读
const existing = this.db.getPhotoByPath(filePath)
if (
existing && existing.deletedAt == null && existing.folderId === folderId &&
existing.fileSize === stats.size && existing.fileMtime === stats.mtimeMs && existing.fileHash &&
(existsSync(this.indexer.getThumbnailPath(existing.fileHash)) || this.db.hasOpenOrFailedTask(existing.id, 'thumbnail'))
) {
// 老库还没判过截图标记的顺手补上(xattr 读取 ~7 µs,名字模式免费)
if (existing.isScreenshot == null) {
this.db.setPhotoScreenshot(existing.id, detectScreenshot(filePath, fileName))
}
return
}
// 计算文件 hash(限并发:首次导入时 add 事件成千上万,同时读文件会把磁盘打满)
const fileBuffer = await this.ioLimit(() => readFile(filePath))
const hash = await getHasher()
const fileHash = hash(new Uint8Array(fileBuffer.buffer, fileBuffer.byteOffset, fileBuffer.byteLength))
const photoId = this.db.addPhoto(folderId, filePath, fileName, stats.size, stats.mtimeMs, fileHash)
this.db.setPhotoScreenshot(photoId, detectScreenshot(filePath, fileName))
// 流水线:thumbnail → embed
const { hasEmbedding } = this.db.hasContentForHash(fileHash)
if (hasEmbedding) {
// 同内容已有 embedding,仅补缩略图(如缺)
this.db.updateEmbedStatusByHash(fileHash)
this.db.addToQueue(photoId, 'thumbnail', 20, origin)
this.indexer.processNext()
} else {
this.db.addToQueue(photoId, 'thumbnail', 20, origin)
this.db.addToQueue(photoId, 'embed', 10, origin)
this.indexer.processNext()
}
} catch (error) {
console.error(`Error handling add for ${filePath}:`, error)
}
}
/**
* 视频不入 photos 直接走 thumbnail pipeline,
* 而是先登记到 videos 表 + queue 一个 'extract_frames' 任务;
* indexer 切 32s 片段编码进 video_segments,并把首帧落成代表 photo。
*/
private async handleAddVideo(folderId: number, filePath: string, kind: 'video' | 'audio', origin: TaskOrigin): Promise<void> {
try {
const stats = await stat(filePath)
// 云端占位:读头尾采样也会触发下载,跳过(真正落盘时会再触发事件)
if (isCloudStub(filePath, stats)) return
const fileName = basename(filePath)
// 视频的 file_hash 用"size + mtime + 头尾 64 KB 内容"做轻量哈希:
// - 不读全文件(GB 级视频会让 watcher 卡几秒)
// - 路径不参与(搬动视频不破坏 dedup)
// - 头尾采样能可靠区分 mtime 巧合相同的不同文件
// (rsync --times / cp -p / 时钟回拨等场景下 size+mtime 单独是不够的)
const hasher = await getHasher()
const lightHash = await this.ioLimit(() => computeVideoLightHash(filePath, stats.size, stats.mtimeMs, hasher))
const existing = this.db.getVideoByPath(filePath)
// 启动时 chokidar 会对已有文件逐个触发 add。内容没变且已扫过
// (frame_count 非空:成功 = 片段数,坏文件 = 0)或已在队列里 → 不重排,
// 否则每次启动都把整个音视频库从头重新编码一遍。判定放在 upsert 之前:没变就连行都不写。
// 坏文件的重试走任务面板;模型未就绪导致的失败由 requeueMissingEmbeddings 补排
const unchanged = existing != null && existing.deletedAt == null &&
existing.fileHash === lightHash && existing.folderId === folderId
if (unchanged && (existing.frameCount != null || this.db.hasOpenOrFailedTask(existing.id, 'extract_frames'))) {
// 内容没变,但缩略图缓存可能被清过:代表帧的缩略图缺了就补一个缩略图任务(不重新抽帧)
for (const frame of this.db.getFramePhotosByVideo(existing.id)) {
if (frame.deletedAt == null && frame.fileHash && !existsSync(this.indexer.getThumbnailPath(frame.fileHash)) &&
!this.db.hasOpenOrFailedTask(frame.id, 'thumbnail')) {
this.db.addToQueue(frame.id, 'thumbnail', 20, origin)
this.indexer.processNext()
}
}
return
}
const videoId = this.db.addVideo(folderId, filePath, fileName, stats.size, stats.mtimeMs, lightHash, kind)
// 同步抽帧太慢;用 indexer 队列异步处理。约定:task_type='extract_frames'
// 时,photo_id 字段携带的是 videos.id 而非 photos.id(schema 不变)。
// 优先级 6:排在图片 embed(10) / face(8) 之后 —— 视频按片段编码,一个长视频
// 就要几分钟,放前面会让整库照片在视频跑完前都搜不到
this.db.addToQueue(videoId, 'extract_frames', 6, origin)
this.indexer.processNext()
} catch (error) {
console.error(`Error handling add for video ${filePath}:`, error)
}
}
private async handleChange(filePath: string): Promise<void> {
const kind = classifyMedia(filePath)
if (kind === null) return
// 音视频原地修改:handleAddVideo 会比对轻量 hash,变了才重排
if (kind !== 'image') {
const video = this.db.getVideoByPath(filePath)
if (video) await this.handleAddVideo(video.folderId, filePath, kind, this.originFor(video.folderId))
return
}
try {
const existing = this.db.getPhotoByPath(filePath)
if (!existing) return
const stats = await stat(filePath)
if (existing.fileSize === stats.size && existing.fileMtime === stats.mtimeMs) {
return
}
if (isCloudStub(filePath, stats)) return
const fileName = basename(filePath)
const fileBuffer = await this.ioLimit(() => readFile(filePath))
const hash = await getHasher()
const fileHash = hash(new Uint8Array(fileBuffer.buffer, fileBuffer.byteOffset, fileBuffer.byteLength))
const photoId = this.db.addPhoto(existing.folderId, filePath, fileName, stats.size, stats.mtimeMs, fileHash)
this.db.setPhotoScreenshot(photoId, detectScreenshot(filePath, fileName))
const origin = this.originFor(existing.folderId)
this.db.addToQueue(photoId, 'thumbnail', 20, origin)
this.db.addToQueue(photoId, 'embed', 10, origin)
this.indexer.processNext()
} catch (error) {
console.error(`Error handling change for ${filePath}:`, error)
}
}
private async handleRemove(filePath: string): Promise<void> {
const kind = classifyMedia(filePath)
if (kind === null) return
try {
if (kind !== 'image') {
await this.removeVideo(filePath)
} else {
this.db.softDeletePhoto(filePath)
}
} catch (error) {
console.error(`Error handling remove for ${filePath}:`, error)
}
}
/**
* 视频删除的完整级联:cascadeRemoveVideo 处理 DB(soft-delete video、
* frame photos、孤立 hash 的内容),这里负责清理磁盘上的 JPG 帧目录
* 与共享的缩略图(如果该 hash 已彻底无活体引用)。
*
* Frame JPG 落在 <userData>/video_frames/<videoHash>/,不在用户监控目录里,
* 所以 chokidar 不会触发它们自己的 unlink — 必须显式删。
*/
private async removeVideo(filePath: string): Promise<void> {
const result = this.db.cascadeRemoveVideo(filePath)
if (!result) return // 视频不在库中
const { fileHash, orphanedFrameHashes } = result
// 帧 JPG 目录:通常以 video hash 命名,整目录 rm
if (fileHash) {
const framesDir = this.indexer.getVideoFramesDir(fileHash)
if (existsSync(framesDir)) {
try {
await rm(framesDir, { recursive: true, force: true })
} catch (err) {
console.warn(`Failed to remove video frames dir ${framesDir}:`, err)
}
}
}
// 共享缩略图:孤立 frame hash 的 .webp 文件
for (const hash of orphanedFrameHashes) {
const thumb = this.indexer.getThumbnailPath(hash)
try {
if (existsSync(thumb)) await unlink(thumb)
} catch (err) {
console.warn(`Failed to remove thumbnail ${thumb}:`, err)
}
}
}
}