diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index 8cb1562..77db493 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -21,6 +21,7 @@ import ( "github.com/mithral/vellum/internal/metadata" "github.com/mithral/vellum/internal/modules/comics" "github.com/mithral/vellum/internal/modules/ebooks" + _ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor "github.com/mithral/vellum/internal/search" "github.com/mithral/vellum/migrations" ) diff --git a/go.mod b/go.mod index 651d88c..01348ce 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.8 require ( golang.org/x/crypto v0.58.0 + golang.org/x/image v0.47.0 modernc.org/sqlite v1.60.1 ) diff --git a/go.sum b/go.sum index e637804..409f07e 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo= +golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= diff --git a/internal/library/handlers.go b/internal/library/handlers.go index 267333a..7c2c45a 100644 --- a/internal/library/handlers.go +++ b/internal/library/handlers.go @@ -178,7 +178,7 @@ func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u aut } rows, err := h.db.QueryContext(r.Context(), ` - SELECT id, kind, path, title, COALESCE(year, 0), added_at + SELECT id, library_id, kind, path, title, COALESCE(year, 0), added_at FROM media_items WHERE library_id = ? AND missing = 0 ORDER BY sort_title @@ -190,17 +190,18 @@ func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u aut defer rows.Close() type itemDTO struct { - ID int64 `json:"id"` - Kind string `json:"kind"` - Path string `json:"path"` - Title string `json:"title"` - Year int `json:"year"` - AddedAt string `json:"added_at"` + ID int64 `json:"id"` + LibraryID int64 `json:"library_id"` + Kind string `json:"kind"` + Path string `json:"path"` + Title string `json:"title"` + Year int `json:"year"` + AddedAt string `json:"added_at"` } items := []itemDTO{} for rows.Next() { var it itemDTO - if err := rows.Scan(&it.ID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.AddedAt); err != nil { + if err := rows.Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.AddedAt); err != nil { h.fail(w, "scan items", err) return } diff --git a/internal/modules/photos/encode.go b/internal/modules/photos/encode.go new file mode 100644 index 0000000..e60a9d2 --- /dev/null +++ b/internal/modules/photos/encode.go @@ -0,0 +1,12 @@ +package photos + +import ( + "image" + "image/jpeg" + "io" +) + +// encodeJPEG writes an image as JPEG at a quality suited to thumbnails. +func encodeJPEG(w io.Writer, img image.Image) error { + return jpeg.Encode(w, img, &jpeg.Options{Quality: 82}) +} diff --git a/internal/modules/photos/photos.go b/internal/modules/photos/photos.go new file mode 100644 index 0000000..f4eeb38 --- /dev/null +++ b/internal/modules/photos/photos.go @@ -0,0 +1,107 @@ +// Package photos generates thumbnails for image libraries. +// +// Photos reuse the generic item, cover, and file endpoints; the only +// photo-specific work is producing a small thumbnail, because serving a +// folder of 12-megapixel originals to a grid would be unusable on a TV box. +package photos + +import ( + "context" + "database/sql" + "fmt" + "image" + "os" + "path/filepath" + + // Decoders registered for image.Decode. + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + "github.com/mithral/vellum/internal/media" + "golang.org/x/image/draw" + _ "golang.org/x/image/webp" +) + +// Kind is the media kind this module owns. +const Kind = "photos" + +// thumbMax is the longest edge of a generated thumbnail. +const thumbMax = 512 + +func init() { + media.RegisterCoverExtractor(Kind, ExtractCover) +} + +// ExtractCover decodes the image and writes a downscaled JPEG thumbnail. +// +// A format the standard library cannot decode returns ("", nil): no thumbnail +// is a normal outcome, and the grid falls back to a placeholder rather than +// the request failing. +func ExtractCover(ctx context.Context, db *sql.DB, item media.Item, absPath, cacheDir string) (string, error) { + f, err := os.Open(absPath) + if err != nil { + return "", nil + } + defer f.Close() + + src, _, err := image.Decode(f) + if err != nil { + return "", nil + } + + dst := scaleToFit(src, thumbMax) + + dir, err := media.CoverCacheDir(cacheDir, Kind) + if err != nil { + return "", err + } + outPath := filepath.Join(dir, fmt.Sprintf("%d.jpg", item.ID)) + + tmp := outPath + ".tmp" + out, err := os.Create(tmp) + if err != nil { + return "", fmt.Errorf("photos: create thumbnail: %w", err) + } + if err := encodeJPEG(out, dst); err != nil { + out.Close() + _ = os.Remove(tmp) + return "", err + } + if err := out.Close(); err != nil { + _ = os.Remove(tmp) + return "", err + } + if err := os.Rename(tmp, outPath); err != nil { + _ = os.Remove(tmp) + return "", err + } + return outPath, nil +} + +// scaleToFit scales src down so its longest edge is at most max. Images +// already smaller than max are returned unchanged. +func scaleToFit(src image.Image, max int) image.Image { + b := src.Bounds() + w, h := b.Dx(), b.Dy() + if w <= max && h <= max { + return src + } + + scale := float64(max) / float64(w) + if h > w { + scale = float64(max) / float64(h) + } + nw, nh := int(float64(w)*scale), int(float64(h)*scale) + if nw < 1 { + nw = 1 + } + if nh < 1 { + nh = 1 + } + + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + // CatmullRom is a good quality/speed trade for thumbnails. + draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Src, nil) + return dst +} diff --git a/internal/modules/photos/photos_test.go b/internal/modules/photos/photos_test.go new file mode 100644 index 0000000..70d3377 --- /dev/null +++ b/internal/modules/photos/photos_test.go @@ -0,0 +1,112 @@ +package photos + +import ( + "context" + "image" + "image/color" + "image/jpeg" + "image/png" + "os" + "path/filepath" + "testing" + + "github.com/mithral/vellum/internal/media" +) + +func writePNG(t *testing.T, w, h int) string { + t.Helper() + p := filepath.Join(t.TempDir(), "photo.png") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255}) + } + } + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + return p +} + +func decode(t *testing.T, path string) image.Image { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + img, _, err := image.Decode(f) + if err != nil { + t.Fatal(err) + } + return img +} + +func TestExtractCoverDownscales(t *testing.T) { + src := writePNG(t, 1000, 1500) + cacheDir := t.TempDir() + item := media.Item{ID: 7, Kind: Kind} + + out, err := ExtractCover(context.Background(), nil, item, src, cacheDir) + if err != nil { + t.Fatalf("ExtractCover: %v", err) + } + if out == "" { + t.Fatal("no thumbnail produced") + } + + img := decode(t, out) + b := img.Bounds() + if b.Dx() > thumbMax || b.Dy() > thumbMax { + t.Errorf("thumbnail is %dx%d, exceeds %d", b.Dx(), b.Dy(), thumbMax) + } + // Aspect ratio must be preserved: 1000x1500 -> 341x512. + if b.Dy() != thumbMax { + t.Errorf("long edge = %d, want %d", b.Dy(), thumbMax) + } + if b.Dx() != 341 { + t.Errorf("short edge = %d, want 341", b.Dx()) + } + + // It must actually be a JPEG. + f, err := os.Open(out) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, err := jpeg.Decode(f); err != nil { + t.Errorf("output is not a JPEG: %v", err) + } +} + +func TestExtractCoverLeavesSmallImagesAlone(t *testing.T) { + src := writePNG(t, 100, 80) + out, err := ExtractCover(context.Background(), nil, media.Item{ID: 1, Kind: Kind}, src, t.TempDir()) + if err != nil { + t.Fatalf("ExtractCover: %v", err) + } + img := decode(t, out) + if img.Bounds().Dx() != 100 || img.Bounds().Dy() != 80 { + t.Errorf("small image was resized to %v", img.Bounds()) + } +} + +func TestExtractCoverIgnoresNonImage(t *testing.T) { + p := filepath.Join(t.TempDir(), "not-an-image.jpg") + if err := os.WriteFile(p, []byte("definitely not an image"), 0o644); err != nil { + t.Fatal(err) + } + out, err := ExtractCover(context.Background(), nil, media.Item{ID: 1, Kind: Kind}, p, t.TempDir()) + if err != nil { + t.Fatalf("a non-image must not error: %v", err) + } + if out != "" { + t.Errorf("expected no thumbnail, got %q", out) + } +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 8bd40bb..4eaa671 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -25,6 +25,7 @@ export interface MediaType { export interface Item { id: number + library_id: number kind: string path: string title: string diff --git a/web/src/lib/components/PhotoViewer.svelte b/web/src/lib/components/PhotoViewer.svelte new file mode 100644 index 0000000..9803b97 --- /dev/null +++ b/web/src/lib/components/PhotoViewer.svelte @@ -0,0 +1,84 @@ + + +{#if error} +
{error}
+{:else if loading} +
+{:else} +
+ {item?.title +
+ +{/if} + + diff --git a/web/src/lib/components/Reader.svelte b/web/src/lib/components/Reader.svelte index 015ee3b..5f03626 100644 --- a/web/src/lib/components/Reader.svelte +++ b/web/src/lib/components/Reader.svelte @@ -4,6 +4,7 @@ import { api, ApiError, type Item, type EbookMetadata } from '../api' import ComicReader from './ComicReader.svelte' import AudiobookPlayer from './AudiobookPlayer.svelte' + import PhotoViewer from './PhotoViewer.svelte' let { itemId }: { itemId: number } = $props() @@ -95,6 +96,8 @@ {:else if item && (item.kind === 'audiobooks' || item.kind === 'music')} +{:else if item && item.kind === 'photos'} + {:else if item && item.kind !== 'ebooks'}

{item.title}