summaryrefslogtreecommitdiff
path: root/player-server/internal/thumb
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-19 19:35:02 +0300
committerPaul Buetow <paul@buetow.org>2026-05-19 19:35:02 +0300
commit41bdcb29178fcbcd71fb3663f507736151c018db (patch)
tree2a519f878b1d4db13174522ad43c1a0b5c8b2065 /player-server/internal/thumb
parent965b36f3e09a0d4cdbb69cb3f423ab01fecf2419 (diff)
Extract thumbnail path helpers into internal/thumb
The scanner (thumbnailForVideo, thumbnailForImage), the service importer (generateThumbnail), and writeService.RegenerateThumbnail all reinvented the same ".thumbnails/<base>.jpg" layout inline. Centralise the convention in internal/thumb/path.go (ThumbnailDir, ThumbnailNameFor, ThumbnailPathFor + DirName) so changing the on-disk layout is a one-line change and the three callers cannot drift apart. Pure path math, no I/O — callers retain their existing MkdirAll mechanism (scanner FS, os.MkdirAll in service) for testability. Covered by table-driven unit tests for trailing slashes, missing extensions, dotfiles, and multi-dot filenames.
Diffstat (limited to 'player-server/internal/thumb')
-rw-r--r--player-server/internal/thumb/path.go38
-rw-r--r--player-server/internal/thumb/path_test.go79
2 files changed, 117 insertions, 0 deletions
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")
+ }
+}