Update .gitignore to include log files and database journal files. Modify go.mod to update dependencies for go-sqlite3 and cloud.google.com/go/compute/metadata. Enhance Makefile to include logging options for manager and runner commands. Introduce new job token handling in auth package and implement database migration scripts. Refactor manager and runner components to improve job processing and metadata extraction. Add support for video preview in frontend components and enhance WebSocket management for channel subscriptions.
This commit is contained in:
831
internal/manager/blender.go
Normal file
831
internal/manager/blender.go
Normal file
@@ -0,0 +1,831 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/bzip2"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
BlenderDownloadBaseURL = "https://download.blender.org/release/"
|
||||
BlenderVersionCacheTTL = 1 * time.Hour
|
||||
)
|
||||
|
||||
// BlenderVersion represents a parsed Blender version
|
||||
type BlenderVersion struct {
|
||||
Major int `json:"major"`
|
||||
Minor int `json:"minor"`
|
||||
Patch int `json:"patch"`
|
||||
Full string `json:"full"` // e.g., "4.2.3"
|
||||
DirName string `json:"dir_name"` // e.g., "Blender4.2"
|
||||
Filename string `json:"filename"` // e.g., "blender-4.2.3-linux-x64.tar.xz"
|
||||
URL string `json:"url"` // Full download URL
|
||||
}
|
||||
|
||||
// BlenderVersionCache caches available Blender versions
|
||||
type BlenderVersionCache struct {
|
||||
versions []BlenderVersion
|
||||
fetchedAt time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var blenderVersionCache = &BlenderVersionCache{}
|
||||
|
||||
// FetchBlenderVersions fetches available Blender versions from download.blender.org
|
||||
// Returns versions sorted by version number (newest first)
|
||||
func (s *Manager) FetchBlenderVersions() ([]BlenderVersion, error) {
|
||||
// Check cache first
|
||||
blenderVersionCache.mu.RLock()
|
||||
if time.Since(blenderVersionCache.fetchedAt) < BlenderVersionCacheTTL && len(blenderVersionCache.versions) > 0 {
|
||||
versions := make([]BlenderVersion, len(blenderVersionCache.versions))
|
||||
copy(versions, blenderVersionCache.versions)
|
||||
blenderVersionCache.mu.RUnlock()
|
||||
return versions, nil
|
||||
}
|
||||
blenderVersionCache.mu.RUnlock()
|
||||
|
||||
// Fetch from website with timeout
|
||||
client := &http.Client{
|
||||
Timeout: WSWriteDeadline,
|
||||
}
|
||||
resp, err := client.Get(BlenderDownloadBaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch blender releases: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch blender releases: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Parse directory listing for Blender version folders
|
||||
// Looking for patterns like href="Blender4.2/" or href="Blender3.6/"
|
||||
dirPattern := regexp.MustCompile(`href="Blender(\d+)\.(\d+)/"`)
|
||||
log.Printf("Fetching Blender versions from %s", BlenderDownloadBaseURL)
|
||||
matches := dirPattern.FindAllStringSubmatch(string(body), -1)
|
||||
|
||||
// Fetch sub-versions concurrently to speed up the process
|
||||
type versionResult struct {
|
||||
versions []BlenderVersion
|
||||
err error
|
||||
}
|
||||
results := make(chan versionResult, len(matches))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
major := 0
|
||||
minor := 0
|
||||
fmt.Sscanf(match[1], "%d", &major)
|
||||
fmt.Sscanf(match[2], "%d", &minor)
|
||||
|
||||
// Skip very old versions (pre-2.80)
|
||||
if major < 2 || (major == 2 && minor < 80) {
|
||||
continue
|
||||
}
|
||||
|
||||
dirName := fmt.Sprintf("Blender%d.%d", major, minor)
|
||||
|
||||
// Fetch the specific version directory concurrently
|
||||
wg.Add(1)
|
||||
go func(dn string, maj, min int) {
|
||||
defer wg.Done()
|
||||
subVersions, err := fetchSubVersions(dn, maj, min)
|
||||
results <- versionResult{versions: subVersions, err: err}
|
||||
}(dirName, major, minor)
|
||||
}
|
||||
|
||||
// Close results channel when all goroutines complete
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
var versions []BlenderVersion
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
log.Printf("Warning: failed to fetch sub-versions: %v", result.err)
|
||||
continue
|
||||
}
|
||||
versions = append(versions, result.versions...)
|
||||
}
|
||||
|
||||
// Sort by version (newest first)
|
||||
sort.Slice(versions, func(i, j int) bool {
|
||||
if versions[i].Major != versions[j].Major {
|
||||
return versions[i].Major > versions[j].Major
|
||||
}
|
||||
if versions[i].Minor != versions[j].Minor {
|
||||
return versions[i].Minor > versions[j].Minor
|
||||
}
|
||||
return versions[i].Patch > versions[j].Patch
|
||||
})
|
||||
|
||||
// Update cache
|
||||
blenderVersionCache.mu.Lock()
|
||||
blenderVersionCache.versions = versions
|
||||
blenderVersionCache.fetchedAt = time.Now()
|
||||
blenderVersionCache.mu.Unlock()
|
||||
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// fetchSubVersions fetches specific version files from a Blender release directory
|
||||
func fetchSubVersions(dirName string, major, minor int) ([]BlenderVersion, error) {
|
||||
url := BlenderDownloadBaseURL + dirName + "/"
|
||||
client := &http.Client{
|
||||
Timeout: WSWriteDeadline,
|
||||
}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Look for linux 64-bit tar.xz/bz2 files
|
||||
// Various naming conventions across versions:
|
||||
// - Modern (2.93+): blender-4.2.3-linux-x64.tar.xz
|
||||
// - 2.83 early: blender-2.83.0-linux64.tar.xz
|
||||
// - 2.80-2.82: blender-2.80-linux-glibc217-x86_64.tar.bz2
|
||||
// Skip: rc versions, alpha/beta, i686 (32-bit)
|
||||
filePatterns := []*regexp.Regexp{
|
||||
// Modern format: blender-X.Y.Z-linux-x64.tar.xz
|
||||
regexp.MustCompile(`blender-(\d+)\.(\d+)\.(\d+)-linux-x64\.tar\.(xz|bz2)`),
|
||||
// Older format: blender-X.Y.Z-linux64.tar.xz
|
||||
regexp.MustCompile(`blender-(\d+)\.(\d+)\.(\d+)-linux64\.tar\.(xz|bz2)`),
|
||||
// glibc format: blender-X.Y.Z-linux-glibc217-x86_64.tar.bz2 (prefer glibc217 for compatibility)
|
||||
regexp.MustCompile(`blender-(\d+)\.(\d+)\.(\d+)-linux-glibc217-x86_64\.tar\.(xz|bz2)`),
|
||||
}
|
||||
|
||||
var versions []BlenderVersion
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, filePattern := range filePatterns {
|
||||
matches := filePattern.FindAllStringSubmatch(string(body), -1)
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) < 5 {
|
||||
continue
|
||||
}
|
||||
|
||||
patch := 0
|
||||
fmt.Sscanf(match[3], "%d", &patch)
|
||||
|
||||
full := fmt.Sprintf("%d.%d.%d", major, minor, patch)
|
||||
if seen[full] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
|
||||
filename := match[0]
|
||||
versions = append(versions, BlenderVersion{
|
||||
Major: major,
|
||||
Minor: minor,
|
||||
Patch: patch,
|
||||
Full: full,
|
||||
DirName: dirName,
|
||||
Filename: filename,
|
||||
URL: url + filename,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// GetLatestBlenderForMajorMinor returns the latest patch version for a given major.minor
|
||||
// If exact match not found, uses fuzzy matching to find the closest available version
|
||||
func (s *Manager) GetLatestBlenderForMajorMinor(major, minor int) (*BlenderVersion, error) {
|
||||
versions, err := s.FetchBlenderVersions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
return nil, fmt.Errorf("no blender versions available")
|
||||
}
|
||||
|
||||
// Try exact match first - find the highest patch for this major.minor
|
||||
var exactMatch *BlenderVersion
|
||||
for i := range versions {
|
||||
v := &versions[i]
|
||||
if v.Major == major && v.Minor == minor {
|
||||
if exactMatch == nil || v.Patch > exactMatch.Patch {
|
||||
exactMatch = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if exactMatch != nil {
|
||||
log.Printf("Found Blender %d.%d.%d for requested %d.%d", exactMatch.Major, exactMatch.Minor, exactMatch.Patch, major, minor)
|
||||
return exactMatch, nil
|
||||
}
|
||||
|
||||
// Fuzzy matching: find closest version
|
||||
// Priority: same major with closest minor > closest major
|
||||
log.Printf("No exact match for Blender %d.%d, using fuzzy matching", major, minor)
|
||||
|
||||
var bestMatch *BlenderVersion
|
||||
bestScore := -1000000 // Large negative number
|
||||
|
||||
for i := range versions {
|
||||
v := &versions[i]
|
||||
score := 0
|
||||
|
||||
if v.Major == major {
|
||||
// Same major version - prefer this
|
||||
score = 10000
|
||||
|
||||
// Prefer lower minor versions (more stable/compatible)
|
||||
// but not too far back
|
||||
minorDiff := minor - v.Minor
|
||||
if minorDiff >= 0 {
|
||||
// v.Minor <= minor (older or same) - prefer closer
|
||||
score += 1000 - minorDiff*10
|
||||
} else {
|
||||
// v.Minor > minor (newer) - less preferred but acceptable
|
||||
score += 500 + minorDiff*10
|
||||
}
|
||||
|
||||
// Higher patch is better
|
||||
score += v.Patch
|
||||
} else {
|
||||
// Different major - less preferred
|
||||
majorDiff := major - v.Major
|
||||
if majorDiff > 0 {
|
||||
// v.Major < major (older major) - acceptable fallback
|
||||
score = 5000 - majorDiff*1000 + v.Minor*10 + v.Patch
|
||||
} else {
|
||||
// v.Major > major (newer major) - avoid if possible
|
||||
score = -majorDiff * 1000
|
||||
}
|
||||
}
|
||||
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestMatch = v
|
||||
}
|
||||
}
|
||||
|
||||
if bestMatch != nil {
|
||||
log.Printf("Fuzzy match: requested %d.%d, using %d.%d.%d", major, minor, bestMatch.Major, bestMatch.Minor, bestMatch.Patch)
|
||||
return bestMatch, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no blender version found for %d.%d", major, minor)
|
||||
}
|
||||
|
||||
// GetBlenderArchivePath returns the path to the cached blender archive for a specific version
|
||||
// Downloads from blender.org and decompresses to .tar if not already cached
|
||||
// The manager caches as uncompressed .tar to save decompression time on runners
|
||||
func (s *Manager) GetBlenderArchivePath(version *BlenderVersion) (string, error) {
|
||||
// Base directory for blender archives
|
||||
blenderDir := filepath.Join(s.storage.BasePath(), "blender-versions")
|
||||
if err := os.MkdirAll(blenderDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create blender directory: %w", err)
|
||||
}
|
||||
|
||||
// Cache as uncompressed .tar for faster runner downloads
|
||||
// Convert filename like "blender-4.2.3-linux-x64.tar.xz" to "blender-4.2.3-linux-x64.tar"
|
||||
tarFilename := version.Filename
|
||||
tarFilename = strings.TrimSuffix(tarFilename, ".xz")
|
||||
tarFilename = strings.TrimSuffix(tarFilename, ".bz2")
|
||||
archivePath := filepath.Join(blenderDir, tarFilename)
|
||||
|
||||
// Check if already cached as .tar
|
||||
if _, err := os.Stat(archivePath); err == nil {
|
||||
log.Printf("Using cached Blender %s at %s", version.Full, archivePath)
|
||||
// Clean up any extracted folders that might exist
|
||||
s.cleanupExtractedBlenderFolders(blenderDir, version)
|
||||
return archivePath, nil
|
||||
}
|
||||
|
||||
// Need to download and decompress
|
||||
log.Printf("Downloading Blender %s from %s", version.Full, version.URL)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 0, // No timeout for large downloads
|
||||
}
|
||||
resp, err := client.Get(version.URL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to download blender: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("failed to download blender: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Download to temp file first
|
||||
compressedPath := filepath.Join(blenderDir, "download-"+version.Filename)
|
||||
compressedFile, err := os.Create(compressedPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(compressedFile, resp.Body); err != nil {
|
||||
compressedFile.Close()
|
||||
os.Remove(compressedPath)
|
||||
return "", fmt.Errorf("failed to download blender: %w", err)
|
||||
}
|
||||
compressedFile.Close()
|
||||
|
||||
log.Printf("Downloaded Blender %s, decompressing to .tar...", version.Full)
|
||||
|
||||
// Decompress to .tar
|
||||
if err := decompressToTar(compressedPath, archivePath); err != nil {
|
||||
os.Remove(compressedPath)
|
||||
os.Remove(archivePath)
|
||||
return "", fmt.Errorf("failed to decompress blender archive: %w", err)
|
||||
}
|
||||
|
||||
// Remove compressed file
|
||||
os.Remove(compressedPath)
|
||||
|
||||
// Clean up any extracted folders for this version (if they exist)
|
||||
s.cleanupExtractedBlenderFolders(blenderDir, version)
|
||||
|
||||
log.Printf("Blender %s cached at %s", version.Full, archivePath)
|
||||
return archivePath, nil
|
||||
}
|
||||
|
||||
// decompressToTar decompresses a .tar.xz or .tar.bz2 file to a plain .tar file
|
||||
func decompressToTar(compressedPath, tarPath string) error {
|
||||
if strings.HasSuffix(compressedPath, ".tar.xz") {
|
||||
// Use xz command for decompression
|
||||
cmd := exec.Command("xz", "-d", "-k", "-c", compressedPath)
|
||||
outFile, err := os.Create(tarPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
cmd.Stdout = outFile
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("xz decompression failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
} else if strings.HasSuffix(compressedPath, ".tar.bz2") {
|
||||
// Use bzip2 for decompression
|
||||
inFile, err := os.Open(compressedPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer inFile.Close()
|
||||
|
||||
bzReader := bzip2.NewReader(inFile)
|
||||
outFile, err := os.Create(tarPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
if _, err := io.Copy(outFile, bzReader); err != nil {
|
||||
return fmt.Errorf("bzip2 decompression failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unsupported compression format: %s", compressedPath)
|
||||
}
|
||||
|
||||
// cleanupExtractedBlenderFolders removes any extracted Blender folders for the given version
|
||||
// This ensures we only keep the .tar file and not extracted folders
|
||||
func (s *Manager) cleanupExtractedBlenderFolders(blenderDir string, version *BlenderVersion) {
|
||||
// Look for folders matching the version (e.g., "4.2.3", "2.83.20")
|
||||
versionDirs := []string{
|
||||
filepath.Join(blenderDir, version.Full), // e.g., "4.2.3"
|
||||
filepath.Join(blenderDir, fmt.Sprintf("%d.%d", version.Major, version.Minor)), // e.g., "4.2"
|
||||
}
|
||||
|
||||
for _, dir := range versionDirs {
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
log.Printf("Removing extracted Blender folder: %s", dir)
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
log.Printf("Warning: failed to remove extracted folder %s: %v", dir, err)
|
||||
} else {
|
||||
log.Printf("Removed extracted Blender folder: %s", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParseBlenderVersionFromFile parses the Blender version that a .blend file was saved with
|
||||
// This reads the file header to determine the version
|
||||
func ParseBlenderVersionFromFile(blendPath string) (major, minor int, err error) {
|
||||
file, err := os.Open(blendPath)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to open blend file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
return ParseBlenderVersionFromReader(file)
|
||||
}
|
||||
|
||||
// ParseBlenderVersionFromReader parses the Blender version from a reader
|
||||
// Useful for reading from uploaded files without saving to disk first
|
||||
func ParseBlenderVersionFromReader(r io.ReadSeeker) (major, minor int, err error) {
|
||||
// Read the first 12 bytes of the blend file header
|
||||
// Format: BLENDER-v<major><minor><patch> or BLENDER_v<major><minor><patch>
|
||||
// The header is: "BLENDER" (7 bytes) + pointer size (1 byte: '-' for 64-bit, '_' for 32-bit)
|
||||
// + endianness (1 byte: 'v' for little-endian, 'V' for big-endian)
|
||||
// + version (3 bytes: e.g., "402" for 4.02)
|
||||
header := make([]byte, 12)
|
||||
n, err := r.Read(header)
|
||||
if err != nil || n < 12 {
|
||||
return 0, 0, fmt.Errorf("failed to read blend file header: %w", err)
|
||||
}
|
||||
|
||||
// Check for BLENDER magic
|
||||
if string(header[:7]) != "BLENDER" {
|
||||
// Might be compressed - try to decompress
|
||||
r.Seek(0, 0)
|
||||
return parseCompressedBlendVersion(r)
|
||||
}
|
||||
|
||||
// Parse version from bytes 9-11 (3 digits)
|
||||
versionStr := string(header[9:12])
|
||||
var vMajor, vMinor int
|
||||
|
||||
// Version format changed in Blender 3.0
|
||||
// Pre-3.0: "279" = 2.79, "280" = 2.80
|
||||
// 3.0+: "300" = 3.0, "402" = 4.02, "410" = 4.10
|
||||
if len(versionStr) == 3 {
|
||||
// First digit is major version
|
||||
fmt.Sscanf(string(versionStr[0]), "%d", &vMajor)
|
||||
// Next two digits are minor version
|
||||
fmt.Sscanf(versionStr[1:3], "%d", &vMinor)
|
||||
}
|
||||
|
||||
return vMajor, vMinor, nil
|
||||
}
|
||||
|
||||
// parseCompressedBlendVersion handles gzip and zstd compressed blend files
|
||||
func parseCompressedBlendVersion(r io.ReadSeeker) (major, minor int, err error) {
|
||||
// Check for compression magic bytes
|
||||
magic := make([]byte, 4)
|
||||
if _, err := r.Read(magic); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
r.Seek(0, 0)
|
||||
|
||||
if magic[0] == 0x1f && magic[1] == 0x8b {
|
||||
// gzip compressed
|
||||
gzReader, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
header := make([]byte, 12)
|
||||
n, err := gzReader.Read(header)
|
||||
if err != nil || n < 12 {
|
||||
return 0, 0, fmt.Errorf("failed to read compressed blend header: %w", err)
|
||||
}
|
||||
|
||||
if string(header[:7]) != "BLENDER" {
|
||||
return 0, 0, fmt.Errorf("invalid blend file format")
|
||||
}
|
||||
|
||||
versionStr := string(header[9:12])
|
||||
var vMajor, vMinor int
|
||||
if len(versionStr) == 3 {
|
||||
fmt.Sscanf(string(versionStr[0]), "%d", &vMajor)
|
||||
fmt.Sscanf(versionStr[1:3], "%d", &vMinor)
|
||||
}
|
||||
|
||||
return vMajor, vMinor, nil
|
||||
}
|
||||
|
||||
// Check for zstd magic (Blender 3.0+): 0x28 0xB5 0x2F 0xFD
|
||||
if magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd {
|
||||
return parseZstdBlendVersion(r)
|
||||
}
|
||||
|
||||
return 0, 0, fmt.Errorf("unknown blend file format")
|
||||
}
|
||||
|
||||
// parseZstdBlendVersion handles zstd-compressed blend files (Blender 3.0+)
|
||||
// Uses zstd command line tool since Go doesn't have native zstd support
|
||||
func parseZstdBlendVersion(r io.ReadSeeker) (major, minor int, err error) {
|
||||
r.Seek(0, 0)
|
||||
|
||||
// We need to decompress just enough to read the header
|
||||
// Use zstd command to decompress from stdin
|
||||
cmd := exec.Command("zstd", "-d", "-c")
|
||||
cmd.Stdin = r
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to create zstd stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to start zstd decompression: %w", err)
|
||||
}
|
||||
|
||||
// Read just the header (12 bytes)
|
||||
header := make([]byte, 12)
|
||||
n, readErr := io.ReadFull(stdout, header)
|
||||
|
||||
// Kill the process early - we only need the header
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
|
||||
if readErr != nil || n < 12 {
|
||||
return 0, 0, fmt.Errorf("failed to read zstd compressed blend header: %v", readErr)
|
||||
}
|
||||
|
||||
if string(header[:7]) != "BLENDER" {
|
||||
return 0, 0, fmt.Errorf("invalid blend file format in zstd archive")
|
||||
}
|
||||
|
||||
versionStr := string(header[9:12])
|
||||
var vMajor, vMinor int
|
||||
if len(versionStr) == 3 {
|
||||
fmt.Sscanf(string(versionStr[0]), "%d", &vMajor)
|
||||
fmt.Sscanf(versionStr[1:3], "%d", &vMinor)
|
||||
}
|
||||
|
||||
return vMajor, vMinor, nil
|
||||
}
|
||||
|
||||
// handleGetBlenderVersions returns available Blender versions
|
||||
func (s *Manager) handleGetBlenderVersions(w http.ResponseWriter, r *http.Request) {
|
||||
versions, err := s.FetchBlenderVersions()
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to fetch blender versions: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Group by major.minor for easier frontend display
|
||||
type VersionGroup struct {
|
||||
MajorMinor string `json:"major_minor"`
|
||||
Latest BlenderVersion `json:"latest"`
|
||||
All []BlenderVersion `json:"all"`
|
||||
}
|
||||
|
||||
groups := make(map[string]*VersionGroup)
|
||||
for _, v := range versions {
|
||||
key := fmt.Sprintf("%d.%d", v.Major, v.Minor)
|
||||
if groups[key] == nil {
|
||||
groups[key] = &VersionGroup{
|
||||
MajorMinor: key,
|
||||
Latest: v, // First one is latest due to sorting
|
||||
All: []BlenderVersion{v},
|
||||
}
|
||||
} else {
|
||||
groups[key].All = append(groups[key].All, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to slice and sort by version
|
||||
var groupedResult []VersionGroup
|
||||
for _, g := range groups {
|
||||
groupedResult = append(groupedResult, *g)
|
||||
}
|
||||
sort.Slice(groupedResult, func(i, j int) bool {
|
||||
// Parse major.minor for comparison
|
||||
var iMaj, iMin, jMaj, jMin int
|
||||
fmt.Sscanf(groupedResult[i].MajorMinor, "%d.%d", &iMaj, &iMin)
|
||||
fmt.Sscanf(groupedResult[j].MajorMinor, "%d.%d", &jMaj, &jMin)
|
||||
if iMaj != jMaj {
|
||||
return iMaj > jMaj
|
||||
}
|
||||
return iMin > jMin
|
||||
})
|
||||
|
||||
// Return both flat list and grouped for flexibility
|
||||
response := map[string]interface{}{
|
||||
"versions": versions, // Flat list of all versions (newest first)
|
||||
"grouped": groupedResult, // Grouped by major.minor
|
||||
}
|
||||
|
||||
s.respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleDownloadBlender serves a cached Blender archive to runners
|
||||
func (s *Manager) handleDownloadBlender(w http.ResponseWriter, r *http.Request) {
|
||||
version := r.URL.Query().Get("version")
|
||||
if version == "" {
|
||||
s.respondError(w, http.StatusBadRequest, "version parameter required")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse version string (e.g., "4.2.3" or "4.2")
|
||||
var major, minor, patch int
|
||||
parts := strings.Split(version, ".")
|
||||
if len(parts) < 2 {
|
||||
s.respondError(w, http.StatusBadRequest, "invalid version format, expected major.minor or major.minor.patch")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Sscanf(parts[0], "%d", &major)
|
||||
fmt.Sscanf(parts[1], "%d", &minor)
|
||||
if len(parts) >= 3 {
|
||||
fmt.Sscanf(parts[2], "%d", &patch)
|
||||
}
|
||||
|
||||
// Find the version
|
||||
var blenderVersion *BlenderVersion
|
||||
if len(parts) >= 3 {
|
||||
// Exact patch version requested - find it
|
||||
versions, err := s.FetchBlenderVersions()
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to fetch versions: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
for _, v := range versions {
|
||||
if v.Major == major && v.Minor == minor && v.Patch == patch {
|
||||
blenderVersion = &v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if blenderVersion == nil {
|
||||
s.respondError(w, http.StatusNotFound, fmt.Sprintf("blender version %s not found", version))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Major.minor only - use helper to get latest patch version
|
||||
var err error
|
||||
blenderVersion, err = s.GetLatestBlenderForMajorMinor(major, minor)
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusNotFound, fmt.Sprintf("blender version %s not found: %v", version, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get or download the archive
|
||||
archivePath, err := s.GetBlenderArchivePath(blenderVersion)
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get blender archive: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to open archive: %v", err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to stat archive: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Filename is now .tar (decompressed)
|
||||
tarFilename := blenderVersion.Filename
|
||||
tarFilename = strings.TrimSuffix(tarFilename, ".xz")
|
||||
tarFilename = strings.TrimSuffix(tarFilename, ".bz2")
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-tar")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", tarFilename))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
||||
w.Header().Set("X-Blender-Version", blenderVersion.Full)
|
||||
|
||||
io.Copy(w, file)
|
||||
}
|
||||
|
||||
// Unused functions from extraction - keeping for reference but not needed on manager
|
||||
var _ = extractBlenderArchive
|
||||
var _ = extractTarXz
|
||||
var _ = extractTar
|
||||
|
||||
// extractBlenderArchive extracts a blender archive (already decompressed to .tar by GetBlenderArchivePath)
|
||||
func extractBlenderArchive(archivePath string, version *BlenderVersion, destDir string) error {
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// The archive is already decompressed to .tar by GetBlenderArchivePath
|
||||
// Just extract it directly
|
||||
if strings.HasSuffix(archivePath, ".tar") {
|
||||
tarReader := tar.NewReader(file)
|
||||
return extractTar(tarReader, version, destDir)
|
||||
}
|
||||
|
||||
// Fallback for any other format (shouldn't happen with current flow)
|
||||
if strings.HasSuffix(archivePath, ".tar.xz") {
|
||||
return extractTarXz(archivePath, version, destDir)
|
||||
} else if strings.HasSuffix(archivePath, ".tar.bz2") {
|
||||
bzReader := bzip2.NewReader(file)
|
||||
tarReader := tar.NewReader(bzReader)
|
||||
return extractTar(tarReader, version, destDir)
|
||||
}
|
||||
|
||||
return fmt.Errorf("unsupported archive format: %s", archivePath)
|
||||
}
|
||||
|
||||
// extractTarXz extracts a tar.xz archive using the xz command
|
||||
func extractTarXz(archivePath string, version *BlenderVersion, destDir string) error {
|
||||
versionDir := filepath.Join(destDir, version.Full)
|
||||
if err := os.MkdirAll(versionDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("tar", "-xJf", archivePath, "-C", versionDir, "--strip-components=1")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("tar extraction failed: %v, output: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTar extracts files from a tar reader
|
||||
func extractTar(tarReader *tar.Reader, version *BlenderVersion, destDir string) error {
|
||||
versionDir := filepath.Join(destDir, version.Full)
|
||||
if err := os.MkdirAll(versionDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stripPrefix := ""
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if stripPrefix == "" {
|
||||
parts := strings.SplitN(header.Name, "/", 2)
|
||||
if len(parts) > 0 {
|
||||
stripPrefix = parts[0] + "/"
|
||||
}
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(header.Name, stripPrefix)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(versionDir, name)
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||
outFile.Close()
|
||||
return err
|
||||
}
|
||||
outFile.Close()
|
||||
case tar.TypeSymlink:
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(header.Linkname, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user