Files
ds-galo/backend/internal/e2e/video_test.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

143 lines
3.7 KiB
Go

package e2e
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"galo/internal/albums"
"galo/internal/config"
"galo/internal/photos"
"galo/internal/video"
)
// makeMP4 generates a tiny h264/yuv420p clip so the web-compatible remux path
// can be exercised. It skips when ffmpeg is unavailable.
func makeMP4(t *testing.T, ffmpeg string) []byte {
t.Helper()
out := filepath.Join(t.TempDir(), "clip.mp4")
cmd := exec.Command(ffmpeg,
"-v", "error", "-y",
"-f", "lavfi", "-i", "testsrc=size=160x120:rate=10",
"-t", "1",
"-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "ultrafast",
out)
if b, err := cmd.CombinedOutput(); err != nil {
t.Skipf("ffmpeg fixture failed: %v: %s", err, b)
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read fixture: %v", err)
}
return data
}
func TestVideoUploadPreparedForPlayback(t *testing.T) {
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg not available")
}
ffprobe, err := exec.LookPath("ffprobe")
if err != nil {
t.Skip("ffprobe not available")
}
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "video@test.local", "password123", "V")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Video"})
if err != nil {
t.Fatalf("create album: %v", err)
}
p, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: album.ID,
UserID: user.ID,
Filename: "clip.mp4",
MimeType: "video/mp4",
Data: makeMP4(t, ffmpeg),
})
if err != nil {
t.Fatalf("upload video: %v", err)
}
if p.Kind != photos.KindVideo {
t.Fatalf("kind = %q, want video", p.Kind)
}
if p.PlaybackURL != "" {
t.Fatalf("playback url should be empty before processing")
}
tools := video.Detect(config.Video{FFmpegPath: ffmpeg, FFprobePath: ffprobe})
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 1)
w.SetVideo(tools, 1)
w.ProcessOnce(ctx)
got, err := st.photos.Get(ctx, p.ID, user.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.PlaybackURL == "" {
t.Fatalf("playback url not set after processing")
}
if got.ThumbURL == "" {
t.Fatalf("poster variants not generated")
}
if got.DurationMS <= 0 {
t.Fatalf("duration not probed: %d", got.DurationMS)
}
if got.Width != 160 || got.Height != 120 {
t.Fatalf("dimensions = %dx%d, want 160x120", got.Width, got.Height)
}
// Public access (share-link path) exposes the same signed playback URL.
pub, err := st.photos.GetPublic(ctx, p.ID)
if err != nil {
t.Fatalf("get public: %v", err)
}
if pub.PlaybackURL == "" {
t.Fatalf("public playback url not set")
}
// Permanently deleting the video collects every object, including playback.
rows, err := st.db.QueryContext(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = ?`, p.ID)
if err != nil {
t.Fatalf("object ids: %v", err)
}
var objectIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
t.Fatalf("scan object id: %v", err)
}
objectIDs = append(objectIDs, id)
}
rows.Close()
if len(objectIDs) < 5 {
t.Fatalf("bound objects = %d, want original+thumb+medium+preview+playback", len(objectIDs))
}
if err := st.photos.PermanentDelete(ctx, p.ID, user.ID); err != nil {
t.Fatalf("permanent delete: %v", err)
}
w.ProcessOnce(ctx)
for _, id := range objectIDs {
var n int
if err := st.db.QueryRowContext(ctx, `SELECT count(*) FROM objects WHERE id = ?`, id).Scan(&n); err != nil {
t.Fatalf("count object: %v", err)
}
if n != 0 {
t.Fatalf("object %s not collected after delete", id)
}
}
}