summaryrefslogtreecommitdiff
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
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.
-rw-r--r--README.md9
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--internal/api/handlers.go14
-rw-r--r--internal/model/media.go61
-rw-r--r--internal/probe/probe.go88
-rw-r--r--internal/repository/media.go57
-rw-r--r--internal/repository/migrate.go11
-rw-r--r--internal/repository/sqlite_test.go17
-rw-r--r--internal/scanner/scanner.go36
-rw-r--r--internal/scanner/scanner_test.go4
-rw-r--r--internal/service/admin.go42
-rw-r--r--internal/service/admin_test.go171
-rw-r--r--internal/service/media.go17
-rw-r--r--internal/service/media_browse.go4
-rw-r--r--internal/service/media_write.go13
-rw-r--r--internal/thumb/thumb.go13
-rw-r--r--internal/thumb/thumb_test.go38
-rw-r--r--web/css/lightbox.css79
-rw-r--r--web/css/player.css21
-rw-r--r--web/index.html26
-rw-r--r--web/js/app.js130
-rw-r--r--web/js/keyboard.js50
-rw-r--r--web/js/lightbox.js181
-rw-r--r--web/js/player.js207
-rw-r--r--web/sw.js2
26 files changed, 1198 insertions, 96 deletions
diff --git a/README.md b/README.md
index 7e71e55..5507968 100644
--- a/README.md
+++ b/README.md
@@ -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
+
+
diff --git a/go.mod b/go.mod
index 16c3d5d..dbce0c4 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index deecc83..b28fe2d 100644
--- a/go.sum
+++ b/go.sum
@@ -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")
+