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.
This commit is contained in:
iu
2026-10-11 20:37:53 +03:00
parent d059d042f0
commit d38ede0201
52 changed files with 2717 additions and 82 deletions
+17 -1
View File
@@ -24,6 +24,7 @@ import (
"galo/internal/sse"
"galo/internal/storage"
"galo/internal/store"
"galo/internal/video"
)
type Server struct {
@@ -42,6 +43,8 @@ type Server struct {
photos *photos.Service
worker *photos.Worker
videoTools *video.Toolchain
social *social.Service
notifications *notifications.Service
@@ -62,6 +65,12 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e
photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub)
photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL, cfg.Worker.Concurrency)
videoTools := video.Detect(cfg.Video)
photosSvc.SetUploadLimits(cfg.Upload.ImageMaxBytes, cfg.Upload.VideoMaxBytes)
photosSvc.SetVideoEnabled(videoTools.Available())
photosSvc.SetTempDir(cfg.Video.TempDir)
photosWorker.SetVideo(videoTools, cfg.Video.TranscodeConcurrency)
socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc)
shareSvc := sharelinks.NewService(repos.ShareLinks, albumsSvc)
@@ -77,6 +86,7 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e
membersSvc: membersSvc,
photos: photosSvc,
worker: photosWorker,
videoTools: videoTools,
social: socialSvc,
notifications: notifSvc,
share: shareSvc,
@@ -188,13 +198,19 @@ func (s *Server) health(c *gin.Context) {
if err := s.storage.Ping(ctx); err != nil {
storeStatus = "error"
}
// Video tooling is optional: its absence does not degrade the service, but
// it is reported so operators can see why video upload is disabled.
videoStatus := "ok"
if !s.videoTools.Available() {
videoStatus = "unavailable"
}
status := http.StatusOK
overall := "ok"
if dbStatus != "ok" || storeStatus != "ok" {
status = http.StatusServiceUnavailable
overall = "degraded"
}
c.JSON(status, gin.H{"status": overall, "db": dbStatus, storageLabel: storeStatus})
c.JSON(status, gin.H{"status": overall, "db": dbStatus, storageLabel: storeStatus, "video": videoStatus})
}
func (s *Server) serveObject(c *gin.Context) {
+21
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
@@ -12,6 +13,7 @@ import (
"galo/internal/config"
"galo/internal/storage"
"galo/internal/video"
)
func mustSigner(t *testing.T) *storage.Signer {
@@ -58,4 +60,23 @@ func TestHealthStorageLabel(t *testing.T) {
if body := call(); body["s3"] != "ok" {
t.Fatalf("expected default s3 label, got %v", body)
}
// A missing toolchain reports video unavailable without degrading the service.
if body := call(); body["video"] != "unavailable" {
t.Fatalf("expected video unavailable, got %v", body)
}
if body := call(); body["status"] != "ok" {
t.Fatalf("video unavailability must not degrade status, got %v", body)
}
// A resolvable toolchain reports video ok. The test binary is a known
// executable on every platform.
self, err := os.Executable()
if err != nil {
t.Fatalf("executable: %v", err)
}
srv.videoTools = video.Detect(config.Video{FFmpegPath: self, FFprobePath: self})
if body := call(); body["video"] != "ok" {
t.Fatalf("expected video ok, got %v", body)
}
}