pinokiod 8.0.37 → 8.0.38

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.
@@ -14,17 +14,40 @@ const Sweeper = require('./sweeper')
14
14
 
15
15
  const SIZE_THRESHOLD = 100 * 1024 * 1024
16
16
  const TMP_SUFFIX = ".pinokio-dedup-tmp"
17
- const PRUNE_DIRS = new Set(["node_modules", ".git", "env", "venv", "__pycache__", "logs"])
18
17
  const NO_LINK_CODES = new Set(["EXDEV", "ENOTSUP", "EPERM", "ENOSYS"])
19
18
  const LOCK_CODES = new Set(["EBUSY", "EPERM", "EACCES"])
19
+ const STAT_CONCURRENCY = 32
20
20
 
21
21
  const isPathWithin = (root, target) => {
22
22
  const rel = path.relative(path.resolve(root), path.resolve(target))
23
23
  return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel))
24
24
  }
25
25
 
26
+ const samePath = (left, right) => {
27
+ const a = path.resolve(left)
28
+ const b = path.resolve(right)
29
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b
30
+ }
31
+
26
32
  const sourceId = (kind, name) => `${kind}:${encodeURIComponent(name)}`
27
33
 
34
+ const statMany = async (paths, concurrency = STAT_CONCURRENCY) => {
35
+ const results = new Array(paths.length)
36
+ let cursor = 0
37
+ const workers = Array.from({ length: Math.min(concurrency, paths.length) }, async () => {
38
+ while (cursor < paths.length) {
39
+ const index = cursor++
40
+ try {
41
+ results[index] = await fs.promises.stat(paths[index])
42
+ } catch (error) {
43
+ results[index] = null
44
+ }
45
+ }
46
+ })
47
+ await Promise.all(workers)
48
+ return results
49
+ }
50
+
28
51
  class Vault {
29
52
  constructor(kernel) {
30
53
  this.kernel = kernel
@@ -36,6 +59,10 @@ class Vault {
36
59
  this.worker = null
37
60
  this.workerJobs = new Map()
38
61
  this.workerSeq = 0
62
+ this.workerStarts = 0
63
+ this.workerIdleTimer = null
64
+ this.workerIdleMs = 750
65
+ this.statConcurrency = STAT_CONCURRENCY
39
66
  this.sizeThreshold = SIZE_THRESHOLD
40
67
  this._sources = []
41
68
  }
@@ -115,6 +142,60 @@ class Vault {
115
142
  sources() {
116
143
  return this._sources
117
144
  }
145
+ async addExternalSource(folderPath) {
146
+ if (typeof folderPath !== "string" || !path.isAbsolute(folderPath.trim())) {
147
+ throw new Error("Choose a valid folder.")
148
+ }
149
+ const requested = folderPath.trim()
150
+ let canonical
151
+ let stats
152
+ try {
153
+ canonical = path.resolve(await fs.promises.realpath(requested))
154
+ stats = await fs.promises.stat(canonical)
155
+ } catch (error) {
156
+ throw new Error("That folder is no longer available.")
157
+ }
158
+ if (!stats.isDirectory()) throw new Error("Choose a folder, not a file.")
159
+
160
+ const home = path.resolve(this.kernel.homedir)
161
+ let canonicalHome = home
162
+ try { canonicalHome = path.resolve(await fs.promises.realpath(home)) } catch (error) {}
163
+ if (isPathWithin(canonicalHome, canonical) || isPathWithin(canonical, canonicalHome)) {
164
+ throw new Error("That folder is already inside Pinokio and is included in scans.")
165
+ }
166
+
167
+ await this.refreshSources()
168
+ const existing = this._sources.find((source) => source.kind === "external" && source.root && samePath(source.root, canonical))
169
+ if (existing) {
170
+ return { created: false, source: existing }
171
+ }
172
+
173
+ const apiRoot = path.resolve(home, "api")
174
+ await fs.promises.mkdir(apiRoot, { recursive: true })
175
+ const baseLabel = path.basename(canonical) || "external-folder"
176
+ let mountPath = null
177
+ for (let index = 1; index < 10000; index++) {
178
+ const label = index === 1 ? baseLabel : `${baseLabel}-${index}`
179
+ const candidate = path.resolve(apiRoot, label)
180
+ try {
181
+ await fs.promises.symlink(canonical, candidate, this.kernel.platform === "win32" ? "junction" : "dir")
182
+ mountPath = candidate
183
+ break
184
+ } catch (error) {
185
+ if (error && error.code === "EEXIST") continue
186
+ if (error && (error.code === "EACCES" || error.code === "EPERM")) {
187
+ throw new Error("Pinokio does not have permission to add that folder.")
188
+ }
189
+ throw new Error("Pinokio could not add that folder.")
190
+ }
191
+ }
192
+ if (!mountPath) throw new Error("Pinokio could not create a unique name for that folder.")
193
+
194
+ await this.refreshSources()
195
+ const source = this._sources.find((item) => item.kind === "external" && item.mount_path && samePath(item.mount_path, mountPath))
196
+ if (!source) throw new Error("The folder was added but could not be loaded. Restart Pinokio and try again.")
197
+ return { created: true, source }
198
+ }
118
199
  sourceForPath(filePath, preferredId) {
119
200
  const sources = this._sources || []
120
201
  if (preferredId) {
@@ -181,7 +262,7 @@ class Vault {
181
262
  await this.refreshSources()
182
263
  const loaded = await this.registry.load()
183
264
  if (loaded.corrupt) {
184
- await this.rebuild()
265
+ await this.rebuild(undefined, { preserveState: false })
185
266
  }
186
267
  this.sweeper = new Sweeper(this)
187
268
  this.initialized = true
@@ -213,9 +294,14 @@ class Vault {
213
294
  return mode
214
295
  }
215
296
  async hashFile(filePath) {
297
+ if (this.workerIdleTimer) {
298
+ clearTimeout(this.workerIdleTimer)
299
+ this.workerIdleTimer = null
300
+ }
216
301
  if (!this.worker) {
217
302
  const worker = new Worker(path.resolve(__dirname, "hash_worker.js"))
218
303
  this.worker = worker
304
+ this.workerStarts += 1
219
305
  worker.unref()
220
306
  worker.on("message", ({ id, hash, size, error }) => {
221
307
  const job = this.workerJobs.get(id)
@@ -223,22 +309,31 @@ class Vault {
223
309
  this.workerJobs.delete(id)
224
310
  if (error) job.reject(new Error(error))
225
311
  else job.resolve({ hash, size })
226
- // Terminate when idle: lingering workers hold pipe handles that can
227
- // keep processes alive (observed under node --test); respawning for
228
- // the next batch costs ~30ms and hashing is idle-priority anyway.
312
+ // Keep the worker warm across the scan queue. Starting one worker per
313
+ // file costs ~30ms and adds up quickly on a first scan.
229
314
  if (this.workerJobs.size === 0 && this.worker === worker) {
230
- this.worker = null
231
- worker.terminate().catch(() => {})
315
+ this.workerIdleTimer = setTimeout(() => {
316
+ this.workerIdleTimer = null
317
+ if (this.workerJobs.size === 0 && this.worker === worker) {
318
+ this.worker = null
319
+ worker.terminate().catch(() => {})
320
+ }
321
+ }, this.workerIdleMs)
322
+ if (this.workerIdleTimer.unref) this.workerIdleTimer.unref()
232
323
  }
233
324
  })
234
325
  worker.on("error", (error) => {
235
326
  if (this.worker !== worker) return
327
+ if (this.workerIdleTimer) clearTimeout(this.workerIdleTimer)
328
+ this.workerIdleTimer = null
236
329
  for (const job of this.workerJobs.values()) job.reject(error)
237
330
  this.workerJobs.clear()
238
331
  this.worker = null
239
332
  })
240
333
  worker.on("exit", (code) => {
241
334
  if (this.worker !== worker) return
335
+ if (this.workerIdleTimer) clearTimeout(this.workerIdleTimer)
336
+ this.workerIdleTimer = null
242
337
  for (const job of this.workerJobs.values()) {
243
338
  job.reject(new Error(`hash worker exited with code ${code}`))
244
339
  }
@@ -314,7 +409,6 @@ class Vault {
314
409
  if (storeStat.size !== targetStat.size) {
315
410
  return { status: "size-mismatch" }
316
411
  }
317
- const volumeMode = this.volumeModes.get(targetStat.dev)
318
412
  const tmp = targetPath + TMP_SUFFIX
319
413
  try {
320
414
  try {
@@ -330,13 +424,12 @@ class Vault {
330
424
  await fs.promises.rename(tmp, targetPath)
331
425
  } catch (e) {
332
426
  await fs.promises.unlink(tmp).catch(() => {})
333
- if (NO_LINK_CODES.has(e.code) && volumeMode !== "link") {
334
- this.registry.addLink(targetPath, { hash, app: meta.app || null, source_id: meta.source_id || null, dev: targetStat.dev, ino: targetStat.ino, mode: "copy" })
335
- return { status: "copy-mode" }
336
- }
337
427
  if (LOCK_CODES.has(e.code)) {
338
428
  return { status: "locked" }
339
429
  }
430
+ if (NO_LINK_CODES.has(e.code)) {
431
+ return { status: "unavailable", code: e.code }
432
+ }
340
433
  throw e
341
434
  }
342
435
  const st = await fs.promises.stat(targetPath)
@@ -413,11 +506,22 @@ class Vault {
413
506
  }
414
507
  this.registry.schedulePersist()
415
508
  }
416
- // Rebuild from disk alone: store filenames are hashes, stat gives nlink,
417
- // (dev, ino) matching re-associates app paths. No hashing required.
418
- async rebuild(roots) {
509
+ // Rebuild derived state from disk: store filenames are hashes, stat gives
510
+ // nlink, and (dev, ino) matching re-associates app paths. The replacement
511
+ // maps are assembled off to the side and swapped in only after the walk so
512
+ // a long repair never persists a half-reset registry.
513
+ async rebuild(roots, options = {}) {
419
514
  if (!this.enabled) return
420
- this.registry.reset()
515
+ const registry = this.registry
516
+ const preserveState = options.preserveState !== false
517
+ const preserved = preserveState ? {
518
+ excluded: new Map(registry.excluded),
519
+ totals: Object.assign({}, registry.totals),
520
+ lastScan: registry.lastScan ? Object.assign({}, registry.lastScan) : null,
521
+ duplicates: new Map(registry.duplicates)
522
+ } : null
523
+ const rebuiltBlobs = new Map()
524
+ const rebuiltLinks = new Map()
421
525
  const storeInoToHash = new Map()
422
526
  let shards = []
423
527
  try {
@@ -432,8 +536,14 @@ class Vault {
432
536
  if (!/^[0-9a-f]{64}$/.test(name)) continue
433
537
  try {
434
538
  const st = await fs.promises.stat(path.resolve(this.blobRoot, shard, name))
435
- this.registry.addBlob(name, { size: st.size })
436
- this.registry.blobs.get(name).orphan = st.nlink === 1
539
+ const previous = registry.blobs.get(name)
540
+ rebuiltBlobs.set(name, {
541
+ size: st.size,
542
+ first_seen: previous && previous.first_seen ? previous.first_seen : Date.now(),
543
+ source_urls: previous && Array.isArray(previous.source_urls) ? [...previous.source_urls] : [],
544
+ verified_at: Date.now(),
545
+ orphan: st.nlink === 1
546
+ })
437
547
  storeInoToHash.set(`${st.dev}:${st.ino}`, name)
438
548
  } catch (e) {}
439
549
  }
@@ -443,34 +553,73 @@ class Vault {
443
553
  ? roots.map((root) => typeof root === "string" ? { root, source_id: null } : root)
444
554
  : this.scanRoots()
445
555
  for (const entry of walkRoots) {
446
- await this.walkForInoMatch(entry.root, storeInoToHash, entry.source_id === "pinokio" ? null : entry.source_id)
556
+ await this.walkForInoMatch(
557
+ entry.root,
558
+ storeInoToHash,
559
+ entry.source_id === "pinokio" ? null : entry.source_id,
560
+ rebuiltLinks
561
+ )
562
+ }
563
+ const rebuiltDuplicates = new Map()
564
+ if (preserved) {
565
+ for (const [duplicatePath, duplicate] of preserved.duplicates) {
566
+ if (preserved.excluded.has(duplicatePath) || rebuiltLinks.has(duplicatePath) || !rebuiltBlobs.has(duplicate.hash)) continue
567
+ try {
568
+ const st = await fs.promises.stat(duplicatePath)
569
+ if (st.isFile() && (!duplicate.size || duplicate.size === st.size)) {
570
+ rebuiltDuplicates.set(duplicatePath, Object.assign({}, duplicate, { size: st.size }))
571
+ }
572
+ } catch (error) {}
573
+ }
447
574
  }
448
- await this.registry.flush()
575
+ const rebuiltByIno = new Map()
576
+ for (const entry of rebuiltLinks.values()) {
577
+ rebuiltByIno.set(registry.inoKey(entry.dev, entry.ino), entry.hash)
578
+ }
579
+ registry.blobs = rebuiltBlobs
580
+ registry.links = rebuiltLinks
581
+ registry.scanIndex = new Map()
582
+ registry.duplicates = rebuiltDuplicates
583
+ registry.excluded = preserved ? preserved.excluded : new Map()
584
+ registry.lastScan = preserved ? preserved.lastScan : null
585
+ registry.totals = preserved ? preserved.totals : { lifetime_bytes_saved: 0 }
586
+ registry.byIno = rebuiltByIno
587
+ await registry.flush()
449
588
  }
450
- async walkForInoMatch(dir, storeInoToHash, preferredSourceId = null) {
451
- let entries = []
452
- try {
453
- entries = await fs.promises.readdir(dir, { withFileTypes: true })
454
- } catch (e) { return }
455
- for (const entry of entries) {
456
- const full = path.resolve(dir, entry.name)
457
- if (entry.isSymbolicLink()) continue
458
- if (entry.isDirectory()) {
459
- if (PRUNE_DIRS.has(entry.name)) continue
460
- await this.walkForInoMatch(full, storeInoToHash, preferredSourceId)
461
- } else if (entry.isFile()) {
462
- try {
463
- const st = await fs.promises.stat(full)
464
- if (st.size < SIZE_THRESHOLD) continue
465
- const hash = storeInoToHash.get(`${st.dev}:${st.ino}`)
466
- if (hash) {
467
- const source = this.sourceForPath(full, preferredSourceId)
468
- this.registry.addLink(full, {
469
- hash, app: source && source.kind === "app" ? source.app : null,
470
- source_id: source ? source.id : null, dev: st.dev, ino: st.ino, mode: "link"
471
- })
589
+ async walkForInoMatch(root, storeInoToHash, preferredSourceId = null, outputLinks = null) {
590
+ const stack = [root]
591
+ const vaultRoot = path.resolve(this.root)
592
+ while (stack.length) {
593
+ const dir = stack.pop()
594
+ let entries = []
595
+ try {
596
+ entries = await fs.promises.readdir(dir, { withFileTypes: true })
597
+ } catch (error) { continue }
598
+ const files = []
599
+ for (const entry of entries) {
600
+ const full = path.resolve(dir, entry.name)
601
+ if (entry.isSymbolicLink()) continue
602
+ if (entry.isDirectory()) {
603
+ if (full === vaultRoot) continue
604
+ stack.push(full)
605
+ }
606
+ else if (entry.isFile()) files.push(full)
607
+ }
608
+ const stats = await statMany(files, this.statConcurrency)
609
+ for (let index = 0; index < files.length; index++) {
610
+ const st = stats[index]
611
+ if (!st || st.size < this.sizeThreshold) continue
612
+ const hash = storeInoToHash.get(`${st.dev}:${st.ino}`)
613
+ if (hash) {
614
+ const source = this.sourceForPath(files[index], preferredSourceId)
615
+ const link = {
616
+ hash, app: source && source.kind === "app" ? source.app : null,
617
+ source_id: source ? source.id : null, dev: st.dev, ino: st.ino,
618
+ mode: "link", created: Date.now()
472
619
  }
473
- } catch (e) {}
620
+ if (outputLinks) outputLinks.set(files[index], link)
621
+ else this.registry.addLink(files[index], link)
622
+ }
474
623
  }
475
624
  }
476
625
  }
@@ -484,19 +633,21 @@ class Vault {
484
633
  if (!this.enabled || !this.registry) return { enabled: false }
485
634
  const registry = this.registry
486
635
  const blobs = []
636
+ const blobByHash = new Map()
487
637
  const storeStats = new Map()
638
+ const namesByHash = new Map()
639
+ for (const [linkPath, entry] of registry.links) {
640
+ if (!namesByHash.has(entry.hash)) namesByHash.set(entry.hash, [])
641
+ const location = this.locationForPath(linkPath, entry.source_id)
642
+ namesByHash.get(entry.hash).push(Object.assign({
643
+ path: linkPath, app: entry.app || null, mode: entry.mode
644
+ }, location))
645
+ }
488
646
  let bytesOnDisk = 0
489
647
  let wouldBe = 0
490
648
  for (const [hash, blob] of registry.blobs) {
491
- const names = []
492
- const apps = new Set()
493
- for (const [linkPath, entry] of registry.links) {
494
- if (entry.hash === hash) {
495
- const location = this.locationForPath(linkPath, entry.source_id)
496
- names.push(Object.assign({ path: linkPath, app: entry.app || null, mode: entry.mode }, location))
497
- if (entry.app) apps.add(entry.app)
498
- }
499
- }
649
+ const names = namesByHash.get(hash) || []
650
+ const apps = new Set(names.map((name) => name.app).filter(Boolean))
500
651
  let nlink = null
501
652
  let storeStat = null
502
653
  try {
@@ -504,12 +655,16 @@ class Vault {
504
655
  nlink = storeStat.nlink
505
656
  } catch (e) {}
506
657
  storeStats.set(hash, storeStat)
507
- bytesOnDisk += blob.size || 0
508
- wouldBe += (blob.size || 0) * Math.max(1, names.length)
509
- blobs.push({
658
+ const size = blob.size || 0
659
+ const physicalCopies = Math.max(1, (storeStat ? 1 : 0) + names.filter((name) => name.mode === "copy").length)
660
+ bytesOnDisk += size * physicalCopies
661
+ wouldBe += size * Math.max(physicalCopies, names.length)
662
+ const publicBlob = {
510
663
  hash, size: blob.size || 0, orphan: !!blob.orphan, nlink,
511
664
  names, apps: [...apps], source_urls: blob.source_urls || []
512
- })
665
+ }
666
+ blobs.push(publicBlob)
667
+ blobByHash.set(hash, publicBlob)
513
668
  }
514
669
  const duplicates = []
515
670
  for (const [p, entry] of registry.duplicates) {
@@ -518,20 +673,21 @@ class Vault {
518
673
  const index = registry.scanIndex.get(p)
519
674
  const storeStat = storeStats.get(entry.hash)
520
675
  const shareable = !!(source && source.shareable && storeStat && (!index || index.dev === undefined || index.dev === storeStat.dev))
521
- const blob = blobs.find((item) => item.hash === entry.hash)
676
+ const blob = blobByHash.get(entry.hash)
522
677
  duplicates.push(Object.assign({
523
678
  path: p, hash: entry.hash, size: entry.size || 0, app: entry.app || null,
524
679
  shareable, matches: blob ? blob.names : []
525
680
  }, location))
526
681
  }
527
682
  const events = (await registry.readEvents()).slice(-100).reverse()
528
- const sweeper = this.sweeper
529
- const scan = sweeper ? Object.assign({}, sweeper.state, {
530
- current_file: sweeper.currentHash ? path.basename(sweeper.currentHash.path) : null
531
- }) : null
683
+ const scan = this.scanStatus()
532
684
  const excluded = []
533
685
  for (const [p, meta] of registry.excluded) {
534
- excluded.push(Object.assign({ path: p, ts: meta.ts || null }, this.locationForPath(p, meta.source_id)))
686
+ let size = Number(meta.size) || 0
687
+ if (!size) {
688
+ try { size = (await fs.promises.stat(p)).size } catch (error) {}
689
+ }
690
+ excluded.push(Object.assign({ path: p, ts: meta.ts || null, size }, this.locationForPath(p, meta.source_id)))
535
691
  }
536
692
  const publicSources = this._sources.map((source) => ({
537
693
  id: source.id, kind: source.kind, label: source.label, root: source.root,
@@ -555,6 +711,19 @@ class Vault {
555
711
  sources: publicSources, blobs, duplicates, excluded, events
556
712
  }
557
713
  }
714
+ scanStatus() {
715
+ const sweeper = this.sweeper
716
+ return sweeper ? Object.assign({}, sweeper.state, {
717
+ current_file: sweeper.currentHash ? path.basename(sweeper.currentHash.path) : null
718
+ }) : null
719
+ }
720
+ progressStatus() {
721
+ return {
722
+ enabled: !!this.enabled,
723
+ scan: this.scanStatus(),
724
+ last_scan: this.registry ? this.registry.lastScan : null
725
+ }
726
+ }
558
727
  // detach: make one name an independent copy again ("go back"), and pin it
559
728
  // so future scans neither re-list nor re-share it. Works on linked names
560
729
  // (copies bytes out) and on pending duplicates (just ignores them).
@@ -562,22 +731,22 @@ class Vault {
562
731
  if (!this.enabled) return { status: "disabled" }
563
732
  const entry = this.registry.links.get(filePath)
564
733
  if (entry) {
565
- const storePath = this.storePathFor(entry.hash)
566
734
  const tmp = filePath + TMP_SUFFIX
567
735
  await fs.promises.copyFile(filePath, tmp)
568
736
  await fs.promises.rename(tmp, filePath)
737
+ const st = await fs.promises.stat(filePath)
569
738
  this.registry.removeLink(filePath)
570
- this.registry.excluded.set(filePath, { ts: Date.now(), source_id: entry.source_id || null })
739
+ this.registry.excluded.set(filePath, { ts: Date.now(), source_id: entry.source_id || null, size: st.size })
571
740
  this.registry.schedulePersist()
572
- await this.registry.appendEvent({ kind: "detach", hash: entry.hash, path: filePath, app: entry.app || null, source_id: entry.source_id || null })
741
+ await this.registry.appendEvent({ kind: "detach", hash: entry.hash, path: filePath, app: entry.app || null, source_id: entry.source_id || null, size: st.size })
573
742
  return { status: "detached" }
574
743
  }
575
744
  if (this.registry.duplicates.has(filePath)) {
576
745
  const dup = this.registry.duplicates.get(filePath)
577
746
  this.registry.duplicates.delete(filePath)
578
- this.registry.excluded.set(filePath, { ts: Date.now(), source_id: dup.source_id || null })
747
+ this.registry.excluded.set(filePath, { ts: Date.now(), source_id: dup.source_id || null, size: dup.size || 0 })
579
748
  this.registry.schedulePersist()
580
- await this.registry.appendEvent({ kind: "detach", hash: dup.hash, path: filePath, app: dup.app || null, source_id: dup.source_id || null })
749
+ await this.registry.appendEvent({ kind: "detach", hash: dup.hash, path: filePath, app: dup.app || null, source_id: dup.source_id || null, size: dup.size || 0 })
581
750
  return { status: "ignored" }
582
751
  }
583
752
  return { status: "not-found" }
@@ -620,7 +789,8 @@ class Vault {
620
789
  this.registry.totals.lifetime_bytes_saved - (event.bytes_saved || 0))
621
790
  await this.registry.appendEvent({
622
791
  kind: "undo", hash: event.hash, path: event.path,
623
- source_id: entry.source_id || event.source_id || null, batch_id: batchId
792
+ source_id: entry.source_id || event.source_id || null, batch_id: batchId,
793
+ bytes_saved: event.bytes_saved || storeStat.size
624
794
  })
625
795
  summary.undone += 1
626
796
  summary.bytes += storeStat.size
@@ -646,6 +816,5 @@ class Vault {
646
816
 
647
817
  Vault.SIZE_THRESHOLD = SIZE_THRESHOLD
648
818
  Vault.TMP_SUFFIX = TMP_SUFFIX
649
- Vault.PRUNE_DIRS = PRUNE_DIRS
650
819
 
651
820
  module.exports = Vault
@@ -19,8 +19,8 @@ class Registry {
19
19
  this.links = new Map() // path -> { hash, app, source_id, dev, ino, created, mode }
20
20
  this.scanIndex = new Map() // path -> { size, mtime, dev, ino, hash, source_id }
21
21
  this.duplicates = new Map() // path -> { hash, size, app, source_id, discovered } pending user conversion
22
- this.excluded = new Map() // path -> { ts, source_id } user chose "keep independent"
23
- this.lastScan = null // { ts, dirs, files, bytes_total, home_bytes_total, source_bytes }
22
+ this.excluded = new Map() // path -> { ts, source_id, size } user chose "keep independent"
23
+ this.lastScan = null // scan totals, duration, and hash work
24
24
  this.totals = { lifetime_bytes_saved: 0 }
25
25
  this.byIno = new Map() // "dev:ino" -> hash
26
26
  }
@@ -12,6 +12,7 @@ const fastq = require('fastq')
12
12
 
13
13
  const SHA256_RE = /^[0-9a-f]{64}$/
14
14
  const TMP_SUFFIX = ".pinokio-dedup-tmp"
15
+ const STAT_CONCURRENCY = 32
15
16
 
16
17
  class Sweeper {
17
18
  constructor(vault) {
@@ -21,31 +22,40 @@ class Sweeper {
21
22
  this.currentHash = null
22
23
  this.hashQueue = fastq.promise(this, this._hashJob, 1)
23
24
  this.stats = { hashes: 0, ingested: 0 }
25
+ this.statConcurrency = vault.statConcurrency || STAT_CONCURRENCY
26
+ this.metadataBaseCache = new Map()
24
27
  }
25
28
  idleState() {
26
29
  return {
27
30
  active: false, dirs: 0, files: 0, bytes_total: 0,
28
31
  home_bytes_total: 0, source_bytes: {},
29
- candidates: 0, hashed: 0, queued: 0, started: null
32
+ candidates: 0, hashed: 0, hash_bytes: 0, queued: 0,
33
+ started: null, duration_ms: null
30
34
  }
31
35
  }
32
36
  // The one entry point: the Pinokio home plus explicit linked imports.
33
37
  async scan() {
34
38
  if (this.state.active) return { already_running: true }
35
39
  this.state = Object.assign(this.idleState(), { active: true, started: Date.now() })
40
+ this.metadataBaseCache.clear()
36
41
  try {
37
42
  await this.vault.refreshSources()
38
43
  for (const source of this.vault.scanRoots()) {
39
44
  await this.walk(source.root, source.source_id === "pinokio" ? null : source.source_id)
40
45
  }
41
46
  await this.settle()
47
+ this.state.duration_ms = Date.now() - this.state.started
42
48
  this.vault.registry.setLastScan({
43
49
  ts: Date.now(),
44
50
  dirs: this.state.dirs,
45
51
  files: this.state.files,
46
52
  bytes_total: this.state.bytes_total,
47
53
  home_bytes_total: this.state.home_bytes_total,
48
- source_bytes: Object.assign({}, this.state.source_bytes)
54
+ source_bytes: Object.assign({}, this.state.source_bytes),
55
+ candidates: this.state.candidates,
56
+ hashed: this.state.hashed,
57
+ hash_bytes: this.state.hash_bytes,
58
+ duration_ms: this.state.duration_ms
49
59
  })
50
60
  } finally {
51
61
  this.state.active = false
@@ -81,21 +91,46 @@ class Sweeper {
81
91
  if (entry.isDirectory()) {
82
92
  if (full === vaultRoot) continue
83
93
  stack.push(full)
84
- } else if (entry.isFile()) {
85
- await this.considerFile(full, preferredSourceId)
86
94
  }
87
95
  }
96
+ const files = entries.filter((entry) => entry.isFile() && !entry.isSymbolicLink())
97
+ .map((entry) => path.resolve(dir, entry.name))
98
+ await this.considerFiles(files, preferredSourceId)
99
+ }
100
+ }
101
+ async considerFiles(filePaths, preferredSourceId = null) {
102
+ const stats = new Array(filePaths.length)
103
+ let cursor = 0
104
+ const workers = Array.from({ length: Math.min(this.statConcurrency, filePaths.length) }, async () => {
105
+ while (cursor < filePaths.length) {
106
+ const index = cursor++
107
+ try {
108
+ stats[index] = await fs.promises.stat(filePaths[index])
109
+ } catch (error) {
110
+ stats[index] = null
111
+ }
112
+ }
113
+ })
114
+ await Promise.all(workers)
115
+ // Preserve deterministic classification order while parallelizing only
116
+ // the expensive filesystem metadata reads.
117
+ for (let index = 0; index < filePaths.length; index++) {
118
+ if (stats[index]) await this.considerStat(filePaths[index], stats[index], preferredSourceId)
88
119
  }
89
120
  }
90
121
  async considerFile(filePath, preferredSourceId = null) {
91
122
  if (filePath.endsWith(TMP_SUFFIX)) return
92
- const registry = this.vault.registry
93
123
  let st
94
124
  try {
95
125
  st = await fs.promises.stat(filePath)
96
126
  } catch (e) {
97
127
  return
98
128
  }
129
+ await this.considerStat(filePath, st, preferredSourceId)
130
+ }
131
+ async considerStat(filePath, st, preferredSourceId = null) {
132
+ if (filePath.endsWith(TMP_SUFFIX)) return
133
+ const registry = this.vault.registry
99
134
  this.state.files += 1
100
135
  this.state.bytes_total += st.size
101
136
  const sourceKey = preferredSourceId || "pinokio"
@@ -136,9 +171,10 @@ class Sweeper {
136
171
  async _hashJob({ filePath, source_id }) {
137
172
  this.currentHash = { path: filePath }
138
173
  try {
139
- const { hash } = await this.vault.hashFile(filePath)
174
+ const { hash, size } = await this.vault.hashFile(filePath)
140
175
  this.stats.hashes += 1
141
176
  this.state.hashed += 1
177
+ this.state.hash_bytes += size || 0
142
178
  const st = await fs.promises.stat(filePath)
143
179
  await this.classify(filePath, st, hash, source_id)
144
180
  } catch (e) {
@@ -224,35 +260,43 @@ class Sweeper {
224
260
  // via the recorded timestamp against mtime (huggingface_hub's own rule).
225
261
  async harvestLocalDirEtag(filePath, st) {
226
262
  try {
227
- let dir = path.dirname(filePath)
263
+ const start = path.dirname(filePath)
264
+ let dir = start
228
265
  const stop = path.resolve(this.kernel.homedir)
229
- const rel = []
266
+ const visited = []
267
+ let base = null
230
268
  for (let depth = 0; depth < 12; depth++) {
269
+ if (this.metadataBaseCache.has(dir)) {
270
+ base = this.metadataBaseCache.get(dir)
271
+ break
272
+ }
273
+ visited.push(dir)
231
274
  const metaRoot = path.resolve(dir, ".cache", "huggingface")
232
275
  let exists = false
233
276
  try {
234
277
  exists = (await fs.promises.stat(metaRoot)).isDirectory()
235
278
  } catch (e) {}
236
279
  if (exists) {
237
- const metaPath = path.resolve(metaRoot, ...rel, path.basename(filePath) + ".metadata")
238
- let raw
239
- try {
240
- raw = await fs.promises.readFile(metaPath, "utf8")
241
- } catch (e) {
242
- return null
243
- }
244
- const lines = raw.split("\n")
245
- const etag = String(lines[1] || "").replace(/"/g, "").trim().toLowerCase()
246
- const ts = parseFloat(lines[2] || "0") * 1000
247
- if (SHA256_RE.test(etag) && st.mtimeMs <= ts + 1000) {
248
- return etag
249
- }
250
- return null
280
+ base = dir
281
+ break
251
282
  }
252
- if (dir === stop || path.dirname(dir) === dir) return null
253
- rel.unshift(path.basename(dir))
283
+ if (dir === stop || path.dirname(dir) === dir) break
254
284
  dir = path.dirname(dir)
255
285
  }
286
+ for (const visitedDir of visited) this.metadataBaseCache.set(visitedDir, base)
287
+ if (!base) return null
288
+ const relative = path.relative(base, filePath)
289
+ const metaPath = path.resolve(base, ".cache", "huggingface", relative + ".metadata")
290
+ let raw
291
+ try {
292
+ raw = await fs.promises.readFile(metaPath, "utf8")
293
+ } catch (e) {
294
+ return null
295
+ }
296
+ const lines = raw.split("\n")
297
+ const etag = String(lines[1] || "").replace(/"/g, "").trim().toLowerCase()
298
+ const ts = parseFloat(lines[2] || "0") * 1000
299
+ if (SHA256_RE.test(etag) && st.mtimeMs <= ts + 1000) return etag
256
300
  } catch (e) {}
257
301
  return null
258
302
  }
@@ -293,8 +337,9 @@ class Sweeper {
293
337
  summary.bytes_saved += result.bytes_saved || 0
294
338
  } else if (result.status === "locked") {
295
339
  summary.locked += 1
340
+ } else if (result.status === "unavailable" || result.status === "copy-mode") {
341
+ summary.unavailable += 1
296
342
  } else {
297
- registry.duplicates.delete(filePath)
298
343
  summary.failed += 1
299
344
  }
300
345
  } catch (e) {