Files
silo-server/internal/branding/assets.go
5afe56cfc0 feat(jellycompat): add runtime-managed Jellyfin Web compatibility (#77)
* feat(jellycompat): install web assets at runtime

* fix(jellycompat): recover stale web operation locks

* fix(jellycompat): harden web component management

* feat(admin): refine compat settings and restart status

* chore(dev): add hot-reload docker compose stack

* fix(dev): include npm in hot-reload backend

* feat(admin): refine Jellyfin compatibility settings

* feat(settings): improve jellyfin proxy summary

* feat(settings): improve jellyfin web controls

* fix(settings): update jellyfin web removal status

* fix(settings): enable jellyfin web after install

* feat(jellycompat): auto-select web ui version

* test(api): update rate limit handler setup

* feat(jellycompat): refine web ui install onboarding

* fix(jellycompat): address web ui install review issues

* fix(onboarding): mirror jellyfin api runtime status

* fix(admin): remove global restart banner

* fix(settings): gate restart required tracking

* fix(jellyfin): ignore live settings for restart status

* fix(jellyfin): avoid restart for live compat settings

* fix(subtitles): normalize AI language codes

* fix(catalog): support partial title search tokens

* feat(branding): add white-label customization

* Add push relay engineering plan

- Document relay API contracts, APNs/FCM behavior, auth, storage, and ops
- Capture implementation plan, provider references, decisions, and README

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-15 09:34:08 -04:00

138 lines
4.1 KiB
Go

package branding
import (
"fmt"
"github.com/Silo-Server/silo-server/internal/imageutil"
)
// assetSpec describes how one branding image kind is validated, processed, and
// stored.
type assetSpec struct {
kind AssetKind
settingKey string // server_settings key holding the current content ref
s3Prefix string // S3 key prefix; object key is "<prefix>/<ref>"
maxBytes int64
// process validates the declared content type and returns the bytes to
// store, the content type to serve them with, and the file extension used
// in the content ref.
process func(data []byte, declaredType string) (out []byte, serveType, ext string, err error)
}
// assetSpecs is the registry of all branding asset kinds. Adding a kind here is
// the only change needed to support a new uploadable asset.
var assetSpecs = map[AssetKind]assetSpec{
KindWordmark: {
kind: KindWordmark,
settingKey: "branding.wordmark_ref",
s3Prefix: "branding/wordmark",
maxBytes: 8 << 20,
process: processWebP(imageutil.GenerateVariants, 640),
},
KindMark: {
kind: KindMark,
settingKey: "branding.mark_ref",
s3Prefix: "branding/mark",
maxBytes: 8 << 20,
process: processWebP(imageutil.GenerateSquareVariants, 512),
},
KindFavicon: {
kind: KindFavicon,
settingKey: "branding.favicon_ref",
s3Prefix: "branding/favicon",
maxBytes: 1 << 20,
process: processFaviconPassthrough,
},
KindLoginBg: {
kind: KindLoginBg,
settingKey: "branding.login_bg_ref",
s3Prefix: "branding/login-bg",
maxBytes: 12 << 20,
process: processWebP(imageutil.GenerateVariants, 2560),
},
}
// imageUploadTypes is the accepted set for WebP-converted kinds.
var imageUploadTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/webp": true,
}
// imageVariantFunc matches imageutil.GenerateVariants / GenerateSquareVariants.
type imageVariantFunc func(data []byte, sizes []int) (*imageutil.VariantResult, error)
// processWebP re-encodes any accepted image to a size-capped WebP using the
// given variant generator (width-preserving or square). The aspect ratio is
// preserved by the generator; narrower images are not upscaled.
func processWebP(generate imageVariantFunc, size int) func([]byte, string) ([]byte, string, string, error) {
return func(data []byte, declaredType string) ([]byte, string, string, error) {
if !imageUploadTypes[declaredType] {
return nil, "", "", ErrUnsupportedImage
}
res, err := generate(data, []int{size})
if err != nil {
return nil, "", "", ErrUnsupportedImage
}
return pickVariant(res, fmt.Sprintf("w%d", size)), "image/webp", ".webp", nil
}
}
// processFaviconPassthrough stores the favicon unchanged (no WebP re-encode) so
// that .ico/.png keep working in browsers that don't render WebP favicons.
func processFaviconPassthrough(data []byte, declaredType string) ([]byte, string, string, error) {
switch declaredType {
case "image/png":
return data, "image/png", ".png", nil
case "image/webp":
return data, "image/webp", ".webp", nil
case "image/x-icon", "image/vnd.microsoft.icon":
return data, "image/x-icon", ".ico", nil
case "image/svg+xml":
return data, "image/svg+xml", ".svg", nil
default:
return nil, "", "", ErrUnsupportedImage
}
}
// pickVariant returns the named variant's bytes, falling back to the re-encoded
// original when the exact width variant is absent.
func pickVariant(res *imageutil.VariantResult, key string) []byte {
var original []byte
for _, v := range res.Variants {
if v.Key == key {
return v.Data
}
if v.Key == "original" {
original = v.Data
}
}
return original
}
// MaxUploadBytes returns the maximum accepted upload size for a kind, or 0 when
// the kind is unknown.
func MaxUploadBytes(kind AssetKind) int64 {
if spec, ok := assetSpecs[kind]; ok {
return spec.maxBytes
}
return 0
}
// contentTypeForExt maps a stored ref's extension to the Content-Type used when
// serving it.
func contentTypeForExt(ext string) string {
switch ext {
case ".webp":
return "image/webp"
case ".png":
return "image/png"
case ".ico":
return "image/x-icon"
case ".svg":
return "image/svg+xml"
default:
return "application/octet-stream"
}
}