Add file fingerprinting for move-resilient media item identity

Computes a SHA-256 partial-content fingerprint (file size + first 64 KB) for
movies, TV episodes, and mixed files during scans. When a file is moved or
renamed within a library, the scan detects the fingerprint match, renames the
media_items row in-place, and updates media_tags.media_key to match — so tags
and NFO metadata survive the move transparently.

- src/lib/fingerprint.ts: new computeFingerprint() using sync FS reads
- src/lib/db.ts: fingerprint TEXT column + index migration
- src/lib/tags.ts: reKeyMediaItem() to update media_tags on rename
- src/lib/scanner.ts: replace clear+upsert with detectMoves/reconcileAndPrune
  for movies, TV episodes, and mixed files; games retain clear+upsert (v1)
- TV scan restructured to a single filesystem pass (no double-scanning)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garret Patti
2026-04-06 18:35:02 -04:00
parent 819748d1ff
commit 38a6886863
4 changed files with 290 additions and 96 deletions

36
src/lib/fingerprint.ts Normal file
View File

@@ -0,0 +1,36 @@
import fs from 'fs'
import crypto from 'crypto'
const CHUNK_SIZE = 64 * 1024 // 64 KB
/**
* Computes a stable partial-content fingerprint for a file.
* Uses SHA-256 of the file size + first 64 KB of content.
* Fast enough for large video files (~instant) and collision-resistant
* for real-world media libraries.
*
* Returns null if the file cannot be read (missing, permission error, etc.).
*/
export function computeFingerprint(absolutePath: string): string | null {
try {
const stat = fs.statSync(absolutePath)
const size = stat.size
const chunkLen = Math.min(CHUNK_SIZE, size)
const buf = Buffer.alloc(chunkLen)
if (chunkLen > 0) {
const fd = fs.openSync(absolutePath, 'r')
try {
fs.readSync(fd, buf, 0, chunkLen, 0)
} finally {
fs.closeSync(fd)
}
}
return crypto
.createHash('sha256')
.update(`${size}:`)
.update(buf)
.digest('hex')
} catch {
return null
}
}