This repository has been archived on 2026-07-15. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
drive/backend/workers/thumbnails.go
Elijah 72b1de2937
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m12s
Add 3MF viewer and enable PDF thumbnails
2026-05-31 21:28:47 -07:00

371 lines
10 KiB
Go

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
go Instance.ScanMissingThumbnails()
})
}
func (m *ThumbnailManager) ScanMissingThumbnails() {
time.Sleep(5 * time.Second) // wait for server to settle
log.Println("Scanning for missing thumbnails...")
rows, err := m.DB.Query("SELECT path, checksum FROM files WHERE checksum IS NOT NULL AND checksum != ''")
if err != nil {
log.Printf("Failed to query files for missing thumbnails: %v\n", err)
return
}
defer rows.Close()
count := 0
for rows.Next() {
var path, checksum string
if err := rows.Scan(&path, &checksum); err != nil {
continue
}
destPath := filepath.Join(m.Config.ThumbnailDir, checksum+".jpg")
if _, err := os.Stat(destPath); err == nil {
continue // Thumbnail already exists
}
ext := strings.ToLower(filepath.Ext(path))
var mimeType string
if ext == ".jpg" || ext == ".png" || ext == ".gif" || ext == ".webp" || ext == ".svg" || ext == ".jpeg" {
mimeType = "image/"
} else if ext == ".mp4" || ext == ".webm" || ext == ".ogg" || ext == ".mov" || ext == ".mkv" || ext == ".avi" {
mimeType = "video/"
} else if ext == ".mp3" || ext == ".flac" || ext == ".wav" || ext == ".m4a" || ext == ".aac" {
mimeType = "audio/"
} else if ext == ".docx" || ext == ".pptx" || ext == ".xlsx" || ext == ".pdf" {
mimeType = "application/"
} else {
continue
}
job := ThumbnailJob{
FilePath: path,
Checksum: checksum,
MimeType: mimeType,
}
// Use blocking send so we don't drop jobs, but it throttles the scanner
m.Queue <- job
count++
}
if count > 0 {
log.Printf("Enqueued %d missing thumbnails for generation\n", count)
} else {
log.Println("No missing thumbnails found.")
}
}
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.HasPrefix(job.MimeType, "audio/") {
err = generateAudioThumbnail(fullSourcePath, destPath)
} else if strings.HasSuffix(job.FilePath, ".docx") || strings.HasSuffix(job.FilePath, ".pptx") || strings.HasSuffix(job.FilePath, ".xlsx") || strings.HasSuffix(strings.ToLower(job.FilePath), ".pdf") {
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 generateAudioThumbnail(src, dest string) error {
// Extract embedded cover art from audio file, scale to 256x256
cmd := exec.Command("ffmpeg", "-y", "-i", src, "-an", "-vcodec", "mjpeg", "-vframes", "1", "-vf", "scale=256:256:force_original_aspect_ratio=decrease", dest)
timer := time.AfterFunc(10*time.Second, func() {
if cmd.Process != nil {
cmd.Process.Kill()
}
})
err := cmd.Run()
timer.Stop()
if err != nil {
return fmt.Errorf("ffmpeg audio cover extraction 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, filePath)
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.
internalURL := cfg.InternalAPIURL
fileUrl := fmt.Sprintf("%s/public/onlyoffice/download?path=%s&token=%s", internalURL, 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": 0,
"orientation": "landscape",
},
}
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
client := &http.Client{Timeout: 30 * time.Second}
imgResp, err := client.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)
}