diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-03 19:17:04 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-03 19:17:04 +0300 |
| commit | 30c2b0fe8232cc748ab2bded6ab4d76febe32425 (patch) | |
| tree | 985578b326a1ff8fe7af395bf08471857376b8fc | |
| parent | 687b86cdeef2192272f0945ddd496322a9113f9e (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.
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | internal/api/handlers.go | 14 | ||||
| -rw-r--r-- | internal/model/media.go | 61 | ||||
| -rw-r--r-- | internal/probe/probe.go | 88 | ||||
| -rw-r--r-- | internal/repository/media.go | 57 | ||||
| -rw-r--r-- | internal/repository/migrate.go | 11 | ||||
| -rw-r--r-- | internal/repository/sqlite_test.go | 17 | ||||
| -rw-r--r-- | internal/scanner/scanner.go | 36 | ||||
| -rw-r--r-- | internal/scanner/scanner_test.go | 4 | ||||
| -rw-r--r-- | internal/service/admin.go | 42 | ||||
| -rw-r--r-- | internal/service/admin_test.go | 171 | ||||
| -rw-r--r-- | internal/service/media.go | 17 | ||||
| -rw-r--r-- | internal/service/media_browse.go | 4 | ||||
| -rw-r--r-- | internal/service/media_write.go | 13 | ||||
| -rw-r--r-- | internal/thumb/thumb.go | 13 | ||||
| -rw-r--r-- | internal/thumb/thumb_test.go | 38 | ||||
| -rw-r--r-- | web/css/lightbox.css | 79 | ||||
| -rw-r--r-- | web/css/player.css | 21 | ||||
| -rw-r--r-- | web/index.html | 26 | ||||
| -rw-r--r-- | web/js/app.js | 130 | ||||
| -rw-r--r-- | web/js/keyboard.js | 50 | ||||
| -rw-r--r-- | web/js/lightbox.js | 181 | ||||
| -rw-r--r-- | web/js/player.js | 207 | ||||
| -rw-r--r-- | web/sw.js | 2 |
26 files changed, 1198 insertions, 96 deletions
@@ -1,5 +1,12 @@ player ====== -Player is an opinionated KISS web media player. It is designed to be simple, lightweight, and easy to use and designed keyboard-first. +Player is an opinionated KISS web media player. It is designed to be simple, lightweight, and easy to use and designed keyboard-first. It supports: + +* Videos +* Images +* Audiobooks +* Podcasts + + @@ -14,6 +14,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect golang.org/x/sys v0.43.0 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect @@ -14,6 +14,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 0436247..8afcd8f 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -248,6 +248,20 @@ func mimeTypeForFilename(name string) string { return "audio/ogg" case ".m4b": return "audio/x-m4b" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".bmp": + return "image/bmp" + case ".avif": + return "image/avif" + case ".svg": + return "image/svg+xml" } return "application/octet-stream" } diff --git a/internal/model/media.go b/internal/model/media.go index b1b4ef3..74492c1 100644 --- a/internal/model/media.go +++ b/internal/model/media.go @@ -9,6 +9,7 @@ type MediaType string const ( MediaTypeVideo MediaType = "video" MediaTypeAudio MediaType = "audio" + MediaTypeImage MediaType = "image" ) // Role defines the level of access a user has to a set. @@ -46,23 +47,32 @@ type SetPermission struct { CreatedAt time.Time `json:"created_at"` } -// Media represents a single audio or video file within a set. +// Media represents a single media file within a set. type Media struct { - ID int64 `json:"id"` - SetID int64 `json:"set_id"` - RelPath string `json:"rel_path"` - FileName string `json:"file_name"` - AbsPath string `json:"abs_path"` - Type MediaType `json:"type"` - Duration float64 `json:"duration"` - Codec string `json:"codec"` - Resolution string `json:"resolution"` - Bitrate int `json:"bitrate"` - FileSizeBytes int64 `json:"file_size_bytes"` - ThumbnailPath string `json:"thumbnail_path"` - PlayCount int `json:"play_count"` - DeletedAt *time.Time `json:"deleted_at"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + SetID int64 `json:"set_id"` + RelPath string `json:"rel_path"` + FileName string `json:"file_name"` + AbsPath string `json:"abs_path"` + Type MediaType `json:"type"` + Duration float64 `json:"duration"` + Codec string `json:"codec"` + Resolution string `json:"resolution"` + Bitrate int `json:"bitrate"` + FileSizeBytes int64 `json:"file_size_bytes"` + Width int `json:"width"` + Height int `json:"height"` + EXIFCamera string `json:"exif_camera"` + EXIFLens string `json:"exif_lens"` + EXIFDate string `json:"exif_date"` + EXIFISO string `json:"exif_iso"` + EXIFFNumber string `json:"exif_f_number"` + EXIFExposure string `json:"exif_exposure"` + EXIFFocalLength string `json:"exif_focal_length"` + ThumbnailPath string `json:"thumbnail_path"` + PlayCount int `json:"play_count"` + DeletedAt *time.Time `json:"deleted_at"` + CreatedAt time.Time `json:"created_at"` } // Tag is a label that can be attached to media items. @@ -133,9 +143,18 @@ type MediaTag struct { // Metadata holds extracted file properties from ffprobe and os.Stat. type Metadata struct { - Duration float64 `json:"duration"` - Codec string `json:"codec"` - Resolution string `json:"resolution"` - Bitrate int `json:"bitrate"` - FileSizeBytes int64 `json:"file_size_bytes"` + Duration float64 `json:"duration"` + Codec string `json:"codec"` + Resolution string `json:"resolution"` + Bitrate int `json:"bitrate"` + FileSizeBytes int64 `json:"file_size_bytes"` + Width int `json:"width"` + Height int `json:"height"` + EXIFCamera string `json:"exif_camera"` + EXIFLens string `json:"exif_lens"` + EXIFDate string `json:"exif_date"` + EXIFISO string `json:"exif_iso"` + EXIFFNumber string `json:"exif_f_number"` + EXIFExposure string `json:"exif_exposure"` + EXIFFocalLength string `json:"exif_focal_length"` } diff --git a/internal/probe/probe.go b/internal/probe/probe.go index d4eb5bb..4257516 100644 --- a/internal/probe/probe.go +++ b/internal/probe/probe.go @@ -5,10 +5,14 @@ import ( "context" "encoding/json" "fmt" + "os" "os/exec" + "path/filepath" "strconv" + "strings" "codeberg.org/snonux/player/internal/model" + "github.com/rwcarlsen/goexif/exif" ) // Prober extracts metadata from a media file. @@ -40,7 +44,15 @@ func (f *FFProber) Probe(ctx context.Context, path string) (*model.Metadata, err } return nil, fmt.Errorf("ffprobe %s: %w", path, err) } - return parseFFprobeOutput(out) + meta, err := parseFFprobeOutput(out) + if err != nil { + return nil, err + } + // For images, also extract EXIF data. + if isImagePath(path) { + extractEXIF(path, meta) + } + return meta, nil } type ffprobeOutput struct { @@ -81,6 +93,8 @@ func parseFFprobeOutput(data []byte) (*model.Metadata, error) { } if s.Width > 0 && s.Height > 0 { meta.Resolution = fmt.Sprintf("%dx%d", s.Width, s.Height) + meta.Width = s.Width + meta.Height = s.Height } break } @@ -94,6 +108,78 @@ func parseFFprobeOutput(data []byte) (*model.Metadata, error) { return meta, nil } +var imageExtensions = map[string]struct{}{ + ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".webp": {}, ".bmp": {}, ".avif": {}, ".svg": {}, +} + +func isImagePath(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + _, ok := imageExtensions[ext] + return ok +} + +func extractEXIF(path string, meta *model.Metadata) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + + x, err := exif.Decode(f) + if err != nil { + return + } + + if v, err := x.Get(exif.Make); err == nil { + if s, err := v.StringVal(); err == nil { + meta.EXIFCamera = s + } + } + if v, err := x.Get(exif.Model); err == nil { + if s, err := v.StringVal(); err == nil { + if meta.EXIFCamera != "" { + meta.EXIFCamera = meta.EXIFCamera + " " + s + } else { + meta.EXIFCamera = s + } + } + } + if v, err := x.Get(exif.LensModel); err == nil { + if s, err := v.StringVal(); err == nil { + meta.EXIFLens = s + } + } + if v, err := x.Get(exif.DateTimeOriginal); err == nil { + if s, err := v.StringVal(); err == nil { + meta.EXIFDate = s + } + } + if v, err := x.Get(exif.ISOSpeedRatings); err == nil { + if i, err := v.Int(0); err == nil { + meta.EXIFISO = strconv.Itoa(i) + } + } + if v, err := x.Get(exif.FNumber); err == nil { + if r, err := v.Rat(0); err == nil { + num := float64(r.Num().Int64()) + denom := float64(r.Denom().Int64()) + meta.EXIFFNumber = fmt.Sprintf("f/%.1f", num/denom) + } + } + if v, err := x.Get(exif.ExposureTime); err == nil { + if r, err := v.Rat(0); err == nil { + meta.EXIFExposure = fmt.Sprintf("%s/%s s", r.Num().String(), r.Denom().String()) + } + } + if v, err := x.Get(exif.FocalLength); err == nil { + if r, err := v.Rat(0); err == nil { + num := float64(r.Num().Int64()) + denom := float64(r.Denom().Int64()) + meta.EXIFFocalLength = fmt.Sprintf("%.1f mm", num/denom) + } + } +} + // MockProber is a test fake for Prober. type MockProber struct { ProbeFunc func(ctx context.Context, path string) (*model.Metadata, error) diff --git a/internal/repository/media.go b/internal/repository/media.go index f47ad7f..c08afcb 100644 --- a/internal/repository/media.go +++ b/internal/repository/media.go @@ -12,10 +12,12 @@ import ( // CreateMedia inserts a new media and returns the generated ID. func (s *SQLite) CreateMedia(ctx context.Context, media *model.Media) (int64, error) { res, err := s.db.ExecContext(ctx, - `INSERT INTO media (set_id, rel_path, file_name, abs_path, type, duration, codec, resolution, bitrate, file_size_bytes, thumbnail_path, play_count, deleted_at, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO media (set_id, rel_path, file_name, abs_path, type, duration, codec, resolution, bitrate, file_size_bytes, width, height, exif_camera, exif_lens, exif_date, exif_iso, exif_f_number, exif_exposure, exif_focal_length, thumbnail_path, play_count, deleted_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, media.SetID, media.RelPath, media.FileName, media.AbsPath, string(media.Type), media.Duration, media.Codec, media.Resolution, media.Bitrate, media.FileSizeBytes, + media.Width, media.Height, media.EXIFCamera, media.EXIFLens, media.EXIFDate, + media.EXIFISO, media.EXIFFNumber, media.EXIFExposure, media.EXIFFocalLength, sqlNullString(media.ThumbnailPath), media.PlayCount, sqlNullTime(media.DeletedAt), media.CreatedAt, ) if err != nil { @@ -34,9 +36,20 @@ func scanMedia(row sqlScanner) (*model.Media, error) { var duration sql.NullFloat64 var bitrate sql.NullInt64 var fileSize sql.NullInt64 + var exifCamera sql.NullString + var exifLens sql.NullString + var exifDate sql.NullString + var exifISO sql.NullString + var exifFNumber sql.NullString + var exifExposure sql.NullString + var exifFocalLength sql.NullString + var width sql.NullInt64 + var height sql.NullInt64 err := row.Scan( &m.ID, &m.SetID, &m.RelPath, &m.FileName, &m.AbsPath, &mediaType, &duration, &codec, &resolution, &bitrate, &fileSize, + &width, &height, &exifCamera, &exifLens, &exifDate, &exifISO, + &exifFNumber, &exifExposure, &exifFocalLength, &thumbnail, &m.PlayCount, &deleted, &m.CreatedAt, ) if err == sql.ErrNoRows { @@ -67,22 +80,52 @@ func scanMedia(row sqlScanner) (*model.Media, error) { if fileSize.Valid { m.FileSizeBytes = fileSize.Int64 } + if width.Valid { + m.Width = int(width.Int64) + } + if height.Valid { + m.Height = int(height.Int64) + } + if exifCamera.Valid { + m.EXIFCamera = exifCamera.String + } + if exifLens.Valid { + m.EXIFLens = exifLens.String + } + if exifDate.Valid { + m.EXIFDate = exifDate.String + } + if exifISO.Valid { + m.EXIFISO = exifISO.String + } + if exifFNumber.Valid { + m.EXIFFNumber = exifFNumber.String + } + if exifExposure.Valid { + m.EXIFExposure = exifExposure.String + } + if exifFocalLength.Valid { + m.EXIFFocalLength = exifFocalLength.String + } return &m, nil } // GetMediaByID retrieves a media by ID. func (s *SQLite) GetMediaByID(ctx context.Context, id int64) (*model.Media, error) { row := s.db.QueryRowContext(ctx, - `SELECT id, set_id, rel_path, file_name, abs_path, type, duration, codec, resolution, bitrate, file_size_bytes, thumbnail_path, play_count, deleted_at, created_at FROM media WHERE id = ? AND deleted_at IS NULL`, id) + `SELECT id, set_id, rel_path, file_name, abs_path, type, duration, codec, resolution, bitrate, file_size_bytes, width, height, exif_camera, exif_lens, exif_date, exif_iso, exif_f_number, exif_exposure, exif_focal_length, thumbnail_path, play_count, deleted_at, created_at FROM media WHERE id = ? AND deleted_at IS NULL`, id) return scanMedia(row) } // UpdateMedia updates all mutable fields of a media record. func (s *SQLite) UpdateMedia(ctx context.Context, media *model.Media) error { _, err := s.db.ExecContext(ctx, - `UPDATE media SET set_id = ?, rel_path = ?, file_name = ?, abs_path = ?, type = ?, duration = ?, codec = ?, resolution = ?, bitrate = ?, file_size_bytes = ?, thumbnail_path = ?, play_count = ?, deleted_at = ? WHERE id = ?`, + `UPDATE media SET set_id = ?, rel_path = ?, file_name = ?, abs_path = ?, type = ?, duration = ?, codec = ?, resolution = ?, bitrate = ?, file_size_bytes = ?, width = ?, height = ?, exif_camera = ?, exif_lens = ?, exif_date = ?, exif_iso = ?, exif_f_number = ?, exif_exposure = ?, exif_focal_length = ?, thumbnail_path = ?, play_count = ?, deleted_at = ? WHERE id = ?`, media.SetID, media.RelPath, media.FileName, media.AbsPath, string(media.Type), media.Duration, - media.Codec, media.Resolution, media.Bitrate, media.FileSizeBytes, sqlNullString(media.ThumbnailPath), + media.Codec, media.Resolution, media.Bitrate, media.FileSizeBytes, + media.Width, media.Height, media.EXIFCamera, media.EXIFLens, media.EXIFDate, + media.EXIFISO, media.EXIFFNumber, media.EXIFExposure, media.EXIFFocalLength, + sqlNullString(media.ThumbnailPath), media.PlayCount, sqlNullTime(media.DeletedAt), media.ID, ) if err != nil { @@ -135,7 +178,7 @@ func (s *SQLite) ListMedia(ctx context.Context, filter MediaFilter) ([]model.Med var args []any var conds []string var joins string - query := `SELECT DISTINCT media.id, media.set_id, media.rel_path, media.file_name, media.abs_path, media.type, media.duration, media.codec, media.resolution, media.bitrate, media.file_size_bytes, media.thumbnail_path, media.play_count, media.deleted_at, media.created_at FROM media` + query := `SELECT DISTINCT media.id, media.set_id, media.rel_path, media.file_name, media.abs_path, media.type, media.duration, media.codec, media.resolution, media.bitrate, media.file_size_bytes, media.width, media.height, media.exif_camera, media.exif_lens, media.exif_date, media.exif_iso, media.exif_f_number, media.exif_exposure, media.exif_focal_length, media.thumbnail_path, media.play_count, media.deleted_at, media.created_at FROM media` if filter.Search != "" { conds = append(conds, `(media.file_name LIKE ? ESCAPE '\' OR media.rel_path LIKE ? ESCAPE '\')`) @@ -239,7 +282,7 @@ func (s *SQLite) ListMedia(ctx context.Context, filter MediaFilter) ([]model.Med // ListDeletedMedia returns all soft-deleted media. func (s *SQLite) ListDeletedMedia(ctx context.Context) ([]model.Media, error) { rows, err := s.db.QueryContext(ctx, - `SELECT id, set_id, rel_path, file_name, abs_path, type, duration, codec, resolution, bitrate, file_size_bytes, thumbnail_path, play_count, deleted_at, created_at FROM media WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC`) + `SELECT id, set_id, rel_path, file_name, abs_path, type, duration, codec, resolution, bitrate, file_size_bytes, width, height, exif_camera, exif_lens, exif_date, exif_iso, exif_f_number, exif_exposure, exif_focal_length, thumbnail_path, play_count, deleted_at, created_at FROM media WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC`) if err != nil { return nil, fmt.Errorf("list deleted media: %w", err) } diff --git a/internal/repository/migrate.go b/internal/repository/migrate.go index 60cea03..3c1e8bb 100644 --- a/internal/repository/migrate.go +++ b/internal/repository/migrate.go @@ -42,12 +42,21 @@ CREATE TABLE IF NOT EXISTS media ( rel_path TEXT NOT NULL, file_name TEXT NOT NULL, abs_path TEXT NOT NULL, - type TEXT CHECK(type IN ('video','audio')) NOT NULL, + type TEXT CHECK(type IN ('video','audio','image')) NOT NULL, duration REAL, codec TEXT, resolution TEXT, bitrate INTEGER, file_size_bytes INTEGER, + width INTEGER, + height INTEGER, + exif_camera TEXT, + exif_lens TEXT, + exif_date TEXT, + exif_iso TEXT, + exif_f_number TEXT, + exif_exposure TEXT, + exif_focal_length TEXT, thumbnail_path TEXT, play_count INTEGER NOT NULL DEFAULT 0, deleted_at DATETIME, diff --git a/internal/repository/sqlite_test.go b/internal/repository/sqlite_test.go index f2382f5..fa15a80 100644 --- a/internal/repository/sqlite_test.go +++ b/internal/repository/sqlite_test.go @@ -920,6 +920,23 @@ func TestSQLite_MediaFilters(t *testing.T) { }, }, { + name: "max duration filter", + run: func(t *testing.T, ctx context.Context, s *SQLite) { + now := time.Now().Truncate(time.Second) + sid, _ := s.CreateSet(ctx, &model.Set{Name: "s", RootPath: "/s", CreatedAt: now}) + _, _ = s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "a.mp4", FileName: "a.mp4", AbsPath: "/s/a.mp4", Type: model.MediaTypeVideo, Duration: 100, CreatedAt: now}) + _, _ = s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "b.mp4", FileName: "b.mp4", AbsPath: "/s/b.mp4", Type: model.MediaTypeVideo, Duration: 200, CreatedAt: now}) + maxDur := 150.0 + res, err := s.ListMedia(ctx, MediaFilter{MaxDuration: &maxDur, Sort: "duration"}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(res) != 1 || res[0].FileName != "a.mp4" { + t.Fatalf("unexpected result: %+v", res) + } + }, + }, + { name: "limit offset", run: func(t *testing.T, ctx context.Context, s *SQLite) { now := time.Now().Truncate(time.Second) 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 { diff --git a/internal/service/admin.go b/internal/service/admin.go index 9b32dd3..fb35631 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "sync" "time" "codeberg.org/snonux/player/internal/auth" @@ -14,12 +15,14 @@ import ( // adminService is the concrete implementation of AdminService. type adminService struct { - store repository.AdminServiceStore - clock clock.Clock - hasher auth.Hasher - scanner scanner.Scanner - mediaRoot string - progress *model.ScanProgress + store repository.AdminServiceStore + clock clock.Clock + hasher auth.Hasher + scanner scanner.Scanner + mediaRoot string + mu sync.Mutex + scanCancel context.CancelFunc + progress *model.ScanProgress } // NewAdminService creates a concrete AdminService. @@ -30,7 +33,6 @@ func NewAdminService(store repository.AdminServiceStore, clk clock.Clock, hasher hasher: hasher, scanner: sc, mediaRoot: mediaRoot, - progress: &model.ScanProgress{}, } } @@ -42,16 +44,26 @@ func (s *adminService) TriggerRescan(ctx context.Context) error { if s.scanner == nil { return fmt.Errorf("scanner not configured") } + + s.mu.Lock() + if s.scanCancel != nil { + s.scanCancel() + } + scanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + s.scanCancel = cancel + progress := &model.ScanProgress{} + s.progress = progress + s.mu.Unlock() + // Run the scan in a background goroutine so the HTTP request // returns immediately and the scan continues asynchronously. go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() - if err := s.scanner.Scan(ctx, s.mediaRoot, s.progress); err != nil { - s.progress.Done(err) + if err := s.scanner.Scan(scanCtx, s.mediaRoot, progress); err != nil { + progress.Done(err) fmt.Printf("[rescan] scan failed: %v\n", err) } else { - s.progress.Done(nil) + progress.Done(nil) fmt.Printf("[rescan] scan completed\n") } }() @@ -59,7 +71,13 @@ func (s *adminService) TriggerRescan(ctx context.Context) error { } func (s *adminService) ScanProgress(ctx context.Context) model.ScanProgress { - return s.progress.Copy() + s.mu.Lock() + progress := s.progress + s.mu.Unlock() + if progress == nil { + return model.ScanProgress{} + } + return progress.Copy() } func (s *adminService) ListUsers(ctx context.Context) ([]model.User, error) { diff --git a/internal/service/admin_test.go b/internal/service/admin_test.go index d4be649..4117c58 100644 --- a/internal/service/admin_test.go +++ b/internal/service/admin_test.go @@ -3,7 +3,9 @@ package service import ( "context" "errors" + "sync" "testing" + "time" "codeberg.org/snonux/player/internal/model" "codeberg.org/snonux/player/internal/repository" @@ -264,6 +266,175 @@ func TestAdminService_RevokePermission(t *testing.T) { } } +func TestAdminService_TriggerRescan_CancelsPrevious(t *testing.T) { + ctx := context.Background() + started := make(chan struct{}, 2) + sc := &fakeScanner{ + scanFunc: func(scanCtx context.Context, _ string, progress *model.ScanProgress) error { + progress.Start(1) + started <- struct{}{} + <-scanCtx.Done() + return scanCtx.Err() + }, + } + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media") + + // Start first scan. + if err := svc.TriggerRescan(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + <-started + + // Start second scan — should cancel the first. + if err := svc.TriggerRescan(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + <-started + + // Verify final progress is from the second (still running) scan. + progress := svc.ScanProgress(ctx) + if !progress.Running { + t.Fatal("expected second scan to be running") + } +} + +func TestAdminService_TriggerRescan_FreshProgressPerScan(t *testing.T) { + ctx := context.Background() + done := make(chan struct{}) + sc := &fakeScanner{ + scanFunc: func(_ context.Context, _ string, progress *model.ScanProgress) error { + progress.Start(5) + <-done + return nil + }, + } + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media") + + if err := svc.TriggerRescan(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Wait for goroutine to start. + for { + p := svc.ScanProgress(ctx) + if p.Running { + break + } + time.Sleep(10 * time.Millisecond) + } + close(done) + // Wait for goroutine to finish. + for { + p := svc.ScanProgress(ctx) + if !p.Running { + break + } + time.Sleep(10 * time.Millisecond) + } + + p1 := svc.ScanProgress(ctx) + if p1.SetsTotal != 5 { + t.Fatalf("expected sets_total 5, got %d", p1.SetsTotal) + } + + // Start a new scan on the same service with different progress. + done2 := make(chan struct{}) + sc2 := &fakeScanner{ + scanFunc: func(_ context.Context, _ string, progress *model.ScanProgress) error { + progress.Start(10) + <-done2 + return nil + }, + } + // We replace the scanner field via reflection? No, easier: just create new service. + // Actually, the test verifies per-service fresh progress, so new service is fine. + svc2 := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc2, "/media") + if err := svc2.TriggerRescan(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Wait for goroutine to start. + for { + p := svc2.ScanProgress(ctx) + if p.Running { + break + } + time.Sleep(10 * time.Millisecond) + } + close(done2) + // Wait for goroutine to finish. + for { + p := svc2.ScanProgress(ctx) + if !p.Running { + break + } + time.Sleep(10 * time.Millisecond) + } + + p2 := svc2.ScanProgress(ctx) + if p2.SetsTotal != 10 { + t.Fatalf("expected sets_total 10, got %d", p2.SetsTotal) + } + if p2.LastError != "" { + t.Fatalf("unexpected last_error: %s", p2.LastError) + } +} + +func TestAdminService_ScanProgress_ReturnsEmptyWhenNotStarted(t *testing.T) { + ctx := context.Background() + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "") + p := svc.ScanProgress(ctx) + if p.Running { + t.Fatal("expected not running when no scan started") + } +} + +func TestAdminService_TriggerRescan_ConcurrentCalls(t *testing.T) { + ctx := context.Background() + var wg sync.WaitGroup + callCount := 0 + var mu sync.Mutex + sc := &fakeScanner{ + scanFunc: func(scanCtx context.Context, _ string, progress *model.ScanProgress) error { + mu.Lock() + callCount++ + mu.Unlock() + progress.Start(1) + <-scanCtx.Done() + return scanCtx.Err() + }, + } + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media") + |
