fix(scanner): match library folders only at directory boundaries

A bare string-prefix test (strings.CutPrefix / strings.HasPrefix) let a
registered folder whose path was a string prefix of a sibling claim the
sibling's files: "/app/audiobooks" matched "/app/audiobookssd/Brian
Jacques/...", so getRelativePath sliced the remnant "sd/" into every
stored relative path for that library (prod scan log: "[AUDIO] Created
folder book 'Taggerung' (41 tracks) at sd/Brian Jacques/Redwall/Book 14").
Scanner lookups built their keys with the same function
(getMediaItemByFilePath, scanStateLookup), so writes and lookups were
consistently poisoned and no scan ever corrected them; everything
outside the scanner resolves paths by joining back to the roots
(ResolveMediaPath), so cover/track URLs 404ed for files sitting one
directory away.

The same unchecked HasPrefix pattern also drove five folder-membership
sites, so a sibling-prefix pair misattributed more than paths:
owning-library resolution (processMediaFile, maybeProcessAudioBook),
per-library-type extension allowlisting (isScannableFile), and
folder-structure metadata extraction on both create and update paths —
a file could be scanned under the wrong library's rules entirely.

folderContains(folder, path) is now the single boundary-aware
primitive: path must equal the folder or continue with a
filepath.Separator (after Clean). All seven sites go through it, so no
folder name — however similar to another — can interfere with any
other's files again, in any order of registration; trailing slashes on
registered folders are normalized. Registration order still decides for
genuinely overlapping (nested) registered roots: pre-existing behavior,
deliberately unchanged.

Regression tests (media_scanner_path_test.go, pure unit — no DB) pin the
prod trap (audiobooks/audiobookssd), order-independence, trailing
slashes, mid-word siblings, and the helper itself.

Repair of already-poisoned rows is deliberately separate and safe only
under this fix: a rescan repoints them via the SHA-256 move path
(MoveMediaItemFilePath) once lookups stop producing poisoned keys.
This commit is contained in:
John O'Keefe committed 2026-10-27 23:44:41 -04:00
1 parent 4c43caa639
commit 7b9e8e4fc2
3 files changed
+157 -9

No files matched your search

+21 -7
View File
@@ -954,7 +954,7 @@ func (s *MediaScanner) isScannableFile(path string) bool {
// Find which folder this file belongs to
var folder string
for _, f := range s.folders {
if strings.HasPrefix(path, f) {
if folderContains(f, path) {
folder = f
break
}
@@ -1117,7 +1117,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
folderLibrary := s.folderLibrary
s.scanStateMu.RUnlock()
for _, folder := range s.folders {
if !strings.HasPrefix(path, folder) {
if !folderContains(folder, path) {
continue
}
if libID, cached := folderLibrary[folder]; cached {
@@ -1260,7 +1260,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
// Use the root folder that contains this file
var rootFolder string
for _, folder := range s.folders {
if strings.HasPrefix(path, folder) {
if folderContains(folder, path) {
rootFolder = folder
break
}
@@ -4052,7 +4052,7 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
// could clear series/author that a sidecar didn't carry (Libation
// writes "series": null while the folder layout knows the series).
for _, folder := range s.folders {
if strings.HasPrefix(path, folder) {
if folderContains(folder, path) {
if folderMetadata := s.extractFolderStructureMetadata(path, folder); folderMetadata != nil {
if metadata.Title == "" && folderMetadata.Title != "" {
metadata.Title = folderMetadata.Title
@@ -5061,13 +5061,27 @@ func (s *MediaScanner) extractHashInfo(filePath string) (*HashInfo, *FormatInfo,
return hashInfo, formatInfo, nil
}
// folderContains reports whether path is folder itself or lies beneath it
// at a directory boundary. This is the only path-correct containment test:
// a bare strings.HasPrefix(path, folder) also matches string-prefix
// siblings ("/app/audiobooks" claims "/app/audiobookssd/..."), which once
// sliced a stray "sd/" into every stored relative path for that library
// and resolved those files against the wrong root (folder ownership,
// library attribution, extension allowlisting, cover 404s).
func folderContains(folder, path string) bool {
folder = filepath.Clean(folder)
return path == folder || strings.HasPrefix(path, folder+string(filepath.Separator))
}
func (s *MediaScanner) getRelativePath(absolutePath string) string {
// Get the base folder paths from scanner
for _, baseFolder := range s.folders {
// Check if path is within this base folder
if relPath, ok := strings.CutPrefix(absolutePath, baseFolder); ok {
return strings.TrimPrefix(relPath, "/")
// Check if path is within this base folder — boundary-aware, so a
// sibling whose name merely extends ours can never be cut.
if !folderContains(baseFolder, absolutePath) {
continue
}
return strings.TrimPrefix(absolutePath[len(filepath.Clean(baseFolder)):], "/")
}
// Fallback: if no match, return as-is (shouldn't happen)
return absolutePath