hako/internal/archival/archiver/thumbnail_extractor.go
2026-01-12 19:35:18 +01:00

183 lines
4.4 KiB
Go

package archiver
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.nakama.town/fmartingr/hako/internal/model"
"git.nakama.town/fmartingr/hako/internal/storage"
)
// ThumbnailArchiver implements thumbnail extraction from URLs
type ThumbnailArchiver struct {
httpClient *http.Client
resize bool
}
// NewThumbnailExtractor creates a new ThumbnailArchiver
func NewThumbnailExtractor() *ThumbnailArchiver {
return &ThumbnailArchiver{
httpClient: &http.Client{
Timeout: 5 * time.Minute,
},
}
}
// Key returns the unique identifier for this archiver
func (e *ThumbnailArchiver) Key() string {
return "thumbnail"
}
// Name returns the human-readable name for this archiver
func (e *ThumbnailArchiver) Name() string {
return "Thumbnail"
}
// IsEnabled checks if this archiver is available
func (e *ThumbnailArchiver) IsEnabled(ctx context.Context) bool {
// Thumbnail archiver is always enabled
return true
}
// Init initializes the archiver
func (e *ThumbnailArchiver) Init() error {
// No initialization needed
return nil
}
// GetDefaultConfig returns the default configuration
func (e *ThumbnailArchiver) GetDefaultConfig() any {
return map[string]any{
"timeout": "5m",
"resize": false,
}
}
// ApplyConfig applies the provided configuration to the archiver
func (e *ThumbnailArchiver) ApplyConfig(config map[string]any) error {
// Parse timeout duration
if timeoutStr, ok := config["timeout"].(string); ok {
timeout, err := time.ParseDuration(timeoutStr)
if err != nil {
return fmt.Errorf("invalid timeout format: %w", err)
}
e.httpClient.Timeout = timeout
}
// Parse resize setting
if resizeVal, ok := config["resize"]; ok {
if resize, ok := resizeVal.(bool); ok {
e.resize = resize
} else {
return fmt.Errorf("invalid resize format: expected boolean")
}
}
return nil
}
// Archive performs the thumbnail extraction
func (e *ThumbnailArchiver) Archive(ctx context.Context, link *model.Link, stor storage.Storage) (*ArchiveResult, error) {
// Fetch the URL to get HTML
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Hako/1.0")
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch URL: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// Check if it's HTML content
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "text/html") {
// Not HTML, skip thumbnail extraction
return &ArchiveResult{
Title: "",
Files: []FileInfo{},
}, nil
}
// Read HTML content
htmlBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read HTML: %w", err)
}
// Extract thumbnail from HTML
thumbnailResult, err := extractThumbnailFromHTML(ctx, htmlBytes, link.URL, e.resize)
if err != nil || thumbnailResult == nil {
// No thumbnail found or error - return empty result (not an error)
return &ArchiveResult{
Title: "",
Files: []FileInfo{},
}, nil
}
// Create a pipe to calculate hash while saving
pipeReader, pipeWriter := io.Pipe()
hash := sha256.New()
multiWriter := io.MultiWriter(pipeWriter, hash)
// Channel for errors from the goroutine
errChan := make(chan error, 1)
var path string
var fileSize int64
// Copy to storage in goroutine
go func() {
defer func() { _ = pipeWriter.Close() }()
reader := bytes.NewReader(thumbnailResult.ImageBytes)
if _, err := io.Copy(multiWriter, reader); err != nil {
errChan <- fmt.Errorf("failed to copy data: %w", err)
return
}
errChan <- nil
}()
// Save to storage
path, err = stor.Save(ctx, link.ID, thumbnailResult.Filename, pipeReader)
if err != nil {
return nil, fmt.Errorf("failed to save thumbnail: %w", err)
}
// Check for copy errors
if copyErr := <-errChan; copyErr != nil {
return nil, copyErr
}
// Get file size
fileSize = int64(len(thumbnailResult.ImageBytes))
// Calculate hash
hashStr := hex.EncodeToString(hash.Sum(nil))
// Create MIME type with +thumbnail suffix
mimeType := thumbnailResult.MimeType + "+thumbnail"
result := &ArchiveResult{
Title: "",
Files: []FileInfo{
{
Filename: thumbnailResult.Filename,
MimeType: mimeType,
FileSize: fileSize,
HashSha256: hashStr,
Path: path,
},
},
}
return result, nil
}