From 30c2b0fe8232cc748ab2bded6ab4d76febe32425 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 3 May 2026 19:17:04 +0300 Subject: 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. --- internal/scanner/scanner.go | 36 ++++++++++++++++++++++++++++++++++++ internal/scanner/scanner_test.go | 4 +++- 2 files changed, 39 insertions(+), 1 deletion(-) (limited to 'internal/scanner') 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 { -- cgit v1.2.3