832 lines
24 KiB
Go
832 lines
24 KiB
Go
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
|
|
}
|