summaryrefslogtreecommitdiff
path: root/internal/scanner
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-03 19:17:04 +0300
committerPaul Buetow <paul@buetow.org>2026-05-03 19:17:04 +0300
commit30c2b0fe8232cc748ab2bded6ab4d76febe32425 (patch)
tree985578b326a1ff8fe7af395bf08471857376b8fc /internal/scanner
parent687b86cdeef2192272f0945ddd496322a9113f9e (diff)
fix(admin): fix rescan goroutine lifecycle and race on shared ScanProgress
- Protect adminService scan state (cancel func + progress pointer) with sync.Mutex. - Allocate fresh ScanProgress per trigger and pass it to the scanner, eliminating races on the previously shared progress struct. - Cancel previous scan context before starting a new one. - Add tests for cancellation, fresh progress per scan, concurrent triggers, and empty progress when never started. - Fix race-prone tests by polling Running==true before waiting for completion.
Diffstat (limited to 'internal/scanner')
-rw-r--r--internal/scanner/scanner.go36
-rw-r--r--internal/scanner/scanner_test.go4
2 files changed, 39 insertions, 1 deletions
diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go
index 4372f91..bb2ea55 100644
--- a/internal/scanner/scanner.go
+++ b/internal/scanner/scanner.go
@@ -131,6 +131,9 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress
coverImages := make(map[string]string)
_ = s.fs.WalkDir(setPath, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !isImageFile(path) {
+ if d != nil && d.IsDir() && strings.HasPrefix(d.Name(), ".") && path != setPath {
+ return filepath.SkipDir
+ }
return nil
}
relPath, _ := filepath.Rel(setPath, path)
@@ -147,6 +150,9 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress
return fmt.Errorf("walk %q: %w", path, err)
}
if d.IsDir() {
+ if strings.HasPrefix(d.Name(), ".") && path != setPath {
+ return filepath.SkipDir
+ }
return nil
}
if !isMediaFile(path) {
@@ -193,6 +199,22 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress
}
} else if mediaType == model.MediaTypeAudio {
thumbnailPath = findCoverImage(path, coverImages, setPath)
+ } else if mediaType == model.MediaTypeImage {
+ ext := strings.ToLower(filepath.Ext(path))
+ if ext == ".svg" {
+ thumbnailPath = path
+ } else {
+ thumbDir := filepath.Join(setPath, ".thumbnails")
+ if err := s.fs.MkdirAll(thumbDir, 0o755); err != nil {
+ return fmt.Errorf("mkdir thumbnails %q: %w", thumbDir, err)
+ }
+ thumbName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".jpg"
+ thumbnailPath = filepath.Join(thumbDir, thumbName)
+ if err := s.thumbGen.Generate(ctx, path, thumbnailPath, 0); err != nil {
+ fmt.Printf("[scanner] skipping thumbnail for %q: %v\n", path, err)
+ thumbnailPath = ""
+ }
+ }
}
media := &model.Media{
@@ -206,6 +228,15 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress
Resolution: meta.Resolution,
Bitrate: meta.Bitrate,
FileSizeBytes: meta.FileSizeBytes,
+ Width: meta.Width,
+ Height: meta.Height,
+ EXIFCamera: meta.EXIFCamera,
+ EXIFLens: meta.EXIFLens,
+ EXIFDate: meta.EXIFDate,
+ EXIFISO: meta.EXIFISO,
+ EXIFFNumber: meta.EXIFFNumber,
+ EXIFExposure: meta.EXIFExposure,
+ EXIFFocalLength: meta.EXIFFocalLength,
ThumbnailPath: thumbnailPath,
CreatedAt: s.clock.Now(),
}
@@ -256,6 +287,7 @@ func findCoverImage(filePath string, coverImages map[string]string, setPath stri
var mediaExtensions = map[string]struct{}{
".mp4": {}, ".mkv": {}, ".avi": {}, ".mov": {}, ".webm": {},
".mp3": {}, ".flac": {}, ".wav": {}, ".aac": {}, ".ogg": {}, ".m4a": {}, ".opus": {}, ".m4b": {},
+ ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".webp": {}, ".bmp": {}, ".avif": {}, ".svg": {},
}
// imageExtensions lists file extensions recognized as cover/artwork images.
@@ -288,6 +320,10 @@ func mediaTypeFromExt(path string) model.MediaType {
switch ext {
case ".mp4", ".mkv", ".avi", ".mov", ".webm":
return model.MediaTypeVideo
+ case ".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a", ".opus", ".m4b":
+ return model.MediaTypeAudio
+ case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif", ".svg":
+ return model.MediaTypeImage
default:
return model.MediaTypeAudio
}
diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go
index 8cc0d27..2d5d7c8 100644
--- a/internal/scanner/scanner_test.go
+++ b/internal/scanner/scanner_test.go
@@ -421,7 +421,7 @@ func Test_isMediaFile(t *testing.T) {
{"movie.mp4", true},
{"song.MP3", true},
{"archive.zip", false},
- {"photo.jpg", false},
+ {"photo.jpg", true},
{"", false},
}
for _, c := range cases {
@@ -440,6 +440,8 @@ func Test_mediaTypeFromExt(t *testing.T) {
{"a.mkv", model.MediaTypeVideo},
{"a.mp3", model.MediaTypeAudio},
{"a.FLAC", model.MediaTypeAudio},
+ {"a.jpg", model.MediaTypeImage},
+ {"a.png", model.MediaTypeImage},
}
for _, c := range cases {
if got := mediaTypeFromExt(c.path); got != c.want {