summaryrefslogtreecommitdiff
path: root/player-server
diff options
context:
space:
mode:
Diffstat (limited to 'player-server')
-rw-r--r--player-server/internal/scanner/scanner.go14
-rw-r--r--player-server/internal/service/import.go8
-rw-r--r--player-server/internal/service/write.go9
-rw-r--r--player-server/internal/thumb/path.go38
-rw-r--r--player-server/internal/thumb/path_test.go79
5 files changed, 136 insertions, 12 deletions
diff --git a/player-server/internal/scanner/scanner.go b/player-server/internal/scanner/scanner.go
index 9233630..0f6e50b 100644
--- a/player-server/internal/scanner/scanner.go
+++ b/player-server/internal/scanner/scanner.go
@@ -205,13 +205,15 @@ func (s *FSScanner) gatherCoverImages(setPath string) map[string]string {
}
// thumbnailForVideo generates a thumbnail for a video file inside the set's .thumbnails directory.
+// The destination directory + filename are derived via internal/thumb so the
+// layout convention stays in lock-step with importers (service.generateThumbnail,
+// writeService.RegenerateThumbnail).
func (s *FSScanner) thumbnailForVideo(ctx context.Context, path, setPath string, duration float64) (string, error) {
- thumbDir := filepath.Join(setPath, ".thumbnails")
+ thumbDir := thumb.ThumbnailDir(setPath)
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)
+ thumbnailPath := thumb.ThumbnailPathFor(path, setPath)
if err := s.thumbGen.Generate(ctx, path, thumbnailPath, duration); err != nil {
s.log().Warn("scanner skipping thumbnail", "path", path, "err", err)
return "", nil
@@ -220,13 +222,13 @@ func (s *FSScanner) thumbnailForVideo(ctx context.Context, path, setPath string,
}
// thumbnailForImage generates a thumbnail for an image file inside the set's .thumbnails directory.
+// See thumbnailForVideo for the shared path-derivation contract.
func (s *FSScanner) thumbnailForImage(ctx context.Context, path, setPath string) (string, error) {
- thumbDir := filepath.Join(setPath, ".thumbnails")
+ thumbDir := thumb.ThumbnailDir(setPath)
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)
+ thumbnailPath := thumb.ThumbnailPathFor(path, setPath)
if err := s.thumbGen.Generate(ctx, path, thumbnailPath, 0); err != nil {
s.log().Warn("scanner skipping thumbnail", "path", path, "err", err)
return "", nil
diff --git a/player-server/internal/service/import.go b/player-server/internal/service/import.go
index 90fa4b1..4253439 100644
--- a/player-server/internal/service/import.go
+++ b/player-server/internal/service/import.go
@@ -69,18 +69,20 @@ func probeMedia(ctx context.Context, prober probe.Prober, path string) (*model.M
}
// generateThumbnail creates a thumbnail for video and image media.
+// Thumbnail directory + filename are derived via internal/thumb to share the
+// on-disk convention with the scanner and RegenerateThumbnail.
func generateThumbnail(ctx context.Context, thumbGen thumb.Generator, media *model.Media, duration float64) error {
ext := strings.ToLower(filepath.Ext(media.AbsPath))
if ext == ".svg" {
media.ThumbnailPath = media.AbsPath
return nil
}
- thumbDir := filepath.Join(filepath.Dir(media.AbsPath), ".thumbnails")
+ parent := filepath.Dir(media.AbsPath)
+ thumbDir := thumb.ThumbnailDir(parent)
if err := os.MkdirAll(thumbDir, 0o755); err != nil {
return fmt.Errorf("mkdir thumbnails: %w", err)
}
- thumbName := strings.TrimSuffix(filepath.Base(media.AbsPath), filepath.Ext(media.AbsPath)) + ".jpg"
- thumbnailPath := filepath.Join(thumbDir, thumbName)
+ thumbnailPath := thumb.ThumbnailPathFor(media.AbsPath, parent)
if thumbGen == nil {
media.ThumbnailPath = thumbnailPath
diff --git a/player-server/internal/service/write.go b/player-server/internal/service/write.go
index 23fd130..3fe4d47 100644
--- a/player-server/internal/service/write.go
+++ b/player-server/internal/service/write.go
@@ -112,12 +112,15 @@ func (s *writeService) RegenerateThumbnail(ctx context.Context, mediaID, userID
return fmt.Errorf("probe media: %w", err)
}
- thumbDir := filepath.Join(filepath.Dir(media.AbsPath), ".thumbnails")
+ // Thumbnail destination is derived via internal/thumb so re-generation
+ // targets the exact same path used by scanner + import; otherwise stale
+ // JPEGs would linger alongside the freshly written one.
+ parent := filepath.Dir(media.AbsPath)
+ thumbDir := thumb.ThumbnailDir(parent)
if err := os.MkdirAll(thumbDir, 0o755); err != nil {
return fmt.Errorf("mkdir thumbnails: %w", err)
}
- thumbName := strings.TrimSuffix(filepath.Base(media.AbsPath), filepath.Ext(media.AbsPath)) + ".jpg"
- thumbnailPath := filepath.Join(thumbDir, thumbName)
+ thumbnailPath := thumb.ThumbnailPathFor(media.AbsPath, parent)
if err := s.thumbGen.Generate(ctx, media.AbsPath, thumbnailPath, meta.Duration); err != nil {
return fmt.Errorf("generate thumbnail: %w", err)
diff --git a/player-server/internal/thumb/path.go b/player-server/internal/thumb/path.go
new file mode 100644
index 0000000..66f3145
--- /dev/null
+++ b/player-server/internal/thumb/path.go
@@ -0,0 +1,38 @@
+package thumb
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+// DirName is the on-disk directory name used to store generated thumbnails.
+// It is a hidden subdirectory placed alongside the source media files.
+const DirName = ".thumbnails"
+
+// thumbExt is the canonical extension used for all generated thumbnails.
+// Thumbnails are always JPEGs regardless of the source media type.
+const thumbExt = ".jpg"
+
+// ThumbnailDir returns the conventional thumbnail directory inside parent.
+// The directory is a hidden ".thumbnails" subfolder sitting next to the
+// source media. filepath.Join handles any trailing slashes on parent.
+func ThumbnailDir(parent string) string {
+ return filepath.Join(parent, DirName)
+}
+
+// ThumbnailNameFor returns the canonical thumbnail filename for the given
+// source file (basename without extension + ".jpg"). For dotfiles (e.g.
+// ".bashrc") filepath.Ext returns the leading-dot name itself; TrimSuffix
+// then yields an empty base, producing just ".jpg" — matching the historical
+// behaviour of the inlined helpers being replaced.
+func ThumbnailNameFor(srcPath string) string {
+ base := filepath.Base(srcPath)
+ return strings.TrimSuffix(base, filepath.Ext(base)) + thumbExt
+}
+
+// ThumbnailPathFor returns the full path to the thumbnail for srcPath stored
+// under parent/.thumbnails/. parent is the directory that should contain the
+// thumbnail folder (typically the set directory or the source's parent dir).
+func ThumbnailPathFor(srcPath, parent string) string {
+ return filepath.Join(ThumbnailDir(parent), ThumbnailNameFor(srcPath))
+}
diff --git a/player-server/internal/thumb/path_test.go b/player-server/internal/thumb/path_test.go
new file mode 100644
index 0000000..c7f45f0
--- /dev/null
+++ b/player-server/internal/thumb/path_test.go
@@ -0,0 +1,79 @@
+package thumb
+
+import "testing"
+
+func TestThumbnailDir(t *testing.T) {
+ tests := []struct {
+ name string
+ parent string
+ want string
+ }{
+ {"simple", "/media/set1", "/media/set1/.thumbnails"},
+ {"trailing slash", "/media/set1/", "/media/set1/.thumbnails"},
+ {"double trailing slash", "/media/set1//", "/media/set1/.thumbnails"},
+ {"relative", "set1", "set1/.thumbnails"},
+ {"empty parent", "", ".thumbnails"},
+ {"root", "/", "/.thumbnails"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := ThumbnailDir(tt.parent); got != tt.want {
+ t.Errorf("ThumbnailDir(%q) = %q, want %q", tt.parent, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestThumbnailNameFor(t *testing.T) {
+ tests := []struct {
+ name string
+ srcPath string
+ want string
+ }{
+ {"video mp4", "/media/set1/clip.mp4", "clip.jpg"},
+ {"image jpg", "/media/set1/photo.JPG", "photo.jpg"},
+ {"audio with cover", "/media/set1/song.mp3", "song.jpg"},
+ {"no extension", "/media/set1/README", "README.jpg"},
+ {"multi-dot filename", "/media/set1/my.movie.final.mkv", "my.movie.final.jpg"},
+ {"dotfile", "/media/set1/.bashrc", ".jpg"},
+ {"just basename", "clip.mp4", "clip.jpg"},
+ {"path with trailing slash", "/media/set1/clip.mp4/", "clip.jpg"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := ThumbnailNameFor(tt.srcPath); got != tt.want {
+ t.Errorf("ThumbnailNameFor(%q) = %q, want %q", tt.srcPath, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestThumbnailPathFor(t *testing.T) {
+ tests := []struct {
+ name string
+ srcPath string
+ parent string
+ want string
+ }{
+ {"video", "/media/set1/clip.mp4", "/media/set1", "/media/set1/.thumbnails/clip.jpg"},
+ {"image trailing slash parent", "/media/set1/photo.jpg", "/media/set1/", "/media/set1/.thumbnails/photo.jpg"},
+ {"src elsewhere", "/uploads/raw/photo.png", "/media/set1", "/media/set1/.thumbnails/photo.jpg"},
+ {"relative", "clip.mp4", "set1", "set1/.thumbnails/clip.jpg"},
+ {"no extension", "/media/set1/RAW", "/media/set1", "/media/set1/.thumbnails/RAW.jpg"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := ThumbnailPathFor(tt.srcPath, tt.parent); got != tt.want {
+ t.Errorf("ThumbnailPathFor(%q, %q) = %q, want %q", tt.srcPath, tt.parent, got, tt.want)
+ }
+ })
+ }
+}
+
+// TestDirNameConstant guards the on-disk convention; many tests in
+// internal/service compare against the literal ".thumbnails" name.
+func TestDirNameConstant(t *testing.T) {
+ if DirName != ".thumbnails" {
+ t.Errorf("DirName = %q, want %q (changing this breaks on-disk layout)", DirName, ".thumbnails")
+ }
+}