extracted cover art handling from library package
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// Package coverart provides utilities for cover art filenames and URL resolution.
|
||||
package coverart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"yellowjacket/backend/system"
|
||||
)
|
||||
|
||||
// PathPrefix is the URL path prefix for cover art served by the asset handler.
|
||||
const PathPrefix = "/covers/"
|
||||
|
||||
// URLs holds the resolved URL paths for all cover art size variants.
|
||||
type URLs struct {
|
||||
Original string
|
||||
Small string
|
||||
Medium string
|
||||
Large string
|
||||
}
|
||||
|
||||
// dirName is the subdirectory name under the user data directory
|
||||
// where cover art files are stored.
|
||||
const dirName = "covers"
|
||||
|
||||
// CoversDir returns the absolute path to the cover art cache directory.
|
||||
func CoversDir() (string, error) {
|
||||
dataDir, err := system.GetUserDataDirPath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"could not get user data directory: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return filepath.Join(dataDir, dirName), nil
|
||||
}
|
||||
|
||||
// SizedFilename derives a sized-variant filename from an original cover art
|
||||
// filename and a size suffix.
|
||||
// For example, SizedFilename("a1b2c3d4.jpg", "_sm") returns "a1b2c3d4_sm.jpg".
|
||||
func SizedFilename(originalFilename, suffix string) string {
|
||||
ext := filepath.Ext(originalFilename)
|
||||
name := strings.TrimSuffix(originalFilename, ext)
|
||||
|
||||
return name + suffix + ".jpg"
|
||||
}
|
||||
|
||||
// ResolveURLs converts a cover art filesystem path into URL paths
|
||||
// for the original and all size variants (small, medium, large).
|
||||
func ResolveURLs(filesystemPath string) URLs {
|
||||
base := filepath.Base(filesystemPath)
|
||||
|
||||
return URLs{
|
||||
Original: PathPrefix + base,
|
||||
Small: PathPrefix + SizedFilename(base, "_sm"),
|
||||
Medium: PathPrefix + SizedFilename(base, "_md"),
|
||||
Large: PathPrefix + SizedFilename(base, "_lg"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package coverart_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/coverart"
|
||||
)
|
||||
|
||||
func TestCoversDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir, err := coverart.CoversDir()
|
||||
if err != nil {
|
||||
t.Fatalf("CoversDir() returned error: %v", err)
|
||||
}
|
||||
|
||||
if dir == "" {
|
||||
t.Fatal("CoversDir() returned empty string")
|
||||
}
|
||||
|
||||
// The path must end with the "covers" directory name.
|
||||
if filepath.Base(dir) != "covers" {
|
||||
t.Errorf(
|
||||
"CoversDir() = %q, want path ending in %q",
|
||||
dir, "covers",
|
||||
)
|
||||
}
|
||||
|
||||
// Must be an absolute path.
|
||||
if !filepath.IsAbs(dir) {
|
||||
t.Errorf("CoversDir() = %q, want absolute path", dir)
|
||||
}
|
||||
|
||||
// Must contain the app name somewhere in the path.
|
||||
if !strings.Contains(dir, "yellowjacket") {
|
||||
t.Errorf(
|
||||
"CoversDir() = %q, expected to contain %q",
|
||||
dir, "yellowjacket",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizedFilename(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
suffix string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "jpg with _sm suffix",
|
||||
filename: "a1b2c3d4.jpg",
|
||||
suffix: "_sm",
|
||||
want: "a1b2c3d4_sm.jpg",
|
||||
},
|
||||
{
|
||||
name: "jpg with _md suffix",
|
||||
filename: "a1b2c3d4.jpg",
|
||||
suffix: "_md",
|
||||
want: "a1b2c3d4_md.jpg",
|
||||
},
|
||||
{
|
||||
name: "jpg with _lg suffix",
|
||||
filename: "a1b2c3d4.jpg",
|
||||
suffix: "_lg",
|
||||
want: "a1b2c3d4_lg.jpg",
|
||||
},
|
||||
{
|
||||
name: "png source outputs jpg",
|
||||
filename: "abcdef01.png",
|
||||
suffix: "_sm",
|
||||
want: "abcdef01_sm.jpg",
|
||||
},
|
||||
{
|
||||
name: "no extension",
|
||||
filename: "abcdef01",
|
||||
suffix: "_md",
|
||||
want: "abcdef01_md.jpg",
|
||||
},
|
||||
{
|
||||
name: "empty suffix",
|
||||
filename: "a1b2c3d4.jpg",
|
||||
suffix: "",
|
||||
want: "a1b2c3d4.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := coverart.SizedFilename(tt.filename, tt.suffix)
|
||||
if got != tt.want {
|
||||
t.Errorf(
|
||||
"SizedFilename(%q, %q) = %q, want %q",
|
||||
tt.filename, tt.suffix, got, tt.want,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantOrig string
|
||||
wantSm string
|
||||
wantMd string
|
||||
wantLg string
|
||||
}{
|
||||
{
|
||||
name: "absolute path",
|
||||
path: "/home/user/.local/share/yellowjacket/covers/a1b2c3d4.jpg",
|
||||
wantOrig: "/covers/a1b2c3d4.jpg",
|
||||
wantSm: "/covers/a1b2c3d4_sm.jpg",
|
||||
wantMd: "/covers/a1b2c3d4_md.jpg",
|
||||
wantLg: "/covers/a1b2c3d4_lg.jpg",
|
||||
},
|
||||
{
|
||||
name: "bare filename",
|
||||
path: "abcdef01.png",
|
||||
wantOrig: "/covers/abcdef01.png",
|
||||
wantSm: "/covers/abcdef01_sm.jpg",
|
||||
wantMd: "/covers/abcdef01_md.jpg",
|
||||
wantLg: "/covers/abcdef01_lg.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
urls := coverart.ResolveURLs(tt.path)
|
||||
|
||||
if urls.Original != tt.wantOrig {
|
||||
t.Errorf(
|
||||
"Original = %q, want %q",
|
||||
urls.Original, tt.wantOrig,
|
||||
)
|
||||
}
|
||||
|
||||
if urls.Small != tt.wantSm {
|
||||
t.Errorf(
|
||||
"Small = %q, want %q",
|
||||
urls.Small, tt.wantSm,
|
||||
)
|
||||
}
|
||||
|
||||
if urls.Medium != tt.wantMd {
|
||||
t.Errorf(
|
||||
"Medium = %q, want %q",
|
||||
urls.Medium, tt.wantMd,
|
||||
)
|
||||
}
|
||||
|
||||
if urls.Large != tt.wantLg {
|
||||
t.Errorf(
|
||||
"Large = %q, want %q",
|
||||
urls.Large, tt.wantLg,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package coverart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Handler serves cover art images via HTTP.
|
||||
type Handler struct {
|
||||
coversDir string
|
||||
}
|
||||
|
||||
// NewHandler creates an HTTP handler that serves cover art from the
|
||||
// user data directory.
|
||||
func NewHandler() (*Handler, error) {
|
||||
dir, err := CoversDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"could not resolve covers directory: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return &Handler{coversDir: dir}, nil
|
||||
}
|
||||
|
||||
// ServeHTTP handles requests for cover art images.
|
||||
func (h *Handler) ServeHTTP(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
// Extract filename from path like "/covers/abc123.jpg".
|
||||
filename := filepath.Base(r.URL.Path)
|
||||
|
||||
// Prevent directory traversal.
|
||||
if filename == "." || filename == ".." {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Filenames are content-hashed (SHA-256), so they are immutable.
|
||||
// Set aggressive cache headers to avoid redundant re-fetches.
|
||||
w.Header().Set(
|
||||
"Cache-Control",
|
||||
"public, max-age=31536000, immutable",
|
||||
)
|
||||
|
||||
filePath := filepath.Join(h.coversDir, filename)
|
||||
http.ServeFile(w, r, filePath)
|
||||
}
|
||||
Reference in New Issue
Block a user