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) } } }