From 01b9709e4f7622744acdf6a64e6fa2afa983bbac Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sat, 23 May 2026 20:26:05 -0400 Subject: [PATCH] Port collection templates to bundled poster storage - Store bundled template posters in public S3 when available - Presign imported user collection posters in API responses - Thread frontend assets into router and collection handlers --- cmd/silo/main.go | 5 +- internal/api/handlers/collection_artwork.go | 32 ++++ .../api/handlers/collection_artwork_test.go | 175 ++++++++++++++++++ internal/api/handlers/library_collections.go | 98 +++++++++- .../api/handlers/user_collection_imports.go | 83 ++++++++- internal/api/router.go | 6 + 6 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 internal/api/handlers/collection_artwork_test.go diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 120f212e..052a35dc 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -1357,15 +1357,16 @@ func main() { compatServer.SessionStore().DeleteByUserID(userID) } } - router := api.NewRouter(deps) - // Step 8: Expose Prometheus metrics endpoint (not behind auth). distFS, fsErr := fs.Sub(siloweb.DistFS, "dist") if fsErr != nil { log.Fatalf("failed to create frontend FS: %v", fsErr) } + deps.FrontendFS = distFS server.WebDistFS = distFS + router := api.NewRouter(deps) + // Step 8: Expose Prometheus metrics endpoint (not behind auth). metricsMux := http.NewServeMux() metricsMux.Handle("/metrics", promhttp.Handler()) metricsMux.Handle("/api/", router) diff --git a/internal/api/handlers/collection_artwork.go b/internal/api/handlers/collection_artwork.go index d2e903ae..8c80bdf8 100644 --- a/internal/api/handlers/collection_artwork.go +++ b/internal/api/handlers/collection_artwork.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "io/fs" "net/http" "net/url" "strings" @@ -18,10 +19,41 @@ import ( const ( adminCollectionImagePrefix = "collection-images" userCollectionImagePrefix = "user-collection-images" + collectionTemplateImageDir = "/images/collection-templates/" collectionImageMaxBytes = 10 << 20 // 10 MB ) +// storeBundledCollectionPosterIfS3Configured stores a built-in collection +// template poster in S3 when public asset storage is configured. Non-S3 +// installs and non-template paths keep the original persisted path. +func storeBundledCollectionPosterIfS3Configured( + ctx context.Context, + s3GP *s3client.Client, + frontendFS fs.FS, + collectionID, prefix, posterPath string, +) (storedPath, thumbhashStr string, stored bool, err error) { + posterPath = strings.TrimSpace(posterPath) + if s3GP == nil || !strings.HasPrefix(posterPath, collectionTemplateImageDir) { + return posterPath, "", false, nil + } + if frontendFS == nil { + return "", "", false, fmt.Errorf("frontend assets are not available") + } + + assetPath := strings.TrimPrefix(posterPath, "/") + data, err := fs.ReadFile(frontendFS, assetPath) + if err != nil { + return "", "", false, fmt.Errorf("reading bundled poster %q: %w", posterPath, err) + } + + storedPath, thumbhashStr, err = uploadCollectionImageVariants(ctx, s3GP, prefix, collectionID, "poster", data) + if err != nil { + return "", "", false, err + } + return storedPath, thumbhashStr, true, nil +} + // readCollectionImageMultipart reads a single image file from a multipart // request, validating MIME type and size. func readCollectionImageMultipart(r *http.Request, fieldName string) ([]byte, error) { diff --git a/internal/api/handlers/collection_artwork_test.go b/internal/api/handlers/collection_artwork_test.go new file mode 100644 index 00000000..7638d876 --- /dev/null +++ b/internal/api/handlers/collection_artwork_test.go @@ -0,0 +1,175 @@ +package handlers + +import ( + "bytes" + "context" + "image" + "image/color" + "image/jpeg" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "testing/fstest" + + "github.com/Silo-Server/silo-server/internal/s3client" +) + +type collectionArtworkS3Recorder struct { + server *httptest.Server + mu sync.Mutex + puts []string +} + +func newCollectionArtworkS3Recorder(t *testing.T) *collectionArtworkS3Recorder { + t.Helper() + + recorder := &collectionArtworkS3Recorder{} + recorder.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + if r.Method == http.MethodPut { + recorder.mu.Lock() + recorder.puts = append(recorder.puts, r.URL.Path) + recorder.mu.Unlock() + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(recorder.server.Close) + return recorder +} + +func (r *collectionArtworkS3Recorder) client() *s3client.Client { + return s3client.NewClient(s3client.BucketConfig{ + Endpoint: r.server.URL, + Region: "us-east-1", + Bucket: "public-assets", + AccessKey: "test", + SecretKey: "test", + PathStyle: true, + }) +} + +func (r *collectionArtworkS3Recorder) putPaths() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.puts)) + copy(out, r.puts) + return out +} + +func TestStoreBundledCollectionPosterIfS3Configured_NoS3KeepsPath(t *testing.T) { + path := "/images/collection-templates/template.jpg" + gotPath, gotThumbhash, stored, err := storeBundledCollectionPosterIfS3Configured( + context.Background(), + nil, + fstest.MapFS{}, + "collection-1", + adminCollectionImagePrefix, + path, + ) + if err != nil { + t.Fatalf("storeBundledCollectionPosterIfS3Configured: %v", err) + } + if stored { + t.Fatal("stored = true, want false") + } + if gotPath != path { + t.Fatalf("path = %q, want %q", gotPath, path) + } + if gotThumbhash != "" { + t.Fatalf("thumbhash = %q, want empty", gotThumbhash) + } +} + +func TestStoreBundledCollectionPosterIfS3Configured_IgnoresNonTemplatePath(t *testing.T) { + recorder := newCollectionArtworkS3Recorder(t) + path := "collection-images/existing/poster/original.webp" + + gotPath, gotThumbhash, stored, err := storeBundledCollectionPosterIfS3Configured( + context.Background(), + recorder.client(), + fstest.MapFS{}, + "collection-1", + adminCollectionImagePrefix, + path, + ) + if err != nil { + t.Fatalf("storeBundledCollectionPosterIfS3Configured: %v", err) + } + if stored { + t.Fatal("stored = true, want false") + } + if gotPath != path { + t.Fatalf("path = %q, want %q", gotPath, path) + } + if gotThumbhash != "" { + t.Fatalf("thumbhash = %q, want empty", gotThumbhash) + } + if puts := recorder.putPaths(); len(puts) != 0 { + t.Fatalf("PUT paths = %#v, want none", puts) + } +} + +func TestStoreBundledCollectionPosterIfS3Configured_UploadsTemplatePoster(t *testing.T) { + recorder := newCollectionArtworkS3Recorder(t) + frontendFS := fstest.MapFS{ + "images/collection-templates/template.jpg": { + Data: testCollectionPosterJPEG(t), + }, + } + + gotPath, gotThumbhash, stored, err := storeBundledCollectionPosterIfS3Configured( + context.Background(), + recorder.client(), + frontendFS, + "collection-1", + adminCollectionImagePrefix, + "/images/collection-templates/template.jpg", + ) + if err != nil { + t.Fatalf("storeBundledCollectionPosterIfS3Configured: %v", err) + } + if !stored { + t.Fatal("stored = false, want true") + } + if gotPath != "collection-images/collection-1/poster/original.webp" { + t.Fatalf("path = %q", gotPath) + } + if gotThumbhash == "" { + t.Fatal("thumbhash is empty") + } + + want := map[string]bool{ + "/public-assets/collection-images/collection-1/poster/original.webp": true, + "/public-assets/collection-images/collection-1/poster/w500.webp": true, + "/public-assets/collection-images/collection-1/poster/w300.webp": true, + } + puts := recorder.putPaths() + if len(puts) != len(want) { + t.Fatalf("PUT paths = %#v", puts) + } + for _, path := range puts { + if !want[path] { + t.Fatalf("unexpected PUT path %q in %#v", path, puts) + } + } +} + +func testCollectionPosterJPEG(t *testing.T) []byte { + t.Helper() + + img := image.NewRGBA(image.Rect(0, 0, 32, 48)) + for y := 0; y < 48; y++ { + for x := 0; x < 32; x++ { + img.Set(x, y, color.RGBA{R: uint8(x * 6), G: uint8(y * 4), B: 120, A: 255}) + } + } + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + return buf.Bytes() +} diff --git a/internal/api/handlers/library_collections.go b/internal/api/handlers/library_collections.go index 7ee22052..b8e33d54 100644 --- a/internal/api/handlers/library_collections.go +++ b/internal/api/handlers/library_collections.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "io/fs" "net/http" "runtime/debug" "slices" @@ -41,6 +42,7 @@ type LibraryCollectionHandler struct { presignTTL time.Duration httpClient *http.Client s3GP *s3client.Client + FrontendFS fs.FS SectionRepo *sections.Repository UserCollectionPool *pgxpool.Pool GroupRepo *catalog.LibraryCollectionGroupRepository @@ -110,6 +112,39 @@ func (h *LibraryCollectionHandler) SetupCollage() { slog.Info("collage: auto-generation enabled") } +func (h *LibraryCollectionHandler) storeBundledTemplatePoster( + ctx context.Context, + collectionID, posterPath string, + fromTemplate bool, +) (bool, string, string, error) { + storedPath, thumbhash, stored, err := storeBundledCollectionPosterIfS3Configured( + ctx, + h.s3GP, + h.FrontendFS, + collectionID, + adminCollectionImagePrefix, + posterPath, + ) + if err != nil || !stored { + return false, storedPath, thumbhash, err + } + + notAutoGenerated := false + input := catalog.UpdateLibraryCollectionInput{ + ID: collectionID, + PosterURL: &storedPath, + PosterThumbhash: &thumbhash, + PosterAutoGenerated: ¬AutoGenerated, + } + if fromTemplate { + input.PosterFromTemplate = &fromTemplate + } + if err := h.repo.Update(ctx, input); err != nil { + return false, "", "", fmt.Errorf("updating bundled poster path: %w", err) + } + return true, storedPath, thumbhash, nil +} + // GenerateCollectionPoster implements catalog.CollageGenerator. // It fetches item poster images, composes a collage, and stores the result. func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context, collectionID string) error { @@ -1107,11 +1142,16 @@ func (h *LibraryCollectionHandler) HandleCreateAdminCollection(w http.ResponseWr return } + posterStored, _, _, err := h.storeBundledTemplatePoster(r.Context(), collection.ID, req.PosterURL, false) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process template poster") + return + } if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images") return } - if r.MultipartForm != nil || strings.TrimSpace(req.PosterSourceURL) != "" || strings.TrimSpace(req.BackdropSourceURL) != "" { + if posterStored || r.MultipartForm != nil || strings.TrimSpace(req.PosterSourceURL) != "" || strings.TrimSpace(req.BackdropSourceURL) != "" { collection, err = h.repo.GetByID(r.Context(), collection.ID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection") @@ -1818,6 +1858,18 @@ func (h *LibraryCollectionHandler) ensureTemplatePoster( if collection.PosterURL == posterPath && collection.PosterFromTemplate { return } + if stored, _, _, err := h.storeBundledTemplatePoster(ctx, collection.ID, posterPath, true); err != nil { + slog.Warn("failed to store template poster", + "collection_id", collection.ID, + "template_id", tmpl.ID, + "poster_path", posterPath, + "error", err, + ) + return + } else if stored { + return + } + emptyThumbhash := "" notAutoGenerated := false fromTemplate := true @@ -2405,6 +2457,16 @@ func (h *LibraryCollectionHandler) createMDBListCollection( if err != nil { return nil, fmt.Errorf("creating collection: %w", err) } + if stored, path, thumbhash, err := h.storeBundledTemplatePoster(ctx, collection.ID, req.PosterURL, req.PosterFromTemplate); err != nil { + return nil, err + } else if stored { + collection.PosterURL = path + collection.PosterThumbhash = thumbhash + collection.PosterAutoGenerated = false + if req.PosterFromTemplate { + collection.PosterFromTemplate = true + } + } return collection, nil } @@ -2455,6 +2517,16 @@ func (h *LibraryCollectionHandler) createTMDBCollection( if err != nil { return nil, fmt.Errorf("creating collection: %w", err) } + if stored, path, thumbhash, err := h.storeBundledTemplatePoster(ctx, collection.ID, req.PosterURL, req.PosterFromTemplate); err != nil { + return nil, err + } else if stored { + collection.PosterURL = path + collection.PosterThumbhash = thumbhash + collection.PosterAutoGenerated = false + if req.PosterFromTemplate { + collection.PosterFromTemplate = true + } + } return collection, nil } @@ -2514,6 +2586,16 @@ func (h *LibraryCollectionHandler) createTMDBFranchiseCollection( if err != nil { return nil, fmt.Errorf("creating collection: %w", err) } + if stored, path, thumbhash, err := h.storeBundledTemplatePoster(ctx, collection.ID, req.PosterURL, req.PosterFromTemplate); err != nil { + return nil, err + } else if stored { + collection.PosterURL = path + collection.PosterThumbhash = thumbhash + collection.PosterAutoGenerated = false + if req.PosterFromTemplate { + collection.PosterFromTemplate = true + } + } return collection, nil } @@ -2567,6 +2649,16 @@ func (h *LibraryCollectionHandler) createTMDBDiscoverCollection( if err != nil { return nil, fmt.Errorf("creating collection: %w", err) } + if stored, path, thumbhash, err := h.storeBundledTemplatePoster(ctx, collection.ID, req.PosterURL, req.PosterFromTemplate); err != nil { + return nil, err + } else if stored { + collection.PosterURL = path + collection.PosterThumbhash = thumbhash + collection.PosterAutoGenerated = false + if req.PosterFromTemplate { + collection.PosterFromTemplate = true + } + } return collection, nil } @@ -2758,6 +2850,10 @@ func (h *LibraryCollectionHandler) HandleImportTraktCollection(w http.ResponseWr return } + if _, _, _, err := h.storeBundledTemplatePoster(r.Context(), collection.ID, req.PosterURL, false); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process template poster") + return + } if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images") return diff --git a/internal/api/handlers/user_collection_imports.go b/internal/api/handlers/user_collection_imports.go index 739f69b3..8891d43b 100644 --- a/internal/api/handlers/user_collection_imports.go +++ b/internal/api/handlers/user_collection_imports.go @@ -1,9 +1,11 @@ package handlers import ( + "context" "encoding/json" "errors" "fmt" + "io/fs" "net/http" "strings" "time" @@ -13,6 +15,7 @@ import ( apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/collections/templates" "github.com/Silo-Server/silo-server/internal/mdblist" + "github.com/Silo-Server/silo-server/internal/s3client" "github.com/Silo-Server/silo-server/internal/usercollections" "github.com/Silo-Server/silo-server/internal/userstore" ) @@ -26,6 +29,9 @@ type UserCollectionImportHandler struct { scheduler *usercollections.Scheduler registry *templates.Registry mdblist *mdblist.Client + s3GP *s3client.Client + frontendFS fs.FS + presignTTL time.Duration } func NewUserCollectionImportHandler( @@ -34,6 +40,9 @@ func NewUserCollectionImportHandler( scheduler *usercollections.Scheduler, registry *templates.Registry, mdblistClient *mdblist.Client, + s3GP *s3client.Client, + frontendFS fs.FS, + presignTTL time.Duration, ) *UserCollectionImportHandler { if registry == nil { registry = templates.Default @@ -44,6 +53,9 @@ func NewUserCollectionImportHandler( scheduler: scheduler, registry: registry, mdblist: mdblistClient, + s3GP: s3GP, + frontendFS: frontendFS, + presignTTL: presignTTL, } } @@ -213,6 +225,11 @@ func (h *UserCollectionImportHandler) createImportedCollection( return } + if err := h.storeBundledTemplatePoster(r, store, collection, strings.TrimSpace(shared.PosterURL)); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process template poster") + return + } + syncResult, updated, syncErr := h.sync.RunSync(r.Context(), store, collection) if syncErr != nil { // Persist failure state inline so the UI shows the error and the user @@ -230,11 +247,75 @@ func (h *UserCollectionImportHandler) createImportedCollection( } writeJSON(w, http.StatusCreated, userImportResponse{ - Collection: toCollectionResponse(*updated), + Collection: h.toCollectionResponse(r, *updated), Sync: syncResult, }) } +func (h *UserCollectionImportHandler) storeBundledTemplatePoster( + r *http.Request, + store userstore.UserStore, + collection *userstore.Collection, + posterPath string, +) error { + if collection == nil { + return nil + } + storedPath, thumbhash, stored, err := storeBundledCollectionPosterIfS3Configured( + r.Context(), + h.s3GP, + h.frontendFS, + collection.ID, + userCollectionImagePrefix, + posterPath, + ) + if err != nil || !stored { + return err + } + + if err := store.UpdateCollection(r.Context(), userstore.UpdateCollectionInput{ + ID: collection.ID, + RequestProfileID: collection.CreatorProfileID, + PosterURL: &storedPath, + PosterThumbhash: &thumbhash, + }); err != nil { + return fmt.Errorf("persisting template poster: %w", err) + } + collection.PosterURL = storedPath + collection.PosterThumbhash = thumbhash + return nil +} + +func (h *UserCollectionImportHandler) toCollectionResponse(r *http.Request, c userstore.Collection) collectionResponse { + resp := toCollectionResponse(c) + resp.PosterURL = h.presignCollectionPoster(r.Context(), c.PosterURL) + return resp +} + +func (h *UserCollectionImportHandler) presignCollectionPoster(ctx context.Context, path string) string { + if path == "" { + return "" + } + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + if strings.HasPrefix(path, "/") { + return path + } + if h.s3GP == nil { + return "" + } + ttl := h.presignTTL + if ttl <= 0 { + ttl = 4 * time.Hour + } + url, err := h.s3GP.PresignGetURL(ctx, h.s3GP.Bucket(), cardThumbnailPath(path), ttl) + if err != nil { + return "" + } + return url +} + func (h *UserCollectionImportHandler) HandleSync(w http.ResponseWriter, r *http.Request) { collectionID := chi.URLParam(r, "id") if collectionID == "" { diff --git a/internal/api/router.go b/internal/api/router.go index b244fbb9..b3db9433 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -4,6 +4,7 @@ package api import ( "context" "errors" + "io/fs" "log/slog" "net/http" "strconv" @@ -69,6 +70,7 @@ type Dependencies struct { BootstrapSensitiveValues map[string]string AppContext context.Context DB *pgxpool.Pool + FrontendFS fs.FS S3Public *s3client.Client // public assets bucket client (may be nil) S3Private *s3client.Client // private internal bucket client (may be nil) S3UserDB *s3client.Client // user-db bucket client (may be nil) @@ -843,6 +845,7 @@ func NewRouter(deps Dependencies) chi.Router { nil, deps.S3Public, ) + libraryCollectionHandler.FrontendFS = deps.FrontendFS libraryCollectionHandler.Executor = &catalog.QueryExecutor{Pool: deps.DB} libraryCollectionHandler.SectionRepo = sectionRepo libraryCollectionHandler.UserCollectionPool = deps.DB @@ -1298,6 +1301,9 @@ func NewRouter(deps Dependencies) chi.Router { deps.UserCollectionScheduler, nil, deps.MDBListClient, + deps.S3Public, + deps.FrontendFS, + 4*time.Hour, ) } r.Route("/collections", func(r chi.Router) {