Files
Kazier/backend/internal/service/conversion.go
T
2026-07-29 18:56:26 +02:00

263 lines
6.4 KiB
Go

package service
import (
"context"
"fmt"
"image"
_ "image/jpeg"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/vaultdrop/backend/internal/config"
"github.com/vaultdrop/backend/internal/db"
)
type ConversionJob struct {
ResourceID string
FilePath string
MimeType string
}
type ConversionService struct {
queries *db.Queries
cfg *config.Config
jobs chan ConversionJob
}
func NewConversionService(queries *db.Queries, cfg *config.Config) *ConversionService {
return &ConversionService{
queries: queries,
cfg: cfg,
jobs: make(chan ConversionJob, 100),
}
}
func (s *ConversionService) Start(workerCount int) {
for i := range workerCount {
go s.worker()
log.Printf("[Conversion] Worker %d started", i)
}
}
func (s *ConversionService) Stop() {
close(s.jobs)
log.Println("[Conversion] Worker stopped")
}
func (s *ConversionService) Enqueue(resourceID, filePath, mimeType string) error {
select {
case s.jobs <- ConversionJob{ResourceID: resourceID, FilePath: filePath, MimeType: mimeType}:
log.Printf("[Conversion] Enqueued resource %s", resourceID)
return nil
default:
log.Printf("[Conversion] Queue full, dropping resource %s", resourceID)
return fmt.Errorf("conversion queue full (%d pending)", len(s.jobs))
}
}
func (s *ConversionService) worker() {
for job := range s.jobs {
s.process(job)
}
}
func (s *ConversionService) process(job ConversionJob) {
log.Printf("[Conversion] Processing resource %s (mime: %s)", job.ResourceID, job.MimeType)
pdfPath := job.FilePath
tmpDir := ""
if isOfficeDocument(job.MimeType) {
var err error
pdfPath, tmpDir, err = s.convertToPDF(job.FilePath)
if err != nil {
log.Printf("[Conversion] Failed to convert resource %s to PDF: %v", job.ResourceID, err)
return
}
defer os.RemoveAll(tmpDir)
} else if !isPDF(job.MimeType) {
log.Printf("[Conversion] Skipping resource %s: unsupported mime type %s", job.ResourceID, job.MimeType)
return
}
thumbDir := filepath.Join(s.cfg.ThumbnailDir, job.ResourceID)
if err := os.MkdirAll(thumbDir, 0o755); err != nil {
log.Printf("[Conversion] Failed to create thumbnail dir for %s: %v", job.ResourceID, err)
return
}
resolutions := []struct {
label string
dpi int
}{
{"thumbnail_small", 21},
{"thumbnail_full", 200},
}
for _, res := range resolutions {
pages, err := s.convertPDFToImages(pdfPath, thumbDir, res.dpi)
if err != nil {
log.Printf("[Conversion] Failed to convert resource %s to images (res=%s): %v", job.ResourceID, res.label, err)
continue
}
for _, page := range pages {
width, height, err := getImageDimensions(page.path)
if err != nil {
log.Printf("[Conversion] Failed to get dimensions for %s: %v", page.path, err)
width, height = 0, 0
}
dstPath := filepath.Join(thumbDir, uuid.New().String()+".jpg")
if err := os.Rename(page.path, dstPath); err != nil {
log.Printf("[Conversion] Failed to move %s to %s: %v", page.path, dstPath, err)
continue
}
resourceUUID, _ := uuid.Parse(job.ResourceID)
_, err = s.queries.CreateResourceVariant(context.Background(), db.CreateResourceVariantParams{
ResourceID: resourceUUID,
VariantType: res.label,
PageNumber: int32(page.number),
Width: int32(width),
Height: int32(height),
MimeType: "image/jpeg",
GeneratedBy: "server",
StorageKey: dstPath,
})
if err != nil {
log.Printf("[Conversion] Failed to create variant record for resource %s page %d: %v", job.ResourceID, page.number, err)
continue
}
}
log.Printf("[Conversion] Generated %d %s images for resource %s", len(pages), res.label, job.ResourceID)
}
log.Printf("[Conversion] Completed resource %s", job.ResourceID)
}
func (s *ConversionService) convertToPDF(inputPath string) (string, string, error) {
tmpDir, err := os.MkdirTemp("", "conversion-*")
if err != nil {
return "", "", fmt.Errorf("create temp dir: %w", err)
}
cmd := exec.Command(s.cfg.LibreOfficePath,
"--headless",
"--convert-to", "pdf",
"--outdir", tmpDir,
inputPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
os.RemoveAll(tmpDir)
return "", "", fmt.Errorf("libreoffice conversion failed: %s: %w", string(output), err)
}
baseName := filepath.Base(inputPath)
pdfName := strings.TrimSuffix(baseName, filepath.Ext(baseName)) + ".pdf"
pdfPath := filepath.Join(tmpDir, pdfName)
if _, err := os.Stat(pdfPath); os.IsNotExist(err) {
os.RemoveAll(tmpDir)
return "", "", fmt.Errorf("PDF not found at %s", pdfPath)
}
return pdfPath, tmpDir, nil
}
type imagePage struct {
number int
path string
}
func (s *ConversionService) convertPDFToImages(pdfPath, outputDir string, dpi int) ([]imagePage, error) {
prefix := filepath.Join(outputDir, fmt.Sprintf("tmp_%d_", dpi))
cmd := exec.Command(s.cfg.PdftoppmPath,
"-jpeg",
"-r", fmt.Sprintf("%d", dpi),
pdfPath,
prefix,
)
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("pdftoppm failed: %s: %w", string(output), err)
}
entries, err := os.ReadDir(outputDir)
if err != nil {
return nil, fmt.Errorf("read output dir: %w", err)
}
var pages []imagePage
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasPrefix(name, fmt.Sprintf("tmp_%d_", dpi)) || !strings.HasSuffix(name, ".jpg") {
continue
}
raw := strings.TrimPrefix(name, fmt.Sprintf("tmp_%d_", dpi))
raw = strings.TrimPrefix(raw, "-")
var num int
if _, err := fmt.Sscanf(raw, "%d", &num); err != nil {
continue
}
pages = append(pages, imagePage{
number: num,
path: filepath.Join(outputDir, name),
})
}
return pages, nil
}
func isPDF(mimeType string) bool {
return strings.Contains(mimeType, "pdf")
}
func isOfficeDocument(mimeType string) bool {
officeTypes := []string{
"application/vnd.openxmlformats-officedocument",
"application/vnd.ms-excel",
"application/msword",
"application/vnd.ms-powerpoint",
"application/vnd.oasis.opendocument",
"application/x-doc",
"application/x-xls",
"application/x-ppt",
}
for _, t := range officeTypes {
if strings.Contains(mimeType, t) {
return true
}
}
return false
}
func IsConvertible(mimeType string) bool {
return isPDF(mimeType) || isOfficeDocument(mimeType)
}
func getImageDimensions(path string) (int, int, error) {
f, err := os.Open(path)
if err != nil {
return 0, 0, err
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0, err
}
return cfg.Width, cfg.Height, nil
}