package jobs import ( "bytes" "context" "image" _ "image/gif" "image/jpeg" _ "image/jpeg" _ "image/png" "io" "log/slog" "os" "os/exec" "strings" "time" _ "golang.org/x/image/webp" "warpbox.dev/backend/libs/config" "warpbox.dev/backend/libs/services" ) type ThumbnailJobResult struct { Scanned int Generated int Failed int } func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) { go func() { box, err := uploadService.GetBox(boxID) if err != nil { logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error()) return } result, err := generateMissingThumbnailsForBox(uploadService, logger, box) if err != nil { logger.Warn("thumbnail one-shot job failed", "source", "thumbnail", "severity", "warn", "code", 4205, "box_id", boxID, "error", err.Error()) return } if result.Generated > 0 || result.Failed > 0 { logger.Info("thumbnail one-shot job complete", "source", "thumbnail", "severity", "user_activity", "code", 2205, "box_id", boxID, "generated", result.Generated, "failed", result.Failed) } }() } func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job { return job{ name: "thumbnail", enabled: cfg.ThumbnailEnabled, interval: cfg.ThumbnailEvery, run: func() { result, err := generateMissingThumbnails(uploadService, logger) if err != nil { logger.Warn("thumbnail job failed", "source", "thumbnail", "severity", "warn", "code", 4203, "error", err.Error()) return } if result.Generated > 0 || result.Failed > 0 { logger.Info("thumbnail job complete", "source", "thumbnail", "severity", "user_activity", "code", 2204, "generated", result.Generated, "failed", result.Failed) } }, } } func RunThumbnailsNow(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) { return generateMissingThumbnails(uploadService, logger) } func generateMissingThumbnails(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) { boxes, err := uploadService.ListBoxes(0) if err != nil { return ThumbnailJobResult{}, err } var result ThumbnailJobResult now := time.Now().UTC() for _, box := range boxes { if !box.ExpiresAt.After(now) { continue } boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box) result.Scanned += boxResult.Scanned result.Generated += boxResult.Generated result.Failed += boxResult.Failed if err != nil { return result, err } } return result, nil } func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (ThumbnailJobResult, error) { var result ThumbnailJobResult if !box.ExpiresAt.After(time.Now().UTC()) { return result, nil } changed := false for i := range box.Files { file := &box.Files[i] if file.Thumbnail != "" || !needsThumbnail(*file) { continue } result.Scanned++ thumbnail, err := generateThumbnail(uploadService, box, *file) if err != nil { logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error()) result.Failed++ continue } if thumbnail == "" { result.Failed++ continue } file.Thumbnail = thumbnail changed = true result.Generated++ } if changed { if err := uploadService.SaveBox(box); err != nil { return result, err } } return result, nil } func needsThumbnail(file services.File) bool { return file.PreviewKind == "image" || file.PreviewKind == "video" } func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { thumbnailName := "@thumb@" + file.ID + ".jpg" object, err := uploadService.OpenFileObject(context.Background(), box, file) if err != nil { return "", err } defer object.Body.Close() switch { case strings.HasPrefix(file.ContentType, "image/"): data, err := createImageThumbnail(object.Body) if err != nil { return "", err } _, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg") return thumbnailName, err case strings.HasPrefix(file.ContentType, "video/"): data, err := createVideoThumbnail(object.Body) if err != nil { return "", err } _, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg") return thumbnailName, err default: return "", nil } } func createImageThumbnail(source io.Reader) ([]byte, error) { img, _, err := image.Decode(source) if err != nil { return nil, err } thumb := resizeNearest(img, 360, 240) var target bytes.Buffer err = jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82}) if err != nil { return nil, err } return target.Bytes(), nil } func createVideoThumbnail(source io.Reader) ([]byte, error) { sourceFile, err := os.CreateTemp("", "warpbox-video-*") if err != nil { return nil, err } defer os.Remove(sourceFile.Name()) if _, err := io.Copy(sourceFile, source); err != nil { sourceFile.Close() return nil, err } if err := sourceFile.Close(); err != nil { return nil, err } targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg") if err != nil { return nil, err } targetPath := targetFile.Name() targetFile.Close() defer os.Remove(targetPath) if err := exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourceFile.Name(), "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run(); err != nil { return nil, err } return os.ReadFile(targetPath) } func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA { bounds := src.Bounds() width := bounds.Dx() height := bounds.Dy() if width <= 0 || height <= 0 { return image.NewRGBA(image.Rect(0, 0, 1, 1)) } scale := min(float64(maxWidth)/float64(width), float64(maxHeight)/float64(height)) if scale > 1 { scale = 1 } targetWidth := max(1, int(float64(width)*scale)) targetHeight := max(1, int(float64(height)*scale)) dst := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight)) for y := 0; y < targetHeight; y++ { for x := 0; x < targetWidth; x++ { srcX := bounds.Min.X + int(float64(x)/scale) srcY := bounds.Min.Y + int(float64(y)/scale) dst.Set(x, y, src.At(srcX, srcY)) } } return dst }