Files
ds-galo/backend/internal/config/config.go
T
iu d38ede0201 feat(video): add video upload with remux/transcode playback
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.
2026-10-11 20:37:53 +03:00

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
}