Add video as a media kind in photos: streaming upload with per-kind limits, ffprobe metadata, poster frame reusing the image variant pipeline, and a playback object (remux when web-compatible, else h264/yuv420p/aac transcode). Video jobs run on a separate pool with a long lease. Frontend gains a video viewer, grid badge/duration and video upload.
328 lines
8.0 KiB
Go
328 lines
8.0 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"galo/internal/logging"
|
|
)
|
|
|
|
type Postgres struct {
|
|
Host string
|
|
Port int
|
|
User string
|
|
Password string
|
|
Database string
|
|
SSLMode string
|
|
}
|
|
|
|
func (p Postgres) DSN() string {
|
|
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
|
p.User, p.Password, p.Host, p.Port, p.Database, p.SSLMode)
|
|
}
|
|
|
|
type S3 struct {
|
|
Endpoint string
|
|
Region string
|
|
AccessKey string
|
|
SecretKey string
|
|
Bucket string
|
|
UsePathStyle bool
|
|
}
|
|
|
|
type Auth struct {
|
|
AccessTTL time.Duration
|
|
RefreshTTL time.Duration
|
|
}
|
|
|
|
type Server struct {
|
|
Addr string
|
|
BaseURL string
|
|
}
|
|
|
|
type SSE struct {
|
|
HeartbeatInterval time.Duration
|
|
ClientBuffer int
|
|
}
|
|
|
|
type Worker struct {
|
|
Interval time.Duration
|
|
Batch int
|
|
PendingTTL time.Duration
|
|
Concurrency int
|
|
}
|
|
|
|
// Upload holds the per-kind upload size limits. Images and video are bounded
|
|
// separately because video files are an order of magnitude larger.
|
|
type Upload struct {
|
|
ImageMaxBytes int64
|
|
VideoMaxBytes int64
|
|
}
|
|
|
|
// Video holds the external tooling used to prepare uploaded video for playback.
|
|
// Availability is detected at startup; an absent toolchain disables video
|
|
// ingestion without failing the server.
|
|
type Video struct {
|
|
FFmpegPath string
|
|
FFprobePath string
|
|
TranscodeConcurrency int
|
|
TempDir string
|
|
}
|
|
|
|
type Storage struct {
|
|
Backend string
|
|
FSRoot string
|
|
URLSecret string
|
|
URLTTL time.Duration
|
|
URLSignWindow time.Duration
|
|
CacheMaxAge time.Duration
|
|
}
|
|
|
|
type Database struct {
|
|
Backend string
|
|
SQLitePath string
|
|
}
|
|
|
|
type Log struct {
|
|
Level string
|
|
Format string
|
|
}
|
|
|
|
type Config struct {
|
|
Postgres Postgres
|
|
S3 S3
|
|
Storage Storage
|
|
Database Database
|
|
Auth Auth
|
|
Server Server
|
|
SSE SSE
|
|
Worker Worker
|
|
Upload Upload
|
|
Video Video
|
|
Log Log
|
|
}
|
|
|
|
func Load() (*Config, error) {
|
|
pgPort, err := strconv.Atoi(getEnv("PG_PORT", "5432"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid PG_PORT: %w", err)
|
|
}
|
|
|
|
accessTTL, err := time.ParseDuration(getEnv("JWT_ACCESS_TTL", "15m"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid JWT_ACCESS_TTL: %w", err)
|
|
}
|
|
|
|
refreshTTL, err := time.ParseDuration(getEnv("JWT_REFRESH_TTL", "720h"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid JWT_REFRESH_TTL: %w", err)
|
|
}
|
|
|
|
urlTTL, err := time.ParseDuration(getEnv("OBJECT_URL_TTL", "1h"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid OBJECT_URL_TTL: %w", err)
|
|
}
|
|
|
|
urlSignWindow, err := time.ParseDuration(getEnv("OBJECT_URL_SIGN_WINDOW", "10m"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid OBJECT_URL_SIGN_WINDOW: %w", err)
|
|
}
|
|
|
|
cacheMaxAge, err := time.ParseDuration(getEnv("OBJECT_CACHE_MAX_AGE", "1h"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid OBJECT_CACHE_MAX_AGE: %w", err)
|
|
}
|
|
|
|
heartbeat, err := time.ParseDuration(getEnv("SSE_HEARTBEAT_INTERVAL", "25s"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid SSE_HEARTBEAT_INTERVAL: %w", err)
|
|
}
|
|
|
|
bufSize, err := strconv.Atoi(getEnv("SSE_CLIENT_BUFFER", "64"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid SSE_CLIENT_BUFFER: %w", err)
|
|
}
|
|
|
|
pathStyle, err := strconv.ParseBool(getEnv("S3_USE_PATH_STYLE", "true"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid S3_USE_PATH_STYLE: %w", err)
|
|
}
|
|
|
|
workerInterval, err := time.ParseDuration(getEnv("WORKER_INTERVAL", "5s"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid WORKER_INTERVAL: %w", err)
|
|
}
|
|
|
|
workerBatch, err := strconv.Atoi(getEnv("WORKER_BATCH", "100"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid WORKER_BATCH: %w", err)
|
|
}
|
|
|
|
workerPendingTTL, err := time.ParseDuration(getEnv("WORKER_PENDING_TTL", "10m"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid WORKER_PENDING_TTL: %w", err)
|
|
}
|
|
|
|
workerConcurrency, err := strconv.Atoi(getEnv("WORKER_CONCURRENCY", "4"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid WORKER_CONCURRENCY: %w", err)
|
|
}
|
|
|
|
imageMaxUpload, err := getSizeEnv("IMAGE_MAX_UPLOAD_SIZE", 100<<20)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
videoMaxUpload, err := getSizeEnv("VIDEO_MAX_UPLOAD_SIZE", 2<<30)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
transcodeConcurrency, err := strconv.Atoi(getEnv("VIDEO_TRANSCODE_CONCURRENCY", "1"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid VIDEO_TRANSCODE_CONCURRENCY: %w", err)
|
|
}
|
|
|
|
logLevel := getEnv("LOG_LEVEL", "info")
|
|
if _, err := logging.ParseLevel(logLevel); err != nil {
|
|
return nil, err
|
|
}
|
|
logFormat := getEnv("LOG_FORMAT", "json")
|
|
if _, err := logging.ParseFormat(logFormat); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cfg := &Config{
|
|
Postgres: Postgres{
|
|
Host: getEnv("PG_HOST", "pg.localnet"),
|
|
Port: pgPort,
|
|
User: getEnv("PG_USER", "album"),
|
|
Password: getEnv("PG_PASSWORD", ""),
|
|
Database: getEnv("PG_DATABASE", "galo"),
|
|
SSLMode: getEnv("PG_SSLMODE", "disable"),
|
|
},
|
|
S3: S3{
|
|
Endpoint: getEnv("S3_ENDPOINT", "http://s3.localnet:3900"),
|
|
Region: getEnv("S3_REGION", "garage"),
|
|
AccessKey: getEnv("S3_ACCESS_KEY", ""),
|
|
SecretKey: getEnv("S3_SECRET_KEY", ""),
|
|
Bucket: getEnv("S3_BUCKET", "albums"),
|
|
UsePathStyle: pathStyle,
|
|
},
|
|
Storage: Storage{
|
|
Backend: getEnv("STORAGE_BACKEND", "fs"),
|
|
FSRoot: getEnv("FS_ROOT", "./data"),
|
|
URLSecret: getEnv("URL_SECRET", ""),
|
|
URLTTL: urlTTL,
|
|
URLSignWindow: urlSignWindow,
|
|
CacheMaxAge: cacheMaxAge,
|
|
},
|
|
Database: Database{
|
|
Backend: getEnv("DB_BACKEND", "sqlite"),
|
|
SQLitePath: getEnv("SQLITE_PATH", "./galo.db"),
|
|
},
|
|
Auth: Auth{
|
|
AccessTTL: accessTTL,
|
|
RefreshTTL: refreshTTL,
|
|
},
|
|
Server: Server{
|
|
Addr: getEnv("HTTP_ADDR", ":8080"),
|
|
BaseURL: getEnv("APP_BASE_URL", "http://localhost:8080"),
|
|
},
|
|
SSE: SSE{
|
|
HeartbeatInterval: heartbeat,
|
|
ClientBuffer: bufSize,
|
|
},
|
|
Worker: Worker{
|
|
Interval: workerInterval,
|
|
Batch: workerBatch,
|
|
PendingTTL: workerPendingTTL,
|
|
Concurrency: workerConcurrency,
|
|
},
|
|
Upload: Upload{
|
|
ImageMaxBytes: imageMaxUpload,
|
|
VideoMaxBytes: videoMaxUpload,
|
|
},
|
|
Video: Video{
|
|
FFmpegPath: getEnv("FFMPEG_PATH", "ffmpeg"),
|
|
FFprobePath: getEnv("FFPROBE_PATH", "ffprobe"),
|
|
TranscodeConcurrency: transcodeConcurrency,
|
|
TempDir: getEnv("VIDEO_TEMP_DIR", ""),
|
|
},
|
|
Log: Log{
|
|
Level: logLevel,
|
|
Format: logFormat,
|
|
},
|
|
}
|
|
|
|
switch cfg.Database.Backend {
|
|
case "postgres":
|
|
if cfg.Postgres.Password == "" {
|
|
return nil, fmt.Errorf("PG_PASSWORD is required")
|
|
}
|
|
case "sqlite":
|
|
if cfg.Database.SQLitePath == "" {
|
|
return nil, fmt.Errorf("SQLITE_PATH is required when DB_BACKEND=sqlite")
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("invalid DB_BACKEND %q (want postgres or sqlite)", cfg.Database.Backend)
|
|
}
|
|
|
|
switch cfg.Storage.Backend {
|
|
case "s3":
|
|
case "fs":
|
|
if cfg.Storage.FSRoot == "" {
|
|
return nil, fmt.Errorf("FS_ROOT is required when STORAGE_BACKEND=fs")
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("invalid STORAGE_BACKEND %q (want s3 or fs)", cfg.Storage.Backend)
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func getEnv(key, fallback string) string {
|
|
if v, ok := os.LookupEnv(key); ok && v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// getSizeEnv reads a byte-size environment variable, accepting a plain integer
|
|
// or a human suffix (KB/MB/GB, binary). An empty value yields the fallback.
|
|
func getSizeEnv(key string, fallback int64) (int64, error) {
|
|
raw := getEnv(key, "")
|
|
if raw == "" {
|
|
return fallback, nil
|
|
}
|
|
n, err := parseSize(raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid %s: %w", key, err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func parseSize(s string) (int64, error) {
|
|
s = strings.TrimSpace(strings.ToUpper(s))
|
|
mult := int64(1)
|
|
switch {
|
|
case strings.HasSuffix(s, "GB"):
|
|
mult, s = 1<<30, strings.TrimSuffix(s, "GB")
|
|
case strings.HasSuffix(s, "MB"):
|
|
mult, s = 1<<20, strings.TrimSuffix(s, "MB")
|
|
case strings.HasSuffix(s, "KB"):
|
|
mult, s = 1<<10, strings.TrimSuffix(s, "KB")
|
|
case strings.HasSuffix(s, "B"):
|
|
mult, s = 1, strings.TrimSuffix(s, "B")
|
|
}
|
|
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if n < 0 {
|
|
return 0, fmt.Errorf("must be non-negative")
|
|
}
|
|
return n * mult, nil
|
|
}
|