diff --git a/package-lock.json b/package-lock.json index a53aa18..778558f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "instaarchive-viewer", - "version": "1.5.0", + "version": "1.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "instaarchive-viewer", - "version": "1.5.0", + "version": "1.5.1", "dependencies": { "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", diff --git a/package.json b/package.json index 6d81a34..2b704b4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "instaarchive-viewer", "private": true, - "version": "1.5.0", + "version": "1.5.1", "type": "module", "scripts": { "dev": "vite --port=3000 --host=0.0.0.0", diff --git a/src/lib/archive-index.test.ts b/src/lib/archive-index.test.ts new file mode 100644 index 0000000..393e094 --- /dev/null +++ b/src/lib/archive-index.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { isSystemDirectory } from './archive-index'; + +describe('isSystemDirectory', () => { + it.each([ + ['@eaDir', 'Synology thumbnail/index metadata, written inside every folder'], + ['@tmp', 'Synology scratch'], + ['.sync', 'Resilio state'], + ['.DS_Store', 'macOS'], + ['#recycle', 'Synology deletions'], + ['#snapshot', 'Synology snapshots'], + ])('skips %s (%s)', name => { + expect(isSystemDirectory(name)).toBe(true); + }); + + it.each([ + '4utumn07', + '4utumn07 - reels', + 'story - dawn_petal', + 'story highlights - official_band - A.B.C', + 'story highlights - theoldlyricmuseinsta - 💙1999-2005 era', + 'Heejin_Bubble heejinmedia', + 'gallery-dl', + 'posts', + ])('keeps %s', name => { + expect(isSystemDirectory(name)).toBe(false); + }); + + it('does not treat a leading underscore as a system directory', () => { + // `_gemini-plans` is filtered separately at the archive root only; nothing + // below the root should be excluded just for starting with an underscore. + expect(isSystemDirectory('_gemini-plans')).toBe(false); + }); +}); diff --git a/src/lib/archive-index.ts b/src/lib/archive-index.ts index a7bf6e8..8fb0826 100644 --- a/src/lib/archive-index.ts +++ b/src/lib/archive-index.ts @@ -33,6 +33,21 @@ interface DirIndex { const MEDIA_RE = /\.(jpg|jpeg|png|webp|gif|bmp|tiff|mp4|webm|ogv|mov)$/i; const STAT_CONCURRENCY = 16; +/** + * Directories the walk must never descend into. + * + * NAS filesystems scatter sidecar metadata *inside* every folder, not just at + * the share root: Synology writes `@eaDir` (thumbnails and indexing data), + * `#recycle` holds deletions, and `.sync` is Resilio's state. Indexing those + * would count NAS thumbnails as archive media and spend a stat on each one — + * measured on a real share, `@eaDir` accounted for 12,516 of 123,023 files. + * + * The archive root is already filtered by prefix; this is the same rule applied + * at every level below it. + */ +export const isSystemDirectory = (name: string): boolean => + name.startsWith('@') || name.startsWith('.') || name === '#recycle' || name === '#snapshot'; + export class ArchiveIndex { private dirs = new Map(); private inFlight = new Map>(); @@ -43,7 +58,7 @@ export class ArchiveIndex { /** Visible (non-system) directories at the archive root. */ private listRootDirs(): string[] { return fs.readdirSync(this.archivesDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && !/^[.@_]/.test(e.name)) + .filter(e => e.isDirectory() && !isSystemDirectory(e.name) && !e.name.startsWith('_')) .map(e => e.name); } @@ -69,6 +84,7 @@ export class ArchiveIndex { return out; } for (const entry of entries) { + if (isSystemDirectory(entry.name)) continue; const rel = base ? `${base}/${entry.name}` : entry.name; if (entry.isDirectory()) out = out.concat(this.walk(path.join(absDir, entry.name), rel)); else if (entry.isFile()) out.push(rel);