diff --git a/PKGBUILD b/PKGBUILD --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Misti pkgname=compressor-git -pkgver=r8.7f948f4 +pkgver=r9.141fef9 pkgrel=1 pkgdesc="Video compressor service that watches input directory and compresses videos using ffmpeg" arch=('x86_64' 'aarch64') diff --git a/cmd/compressor/discord.go b/cmd/compressor/discord.go --- a/cmd/compressor/discord.go +++ b/cmd/compressor/discord.go @@ -4,8 +4,11 @@ import ( "bytes" "encoding/json" "fmt" + "io" "log" + "mime/multipart" "net/http" + "os" "path/filepath" "time" ) @@ -19,9 +22,14 @@ Title string `json:"title"` Description string `json:"description"` Color int `json:"color"` Fields []DiscordEmbedField `json:"fields"` + Image *DiscordEmbedImage `json:"image,omitempty"` Timestamp string `json:"timestamp"` } +type DiscordEmbedImage struct { + URL string `json:"url"` +} + type DiscordEmbedField struct { Name string `json:"name"` Value string `json:"value"` @@ -33,6 +41,10 @@ Embeds []DiscordEmbed `json:"embeds"` } func sendDiscordSuccess(webhookURL, fileName string, originalSize, compressedSize int64) { + sendDiscordSuccessWithThumbnail(webhookURL, fileName, originalSize, compressedSize, "") +} + +func sendDiscordSuccessWithThumbnail(webhookURL, fileName string, originalSize, compressedSize int64, thumbnailPath string) { if webhookURL == "" { return } @@ -66,7 +78,14 @@ }, Timestamp: time.Now().Format(time.RFC3339), } - sendDiscordMessage(webhookURL, embed) + // Add thumbnail image to embed if available + if thumbnailPath != "" { + embed.Image = &DiscordEmbedImage{ + URL: "attachment://thumbnail.jpg", + } + } + + sendDiscordMessageWithAttachment(webhookURL, embed, thumbnailPath, "thumbnail.jpg") } func sendDiscordFailure(webhookURL, fileName, errorMsg string) { @@ -92,25 +111,89 @@ sendDiscordMessage(webhookURL, embed) } func sendDiscordMessage(webhookURL string, embed DiscordEmbed) { + sendDiscordMessageWithAttachment(webhookURL, embed, "", "") +} + +func sendDiscordMessageWithAttachment(webhookURL string, embed DiscordEmbed, attachmentPath, attachmentName string) { message := DiscordMessage{ Embeds: []DiscordEmbed{embed}, } + if attachmentPath == "" { + // Send JSON-only message + jsonData, err := json.Marshal(message) + if err != nil { + log.Printf("Failed to marshal Discord message: %v", err) + return + } + + resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + log.Printf("Failed to send Discord webhook: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + log.Printf("Discord webhook returned status %d", resp.StatusCode) + } + return + } + + // Send message with file attachment using multipart/form-data + var b bytes.Buffer + w := multipart.NewWriter(&b) + + // Add the JSON payload jsonData, err := json.Marshal(message) if err != nil { log.Printf("Failed to marshal Discord message: %v", err) return } - resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(jsonData)) + if err := w.WriteField("payload_json", string(jsonData)); err != nil { + log.Printf("Failed to write payload_json field: %v", err) + return + } + + // Add the file attachment + file, err := os.Open(attachmentPath) + if err != nil { + log.Printf("Failed to open attachment file: %v", err) + return + } + defer file.Close() + + fw, err := w.CreateFormFile("file", attachmentName) + if err != nil { + log.Printf("Failed to create form file: %v", err) + return + } + + if _, err := io.Copy(fw, file); err != nil { + log.Printf("Failed to copy file data: %v", err) + return + } + + w.Close() + + req, err := http.NewRequest("POST", webhookURL, &b) if err != nil { - log.Printf("Failed to send Discord webhook: %v", err) + log.Printf("Failed to create HTTP request: %v", err) + return + } + req.Header.Set("Content-Type", w.FormDataContentType()) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.Printf("Failed to send Discord webhook with attachment: %v", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { - log.Printf("Discord webhook returned status %d", resp.StatusCode) + log.Printf("Discord webhook with attachment returned status %d", resp.StatusCode) } } diff --git a/cmd/compressor/processor.go b/cmd/compressor/processor.go --- a/cmd/compressor/processor.go +++ b/cmd/compressor/processor.go @@ -2,12 +2,15 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "log" + "math" "os" "os/exec" "path/filepath" + "strconv" "strings" "time" @@ -81,7 +84,27 @@ // Send Discord success notification if compressedInfo, err := os.Stat(outputPath); err == nil { compressedSize := compressedInfo.Size() - sendDiscordSuccess(cfg.discordWebhookURL, originalPath, originalSize, compressedSize) + + // Generate thumbnail for Discord webhook + thumbnailPath := "" + if cfg.discordWebhookURL != "" { + // Generate unique thumbnail path in /tmp + baseName := filepath.Base(strings.TrimSuffix(originalPath, filepath.Ext(originalPath))) + thumbnailPath = fmt.Sprintf("/tmp/compressor_thumb_%s_%d.jpg", baseName, time.Now().UnixNano()) + if thumbErr := generateThumbnail(ctx, cfg, outputPath, thumbnailPath); thumbErr != nil { + log.Printf("Failed to generate thumbnail: %v", thumbErr) + thumbnailPath = "" // Continue without thumbnail + } + } + + sendDiscordSuccessWithThumbnail(cfg.discordWebhookURL, originalPath, originalSize, compressedSize, thumbnailPath) + + // Clean up thumbnail file + if thumbnailPath != "" { + if err := os.Remove(thumbnailPath); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Printf("Failed to clean up thumbnail %s: %v", thumbnailPath, err) + } + } } processed.Store(originalPath, time.Now()) @@ -181,3 +204,79 @@ return fmt.Errorf("ffmpeg failed: %w", err) } return nil } + +func generateThumbnail(ctx context.Context, cfg config, videoPath, thumbnailPath string) error { + if err := os.MkdirAll(filepath.Dir(thumbnailPath), 0o755); err != nil { + return fmt.Errorf("prepare thumbnail dir: %w", err) + } + + // First, probe the video duration + duration, err := getVideoDuration(ctx, cfg, videoPath) + if err != nil { + log.Printf("Failed to get video duration, using default seek: %v", err) + duration = 10 // fallback to 10 seconds + } + + // Seek to 10% of the video duration, but at least 1 second + seekTime := math.Max(1.0, duration*0.1) + seekString := fmt.Sprintf("%.3f", seekTime) + + // Generate thumbnail at calculated position, full resolution + args := []string{ + "-skip_frame", "nokey", // Skip non-key frames for faster seeking + "-i", videoPath, + "-ss", seekString, // Seek to calculated position + "-vframes", "1", // Extract 1 frame + "-y", // Overwrite output + thumbnailPath, + } + + cmd := exec.CommandContext(ctx, cfg.ffmpegBinary, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + + log.Printf("generating thumbnail: %s -> %s (seek to %.1fs)", videoPath, thumbnailPath, seekTime) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("thumbnail generation failed: %w", err) + } + return nil +} + +func getVideoDuration(ctx context.Context, cfg config, videoPath string) (float64, error) { + // Use ffprobe to get video duration + // Assume ffprobe is available alongside ffmpeg + ffprobePath := strings.Replace(cfg.ffmpegBinary, "ffmpeg", "ffprobe", 1) + + args := []string{ + "-v", "quiet", + "-print_format", "json", + "-show_format", + videoPath, + } + + cmd := exec.CommandContext(ctx, ffprobePath, args...) + output, err := cmd.Output() + if err != nil { + return 0, fmt.Errorf("ffprobe failed: %w", err) + } + + // Parse the JSON output to extract duration + var probeResult struct { + Format struct { + Duration string `json:"duration"` + } `json:"format"` + } + + if err := json.Unmarshal(output, &probeResult); err != nil { + return 0, fmt.Errorf("parse ffprobe output: %w", err) + } + + duration, err := strconv.ParseFloat(probeResult.Format.Duration, 64) + if err != nil { + return 0, fmt.Errorf("parse duration: %w", err) + } + + return duration, nil +}