diff options
Diffstat (limited to 'player-server')
| -rw-r--r-- | player-server/internal/scanner/scanner.go | 14 | ||||
| -rw-r--r-- | player-server/internal/service/import.go | 8 | ||||
| -rw-r--r-- | player-server/internal/service/write.go | 9 | ||||
| -rw-r--r-- | player-server/internal/thumb/path.go | 38 | ||||
| -rw-r--r-- | player-server/internal/thumb/path_test.go | 79 |
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") + } +} |
