package workers import ( "fmt" "log" "os" "os/exec" "path/filepath" "strings" "sync" "time" "git.elijahkuntz.com/Elijah/drive/config" "git.elijahkuntz.com/Elijah/drive/database" "github.com/disintegration/imaging" "git.elijahkuntz.com/Elijah/drive/middleware" "net/url" "io" "net/http" "encoding/json" "bytes" "github.com/golang-jwt/jwt/v5" ) type ThumbnailJob struct { FilePath string Checksum string MimeType string } type ThumbnailManager struct { Queue chan ThumbnailJob Config *config.Config DB *database.DB wg sync.WaitGroup } var ( Instance *ThumbnailManager once sync.Once ) func InitThumbnailManager(cfg *config.Config, db *database.DB) { once.Do(func() { Instance = &ThumbnailManager{ Queue: make(chan ThumbnailJob, 1000), Config: cfg, DB: db, } Instance.Start(2) // Run 2 concurrent workers }) } func (m *ThumbnailManager) Start(workers int) { for i := 0; i < workers; i++ { m.wg.Add(1) go m.worker(i) } } func (m *ThumbnailManager) Stop() { close(m.Queue) m.wg.Wait() } func (m *ThumbnailManager) Enqueue(job ThumbnailJob) { if job.Checksum == "" { return // Cannot store thumbnail without checksum } select { case m.Queue <- job: default: log.Println("Thumbnail queue is full, dropping job for", job.FilePath) } } func (m *ThumbnailManager) worker(id int) { defer m.wg.Done() for job := range m.Queue { destPath := filepath.Join(m.Config.ThumbnailDir, job.Checksum+".jpg") // Skip if thumbnail already exists if _, err := os.Stat(destPath); err == nil { continue } fullSourcePath := filepath.Join(m.Config.StorageDir, filepath.FromSlash(job.FilePath)) var err error if strings.HasPrefix(job.MimeType, "image/") { err = generateImageThumbnail(fullSourcePath, destPath) } else if strings.HasPrefix(job.MimeType, "video/") { err = generateVideoThumbnail(fullSourcePath, destPath) } else if strings.HasSuffix(job.FilePath, ".docx") || strings.HasSuffix(job.FilePath, ".pptx") || strings.HasSuffix(job.FilePath, ".xlsx") { err = generateOfficeThumbnail(job.FilePath, destPath, m.Config) } else { continue } if err != nil { log.Printf("[Worker %d] Failed to generate thumbnail for %s: %v\n", id, job.FilePath, err) } else { log.Printf("[Worker %d] Generated thumbnail for %s\n", id, job.FilePath) } } } func generateImageThumbnail(src, dest string) error { img, err := imaging.Open(src, imaging.AutoOrientation(true)) if err != nil { return err } // Resize to fit within 256x256 while preserving aspect ratio thumb := imaging.Fit(img, 256, 256, imaging.Lanczos) return imaging.Save(thumb, dest, imaging.JPEGQuality(80)) } func generateVideoThumbnail(src, dest string) error { // Extract a frame at 1 second mark, scale to max width/height 256 cmd := exec.Command("ffmpeg", "-y", "-i", src, "-ss", "00:00:01.000", "-vframes", "1", "-vf", "scale=256:256:force_original_aspect_ratio=decrease", dest) // Set a timeout to prevent hanging on corrupted videos timer := time.AfterFunc(15*time.Second, func() { if cmd.Process != nil { cmd.Process.Kill() } }) err := cmd.Run() timer.Stop() if err != nil { return fmt.Errorf("ffmpeg failed: %w", err) } return nil } func generateOfficeThumbnail(filePath, destPath string, cfg *config.Config) error { // Generate a download token for the Conversion API to fetch the file from our backend token, err := middleware.GenerateDownloadToken(0, "system_worker", cfg.JWTSecret) if err != nil { return fmt.Errorf("failed to generate download token: %w", err) } // Assuming backend runs on port from config, but we need the external URL or internal docker URL that Document Server can reach. // Since we saw in OnlyOfficeEditor that the Document Server can reach the host at 192.168.50.81:5827, we use that. // We'll try to build a robust URL. hostIP := "192.168.50.81" // Hardcoded as per OnlyOfficeEditor.tsx implementation for this specific deployment port := cfg.Port fileUrl := fmt.Sprintf("http://%s:%s/api/public/onlyoffice/download?path=%s&token=%s", hostIP, port, url.QueryEscape(filePath), token) ext := strings.TrimPrefix(filepath.Ext(filePath), ".") if ext == "" { ext = "docx" } callConvert := func(payload map[string]interface{}) (string, error) { payloadBytes, err := json.Marshal(payload) if err != nil { return "", err } req, err := http.NewRequest("POST", "https://office.elijahkuntz.com/ConvertService.ashx", bytes.NewBuffer(payloadBytes)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") if cfg.OnlyOfficeJWT != "" { jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(payload)) signedToken, err := jwtToken.SignedString([]byte(cfg.OnlyOfficeJWT)) if err == nil { req.Header.Set("Authorization", "Bearer "+signedToken) } } client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("conversion request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("conversion request returned status: %d", resp.StatusCode) } var result struct { Error *int `json:"error,omitempty"` FileUrl string `json:"fileUrl,omitempty"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("failed to decode conversion response: %w", err) } if result.Error != nil && *result.Error != 0 { return "", fmt.Errorf("conversion service returned error code: %d", *result.Error) } if result.FileUrl == "" { return "", fmt.Errorf("no fileUrl in conversion response") } return result.FileUrl, nil } var finalImgUrl string if ext == "xlsx" || ext == "xls" { pdfPayload := map[string]interface{}{ "async": false, "filetype": ext, "key": fmt.Sprintf("thumb_pdf_%d", time.Now().UnixNano()), "outputtype": "pdf", "title": "preview.pdf", "url": fileUrl, "spreadsheetLayout": map[string]interface{}{ "ignorePrintArea": true, "printGridlines": true, "printHeadings": true, "fitToWidth": 1, }, } pdfUrl, err := callConvert(pdfPayload) if err != nil { return fmt.Errorf("failed to convert xlsx to pdf: %w", err) } jpgPayload := map[string]interface{}{ "async": false, "filetype": "pdf", "key": fmt.Sprintf("thumb_jpg_%d", time.Now().UnixNano()), "outputtype": "jpg", "title": "preview.jpg", "url": pdfUrl, "thumbnail": map[string]interface{}{ "aspect": 1, "first": true, "width": 1024, "height": 1024, }, } finalImgUrl, err = callConvert(jpgPayload) if err != nil { return fmt.Errorf("failed to convert pdf to jpg: %w", err) } } else { payload := map[string]interface{}{ "async": false, "filetype": ext, "key": fmt.Sprintf("thumb_%d", time.Now().UnixNano()), "outputtype": "jpg", "title": "preview.jpg", "url": fileUrl, "thumbnail": map[string]interface{}{ "aspect": 1, "first": true, "width": 1024, "height": 1024, }, } var err error finalImgUrl, err = callConvert(payload) if err != nil { return err } } // Download the resulting image imgResp, err := http.Get(finalImgUrl) if err != nil { return fmt.Errorf("failed to download converted image: %w", err) } defer imgResp.Body.Close() if imgResp.StatusCode != http.StatusOK { return fmt.Errorf("downloading image returned status: %d", imgResp.StatusCode) } // Create temp file for the downloaded image tmpFile, err := os.CreateTemp("", "office_thumb_*.jpg") if err != nil { return err } tmpName := tmpFile.Name() defer os.Remove(tmpName) if _, err := io.Copy(tmpFile, imgResp.Body); err != nil { tmpFile.Close() return err } tmpFile.Close() // Resize it using imaging to ensure it matches standard thumbnail sizes return generateImageThumbnail(tmpName, destPath) }