- Introduced unit tests for the main package to ensure compilation. - Added tests for the manager, including validation of upload sessions and handling of Blender binary paths. - Implemented tests for job token generation and validation, ensuring security and integrity. - Created tests for configuration management and database schema to verify functionality. - Added tests for logger and runner components to enhance overall test coverage and reliability.
5441 lines
160 KiB
Go
5441 lines
160 KiB
Go
package api
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"crypto/md5"
|
|
"database/sql"
|
|
_ "embed"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
authpkg "jiggablend/internal/auth"
|
|
"jiggablend/internal/runner/blender"
|
|
"jiggablend/pkg/executils"
|
|
"jiggablend/pkg/scripts"
|
|
"jiggablend/pkg/types"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// generateETag generates an ETag from data hash
|
|
func generateETag(data interface{}) string {
|
|
jsonData, err := json.Marshal(data)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
hash := md5.Sum(jsonData)
|
|
return fmt.Sprintf(`"%s"`, hex.EncodeToString(hash[:]))
|
|
}
|
|
|
|
// checkETag checks if the request has If-None-Match header matching the ETag
|
|
func checkETag(r *http.Request, etag string) bool {
|
|
ifNoneMatch := r.Header.Get("If-None-Match")
|
|
return ifNoneMatch != "" && ifNoneMatch == etag
|
|
}
|
|
|
|
// isAdminUser checks if the current user is an admin
|
|
func isAdminUser(r *http.Request) bool {
|
|
return authpkg.IsAdmin(r.Context())
|
|
}
|
|
|
|
func uploadSessionPhase(status string) string {
|
|
switch status {
|
|
case "uploading":
|
|
return "upload"
|
|
case "completed":
|
|
return "ready"
|
|
case "error":
|
|
return "error"
|
|
case "select_blend":
|
|
return "action_required"
|
|
default:
|
|
return "processing"
|
|
}
|
|
}
|
|
|
|
func (s *Manager) updateUploadSessionState(sessionID string, progress float64, status, message string) (int64, bool) {
|
|
s.uploadSessionsMu.Lock()
|
|
defer s.uploadSessionsMu.Unlock()
|
|
|
|
session, exists := s.uploadSessions[sessionID]
|
|
if !exists {
|
|
return 0, false
|
|
}
|
|
session.Progress = progress
|
|
session.Status = status
|
|
session.Phase = uploadSessionPhase(status)
|
|
session.Message = message
|
|
return session.UserID, true
|
|
}
|
|
|
|
func (s *Manager) failUploadSession(sessionID, errorMessage string) (int64, bool) {
|
|
userID, exists := s.updateUploadSessionState(sessionID, 0, "error", errorMessage)
|
|
if !exists {
|
|
return 0, false
|
|
}
|
|
|
|
s.uploadSessionsMu.Lock()
|
|
if session, ok := s.uploadSessions[sessionID]; ok {
|
|
session.ErrorMessage = errorMessage
|
|
}
|
|
s.uploadSessionsMu.Unlock()
|
|
return userID, true
|
|
}
|
|
|
|
const (
|
|
uploadSessionExpiredCode = "UPLOAD_SESSION_EXPIRED"
|
|
uploadSessionNotReadyCode = "UPLOAD_SESSION_NOT_READY"
|
|
)
|
|
|
|
type uploadSessionValidationError struct {
|
|
Code string
|
|
Message string
|
|
}
|
|
|
|
func (e *uploadSessionValidationError) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
// validateUploadSessionForJobCreation validates that an upload session can be used for job creation.
|
|
// Returns the session and its context tar path when valid.
|
|
func (s *Manager) validateUploadSessionForJobCreation(sessionID string, userID int64) (*UploadSession, string, error) {
|
|
s.uploadSessionsMu.RLock()
|
|
uploadSession := s.uploadSessions[sessionID]
|
|
s.uploadSessionsMu.RUnlock()
|
|
|
|
if uploadSession == nil || uploadSession.UserID != userID {
|
|
return nil, "", &uploadSessionValidationError{
|
|
Code: uploadSessionExpiredCode,
|
|
Message: "Upload session expired or not found. Please upload the file again.",
|
|
}
|
|
}
|
|
if uploadSession.Status != "completed" {
|
|
return nil, "", &uploadSessionValidationError{
|
|
Code: uploadSessionNotReadyCode,
|
|
Message: "Upload session is not ready yet. Wait for processing to complete.",
|
|
}
|
|
}
|
|
if uploadSession.TempDir == "" {
|
|
return nil, "", &uploadSessionValidationError{
|
|
Code: uploadSessionExpiredCode,
|
|
Message: "Upload session context data is missing. Please upload the file again.",
|
|
}
|
|
}
|
|
|
|
tempContextPath := filepath.Join(uploadSession.TempDir, "context.tar")
|
|
if _, statErr := os.Stat(tempContextPath); statErr != nil {
|
|
log.Printf("ERROR: Context archive not found at %s for session %s: %v", tempContextPath, sessionID, statErr)
|
|
return nil, "", &uploadSessionValidationError{
|
|
Code: uploadSessionExpiredCode,
|
|
Message: "Upload session context archive was not found (possibly after manager restart). Please upload the file again.",
|
|
}
|
|
}
|
|
|
|
return uploadSession, tempContextPath, nil
|
|
}
|
|
|
|
// handleCreateJob creates a new job
|
|
func (s *Manager) handleCreateJob(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
var req types.CreateJobRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: expected valid JSON - %v", err))
|
|
return
|
|
}
|
|
|
|
// Validate job type - only render jobs are supported now
|
|
if req.JobType != types.JobTypeRender {
|
|
s.respondError(w, http.StatusBadRequest, "Invalid job_type: only 'render' jobs are supported")
|
|
return
|
|
}
|
|
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.Name == "" {
|
|
s.respondError(w, http.StatusBadRequest, "Job name is required")
|
|
return
|
|
}
|
|
nameTaken := false
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow(
|
|
`SELECT EXISTS(
|
|
SELECT 1
|
|
FROM jobs
|
|
WHERE user_id = ? AND LOWER(TRIM(name)) = LOWER(?)
|
|
)`,
|
|
userID, req.Name,
|
|
).Scan(&nameTaken)
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to validate job name: %v", err))
|
|
return
|
|
}
|
|
if nameTaken {
|
|
s.respondError(w, http.StatusBadRequest, "You already have a job with this name")
|
|
return
|
|
}
|
|
|
|
// Validate render job requirements
|
|
if req.JobType == types.JobTypeRender {
|
|
if req.FrameStart == nil || req.FrameEnd == nil {
|
|
s.respondError(w, http.StatusBadRequest, "frame_start and frame_end are required for render jobs")
|
|
return
|
|
}
|
|
if *req.FrameStart < 0 {
|
|
s.respondError(w, http.StatusBadRequest, "frame_start must be 0 or greater. Negative starting frames are not supported.")
|
|
return
|
|
}
|
|
if *req.FrameEnd < 0 {
|
|
s.respondError(w, http.StatusBadRequest, "frame_end must be 0 or greater. Negative frame numbers are not supported.")
|
|
return
|
|
}
|
|
if *req.FrameEnd < *req.FrameStart {
|
|
s.respondError(w, http.StatusBadRequest, "Invalid frame range")
|
|
return
|
|
}
|
|
// Validate frame range limits (prevent abuse)
|
|
const maxFrameRange = 10000
|
|
if *req.FrameEnd-*req.FrameStart+1 > maxFrameRange {
|
|
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("Frame range too large. Maximum allowed: %d frames", maxFrameRange))
|
|
return
|
|
}
|
|
if req.OutputFormat == nil || *req.OutputFormat == "" {
|
|
defaultFormat := "EXR"
|
|
req.OutputFormat = &defaultFormat
|
|
}
|
|
// Blender outputs only EXR; allowed deliverable formats
|
|
allowedFormats := map[string]bool{"EXR": true, "EXR_264_MP4": true, "EXR_AV1_MP4": true, "EXR_VP9_WEBM": true}
|
|
if *req.OutputFormat != "" && !allowedFormats[*req.OutputFormat] {
|
|
s.respondError(w, http.StatusBadRequest, "Invalid output_format. Allowed: EXR, EXR_264_MP4, EXR_AV1_MP4, EXR_VP9_WEBM")
|
|
return
|
|
}
|
|
}
|
|
|
|
var uploadSession *UploadSession
|
|
var tempContextPath string
|
|
if req.UploadSessionID != nil && *req.UploadSessionID != "" {
|
|
var validateErr error
|
|
uploadSession, tempContextPath, validateErr = s.validateUploadSessionForJobCreation(*req.UploadSessionID, userID)
|
|
if validateErr != nil {
|
|
var sessionErr *uploadSessionValidationError
|
|
if errors.As(validateErr, &sessionErr) {
|
|
s.respondErrorWithCode(w, http.StatusBadRequest, sessionErr.Code, sessionErr.Message)
|
|
} else {
|
|
s.respondError(w, http.StatusBadRequest, validateErr.Error())
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Store render settings, unhide_objects, enable_execution, and blender_version in blend_metadata if provided.
|
|
var blendMetadataJSON *string
|
|
if req.RenderSettings != nil || req.UnhideObjects != nil || req.EnableExecution != nil || req.BlenderVersion != nil || req.OutputFormat != nil {
|
|
metadata := types.BlendMetadata{
|
|
FrameStart: *req.FrameStart,
|
|
FrameEnd: *req.FrameEnd,
|
|
RenderSettings: types.RenderSettings{},
|
|
UnhideObjects: req.UnhideObjects,
|
|
EnableExecution: req.EnableExecution,
|
|
}
|
|
if req.RenderSettings != nil {
|
|
metadata.RenderSettings = *req.RenderSettings
|
|
}
|
|
// Always set output_format in metadata from job's output_format field
|
|
if req.OutputFormat != nil {
|
|
metadata.RenderSettings.OutputFormat = *req.OutputFormat
|
|
}
|
|
if req.BlenderVersion != nil {
|
|
metadata.BlenderVersion = *req.BlenderVersion
|
|
}
|
|
metadataBytes, err := json.Marshal(metadata)
|
|
if err == nil {
|
|
metadataStr := string(metadataBytes)
|
|
blendMetadataJSON = &metadataStr
|
|
}
|
|
}
|
|
|
|
log.Printf("Creating render job with output_format: '%s' (from user selection)", *req.OutputFormat)
|
|
var jobID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
result, err := conn.Exec(
|
|
`INSERT INTO jobs (user_id, job_type, name, status, progress, frame_start, frame_end, output_format, blend_metadata)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
userID, req.JobType, req.Name, types.JobStatusPending, 0.0, *req.FrameStart, *req.FrameEnd, *req.OutputFormat, blendMetadataJSON,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
jobID, err = result.LastInsertId()
|
|
return err
|
|
})
|
|
if err == nil {
|
|
log.Printf("Created render job %d with output_format: '%s'", jobID, *req.OutputFormat)
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create job: %v", err))
|
|
return
|
|
}
|
|
cleanupCreatedJob := func(reason string) {
|
|
log.Printf("Cleaning up partially created job %d: %s", jobID, reason)
|
|
_ = s.db.With(func(conn *sql.DB) error {
|
|
// Be defensive in case foreign key cascade is disabled.
|
|
_, _ = conn.Exec(`DELETE FROM task_logs WHERE task_id IN (SELECT id FROM tasks WHERE job_id = ?)`, jobID)
|
|
_, _ = conn.Exec(`DELETE FROM task_steps WHERE task_id IN (SELECT id FROM tasks WHERE job_id = ?)`, jobID)
|
|
_, _ = conn.Exec(`DELETE FROM tasks WHERE job_id = ?`, jobID)
|
|
_, _ = conn.Exec(`DELETE FROM job_files WHERE job_id = ?`, jobID)
|
|
_, _ = conn.Exec(`DELETE FROM jobs WHERE id = ?`, jobID)
|
|
return nil
|
|
})
|
|
_ = os.RemoveAll(s.storage.JobPath(jobID))
|
|
}
|
|
|
|
// If upload session ID is provided, move the context archive from temp to job directory
|
|
if uploadSession != nil {
|
|
log.Printf("Processing upload session for job %d: %s", jobID, *req.UploadSessionID)
|
|
|
|
log.Printf("Found context archive at %s, moving to job %d directory", tempContextPath, jobID)
|
|
jobPath := s.storage.JobPath(jobID)
|
|
if err := os.MkdirAll(jobPath, 0755); err != nil {
|
|
log.Printf("ERROR: Failed to create job directory for job %d: %v", jobID, err)
|
|
cleanupCreatedJob("failed to create job directory")
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create job directory: %v", err))
|
|
return
|
|
}
|
|
|
|
jobContextPath := filepath.Join(jobPath, "context.tar")
|
|
srcFile, err := os.Open(tempContextPath)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to open source context archive %s: %v", tempContextPath, err)
|
|
cleanupCreatedJob("failed to open source context archive")
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to open context archive: %v", err))
|
|
return
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
dstFile, err := os.Create(jobContextPath)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to create destination context archive %s: %v", jobContextPath, err)
|
|
cleanupCreatedJob("failed to create destination context archive")
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create context archive: %v", err))
|
|
return
|
|
}
|
|
defer dstFile.Close()
|
|
|
|
if _, err = io.Copy(dstFile, srcFile); err != nil {
|
|
dstFile.Close()
|
|
os.Remove(jobContextPath)
|
|
log.Printf("ERROR: Failed to copy context archive from %s to %s: %v", tempContextPath, jobContextPath, err)
|
|
cleanupCreatedJob("failed to copy context archive")
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to copy context archive: %v", err))
|
|
return
|
|
}
|
|
|
|
srcFile.Close()
|
|
if err := dstFile.Close(); err != nil {
|
|
log.Printf("ERROR: Failed to close destination file: %v", err)
|
|
cleanupCreatedJob("failed to finalize destination context archive")
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to finalize context archive: %v", err))
|
|
return
|
|
}
|
|
if err := os.Remove(tempContextPath); err != nil {
|
|
log.Printf("Warning: Failed to remove source context archive %s: %v", tempContextPath, err)
|
|
}
|
|
|
|
contextInfo, err := os.Stat(jobContextPath)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to stat context archive after move: %v", err)
|
|
cleanupCreatedJob("failed to stat copied context archive")
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify context archive: %v", err))
|
|
return
|
|
}
|
|
|
|
var fileID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
result, err := conn.Exec(
|
|
`INSERT INTO job_files (job_id, file_type, file_path, file_name, file_size)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
jobID, types.JobFileTypeInput, jobContextPath, filepath.Base(jobContextPath), contextInfo.Size(),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fileID, err = result.LastInsertId()
|
|
return err
|
|
})
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to record context archive in database for job %d: %v", jobID, err)
|
|
cleanupCreatedJob("failed to record context archive in database")
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to record context archive: %v", err))
|
|
return
|
|
}
|
|
|
|
s.broadcastJobUpdate(jobID, "file_added", map[string]interface{}{
|
|
"file_id": fileID,
|
|
"file_type": types.JobFileTypeInput,
|
|
"file_name": filepath.Base(jobContextPath),
|
|
"file_size": contextInfo.Size(),
|
|
})
|
|
|
|
if err := os.RemoveAll(uploadSession.TempDir); err != nil {
|
|
log.Printf("Warning: Failed to clean up temp directory %s: %v", uploadSession.TempDir, err)
|
|
}
|
|
|
|
s.uploadSessionsMu.Lock()
|
|
delete(s.uploadSessions, *req.UploadSessionID)
|
|
s.uploadSessionsMu.Unlock()
|
|
} else {
|
|
log.Printf("Warning: No upload session ID provided for job %d - job created without input files", jobID)
|
|
}
|
|
|
|
// Only create render tasks for render jobs
|
|
if req.JobType == types.JobTypeRender {
|
|
// Determine task timeout based on output format
|
|
taskTimeout := s.renderTimeout
|
|
if *req.OutputFormat == "EXR_264_MP4" || *req.OutputFormat == "EXR_AV1_MP4" || *req.OutputFormat == "EXR_VP9_WEBM" {
|
|
taskTimeout = s.videoEncodeTimeout
|
|
}
|
|
|
|
// Create tasks for the job (batch INSERT in a single transaction)
|
|
// Chunk job frame range by frames_per_render_task config
|
|
framesPerTask := s.cfg.GetFramesPerRenderTask()
|
|
totalFrames := *req.FrameEnd - *req.FrameStart + 1
|
|
if framesPerTask > totalFrames {
|
|
framesPerTask = totalFrames
|
|
}
|
|
var createdTaskIDs []int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
stmt, err := conn.Prepare(`INSERT INTO tasks (job_id, frame, frame_end, task_type, status, timeout_seconds, max_retries) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
for start := *req.FrameStart; start <= *req.FrameEnd; start += framesPerTask {
|
|
end := start + framesPerTask - 1
|
|
if end > *req.FrameEnd {
|
|
end = *req.FrameEnd
|
|
}
|
|
result, err := stmt.Exec(jobID, start, end, types.TaskTypeRender, types.TaskStatusPending, taskTimeout, 3)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
taskID, err := result.LastInsertId()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
createdTaskIDs = append(createdTaskIDs, taskID)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
cleanupCreatedJob("failed to create render tasks")
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create tasks: %v", err))
|
|
return
|
|
}
|
|
log.Printf("Created %d render tasks for job %d (frames %d-%d, %d frames per task)", len(createdTaskIDs), jobID, *req.FrameStart, *req.FrameEnd, framesPerTask)
|
|
|
|
// Create encode task immediately if output format requires it
|
|
// The task will have a condition that prevents it from being assigned until all render tasks are completed
|
|
if *req.OutputFormat == "EXR_264_MP4" || *req.OutputFormat == "EXR_AV1_MP4" || *req.OutputFormat == "EXR_VP9_WEBM" {
|
|
encodeTaskTimeout := s.videoEncodeTimeout
|
|
conditionJSON := `{"type": "all_render_tasks_completed"}`
|
|
var encodeTaskID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
result, err := conn.Exec(
|
|
`INSERT INTO tasks (job_id, frame, task_type, status, timeout_seconds, max_retries, condition)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
jobID, 0, types.TaskTypeEncode, types.TaskStatusPending, encodeTaskTimeout, 1, conditionJSON,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
encodeTaskID, err = result.LastInsertId()
|
|
return err
|
|
})
|
|
if err != nil {
|
|
log.Printf("Failed to create encode task for job %d: %v", jobID, err)
|
|
// Don't fail the job creation if encode task creation fails
|
|
} else {
|
|
createdTaskIDs = append(createdTaskIDs, encodeTaskID)
|
|
log.Printf("Created encode task %d for job %d (with condition: all render tasks must be completed)", encodeTaskID, jobID)
|
|
}
|
|
}
|
|
// Update job status (should be pending since tasks are pending)
|
|
s.updateJobStatusFromTasks(jobID)
|
|
|
|
// Broadcast that new tasks were added
|
|
if len(createdTaskIDs) > 0 {
|
|
log.Printf("Broadcasting tasks_added for job %d: %d tasks", jobID, len(createdTaskIDs))
|
|
s.broadcastTaskUpdate(jobID, 0, "tasks_added", map[string]interface{}{
|
|
"task_ids": createdTaskIDs,
|
|
"count": len(createdTaskIDs),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Build response job object
|
|
job := types.Job{
|
|
ID: jobID,
|
|
UserID: userID,
|
|
JobType: req.JobType,
|
|
Name: req.Name,
|
|
Status: types.JobStatusPending,
|
|
Progress: 0.0,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
if req.JobType == types.JobTypeRender {
|
|
job.FrameStart = req.FrameStart
|
|
job.FrameEnd = req.FrameEnd
|
|
job.OutputFormat = req.OutputFormat
|
|
}
|
|
|
|
// Broadcast job_created to all clients via jobs channel
|
|
s.broadcastToAllClients("jobs", map[string]interface{}{
|
|
"type": "job_created",
|
|
"job_id": jobID,
|
|
"data": map[string]interface{}{
|
|
"id": jobID,
|
|
"name": req.Name,
|
|
"status": types.JobStatusPending,
|
|
"progress": 0.0,
|
|
"frame_start": *req.FrameStart,
|
|
"frame_end": *req.FrameEnd,
|
|
"output_format": *req.OutputFormat,
|
|
"created_at": time.Now(),
|
|
},
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
|
|
s.respondJSON(w, http.StatusCreated, job)
|
|
}
|
|
|
|
// handleListJobs lists jobs for the current user with pagination and filtering
|
|
func (s *Manager) handleListJobs(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
// Parse query parameters
|
|
limit := 50 // default
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
offset := 0
|
|
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
|
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
|
offset = o
|
|
}
|
|
}
|
|
|
|
statusFilter := r.URL.Query().Get("status")
|
|
sortBy := r.URL.Query().Get("sort")
|
|
if sortBy == "" {
|
|
sortBy = "created_at:desc"
|
|
}
|
|
|
|
// Parse sort parameter (format: "field:direction")
|
|
sortParts := strings.Split(sortBy, ":")
|
|
sortField := "created_at"
|
|
sortDir := "DESC"
|
|
if len(sortParts) == 2 {
|
|
sortField = sortParts[0]
|
|
sortDir = strings.ToUpper(sortParts[1])
|
|
if sortDir != "ASC" && sortDir != "DESC" {
|
|
sortDir = "DESC"
|
|
}
|
|
// Validate sort field
|
|
validFields := map[string]bool{
|
|
"created_at": true, "started_at": true, "completed_at": true,
|
|
"status": true, "progress": true, "name": true,
|
|
}
|
|
if !validFields[sortField] {
|
|
sortField = "created_at"
|
|
}
|
|
}
|
|
|
|
// Build query with filters
|
|
query := `SELECT id, user_id, job_type, name, status, progress, frame_start, frame_end, output_format,
|
|
blend_metadata, created_at, started_at, completed_at, error_message
|
|
FROM jobs WHERE user_id = ?`
|
|
args := []interface{}{userID}
|
|
|
|
if statusFilter != "" {
|
|
// Support multiple statuses: "running,pending" or single "running"
|
|
statuses := strings.Split(statusFilter, ",")
|
|
placeholders := make([]string, len(statuses))
|
|
for i, status := range statuses {
|
|
placeholders[i] = "?"
|
|
args = append(args, strings.TrimSpace(status))
|
|
}
|
|
query += fmt.Sprintf(" AND status IN (%s)", strings.Join(placeholders, ","))
|
|
}
|
|
|
|
query += fmt.Sprintf(" ORDER BY %s %s LIMIT ? OFFSET ?", sortField, sortDir)
|
|
args = append(args, limit, offset)
|
|
|
|
var rows *sql.Rows
|
|
var total int
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
var err error
|
|
rows, err = conn.Query(query, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get total count for pagination metadata
|
|
countQuery := `SELECT COUNT(*) FROM jobs WHERE user_id = ?`
|
|
countArgs := []interface{}{userID}
|
|
if statusFilter != "" {
|
|
statuses := strings.Split(statusFilter, ",")
|
|
placeholders := make([]string, len(statuses))
|
|
for i, status := range statuses {
|
|
placeholders[i] = "?"
|
|
countArgs = append(countArgs, strings.TrimSpace(status))
|
|
}
|
|
countQuery += fmt.Sprintf(" AND status IN (%s)", strings.Join(placeholders, ","))
|
|
}
|
|
err = conn.QueryRow(countQuery, countArgs...).Scan(&total)
|
|
if err != nil {
|
|
// If count fails, continue without it
|
|
total = -1
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query jobs: %v", err))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
jobs := []types.Job{}
|
|
for rows.Next() {
|
|
var job types.Job
|
|
var jobType string
|
|
var startedAt, completedAt sql.NullTime
|
|
var blendMetadataJSON sql.NullString
|
|
var errorMessage sql.NullString
|
|
var frameStart, frameEnd sql.NullInt64
|
|
var outputFormat sql.NullString
|
|
err := rows.Scan(
|
|
&job.ID, &job.UserID, &jobType, &job.Name, &job.Status, &job.Progress,
|
|
&frameStart, &frameEnd, &outputFormat,
|
|
&blendMetadataJSON, &job.CreatedAt, &startedAt, &completedAt, &errorMessage,
|
|
)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan job: %v", err))
|
|
return
|
|
}
|
|
|
|
job.JobType = types.JobType(jobType)
|
|
if frameStart.Valid {
|
|
fs := int(frameStart.Int64)
|
|
job.FrameStart = &fs
|
|
}
|
|
if frameEnd.Valid {
|
|
fe := int(frameEnd.Int64)
|
|
job.FrameEnd = &fe
|
|
}
|
|
if outputFormat.Valid {
|
|
job.OutputFormat = &outputFormat.String
|
|
}
|
|
if startedAt.Valid {
|
|
job.StartedAt = &startedAt.Time
|
|
}
|
|
if completedAt.Valid {
|
|
job.CompletedAt = &completedAt.Time
|
|
}
|
|
if blendMetadataJSON.Valid && blendMetadataJSON.String != "" {
|
|
var metadata types.BlendMetadata
|
|
if err := json.Unmarshal([]byte(blendMetadataJSON.String), &metadata); err == nil {
|
|
job.BlendMetadata = &metadata
|
|
}
|
|
}
|
|
if errorMessage.Valid {
|
|
job.ErrorMessage = errorMessage.String
|
|
}
|
|
|
|
jobs = append(jobs, job)
|
|
}
|
|
|
|
// Generate ETag and check If-None-Match
|
|
response := map[string]interface{}{
|
|
"data": jobs,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
etag := generateETag(response)
|
|
w.Header().Set("ETag", etag)
|
|
|
|
if checkETag(r, etag) {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
s.respondJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleListJobsSummary lists lightweight job summaries for the current user
|
|
func (s *Manager) handleListJobsSummary(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
// Parse query parameters (same as handleListJobs)
|
|
limit := 50
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
offset := 0
|
|
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
|
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
|
offset = o
|
|
}
|
|
}
|
|
|
|
statusFilter := r.URL.Query().Get("status")
|
|
sortBy := r.URL.Query().Get("sort")
|
|
if sortBy == "" {
|
|
sortBy = "created_at:desc"
|
|
}
|
|
|
|
sortParts := strings.Split(sortBy, ":")
|
|
sortField := "created_at"
|
|
sortDir := "DESC"
|
|
if len(sortParts) == 2 {
|
|
sortField = sortParts[0]
|
|
sortDir = strings.ToUpper(sortParts[1])
|
|
if sortDir != "ASC" && sortDir != "DESC" {
|
|
sortDir = "DESC"
|
|
}
|
|
validFields := map[string]bool{
|
|
"created_at": true, "started_at": true, "completed_at": true,
|
|
"status": true, "progress": true, "name": true,
|
|
}
|
|
if !validFields[sortField] {
|
|
sortField = "created_at"
|
|
}
|
|
}
|
|
|
|
// Build query - only select summary fields
|
|
query := `SELECT id, name, status, progress, frame_start, frame_end, output_format, created_at
|
|
FROM jobs WHERE user_id = ?`
|
|
args := []interface{}{userID}
|
|
|
|
if statusFilter != "" {
|
|
statuses := strings.Split(statusFilter, ",")
|
|
placeholders := make([]string, len(statuses))
|
|
for i, status := range statuses {
|
|
placeholders[i] = "?"
|
|
args = append(args, strings.TrimSpace(status))
|
|
}
|
|
query += fmt.Sprintf(" AND status IN (%s)", strings.Join(placeholders, ","))
|
|
}
|
|
|
|
query += fmt.Sprintf(" ORDER BY %s %s LIMIT ? OFFSET ?", sortField, sortDir)
|
|
args = append(args, limit, offset)
|
|
|
|
var rows *sql.Rows
|
|
var total int
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
var err error
|
|
rows, err = conn.Query(query, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get total count
|
|
countQuery := `SELECT COUNT(*) FROM jobs WHERE user_id = ?`
|
|
countArgs := []interface{}{userID}
|
|
if statusFilter != "" {
|
|
statuses := strings.Split(statusFilter, ",")
|
|
placeholders := make([]string, len(statuses))
|
|
for i, status := range statuses {
|
|
placeholders[i] = "?"
|
|
countArgs = append(countArgs, strings.TrimSpace(status))
|
|
}
|
|
countQuery += fmt.Sprintf(" AND status IN (%s)", strings.Join(placeholders, ","))
|
|
}
|
|
err = conn.QueryRow(countQuery, countArgs...).Scan(&total)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
total = -1
|
|
}
|
|
|
|
type JobSummary struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Progress float64 `json:"progress"`
|
|
FrameStart *int `json:"frame_start,omitempty"`
|
|
FrameEnd *int `json:"frame_end,omitempty"`
|
|
OutputFormat *string `json:"output_format,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
summaries := []JobSummary{}
|
|
for rows.Next() {
|
|
var summary JobSummary
|
|
var frameStart, frameEnd sql.NullInt64
|
|
var outputFormat sql.NullString
|
|
|
|
err := rows.Scan(
|
|
&summary.ID, &summary.Name, &summary.Status, &summary.Progress,
|
|
&frameStart, &frameEnd, &outputFormat, &summary.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan job: %v", err))
|
|
return
|
|
}
|
|
|
|
if frameStart.Valid {
|
|
fs := int(frameStart.Int64)
|
|
summary.FrameStart = &fs
|
|
}
|
|
if frameEnd.Valid {
|
|
fe := int(frameEnd.Int64)
|
|
summary.FrameEnd = &fe
|
|
}
|
|
if outputFormat.Valid {
|
|
summary.OutputFormat = &outputFormat.String
|
|
}
|
|
|
|
summaries = append(summaries, summary)
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": summaries,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
s.respondJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleBatchGetJobs fetches multiple jobs by IDs
|
|
func (s *Manager) handleBatchGetJobs(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
JobIDs []int64 `json:"job_ids"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: expected valid JSON - %v", err))
|
|
return
|
|
}
|
|
|
|
if len(req.JobIDs) == 0 {
|
|
s.respondJSON(w, http.StatusOK, []types.Job{})
|
|
return
|
|
}
|
|
|
|
if len(req.JobIDs) > 100 {
|
|
s.respondError(w, http.StatusBadRequest, "Maximum 100 job IDs allowed per batch")
|
|
return
|
|
}
|
|
|
|
// Build query with IN clause
|
|
placeholders := make([]string, len(req.JobIDs))
|
|
args := make([]interface{}, len(req.JobIDs)+1)
|
|
args[0] = userID
|
|
for i, jobID := range req.JobIDs {
|
|
placeholders[i] = "?"
|
|
args[i+1] = jobID
|
|
}
|
|
|
|
query := fmt.Sprintf(`SELECT id, user_id, job_type, name, status, progress, frame_start, frame_end, output_format,
|
|
blend_metadata, created_at, started_at, completed_at, error_message
|
|
FROM jobs WHERE user_id = ? AND id IN (%s) ORDER BY created_at DESC`, strings.Join(placeholders, ","))
|
|
|
|
var rows *sql.Rows
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
var err error
|
|
rows, err = conn.Query(query, args...)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query jobs: %v", err))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
jobs := []types.Job{}
|
|
for rows.Next() {
|
|
var job types.Job
|
|
var jobType string
|
|
var startedAt, completedAt sql.NullTime
|
|
var blendMetadataJSON sql.NullString
|
|
var errorMessage sql.NullString
|
|
var frameStart, frameEnd sql.NullInt64
|
|
var outputFormat sql.NullString
|
|
err := rows.Scan(
|
|
&job.ID, &job.UserID, &jobType, &job.Name, &job.Status, &job.Progress,
|
|
&frameStart, &frameEnd, &outputFormat,
|
|
&blendMetadataJSON, &job.CreatedAt, &startedAt, &completedAt, &errorMessage,
|
|
)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan job: %v", err))
|
|
return
|
|
}
|
|
|
|
job.JobType = types.JobType(jobType)
|
|
if frameStart.Valid {
|
|
fs := int(frameStart.Int64)
|
|
job.FrameStart = &fs
|
|
}
|
|
if frameEnd.Valid {
|
|
fe := int(frameEnd.Int64)
|
|
job.FrameEnd = &fe
|
|
}
|
|
if outputFormat.Valid {
|
|
job.OutputFormat = &outputFormat.String
|
|
}
|
|
if startedAt.Valid {
|
|
job.StartedAt = &startedAt.Time
|
|
}
|
|
if completedAt.Valid {
|
|
job.CompletedAt = &completedAt.Time
|
|
}
|
|
if blendMetadataJSON.Valid && blendMetadataJSON.String != "" {
|
|
var metadata types.BlendMetadata
|
|
if err := json.Unmarshal([]byte(blendMetadataJSON.String), &metadata); err == nil {
|
|
job.BlendMetadata = &metadata
|
|
}
|
|
}
|
|
if errorMessage.Valid {
|
|
job.ErrorMessage = errorMessage.String
|
|
}
|
|
|
|
jobs = append(jobs, job)
|
|
}
|
|
|
|
s.respondJSON(w, http.StatusOK, jobs)
|
|
}
|
|
|
|
// handleGetJob gets a specific job
|
|
func (s *Manager) handleGetJob(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
var job types.Job
|
|
var jobType string
|
|
var startedAt, completedAt sql.NullTime
|
|
var blendMetadataJSON sql.NullString
|
|
var errorMessage sql.NullString
|
|
var frameStart, frameEnd sql.NullInt64
|
|
var outputFormat sql.NullString
|
|
// Allow admins to view any job, regular users can only view their own
|
|
isAdmin := isAdminUser(r)
|
|
var err2 error
|
|
err2 = s.db.With(func(conn *sql.DB) error {
|
|
if isAdmin {
|
|
return conn.QueryRow(
|
|
`SELECT id, user_id, job_type, name, status, progress, frame_start, frame_end, output_format,
|
|
blend_metadata, created_at, started_at, completed_at, error_message
|
|
FROM jobs WHERE id = ?`,
|
|
jobID,
|
|
).Scan(
|
|
&job.ID, &job.UserID, &jobType, &job.Name, &job.Status, &job.Progress,
|
|
&frameStart, &frameEnd, &outputFormat,
|
|
&blendMetadataJSON, &job.CreatedAt, &startedAt, &completedAt, &errorMessage,
|
|
)
|
|
} else {
|
|
return conn.QueryRow(
|
|
`SELECT id, user_id, job_type, name, status, progress, frame_start, frame_end, output_format,
|
|
blend_metadata, created_at, started_at, completed_at, error_message
|
|
FROM jobs WHERE id = ? AND user_id = ?`,
|
|
jobID, userID,
|
|
).Scan(
|
|
&job.ID, &job.UserID, &jobType, &job.Name, &job.Status, &job.Progress,
|
|
&frameStart, &frameEnd, &outputFormat,
|
|
&blendMetadataJSON, &job.CreatedAt, &startedAt, &completedAt, &errorMessage,
|
|
)
|
|
}
|
|
})
|
|
|
|
if err2 == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err2 != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query job: %v", err2))
|
|
return
|
|
}
|
|
|
|
job.JobType = types.JobType(jobType)
|
|
if frameStart.Valid {
|
|
fs := int(frameStart.Int64)
|
|
job.FrameStart = &fs
|
|
}
|
|
if frameEnd.Valid {
|
|
fe := int(frameEnd.Int64)
|
|
job.FrameEnd = &fe
|
|
}
|
|
if outputFormat.Valid {
|
|
job.OutputFormat = &outputFormat.String
|
|
}
|
|
if startedAt.Valid {
|
|
job.StartedAt = &startedAt.Time
|
|
}
|
|
if completedAt.Valid {
|
|
job.CompletedAt = &completedAt.Time
|
|
}
|
|
if blendMetadataJSON.Valid && blendMetadataJSON.String != "" {
|
|
var metadata types.BlendMetadata
|
|
if err := json.Unmarshal([]byte(blendMetadataJSON.String), &metadata); err == nil {
|
|
job.BlendMetadata = &metadata
|
|
}
|
|
}
|
|
if errorMessage.Valid {
|
|
job.ErrorMessage = errorMessage.String
|
|
}
|
|
|
|
// Generate ETag and check If-None-Match
|
|
etag := generateETag(job)
|
|
w.Header().Set("ETag", etag)
|
|
|
|
if checkETag(r, etag) {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
s.respondJSON(w, http.StatusOK, job)
|
|
}
|
|
|
|
// handleCancelJob cancels a job
|
|
func (s *Manager) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Check if this is a metadata extraction job - if so, don't cancel running metadata tasks
|
|
var jobType string
|
|
var jobStatus string
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT job_type, status FROM jobs WHERE id = ? AND user_id = ?", jobID, userID).Scan(&jobType, &jobStatus)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
|
|
// Don't allow cancelling already completed or cancelled jobs
|
|
if jobStatus == string(types.JobStatusCompleted) || jobStatus == string(types.JobStatusCancelled) {
|
|
s.respondJSON(w, http.StatusOK, map[string]string{"message": "Job already " + jobStatus})
|
|
return
|
|
}
|
|
|
|
var rowsAffected int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
result, err := conn.Exec(
|
|
`UPDATE jobs SET status = ? WHERE id = ? AND user_id = ?`,
|
|
types.JobStatusCancelled, jobID, userID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rowsAffected, _ = result.RowsAffected()
|
|
if rowsAffected == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to cancel job: %v", err))
|
|
return
|
|
}
|
|
|
|
// Cancel both pending and running tasks so they cannot be reassigned or keep executing logically.
|
|
if err := s.cancelActiveTasksForJob(jobID); err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to cancel active tasks: %v", err))
|
|
return
|
|
}
|
|
|
|
log.Printf("Cancelling job %d (type: %s)", jobID, jobType)
|
|
|
|
s.respondJSON(w, http.StatusOK, map[string]string{"message": "Job cancelled"})
|
|
}
|
|
|
|
// handleDeleteJob permanently deletes a job and all its associated data
|
|
func (s *Manager) handleDeleteJob(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin) and check status
|
|
isAdmin := isAdminUser(r)
|
|
var jobUserID int64
|
|
var jobStatus string
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
if isAdmin {
|
|
return conn.QueryRow("SELECT user_id, status FROM jobs WHERE id = ?", jobID).Scan(&jobUserID, &jobStatus)
|
|
} else {
|
|
// Non-admin users can only delete their own jobs
|
|
return conn.QueryRow("SELECT user_id, status FROM jobs WHERE id = ? AND user_id = ?", jobID, userID).Scan(&jobUserID, &jobStatus)
|
|
}
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if !isAdmin && jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
|
|
// Prevent deletion of jobs that are still cancellable (pending or running)
|
|
if jobStatus == string(types.JobStatusPending) || jobStatus == string(types.JobStatusRunning) {
|
|
s.respondError(w, http.StatusBadRequest, "Cannot delete a job that is pending or running. Please cancel it first.")
|
|
return
|
|
}
|
|
|
|
// Delete in transaction to ensure consistency
|
|
err = s.db.WithTx(func(tx *sql.Tx) error {
|
|
// Delete task logs
|
|
_, err := tx.Exec(`DELETE FROM task_logs WHERE task_id IN (SELECT id FROM tasks WHERE job_id = ?)`, jobID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete task logs: %w", err)
|
|
}
|
|
|
|
// Delete task steps
|
|
_, err = tx.Exec(`DELETE FROM task_steps WHERE task_id IN (SELECT id FROM tasks WHERE job_id = ?)`, jobID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete task steps: %w", err)
|
|
}
|
|
|
|
// Delete tasks
|
|
_, err = tx.Exec("DELETE FROM tasks WHERE job_id = ?", jobID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete tasks: %w", err)
|
|
}
|
|
|
|
// Delete job files
|
|
_, err = tx.Exec("DELETE FROM job_files WHERE job_id = ?", jobID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete job files: %w", err)
|
|
}
|
|
|
|
// Delete the job
|
|
_, err = tx.Exec("DELETE FROM jobs WHERE id = ?", jobID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete job: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
// Delete physical files
|
|
if err := s.storage.DeleteJobFiles(jobID); err != nil {
|
|
log.Printf("Warning: Failed to delete job files for job %d: %v", jobID, err)
|
|
// Don't fail the request if file deletion fails - the database records are already deleted
|
|
}
|
|
|
|
log.Printf("Deleted job %d (user: %d, admin: %v)", jobID, jobUserID, isAdmin)
|
|
s.respondJSON(w, http.StatusOK, map[string]string{"message": "Job deleted"})
|
|
}
|
|
|
|
// cleanupOldRenderJobs periodically deletes render jobs older than 1 month
|
|
func (s *Manager) cleanupOldRenderJobs() {
|
|
// Run cleanup every hour
|
|
ticker := time.NewTicker(1 * time.Hour)
|
|
defer ticker.Stop()
|
|
|
|
// Run once immediately on startup
|
|
s.cleanupOldRenderJobsOnce()
|
|
|
|
for range ticker.C {
|
|
s.cleanupOldRenderJobsOnce()
|
|
}
|
|
}
|
|
|
|
// cleanupOldRenderJobsOnce finds and deletes render jobs older than 1 month that are completed, failed, or cancelled
|
|
func (s *Manager) cleanupOldRenderJobsOnce() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic in cleanupOldRenderJobs: %v", r)
|
|
}
|
|
}()
|
|
|
|
// Find render jobs older than 1 month that are in a final state (completed, failed, or cancelled)
|
|
// Don't delete running or pending jobs
|
|
var rows *sql.Rows
|
|
err := s.db.With(func(conn *sql.DB) error {
|
|
var err error
|
|
rows, err = conn.Query(
|
|
`SELECT id FROM jobs
|
|
WHERE job_type = ?
|
|
AND status IN (?, ?, ?)
|
|
AND created_at < datetime('now', '-1 month')`,
|
|
types.JobTypeRender,
|
|
types.JobStatusCompleted,
|
|
types.JobStatusFailed,
|
|
types.JobStatusCancelled,
|
|
)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
log.Printf("Failed to query old render jobs: %v", err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var jobIDs []int64
|
|
for rows.Next() {
|
|
var jobID int64
|
|
if err := rows.Scan(&jobID); err == nil {
|
|
jobIDs = append(jobIDs, jobID)
|
|
} else {
|
|
log.Printf("Failed to scan job ID in cleanupOldRenderJobs: %v", err)
|
|
}
|
|
}
|
|
rows.Close()
|
|
|
|
if len(jobIDs) == 0 {
|
|
return
|
|
}
|
|
|
|
log.Printf("Cleaning up %d old render jobs", len(jobIDs))
|
|
|
|
// Delete each job
|
|
for _, jobID := range jobIDs {
|
|
// Delete in transaction to ensure consistency
|
|
err := s.db.WithTx(func(tx *sql.Tx) error {
|
|
// Delete task logs
|
|
_, err := tx.Exec(`DELETE FROM task_logs WHERE task_id IN (SELECT id FROM tasks WHERE job_id = ?)`, jobID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete task logs: %w", err)
|
|
}
|
|
|
|
// Delete task steps
|
|
_, err = tx.Exec(`DELETE FROM task_steps WHERE task_id IN (SELECT id FROM tasks WHERE job_id = ?)`, jobID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete task steps: %w", err)
|
|
}
|
|
|
|
// Delete tasks
|
|
_, err = tx.Exec("DELETE FROM tasks WHERE job_id = ?", jobID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete tasks: %w", err)
|
|
}
|
|
|
|
// Delete job files
|
|
_, err = tx.Exec("DELETE FROM job_files WHERE job_id = ?", jobID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete job files: %w", err)
|
|
}
|
|
|
|
// Delete the job
|
|
_, err = tx.Exec("DELETE FROM jobs WHERE id = ?", jobID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete job: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
log.Printf("Failed to delete job %d: %v", jobID, err)
|
|
continue
|
|
}
|
|
|
|
// Delete physical files (best effort, don't fail if this errors)
|
|
if err := s.storage.DeleteJobFiles(jobID); err != nil {
|
|
log.Printf("Warning: Failed to delete files for render job %d: %v", jobID, err)
|
|
}
|
|
}
|
|
|
|
log.Printf("Cleaned up %d old render jobs", len(jobIDs))
|
|
}
|
|
|
|
// handleUploadJobFile handles file upload for a job
|
|
func (s *Manager) handleUploadJobFile(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
|
|
// Parse multipart form with large limit for big files
|
|
// Note: For very large files, this will use temporary files on disk
|
|
err = r.ParseMultipartForm(20 << 30) // 20 GB (for large ZIP files and blend files)
|
|
if err != nil {
|
|
log.Printf("Error parsing multipart form for job %d: %v", jobID, err)
|
|
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to parse form: %v", err))
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
log.Printf("Error getting file from form for job %d: %v", jobID, err)
|
|
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("No file provided: %v", err))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
log.Printf("Uploading file '%s' (size: %d bytes) for job %d", header.Filename, header.Size, jobID)
|
|
|
|
jobPath := s.storage.JobPath(jobID)
|
|
if err := os.MkdirAll(jobPath, 0755); err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create job directory: %v", err))
|
|
return
|
|
}
|
|
|
|
// Create temporary directory for processing upload
|
|
tmpDir, err := s.storage.TempDir(fmt.Sprintf("jiggablend-upload-%d-*", jobID))
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create temporary directory: %v", err))
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := os.RemoveAll(tmpDir); err != nil {
|
|
log.Printf("Warning: Failed to clean up temp directory %s: %v", tmpDir, err)
|
|
}
|
|
}()
|
|
|
|
var fileID int64
|
|
var mainBlendFile string
|
|
var extractedFiles []string
|
|
|
|
// Check if this is a ZIP file
|
|
if strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
|
|
log.Printf("Processing ZIP file '%s' for job %d", header.Filename, jobID)
|
|
// Save ZIP to temporary directory
|
|
zipPath := filepath.Join(tmpDir, header.Filename)
|
|
log.Printf("Creating ZIP file at: %s", zipPath)
|
|
zipFile, err := os.Create(zipPath)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to create ZIP file for job %d: %v", jobID, err)
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create ZIP file: %v", err))
|
|
return
|
|
}
|
|
log.Printf("Copying %d bytes to ZIP file for job %d...", header.Size, jobID)
|
|
copied, err := io.Copy(zipFile, file)
|
|
zipFile.Close()
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to save ZIP file for job %d (copied %d bytes): %v", jobID, copied, err)
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to save ZIP file: %v", err))
|
|
return
|
|
}
|
|
log.Printf("Successfully copied %d bytes to ZIP file for job %d", copied, jobID)
|
|
|
|
// Extract ZIP file to temporary directory
|
|
log.Printf("Extracting ZIP file for job %d...", jobID)
|
|
extractedFiles, err = s.storage.ExtractZip(zipPath, tmpDir)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to extract ZIP file for job %d: %v", jobID, err)
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to extract ZIP file: %v", err))
|
|
return
|
|
}
|
|
log.Printf("Successfully extracted %d files from ZIP for job %d", len(extractedFiles), jobID)
|
|
|
|
// Find main blend file (check for user selection first, then auto-detect)
|
|
mainBlendParam := r.FormValue("main_blend_file")
|
|
if mainBlendParam != "" {
|
|
// User specified main blend file
|
|
mainBlendFile = filepath.Join(tmpDir, mainBlendParam)
|
|
if _, err := os.Stat(mainBlendFile); err != nil {
|
|
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("Specified main blend file not found: %s", mainBlendParam))
|
|
return
|
|
}
|
|
} else {
|
|
// Auto-detect: find blend files in root directory
|
|
blendFiles := []string{}
|
|
err := filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Only check files in root directory (not subdirectories)
|
|
relPath, _ := filepath.Rel(tmpDir, path)
|
|
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".blend") {
|
|
// Check if it's in root (no path separators)
|
|
if !strings.Contains(relPath, string(filepath.Separator)) {
|
|
blendFiles = append(blendFiles, path)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err == nil && len(blendFiles) == 1 {
|
|
// Only one blend file in root - use it
|
|
mainBlendFile = blendFiles[0]
|
|
} else if len(blendFiles) > 1 {
|
|
// Multiple blend files - need user to specify
|
|
// Return list of blend files for user to choose
|
|
blendFileNames := []string{}
|
|
for _, f := range blendFiles {
|
|
rel, _ := filepath.Rel(tmpDir, f)
|
|
blendFileNames = append(blendFileNames, rel)
|
|
}
|
|
s.respondJSON(w, http.StatusOK, map[string]interface{}{
|
|
"zip_extracted": true,
|
|
"blend_files": blendFileNames,
|
|
"message": "Multiple blend files found. Please specify the main blend file.",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
} else {
|
|
// Regular file upload (not ZIP) - save to temporary directory
|
|
filePath := filepath.Join(tmpDir, header.Filename)
|
|
outFile, err := os.Create(filePath)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create file: %v", err))
|
|
return
|
|
}
|
|
|
|
// Get a fresh file reader (FormFile returns a new reader each time)
|
|
fileReader, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
outFile.Close()
|
|
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("No file provided: %v", err))
|
|
return
|
|
}
|
|
|
|
if _, err := io.Copy(outFile, fileReader); err != nil {
|
|
fileReader.Close()
|
|
outFile.Close()
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to save file: %v", err))
|
|
return
|
|
}
|
|
fileReader.Close()
|
|
outFile.Close()
|
|
|
|
if strings.HasSuffix(strings.ToLower(header.Filename), ".blend") {
|
|
mainBlendFile = filePath
|
|
}
|
|
}
|
|
|
|
// Create context archive from temporary directory - this is the primary artifact
|
|
// Exclude the original uploaded ZIP file (but keep blend files as they're needed for rendering)
|
|
var excludeFiles []string
|
|
if strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
|
|
excludeFiles = append(excludeFiles, header.Filename)
|
|
}
|
|
contextPath, err := s.storage.CreateJobContextFromDir(tmpDir, jobID, excludeFiles...)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to create context archive for job %d: %v", jobID, err)
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create context archive: %v", err))
|
|
return
|
|
}
|
|
|
|
// Record context archive in database
|
|
contextInfo, err := os.Stat(contextPath)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to stat context archive for job %d: %v", jobID, err)
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to stat context archive: %v", err))
|
|
return
|
|
}
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
result, err := conn.Exec(
|
|
`INSERT INTO job_files (job_id, file_type, file_path, file_name, file_size)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
jobID, types.JobFileTypeInput, contextPath, filepath.Base(contextPath), contextInfo.Size(),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fileID, err = result.LastInsertId()
|
|
return err
|
|
})
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to record context archive in database for job %d: %v", jobID, err)
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to record context archive: %v", err))
|
|
return
|
|
}
|
|
log.Printf("Context archive recorded in database with ID %d for job %d", fileID, jobID)
|
|
|
|
// Broadcast file addition
|
|
s.broadcastJobUpdate(jobID, "file_added", map[string]interface{}{
|
|
"file_id": fileID,
|
|
"file_type": types.JobFileTypeInput,
|
|
"file_name": filepath.Base(contextPath),
|
|
"file_size": contextInfo.Size(),
|
|
})
|
|
|
|
// Extract metadata directly from the context archive
|
|
log.Printf("Extracting metadata for job %d...", jobID)
|
|
metadata, err := s.extractMetadataFromContext(jobID)
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to extract metadata for job %d: %v", jobID, err)
|
|
// Don't fail the upload if metadata extraction fails - job can still proceed
|
|
} else {
|
|
// Update job with metadata
|
|
metadataJSON, err := json.Marshal(metadata)
|
|
if err == nil {
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
_, err := conn.Exec(
|
|
`UPDATE jobs SET blend_metadata = ? WHERE id = ?`,
|
|
string(metadataJSON), jobID,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to update job metadata in database: %v", err)
|
|
} else {
|
|
log.Printf("Successfully extracted and stored metadata for job %d", jobID)
|
|
}
|
|
return err
|
|
})
|
|
} else {
|
|
log.Printf("Warning: Failed to marshal metadata: %v", err)
|
|
}
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"id": fileID,
|
|
"file_name": header.Filename,
|
|
"file_size": header.Size,
|
|
"context_archive": filepath.Base(contextPath),
|
|
}
|
|
|
|
if strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
|
|
response["zip_extracted"] = true
|
|
response["extracted_files_count"] = len(extractedFiles)
|
|
if mainBlendFile != "" {
|
|
// Get relative path from temp dir
|
|
relPath, _ := filepath.Rel(tmpDir, mainBlendFile)
|
|
response["main_blend_file"] = relPath
|
|
}
|
|
} else if mainBlendFile != "" {
|
|
relPath, _ := filepath.Rel(tmpDir, mainBlendFile)
|
|
response["main_blend_file"] = relPath
|
|
}
|
|
|
|
s.respondJSON(w, http.StatusCreated, response)
|
|
}
|
|
|
|
// handleUploadFileForJobCreation handles file upload before job creation
|
|
// Creates context archive and extracts metadata, returns metadata and upload session ID
|
|
func (s *Manager) handleUploadFileForJobCreation(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
// Use MultipartReader to stream the file instead of loading it all into memory
|
|
// This allows us to report progress during upload
|
|
reader, err := r.MultipartReader()
|
|
if err != nil {
|
|
log.Printf("Error creating multipart reader: %v", err)
|
|
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to parse multipart form: %v", err))
|
|
return
|
|
}
|
|
|
|
// Find the file part and collect form values
|
|
// IMPORTANT: With MultipartReader, we must read the file part's data immediately
|
|
// before calling NextPart() again, otherwise the data becomes unavailable
|
|
var header *multipart.FileHeader
|
|
var filePath string
|
|
formValues := make(map[string]string)
|
|
var tmpDir string
|
|
var sessionID string
|
|
var mainBlendFile string
|
|
|
|
// Create temporary directory first (before reading parts)
|
|
tmpDir, err = s.storage.TempDir(fmt.Sprintf("jiggablend-upload-user-%d-*", userID))
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create temporary directory: %v", err))
|
|
return
|
|
}
|
|
|
|
// Generate opaque session ID and keep temp path internal.
|
|
sessionID = uuid.NewString()
|
|
|
|
// Create upload session
|
|
s.uploadSessionsMu.Lock()
|
|
s.uploadSessions[sessionID] = &UploadSession{
|
|
SessionID: sessionID,
|
|
UserID: userID,
|
|
TempDir: tmpDir,
|
|
Progress: 0.0,
|
|
Status: "uploading",
|
|
Phase: uploadSessionPhase("uploading"),
|
|
Message: "Uploading file...",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
s.uploadSessionsMu.Unlock()
|
|
|
|
// Client tracks upload progress via XHR - no need to broadcast here
|
|
// We only broadcast processing status changes (extracting, creating context, etc.)
|
|
|
|
fileFound := false
|
|
for {
|
|
part, err := reader.NextPart()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
log.Printf("Error reading multipart: %v", err)
|
|
os.RemoveAll(tmpDir)
|
|
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to read multipart: %v", err))
|
|
return
|
|
}
|
|
|
|
formName := part.FormName()
|
|
if formName == "file" {
|
|
// Read file part immediately - can't store it for later with MultipartReader
|
|
header = &multipart.FileHeader{
|
|
Filename: part.FileName(),
|
|
}
|
|
// Try to get Content-Length from header if available
|
|
if cl := part.Header.Get("Content-Length"); cl != "" {
|
|
if size, err := strconv.ParseInt(cl, 10, 64); err == nil {
|
|
header.Size = size
|
|
}
|
|
}
|
|
|
|
// Determine file path
|
|
if strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
|
|
filePath = filepath.Join(tmpDir, header.Filename)
|
|
} else {
|
|
filePath = filepath.Join(tmpDir, header.Filename)
|
|
if strings.HasSuffix(strings.ToLower(header.Filename), ".blend") {
|
|
mainBlendFile = filePath
|
|
}
|
|
}
|
|
|
|
// Create file and copy data immediately
|
|
outFile, err := os.Create(filePath)
|
|
if err != nil {
|
|
part.Close()
|
|
os.RemoveAll(tmpDir)
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create file: %v", err))
|
|
return
|
|
}
|
|
|
|
// Copy file data - must do this before calling NextPart() again
|
|
copied, err := io.Copy(outFile, part)
|
|
outFile.Close()
|
|
part.Close()
|
|
|
|
if err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to save file: %v", err))
|
|
return
|
|
}
|
|
|
|
// Update header size with actual bytes copied (in case Content-Length was wrong)
|
|
if header.Size == 0 {
|
|
header.Size = copied
|
|
}
|
|
|
|
fileFound = true
|
|
log.Printf("Uploading file '%s' (size: %d bytes, copied: %d bytes) for user %d (pre-job creation)", header.Filename, header.Size, copied, userID)
|
|
} else if formName != "" {
|
|
// Read form value
|
|
valueBytes, err := io.ReadAll(part)
|
|
if err == nil {
|
|
formValues[formName] = string(valueBytes)
|
|
}
|
|
part.Close()
|
|
} else {
|
|
part.Close()
|
|
}
|
|
}
|
|
|
|
if !fileFound {
|
|
os.RemoveAll(tmpDir)
|
|
s.respondError(w, http.StatusBadRequest, "No file provided")
|
|
return
|
|
}
|
|
|
|
filename := header.Filename
|
|
fileSize := header.Size
|
|
mainBlendParam := formValues["main_blend_file"]
|
|
|
|
// Return immediately so the client gets a quick response and can poll for status.
|
|
// ZIP extraction, blend detection, context creation, and metadata extraction run in background.
|
|
log.Printf("Upload received for session %s, returning 200 immediately (ZIP/metadata will run in background)", sessionID)
|
|
s.updateUploadSessionState(sessionID, 0.2, "processing", "Extracting and preparing...")
|
|
s.broadcastUploadProgressSync(userID, sessionID, 0.2, "processing", "Extracting and preparing...")
|
|
|
|
response := map[string]interface{}{
|
|
"session_id": sessionID,
|
|
"file_name": filename,
|
|
"file_size": fileSize,
|
|
"status": "processing",
|
|
"phase": uploadSessionPhase("processing"),
|
|
}
|
|
s.respondJSON(w, http.StatusOK, response)
|
|
|
|
go s.runBackgroundUploadProcessing(tmpDir, sessionID, userID, filename, fileSize, mainBlendParam, mainBlendFile)
|
|
}
|
|
|
|
// runBackgroundUploadProcessing runs ZIP extraction (if needed), blend detection, context creation, and metadata extraction.
|
|
// Called in a goroutine after the upload handler returns; updates upload session and broadcasts when done.
|
|
func (s *Manager) runBackgroundUploadProcessing(tmpDir, sessionID string, userID int64, filename string, fileSize int64, mainBlendParam string, mainBlendFile string) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic in runBackgroundUploadProcessing: %v", r)
|
|
errMsg := fmt.Sprintf("%v", r)
|
|
if ownerUserID, ok := s.failUploadSession(sessionID, "Processing failed: "+errMsg); ok {
|
|
s.broadcastUploadProgressSync(ownerUserID, sessionID, 0, "error", "Processing failed: "+errMsg)
|
|
}
|
|
}
|
|
}()
|
|
|
|
var processedMainBlendFile string
|
|
var excludeFiles []string
|
|
extractedFilesCount := 0
|
|
if strings.HasSuffix(strings.ToLower(filename), ".zip") {
|
|
excludeFiles = append(excludeFiles, filename)
|
|
s.broadcastUploadProgressSync(userID, sessionID, 0.25, "processing", "Extracting ZIP...")
|
|
zipPath := filepath.Join(tmpDir, filename)
|
|
log.Printf("Extracting ZIP file: %s", zipPath)
|
|
processedExtractedFiles, err := s.storage.ExtractZip(zipPath, tmpDir)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to extract ZIP file: %v", err)
|
|
os.RemoveAll(tmpDir)
|
|
if ownerUserID, ok := s.failUploadSession(sessionID, err.Error()); ok {
|
|
s.broadcastUploadProgressSync(ownerUserID, sessionID, 0, "error", err.Error())
|
|
}
|
|
return
|
|
}
|
|
extractedFilesCount = len(processedExtractedFiles)
|
|
log.Printf("Successfully extracted %d files from ZIP", extractedFilesCount)
|
|
|
|
if mainBlendParam != "" {
|
|
processedMainBlendFile = filepath.Join(tmpDir, mainBlendParam)
|
|
if _, err := os.Stat(processedMainBlendFile); err != nil {
|
|
log.Printf("ERROR: Specified main blend file not found: %s", mainBlendParam)
|
|
errMsg := "Specified main blend file not found: " + mainBlendParam
|
|
if ownerUserID, ok := s.failUploadSession(sessionID, errMsg); ok {
|
|
s.broadcastUploadProgressSync(ownerUserID, sessionID, 0, "error", errMsg)
|
|
}
|
|
return
|
|
}
|
|
} else {
|
|
blendFiles := []string{}
|
|
err := filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
relPath, _ := filepath.Rel(tmpDir, path)
|
|
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".blend") {
|
|
if !strings.Contains(relPath, string(filepath.Separator)) {
|
|
blendFiles = append(blendFiles, path)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if ownerUserID, ok := s.failUploadSession(sessionID, err.Error()); ok {
|
|
s.broadcastUploadProgressSync(ownerUserID, sessionID, 0, "error", err.Error())
|
|
}
|
|
return
|
|
}
|
|
if len(blendFiles) == 1 {
|
|
processedMainBlendFile = blendFiles[0]
|
|
} else if len(blendFiles) > 1 {
|
|
blendFileNames := []string{}
|
|
for _, f := range blendFiles {
|
|
rel, _ := filepath.Rel(tmpDir, f)
|
|
blendFileNames = append(blendFileNames, rel)
|
|
}
|
|
s.uploadSessionsMu.Lock()
|
|
if session, exists := s.uploadSessions[sessionID]; exists {
|
|
session.Status = "select_blend"
|
|
session.Phase = uploadSessionPhase("select_blend")
|
|
session.Message = "Select main blend file"
|
|
session.Progress = 0.5
|
|
session.ResultBlendFiles = blendFileNames
|
|
session.ResultFileName = filename
|
|
session.ResultFileSize = fileSize
|
|
session.ResultZipExtracted = true
|
|
session.ResultExtractedFilesCnt = extractedFilesCount
|
|
}
|
|
s.uploadSessionsMu.Unlock()
|
|
s.broadcastUploadProgressSync(userID, sessionID, 0.5, "select_blend", "Select main blend file")
|
|
return
|
|
}
|
|
}
|
|
} else {
|
|
processedMainBlendFile = mainBlendFile
|
|
}
|
|
|
|
s.broadcastUploadProgressSync(userID, sessionID, 0.4, "creating_context", "Creating context archive...")
|
|
contextPath := filepath.Join(tmpDir, "context.tar")
|
|
contextPath, err := s.createContextFromDir(tmpDir, contextPath, excludeFiles...)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to create context archive: %v", err)
|
|
if ownerUserID, ok := s.failUploadSession(sessionID, err.Error()); ok {
|
|
s.broadcastUploadProgressSync(ownerUserID, sessionID, 0, "error", err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
s.broadcastUploadProgressSync(userID, sessionID, 0.7, "extracting_metadata", "Extracting metadata...")
|
|
processedMetadata, err := s.extractMetadataFromTempContext(contextPath)
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to extract metadata: %v", err)
|
|
processedMetadata = nil
|
|
}
|
|
|
|
var mainBlendRel string
|
|
if processedMainBlendFile != "" {
|
|
mainBlendRel, _ = filepath.Rel(tmpDir, processedMainBlendFile)
|
|
}
|
|
s.uploadSessionsMu.Lock()
|
|
if session, exists := s.uploadSessions[sessionID]; exists {
|
|
session.Status = "completed"
|
|
session.Phase = uploadSessionPhase("completed")
|
|
session.Message = "Ready"
|
|
session.Progress = 1.0
|
|
session.ResultContextArchive = filepath.Base(contextPath)
|
|
session.ResultMetadata = processedMetadata
|
|
session.ResultMainBlendFile = mainBlendRel
|
|
session.ResultFileName = filename
|
|
session.ResultFileSize = fileSize
|
|
session.ResultZipExtracted = strings.HasSuffix(strings.ToLower(filename), ".zip")
|
|
session.ResultExtractedFilesCnt = extractedFilesCount
|
|
session.ResultMetadataExtracted = processedMetadata != nil
|
|
if err != nil {
|
|
session.ResultMetadataError = err.Error()
|
|
}
|
|
}
|
|
s.uploadSessionsMu.Unlock()
|
|
s.broadcastUploadProgressSync(userID, sessionID, 1.0, "completed", "Ready")
|
|
}
|
|
|
|
// handleUploadStatus returns the current status of an upload session (for polling when processing is async).
|
|
func (s *Manager) handleUploadStatus(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
sessionID := r.URL.Query().Get("session_id")
|
|
if sessionID == "" {
|
|
s.respondError(w, http.StatusBadRequest, "session_id required")
|
|
return
|
|
}
|
|
s.uploadSessionsMu.RLock()
|
|
session, exists := s.uploadSessions[sessionID]
|
|
s.uploadSessionsMu.RUnlock()
|
|
if !exists || session.UserID != userID {
|
|
s.respondError(w, http.StatusNotFound, "Upload session not found")
|
|
return
|
|
}
|
|
phase := session.Phase
|
|
if phase == "" {
|
|
phase = uploadSessionPhase(session.Status)
|
|
}
|
|
response := map[string]interface{}{
|
|
"session_id": sessionID,
|
|
"status": session.Status,
|
|
"phase": phase,
|
|
"progress": session.Progress,
|
|
"message": session.Message,
|
|
}
|
|
if session.Status == "completed" {
|
|
response["file_name"] = session.ResultFileName
|
|
response["file_size"] = session.ResultFileSize
|
|
response["context_archive"] = session.ResultContextArchive
|
|
response["main_blend_file"] = session.ResultMainBlendFile
|
|
response["zip_extracted"] = session.ResultZipExtracted
|
|
response["extracted_files_count"] = session.ResultExtractedFilesCnt
|
|
response["metadata_extracted"] = session.ResultMetadataExtracted
|
|
if session.ResultMetadataError != "" {
|
|
response["metadata_error"] = session.ResultMetadataError
|
|
}
|
|
if session.ResultMetadata != nil {
|
|
response["metadata"] = session.ResultMetadata
|
|
}
|
|
} else if session.Status == "error" {
|
|
response["error"] = session.ErrorMessage
|
|
} else if session.Status == "select_blend" {
|
|
response["zip_extracted"] = true
|
|
response["blend_files"] = session.ResultBlendFiles
|
|
response["file_name"] = session.ResultFileName
|
|
response["file_size"] = session.ResultFileSize
|
|
}
|
|
s.respondJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// extractMetadataFromTempContext extracts metadata from a context archive in a temporary location
|
|
func (s *Manager) extractMetadataFromTempContext(contextPath string) (*types.BlendMetadata, error) {
|
|
return s.extractMetadataFromTempContextWithProgress(contextPath, nil)
|
|
}
|
|
|
|
// extractMetadataFromTempContextWithProgress extracts metadata with progress callbacks
|
|
func (s *Manager) extractMetadataFromTempContextWithProgress(contextPath string, progressCallback func(float64, string)) (*types.BlendMetadata, error) {
|
|
// Create temporary directory for extraction under storage base path
|
|
tmpDir, err := s.storage.TempDir("jiggablend-metadata-temp-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := os.RemoveAll(tmpDir); err != nil {
|
|
log.Printf("Warning: Failed to clean up temp directory %s: %v", tmpDir, err)
|
|
}
|
|
}()
|
|
|
|
// Extract context archive
|
|
if err := s.extractTar(contextPath, tmpDir); err != nil {
|
|
return nil, fmt.Errorf("failed to extract context: %w", err)
|
|
}
|
|
|
|
// Find .blend file
|
|
blendFile := ""
|
|
err = filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".blend") {
|
|
lower := strings.ToLower(info.Name())
|
|
idx := strings.LastIndex(lower, ".blend")
|
|
if idx != -1 {
|
|
suffix := lower[idx+len(".blend"):]
|
|
isSaveFile := false
|
|
if len(suffix) > 0 {
|
|
isSaveFile = true
|
|
for _, r := range suffix {
|
|
if r < '0' || r > '9' {
|
|
isSaveFile = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !isSaveFile {
|
|
blendFile = path
|
|
return filepath.SkipAll
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil || blendFile == "" {
|
|
return nil, fmt.Errorf("no .blend file found in context - the uploaded context archive must contain at least one .blend file to render")
|
|
}
|
|
|
|
// Detect Blender version from blend file header BEFORE running Blender
|
|
// This allows us to use the correct Blender version for metadata extraction
|
|
detectedVersion := ""
|
|
major, minor, versionErr := ParseBlenderVersionFromFile(blendFile)
|
|
if versionErr == nil {
|
|
detectedVersion = fmt.Sprintf("%d.%d", major, minor)
|
|
log.Printf("Detected Blender version %s from blend file header", detectedVersion)
|
|
} else {
|
|
log.Printf("Warning: Could not detect Blender version from blend file: %v", versionErr)
|
|
}
|
|
|
|
// Use the same extraction script and process as extractMetadataFromContext
|
|
// (Copy the logic from extractMetadataFromContext but use tmpDir and blendFile)
|
|
metadata, err := s.runBlenderMetadataExtraction(blendFile, tmpDir, detectedVersion, nil, progressCallback)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Set the detected/resolved Blender version in metadata
|
|
if metadata != nil && detectedVersion != "" {
|
|
// Get the latest patch version for this major.minor
|
|
version, verr := s.GetLatestBlenderForMajorMinor(major, minor)
|
|
if verr == nil {
|
|
metadata.BlenderVersion = version.Full
|
|
log.Printf("Resolved Blender version to %s", version.Full)
|
|
} else {
|
|
metadata.BlenderVersion = detectedVersion
|
|
log.Printf("Using detected version %s (could not resolve latest: %v)", detectedVersion, verr)
|
|
}
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
// runBlenderMetadataExtraction runs Blender to extract metadata from a blend file
|
|
// blenderVersion is optional - if provided, will use versioned blender from cache
|
|
// stderrCallback is optional and will be called for each stderr line (note: with RunCommand, this is called after completion)
|
|
// progressCallback is optional and will be called with progress updates (0.0-1.0)
|
|
func (s *Manager) runBlenderMetadataExtraction(blendFile, workDir, blenderVersion string, stderrCallback func(string), progressCallback func(float64, string)) (*types.BlendMetadata, error) {
|
|
// Use embedded Python script
|
|
scriptPath := filepath.Join(workDir, "extract_metadata.py")
|
|
if err := os.WriteFile(scriptPath, []byte(scripts.ExtractMetadata), 0644); err != nil {
|
|
return nil, fmt.Errorf("failed to create extraction script: %w", err)
|
|
}
|
|
|
|
// Use absolute paths to avoid path normalization issues with relative traversal.
|
|
blendFileAbs, err := filepath.Abs(blendFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get absolute path for blend file: %w", err)
|
|
}
|
|
scriptPathAbs, err := filepath.Abs(scriptPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get absolute path for extraction script: %w", err)
|
|
}
|
|
|
|
// Determine which blender binary to use
|
|
blenderBinary := "blender" // Default to system blender
|
|
var version *BlenderVersion // Track version for cleanup
|
|
if blenderVersion != "" {
|
|
// Try to get versioned blender from cache
|
|
var major, minor int
|
|
fmt.Sscanf(blenderVersion, "%d.%d", &major, &minor)
|
|
version, err = s.GetLatestBlenderForMajorMinor(major, minor)
|
|
if err == nil {
|
|
archivePath, err := s.GetBlenderArchivePath(version)
|
|
if err == nil {
|
|
// Extract to temp location for manager-side metadata extraction
|
|
blenderDir := filepath.Join(s.storage.BasePath(), "blender-versions")
|
|
binaryPath := filepath.Join(blenderDir, version.Full, "blender")
|
|
// Make path absolute to avoid working directory issues
|
|
if absBinaryPath, absErr := filepath.Abs(binaryPath); absErr == nil {
|
|
binaryPath = absBinaryPath
|
|
}
|
|
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
|
|
// Need to extract
|
|
if progressCallback != nil {
|
|
progressCallback(0.5, "Extracting Blender binary...")
|
|
}
|
|
if err := extractBlenderArchive(archivePath, version, blenderDir); err == nil {
|
|
blenderBinary = binaryPath
|
|
log.Printf("Using Blender %s at %s for metadata extraction", version.Full, binaryPath)
|
|
if progressCallback != nil {
|
|
progressCallback(0.7, "Blender extracted, extracting metadata...")
|
|
}
|
|
} else {
|
|
log.Printf("Warning: Failed to extract Blender %s: %v, using system blender", version.Full, err)
|
|
}
|
|
} else {
|
|
blenderBinary = binaryPath
|
|
log.Printf("Using cached Blender %s at %s for metadata extraction", version.Full, binaryPath)
|
|
if progressCallback != nil {
|
|
progressCallback(0.7, "Extracting metadata from blend file...")
|
|
}
|
|
}
|
|
} else {
|
|
log.Printf("Warning: Failed to get Blender archive for %s: %v, using system blender", version.Full, err)
|
|
}
|
|
} else {
|
|
log.Printf("Warning: Failed to find Blender version %s: %v, using system blender", blenderVersion, err)
|
|
}
|
|
}
|
|
|
|
// Ensure Blender binary is always an absolute path.
|
|
blenderBinary, err = resolveBlenderBinaryPath(blenderBinary)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Execute Blender using executils (set LD_LIBRARY_PATH for tarball installs)
|
|
runEnv := blender.TarballEnv(blenderBinary, os.Environ())
|
|
result, err := executils.RunCommand(
|
|
blenderBinary,
|
|
[]string{"-b", blendFileAbs, "--python", scriptPathAbs},
|
|
workDir,
|
|
runEnv,
|
|
0, // no task ID for metadata extraction
|
|
nil, // no process tracker needed
|
|
)
|
|
|
|
// Forward stderr via callback if provided
|
|
if result != nil && stderrCallback != nil && result.Stderr != "" {
|
|
for _, line := range strings.Split(result.Stderr, "\n") {
|
|
if line != "" {
|
|
stderrCallback(line)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
stderrOutput := ""
|
|
stdoutOutput := ""
|
|
if result != nil {
|
|
stderrOutput = strings.TrimSpace(result.Stderr)
|
|
stdoutOutput = strings.TrimSpace(result.Stdout)
|
|
}
|
|
log.Printf("Blender metadata extraction failed:")
|
|
if stderrOutput != "" {
|
|
log.Printf("Blender stderr: %s", stderrOutput)
|
|
}
|
|
if stdoutOutput != "" {
|
|
log.Printf("Blender stdout (last 500 chars): %s", truncateString(stdoutOutput, 500))
|
|
}
|
|
if stderrOutput != "" {
|
|
return nil, fmt.Errorf("blender metadata extraction failed: %w (stderr: %s)", err, truncateString(stderrOutput, 200))
|
|
}
|
|
return nil, fmt.Errorf("blender metadata extraction failed: %w", err)
|
|
}
|
|
|
|
metadataJSON := strings.TrimSpace(result.Stdout)
|
|
jsonStart := strings.Index(metadataJSON, "{")
|
|
jsonEnd := strings.LastIndex(metadataJSON, "}")
|
|
if jsonStart == -1 || jsonEnd == -1 || jsonEnd <= jsonStart {
|
|
return nil, errors.New("failed to extract JSON from Blender output")
|
|
}
|
|
metadataJSON = metadataJSON[jsonStart : jsonEnd+1]
|
|
|
|
var metadata types.BlendMetadata
|
|
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
|
return nil, fmt.Errorf("failed to parse metadata JSON: %w", err)
|
|
}
|
|
|
|
log.Printf("Metadata extracted: frame_start=%d, frame_end=%d", metadata.FrameStart, metadata.FrameEnd)
|
|
return &metadata, nil
|
|
}
|
|
|
|
// createContextFromDir creates a context archive from a source directory to a specific destination path
|
|
func (s *Manager) createContextFromDir(sourceDir, destPath string, excludeFiles ...string) (string, error) {
|
|
return s.storage.CreateContextArchiveFromDirToPath(sourceDir, destPath, excludeFiles...)
|
|
}
|
|
|
|
// handleListJobFiles lists files for a job with pagination
|
|
func (s *Manager) handleListJobFiles(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
if !isAdmin {
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
} else {
|
|
// Admin: verify job exists
|
|
var exists bool
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?)", jobID).Scan(&exists)
|
|
})
|
|
if err != nil || !exists {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Parse query parameters
|
|
limit := 50
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
offset := 0
|
|
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
|
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
|
offset = o
|
|
}
|
|
}
|
|
|
|
fileTypeFilter := r.URL.Query().Get("file_type")
|
|
extensionFilter := r.URL.Query().Get("extension")
|
|
|
|
// Build query with filters
|
|
query := `SELECT id, job_id, file_type, file_path, file_name, file_size, created_at
|
|
FROM job_files WHERE job_id = ?`
|
|
args := []interface{}{jobID}
|
|
|
|
if fileTypeFilter != "" {
|
|
query += " AND file_type = ?"
|
|
args = append(args, fileTypeFilter)
|
|
}
|
|
|
|
if extensionFilter != "" {
|
|
query += " AND file_name LIKE ?"
|
|
args = append(args, "%."+extensionFilter)
|
|
}
|
|
|
|
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
|
args = append(args, limit, offset)
|
|
|
|
var rows *sql.Rows
|
|
var total int
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
var err error
|
|
rows, err = conn.Query(query, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get total count
|
|
countQuery := `SELECT COUNT(*) FROM job_files WHERE job_id = ?`
|
|
countArgs := []interface{}{jobID}
|
|
if fileTypeFilter != "" {
|
|
countQuery += " AND file_type = ?"
|
|
countArgs = append(countArgs, fileTypeFilter)
|
|
}
|
|
if extensionFilter != "" {
|
|
countQuery += " AND file_name LIKE ?"
|
|
countArgs = append(countArgs, "%."+extensionFilter)
|
|
}
|
|
err = conn.QueryRow(countQuery, countArgs...).Scan(&total)
|
|
if err != nil {
|
|
total = -1
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query files: %v", err))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
files := []types.JobFile{}
|
|
for rows.Next() {
|
|
var file types.JobFile
|
|
err := rows.Scan(
|
|
&file.ID, &file.JobID, &file.FileType, &file.FilePath,
|
|
&file.FileName, &file.FileSize, &file.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan file: %v", err))
|
|
return
|
|
}
|
|
files = append(files, file)
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": files,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
s.respondJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleGetJobFilesCount returns the count of files for a job
|
|
func (s *Manager) handleGetJobFilesCount(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
if !isAdmin {
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
} else {
|
|
var exists bool
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?)", jobID).Scan(&exists)
|
|
})
|
|
if err != nil || !exists {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
fileTypeFilter := r.URL.Query().Get("file_type")
|
|
|
|
var count int
|
|
query := `SELECT COUNT(*) FROM job_files WHERE job_id = ?`
|
|
args := []interface{}{jobID}
|
|
|
|
if fileTypeFilter != "" {
|
|
query += " AND file_type = ?"
|
|
args = append(args, fileTypeFilter)
|
|
}
|
|
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow(query, args...).Scan(&count)
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to count files: %v", err))
|
|
return
|
|
}
|
|
|
|
s.respondJSON(w, http.StatusOK, map[string]interface{}{"count": count})
|
|
}
|
|
|
|
// handleListContextArchive lists files inside the context archive
|
|
// Optimized to only read tar headers, skipping file data for fast directory listing
|
|
func (s *Manager) handleListContextArchive(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
if !isAdmin {
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get context archive path
|
|
contextPath := filepath.Join(s.storage.JobPath(jobID), "context.tar")
|
|
if !s.storage.FileExists(contextPath) {
|
|
s.respondError(w, http.StatusNotFound, "Context archive not found")
|
|
return
|
|
}
|
|
|
|
// Open file directly for seeking (much faster than reading all data)
|
|
file, err := os.Open(contextPath)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to open context archive: %v", err))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
type ArchiveFile struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
var archiveFiles []ArchiveFile
|
|
const tarBlockSize = 512
|
|
|
|
// Read tar headers sequentially, skipping file data by seeking
|
|
// This is much faster than reading all file contents
|
|
for {
|
|
// Read 512-byte tar header
|
|
headerBuf := make([]byte, tarBlockSize)
|
|
n, err := file.Read(headerBuf)
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read archive header: %v", err))
|
|
return
|
|
}
|
|
if n < tarBlockSize {
|
|
// Incomplete header, likely end of archive
|
|
break
|
|
}
|
|
|
|
// Check if this is the end marker (all zeros) - tar files end with two zero blocks
|
|
allZeros := true
|
|
for _, b := range headerBuf {
|
|
if b != 0 {
|
|
allZeros = false
|
|
break
|
|
}
|
|
}
|
|
if allZeros {
|
|
break
|
|
}
|
|
|
|
// Parse tar header
|
|
var header tar.Header
|
|
if err := parseTarHeader(headerBuf, &header); err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse archive header: %v", err))
|
|
return
|
|
}
|
|
|
|
// Handle GNU tar long filename extension (type 'L')
|
|
// If typeflag is 'L', the next block contains the actual filename
|
|
if header.Typeflag == 'L' {
|
|
// Read the long filename from the next block
|
|
longNameBuf := make([]byte, tarBlockSize)
|
|
if _, err := file.Read(longNameBuf); err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read long filename: %v", err))
|
|
return
|
|
}
|
|
header.Name = strings.TrimRight(string(longNameBuf), "\x00")
|
|
// Read the actual header after the long filename
|
|
if _, err := file.Read(headerBuf); err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read header after long filename: %v", err))
|
|
return
|
|
}
|
|
if err := parseTarHeader(headerBuf, &header); err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse header after long filename: %v", err))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Only include regular files (not directories)
|
|
if header.Typeflag == tar.TypeReg {
|
|
archiveFiles = append(archiveFiles, ArchiveFile{
|
|
Name: filepath.Base(header.Name),
|
|
Size: header.Size,
|
|
Path: header.Name,
|
|
})
|
|
}
|
|
|
|
// Skip file data by seeking forward
|
|
// Tar format: file data is padded to 512-byte boundary
|
|
dataSize := header.Size
|
|
blockPadding := (tarBlockSize - (dataSize % tarBlockSize)) % tarBlockSize
|
|
skipSize := dataSize + blockPadding
|
|
|
|
// Seek forward to next header (much faster than reading)
|
|
_, err = file.Seek(skipSize, io.SeekCurrent)
|
|
if err != nil {
|
|
// If seek fails (e.g., on non-seekable stream), fall back to reading and discarding
|
|
_, readErr := io.CopyN(io.Discard, file, skipSize)
|
|
if readErr != nil && readErr != io.EOF {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to skip file data: %v", readErr))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
s.respondJSON(w, http.StatusOK, archiveFiles)
|
|
}
|
|
|
|
// parseTarHeader parses a 512-byte tar header block into a tar.Header
|
|
// This is a simplified parser that extracts the essential fields we need
|
|
func parseTarHeader(buf []byte, h *tar.Header) error {
|
|
const tarHeaderSize = 512
|
|
if len(buf) < tarHeaderSize {
|
|
return fmt.Errorf("buffer too small for tar header")
|
|
}
|
|
|
|
// Tar header format (UStar/POSIX format)
|
|
// Field offsets based on POSIX.1-1988 tar format
|
|
h.Name = strings.TrimRight(string(buf[0:100]), "\x00")
|
|
|
|
// Parse mode (octal)
|
|
modeStr := strings.TrimRight(string(buf[100:108]), " \x00")
|
|
mode, err := strconv.ParseUint(modeStr, 8, 32)
|
|
if err == nil {
|
|
h.Mode = int64(mode)
|
|
}
|
|
|
|
// Parse size (octal)
|
|
sizeStr := strings.TrimRight(string(buf[124:136]), " \x00")
|
|
size, err := strconv.ParseInt(sizeStr, 8, 64)
|
|
if err == nil {
|
|
h.Size = size
|
|
}
|
|
|
|
// Parse typeflag
|
|
if len(buf) > 156 {
|
|
h.Typeflag = buf[156]
|
|
}
|
|
|
|
// Handle UStar format prefix (for long filenames)
|
|
prefix := strings.TrimRight(string(buf[345:500]), "\x00")
|
|
if prefix != "" {
|
|
h.Name = prefix + "/" + h.Name
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleDownloadJobFile downloads a job file
|
|
func (s *Manager) handleDownloadJobFile(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
fileID, err := parseID(r, "fileId")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
if !isAdmin {
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
} else {
|
|
// Admin: verify job exists
|
|
var exists bool
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?)", jobID).Scan(&exists)
|
|
})
|
|
if err != nil || !exists {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get file info
|
|
var filePath, fileName string
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow(
|
|
`SELECT file_path, file_name FROM job_files WHERE id = ? AND job_id = ?`,
|
|
fileID, jobID,
|
|
).Scan(&filePath, &fileName)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "File not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query file: %v", err))
|
|
return
|
|
}
|
|
|
|
// Open file
|
|
file, err := s.storage.GetFile(filePath)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusNotFound, "File not found on disk")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Determine content type based on file extension
|
|
contentType := "application/octet-stream"
|
|
disposition := "attachment"
|
|
|
|
fileNameLower := strings.ToLower(fileName)
|
|
switch {
|
|
case strings.HasSuffix(fileNameLower, ".png"):
|
|
contentType = "image/png"
|
|
disposition = "inline"
|
|
case strings.HasSuffix(fileNameLower, ".jpg") || strings.HasSuffix(fileNameLower, ".jpeg"):
|
|
contentType = "image/jpeg"
|
|
disposition = "inline"
|
|
case strings.HasSuffix(fileNameLower, ".gif"):
|
|
contentType = "image/gif"
|
|
disposition = "inline"
|
|
case strings.HasSuffix(fileNameLower, ".webp"):
|
|
contentType = "image/webp"
|
|
disposition = "inline"
|
|
case strings.HasSuffix(fileNameLower, ".bmp"):
|
|
contentType = "image/bmp"
|
|
disposition = "inline"
|
|
case strings.HasSuffix(fileNameLower, ".svg"):
|
|
contentType = "image/svg+xml"
|
|
disposition = "inline"
|
|
}
|
|
|
|
// Set headers
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q", disposition, fileName))
|
|
w.Header().Set("Content-Type", contentType)
|
|
|
|
// Stream file
|
|
io.Copy(w, file)
|
|
}
|
|
|
|
// handleDownloadEXRZip downloads all EXR output files for a job as a ZIP archive.
|
|
func (s *Manager) handleDownloadEXRZip(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
var jobName string
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
if !isAdmin {
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id, name FROM jobs WHERE id = ?", jobID).Scan(&jobUserID, &jobName)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
} else {
|
|
var exists bool
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?), COALESCE((SELECT name FROM jobs WHERE id = ?), '')", jobID, jobID).Scan(&exists, &jobName)
|
|
})
|
|
if err != nil || !exists {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
type zipFile struct {
|
|
Path string
|
|
Name string
|
|
}
|
|
files := make([]zipFile, 0)
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
rows, qErr := conn.Query(
|
|
`SELECT file_path, file_name
|
|
FROM job_files
|
|
WHERE job_id = ? AND file_type = ? AND LOWER(file_name) LIKE '%.exr'
|
|
ORDER BY created_at ASC`,
|
|
jobID, types.JobFileTypeOutput,
|
|
)
|
|
if qErr != nil {
|
|
return qErr
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var item zipFile
|
|
if scanErr := rows.Scan(&item.Path, &item.Name); scanErr != nil {
|
|
return scanErr
|
|
}
|
|
files = append(files, item)
|
|
}
|
|
return rows.Err()
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query EXR files: %v", err))
|
|
return
|
|
}
|
|
if len(files) == 0 {
|
|
s.respondError(w, http.StatusNotFound, "No EXR output files found for this job")
|
|
return
|
|
}
|
|
|
|
safeJobName := strings.TrimSpace(jobName)
|
|
if safeJobName == "" {
|
|
safeJobName = fmt.Sprintf("job-%d", jobID)
|
|
}
|
|
safeJobName = strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
return r
|
|
case r >= 'A' && r <= 'Z':
|
|
return r
|
|
case r >= '0' && r <= '9':
|
|
return r
|
|
case r == '-', r == '_', r == '.':
|
|
return r
|
|
case r == ' ':
|
|
return '-'
|
|
default:
|
|
return '-'
|
|
}
|
|
}, safeJobName)
|
|
for strings.Contains(safeJobName, "--") {
|
|
safeJobName = strings.ReplaceAll(safeJobName, "--", "-")
|
|
}
|
|
safeJobName = strings.Trim(safeJobName, "-_.")
|
|
if safeJobName == "" {
|
|
safeJobName = fmt.Sprintf("job-%d", jobID)
|
|
}
|
|
|
|
fileName := fmt.Sprintf("%s-exr.zip", safeJobName)
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fileName))
|
|
|
|
zipWriter := zip.NewWriter(w)
|
|
defer zipWriter.Close()
|
|
|
|
usedNames := make(map[string]int)
|
|
for _, item := range files {
|
|
file, openErr := s.storage.GetFile(item.Path)
|
|
if openErr != nil {
|
|
log.Printf("Warning: skipping missing EXR file for job %d: %s (%v)", jobID, item.Path, openErr)
|
|
continue
|
|
}
|
|
|
|
entryName := strings.TrimSpace(item.Name)
|
|
if entryName == "" {
|
|
entryName = filepath.Base(item.Path)
|
|
}
|
|
entryName = strings.ReplaceAll(entryName, "\\", "/")
|
|
entryName = strings.TrimLeft(entryName, "/")
|
|
if entryName == "" || strings.Contains(entryName, "..") {
|
|
entryName = filepath.Base(item.Path)
|
|
}
|
|
if entryName == "" {
|
|
entryName = fmt.Sprintf("frame-%d.exr", len(usedNames)+1)
|
|
}
|
|
|
|
baseEntryName := entryName
|
|
if n := usedNames[baseEntryName]; n > 0 {
|
|
ext := filepath.Ext(entryName)
|
|
base := strings.TrimSuffix(entryName, ext)
|
|
entryName = fmt.Sprintf("%s (%d)%s", base, n+1, ext)
|
|
}
|
|
usedNames[baseEntryName]++
|
|
|
|
entryWriter, createErr := zipWriter.Create(entryName)
|
|
if createErr != nil {
|
|
file.Close()
|
|
log.Printf("Warning: skipping EXR file entry for job %d: %s (%v)", jobID, entryName, createErr)
|
|
continue
|
|
}
|
|
if _, copyErr := io.Copy(entryWriter, file); copyErr != nil {
|
|
file.Close()
|
|
log.Printf("Warning: failed to add EXR file to zip for job %d: %s (%v)", jobID, entryName, copyErr)
|
|
continue
|
|
}
|
|
file.Close()
|
|
}
|
|
}
|
|
|
|
// handlePreviewEXR converts an EXR file to PNG for browser preview
|
|
// Uses ImageMagick to convert with HDR tone mapping and alpha preservation
|
|
func (s *Manager) handlePreviewEXR(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
fileID, err := parseID(r, "fileId")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
if !isAdmin {
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
} else {
|
|
// Admin: verify job exists
|
|
var exists bool
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?)", jobID).Scan(&exists)
|
|
})
|
|
if err != nil || !exists {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get file info
|
|
var filePath, fileName string
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow(
|
|
`SELECT file_path, file_name FROM job_files WHERE id = ? AND job_id = ?`,
|
|
fileID, jobID,
|
|
).Scan(&filePath, &fileName)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "File not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query file: %v", err))
|
|
return
|
|
}
|
|
|
|
// Verify it's an EXR file
|
|
if !strings.HasSuffix(strings.ToLower(fileName), ".exr") {
|
|
s.respondError(w, http.StatusBadRequest, "File is not an EXR file")
|
|
return
|
|
}
|
|
|
|
// Check if source file exists
|
|
if !s.storage.FileExists(filePath) {
|
|
s.respondError(w, http.StatusNotFound, "File not found on disk")
|
|
return
|
|
}
|
|
|
|
// Create temp file for PNG output
|
|
tmpFile, err := os.CreateTemp("", "exr-preview-*.png")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create temp file: %v", err))
|
|
return
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
tmpFile.Close()
|
|
defer os.Remove(tmpPath)
|
|
|
|
// Convert EXR to PNG for in-browser preview. EXR is linear; we convert to sRGB for display.
|
|
// -resize 1920x1080>: only shrink if larger, limits preview size and conversion time
|
|
// -colorspace sRGB, -depth 16, -alpha on: quality and pipeline match
|
|
exrPreviewTimeout := 60 * time.Second
|
|
magickArgs := []string{
|
|
filePath,
|
|
"-resize", "1920x1080>",
|
|
"-colorspace", "sRGB",
|
|
"-depth", "16",
|
|
"-alpha", "on",
|
|
tmpPath,
|
|
}
|
|
result, err := executils.RunCommandWithTimeout(exrPreviewTimeout, "magick", magickArgs, "", nil, 0, nil)
|
|
if err != nil {
|
|
result, err = executils.RunCommandWithTimeout(exrPreviewTimeout, "convert", magickArgs, "", nil, 0, nil)
|
|
if err != nil {
|
|
log.Printf("EXR conversion failed: %v, output: %s %s", err, result.Stdout, result.Stderr)
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to convert EXR: %v", err))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Read the converted PNG
|
|
pngData, err := os.ReadFile(tmpPath)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read converted file: %v", err))
|
|
return
|
|
}
|
|
|
|
// Set headers
|
|
pngFileName := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".png"
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", pngFileName))
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(pngData)))
|
|
|
|
// Write response
|
|
w.Write(pngData)
|
|
}
|
|
|
|
// handleStreamVideo streams MP4 video file with range support
|
|
func (s *Manager) handleStreamVideo(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
var jobUserID int64
|
|
var outputFormat string
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
if isAdmin {
|
|
return conn.QueryRow("SELECT user_id, output_format FROM jobs WHERE id = ?", jobID).Scan(&jobUserID, &outputFormat)
|
|
} else {
|
|
return conn.QueryRow("SELECT user_id, output_format FROM jobs WHERE id = ? AND user_id = ?", jobID, userID).Scan(&jobUserID, &outputFormat)
|
|
}
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query job: %v", err))
|
|
return
|
|
}
|
|
if !isAdmin && jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
|
|
// Find MP4 file
|
|
var filePath, fileName string
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow(
|
|
`SELECT file_path, file_name FROM job_files
|
|
WHERE job_id = ? AND file_type = ? AND file_name LIKE '%.mp4'
|
|
ORDER BY created_at DESC LIMIT 1`,
|
|
jobID, types.JobFileTypeOutput,
|
|
).Scan(&filePath, &fileName)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Video file not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query file: %v", err))
|
|
return
|
|
}
|
|
|
|
// Open file
|
|
file, err := s.storage.GetFile(filePath)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusNotFound, "File not found on disk")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Get file info
|
|
fileInfo, err := file.Stat()
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, "Failed to get file info")
|
|
return
|
|
}
|
|
|
|
fileSize := fileInfo.Size()
|
|
|
|
// Handle range requests for video seeking
|
|
rangeHeader := r.Header.Get("Range")
|
|
if rangeHeader != "" {
|
|
// Parse range header
|
|
var start, end int64
|
|
fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end)
|
|
if end == 0 {
|
|
end = fileSize - 1
|
|
}
|
|
|
|
// Seek to start position
|
|
file.Seek(start, 0)
|
|
|
|
// Set headers for partial content
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, fileSize))
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
|
|
// Copy partial content
|
|
io.CopyN(w, file, end-start+1)
|
|
} else {
|
|
// Full file
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileSize))
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
io.Copy(w, file)
|
|
}
|
|
}
|
|
|
|
// handleListJobTasks lists all tasks for a job with pagination and filtering
|
|
func (s *Manager) handleListJobTasks(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
if !isAdmin {
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
} else {
|
|
// Admin: verify job exists
|
|
var exists bool
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?)", jobID).Scan(&exists)
|
|
})
|
|
if err != nil || !exists {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Parse query parameters
|
|
limit := 100 // default
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 5000 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
offset := 0
|
|
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
|
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
|
offset = o
|
|
}
|
|
}
|
|
|
|
statusFilter := r.URL.Query().Get("status")
|
|
frameStartFilter := r.URL.Query().Get("frame_start")
|
|
frameEndFilter := r.URL.Query().Get("frame_end")
|
|
sortBy := r.URL.Query().Get("sort")
|
|
if sortBy == "" {
|
|
sortBy = "frame:asc"
|
|
}
|
|
|
|
// Parse sort parameter
|
|
sortParts := strings.Split(sortBy, ":")
|
|
sortField := "frame"
|
|
sortDir := "ASC"
|
|
if len(sortParts) == 2 {
|
|
sortField = sortParts[0]
|
|
sortDir = strings.ToUpper(sortParts[1])
|
|
if sortDir != "ASC" && sortDir != "DESC" {
|
|
sortDir = "ASC"
|
|
}
|
|
validFields := map[string]bool{
|
|
"frame": true, "status": true,
|
|
"created_at": true, "started_at": true, "completed_at": true,
|
|
}
|
|
if !validFields[sortField] {
|
|
sortField = "frame"
|
|
}
|
|
}
|
|
|
|
// Build query with filters
|
|
query := `SELECT id, job_id, runner_id, frame, frame_end, status, task_type,
|
|
current_step, retry_count, max_retries, output_path, created_at, started_at,
|
|
completed_at, error_message, timeout_seconds
|
|
FROM tasks WHERE job_id = ?`
|
|
args := []interface{}{jobID}
|
|
|
|
if statusFilter != "" {
|
|
statuses := strings.Split(statusFilter, ",")
|
|
placeholders := make([]string, len(statuses))
|
|
for i, status := range statuses {
|
|
placeholders[i] = "?"
|
|
args = append(args, strings.TrimSpace(status))
|
|
}
|
|
query += fmt.Sprintf(" AND status IN (%s)", strings.Join(placeholders, ","))
|
|
}
|
|
|
|
if frameStartFilter != "" {
|
|
if fs, err := strconv.Atoi(frameStartFilter); err == nil {
|
|
query += " AND frame >= ?"
|
|
args = append(args, fs)
|
|
}
|
|
}
|
|
|
|
if frameEndFilter != "" {
|
|
if fe, err := strconv.Atoi(frameEndFilter); err == nil {
|
|
query += " AND frame <= ?"
|
|
args = append(args, fe)
|
|
}
|
|
}
|
|
|
|
query += fmt.Sprintf(" ORDER BY %s %s LIMIT ? OFFSET ?", sortField, sortDir)
|
|
args = append(args, limit, offset)
|
|
|
|
var rows *sql.Rows
|
|
var total int
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
var err error
|
|
rows, err = conn.Query(query, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get total count
|
|
countQuery := `SELECT COUNT(*) FROM tasks WHERE job_id = ?`
|
|
countArgs := []interface{}{jobID}
|
|
if statusFilter != "" {
|
|
statuses := strings.Split(statusFilter, ",")
|
|
placeholders := make([]string, len(statuses))
|
|
for i, status := range statuses {
|
|
placeholders[i] = "?"
|
|
countArgs = append(countArgs, strings.TrimSpace(status))
|
|
}
|
|
countQuery += fmt.Sprintf(" AND status IN (%s)", strings.Join(placeholders, ","))
|
|
}
|
|
if frameStartFilter != "" {
|
|
if fs, err := strconv.Atoi(frameStartFilter); err == nil {
|
|
countQuery += " AND frame >= ?"
|
|
countArgs = append(countArgs, fs)
|
|
}
|
|
}
|
|
if frameEndFilter != "" {
|
|
if fe, err := strconv.Atoi(frameEndFilter); err == nil {
|
|
countQuery += " AND frame <= ?"
|
|
countArgs = append(countArgs, fe)
|
|
}
|
|
}
|
|
err = conn.QueryRow(countQuery, countArgs...).Scan(&total)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query tasks: %v", err))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
tasks := []types.Task{}
|
|
for rows.Next() {
|
|
var task types.Task
|
|
var runnerID sql.NullInt64
|
|
var frameEnd sql.NullInt64
|
|
var startedAt, completedAt sql.NullTime
|
|
var timeoutSeconds sql.NullInt64
|
|
var errorMessage sql.NullString
|
|
var currentStep sql.NullString
|
|
var outputPath sql.NullString
|
|
|
|
err := rows.Scan(
|
|
&task.ID, &task.JobID, &runnerID, &task.Frame, &frameEnd,
|
|
&task.Status, &task.TaskType, ¤tStep, &task.RetryCount,
|
|
&task.MaxRetries, &outputPath, &task.CreatedAt, &startedAt,
|
|
&completedAt, &errorMessage, &timeoutSeconds,
|
|
)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan task: %v", err))
|
|
return
|
|
}
|
|
|
|
if runnerID.Valid {
|
|
task.RunnerID = &runnerID.Int64
|
|
}
|
|
if frameEnd.Valid {
|
|
fe := int(frameEnd.Int64)
|
|
task.FrameEnd = &fe
|
|
}
|
|
if startedAt.Valid {
|
|
task.StartedAt = &startedAt.Time
|
|
}
|
|
if completedAt.Valid {
|
|
task.CompletedAt = &completedAt.Time
|
|
}
|
|
if timeoutSeconds.Valid {
|
|
timeout := int(timeoutSeconds.Int64)
|
|
task.TimeoutSeconds = &timeout
|
|
}
|
|
if errorMessage.Valid {
|
|
task.ErrorMessage = errorMessage.String
|
|
}
|
|
if currentStep.Valid {
|
|
task.CurrentStep = currentStep.String
|
|
}
|
|
if outputPath.Valid {
|
|
task.OutputPath = outputPath.String
|
|
}
|
|
|
|
tasks = append(tasks, task)
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": tasks,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
|
|
// Generate ETag and check If-None-Match
|
|
etag := generateETag(response)
|
|
w.Header().Set("ETag", etag)
|
|
|
|
if checkETag(r, etag) {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
s.respondJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleListJobTasksSummary lists lightweight task summaries for a job
|
|
func (s *Manager) handleListJobTasksSummary(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
if !isAdmin {
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
} else {
|
|
var exists bool
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?)", jobID).Scan(&exists)
|
|
})
|
|
if err != nil || !exists {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Parse query parameters
|
|
limit := 0 // 0 means unlimited
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
offset := 0
|
|
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
|
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
|
offset = o
|
|
}
|
|
}
|
|
|
|
statusFilter := r.URL.Query().Get("status")
|
|
sortBy := r.URL.Query().Get("sort")
|
|
if sortBy == "" {
|
|
sortBy = "frame:asc"
|
|
}
|
|
|
|
sortParts := strings.Split(sortBy, ":")
|
|
sortField := "frame"
|
|
sortDir := "ASC"
|
|
if len(sortParts) == 2 {
|
|
sortField = sortParts[0]
|
|
sortDir = strings.ToUpper(sortParts[1])
|
|
if sortDir != "ASC" && sortDir != "DESC" {
|
|
sortDir = "ASC"
|
|
}
|
|
validFields := map[string]bool{
|
|
"frame": true, "status": true,
|
|
}
|
|
if !validFields[sortField] {
|
|
sortField = "frame"
|
|
}
|
|
}
|
|
|
|
// Build query - only select summary fields
|
|
query := `SELECT id, frame, frame_end, status, task_type, runner_id
|
|
FROM tasks WHERE job_id = ?`
|
|
args := []interface{}{jobID}
|
|
|
|
if statusFilter != "" {
|
|
statuses := strings.Split(statusFilter, ",")
|
|
placeholders := make([]string, len(statuses))
|
|
for i, status := range statuses {
|
|
placeholders[i] = "?"
|
|
args = append(args, strings.TrimSpace(status))
|
|
}
|
|
query += fmt.Sprintf(" AND status IN (%s)", strings.Join(placeholders, ","))
|
|
}
|
|
|
|
query += fmt.Sprintf(" ORDER BY %s %s", sortField, sortDir)
|
|
if limit > 0 {
|
|
query += " LIMIT ? OFFSET ?"
|
|
args = append(args, limit, offset)
|
|
} else {
|
|
// Unlimited - only apply offset if specified
|
|
if offset > 0 {
|
|
query += " OFFSET ?"
|
|
args = append(args, offset)
|
|
}
|
|
}
|
|
|
|
var rows *sql.Rows
|
|
var total int
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
var err error
|
|
rows, err = conn.Query(query, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get total count
|
|
countQuery := `SELECT COUNT(*) FROM tasks WHERE job_id = ?`
|
|
countArgs := []interface{}{jobID}
|
|
if statusFilter != "" {
|
|
statuses := strings.Split(statusFilter, ",")
|
|
placeholders := make([]string, len(statuses))
|
|
for i, status := range statuses {
|
|
placeholders[i] = "?"
|
|
countArgs = append(countArgs, strings.TrimSpace(status))
|
|
}
|
|
countQuery += fmt.Sprintf(" AND status IN (%s)", strings.Join(placeholders, ","))
|
|
}
|
|
err = conn.QueryRow(countQuery, countArgs...).Scan(&total)
|
|
if err != nil {
|
|
total = -1
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query tasks: %v", err))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type TaskSummary struct {
|
|
ID int64 `json:"id"`
|
|
Frame int `json:"frame"`
|
|
FrameEnd *int `json:"frame_end,omitempty"`
|
|
Status string `json:"status"`
|
|
TaskType string `json:"task_type"`
|
|
RunnerID *int64 `json:"runner_id,omitempty"`
|
|
}
|
|
|
|
summaries := []TaskSummary{}
|
|
for rows.Next() {
|
|
var summary TaskSummary
|
|
var runnerID sql.NullInt64
|
|
var frameEnd sql.NullInt64
|
|
|
|
err := rows.Scan(
|
|
&summary.ID, &summary.Frame, &frameEnd,
|
|
&summary.Status, &summary.TaskType, &runnerID,
|
|
)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan task: %v", err))
|
|
return
|
|
}
|
|
|
|
if frameEnd.Valid {
|
|
fe := int(frameEnd.Int64)
|
|
summary.FrameEnd = &fe
|
|
}
|
|
if runnerID.Valid {
|
|
summary.RunnerID = &runnerID.Int64
|
|
}
|
|
|
|
summaries = append(summaries, summary)
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": summaries,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
s.respondJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleBatchGetTasks fetches multiple tasks by IDs for a job
|
|
func (s *Manager) handleBatchGetTasks(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
if !isAdmin {
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
} else {
|
|
var exists bool
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?)", jobID).Scan(&exists)
|
|
})
|
|
if err != nil || !exists {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
var req struct {
|
|
TaskIDs []int64 `json:"task_ids"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: expected valid JSON - %v", err))
|
|
return
|
|
}
|
|
|
|
if len(req.TaskIDs) == 0 {
|
|
s.respondJSON(w, http.StatusOK, []types.Task{})
|
|
return
|
|
}
|
|
|
|
if len(req.TaskIDs) > 500 {
|
|
s.respondError(w, http.StatusBadRequest, "Maximum 500 task IDs allowed per batch")
|
|
return
|
|
}
|
|
|
|
// Build query with IN clause
|
|
placeholders := make([]string, len(req.TaskIDs))
|
|
args := make([]interface{}, len(req.TaskIDs)+1)
|
|
args[0] = jobID
|
|
for i, taskID := range req.TaskIDs {
|
|
placeholders[i] = "?"
|
|
args[i+1] = taskID
|
|
}
|
|
|
|
query := fmt.Sprintf(`SELECT id, job_id, runner_id, frame, frame_end, status, task_type,
|
|
current_step, retry_count, max_retries, output_path, created_at, started_at,
|
|
completed_at, error_message, timeout_seconds
|
|
FROM tasks WHERE job_id = ? AND id IN (%s) ORDER BY frame ASC`, strings.Join(placeholders, ","))
|
|
|
|
var rows *sql.Rows
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
var err error
|
|
rows, err = conn.Query(query, args...)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query tasks: %v", err))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
tasks := []types.Task{}
|
|
for rows.Next() {
|
|
var task types.Task
|
|
var runnerID sql.NullInt64
|
|
var frameEnd sql.NullInt64
|
|
var startedAt, completedAt sql.NullTime
|
|
var timeoutSeconds sql.NullInt64
|
|
var errorMessage sql.NullString
|
|
var currentStep sql.NullString
|
|
var outputPath sql.NullString
|
|
|
|
err := rows.Scan(
|
|
&task.ID, &task.JobID, &runnerID, &task.Frame, &frameEnd,
|
|
&task.Status, &task.TaskType, ¤tStep, &task.RetryCount,
|
|
&task.MaxRetries, &outputPath, &task.CreatedAt, &startedAt,
|
|
&completedAt, &errorMessage, &timeoutSeconds,
|
|
)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan task: %v", err))
|
|
return
|
|
}
|
|
|
|
if runnerID.Valid {
|
|
task.RunnerID = &runnerID.Int64
|
|
}
|
|
if frameEnd.Valid {
|
|
fe := int(frameEnd.Int64)
|
|
task.FrameEnd = &fe
|
|
}
|
|
if startedAt.Valid {
|
|
task.StartedAt = &startedAt.Time
|
|
}
|
|
if completedAt.Valid {
|
|
task.CompletedAt = &completedAt.Time
|
|
}
|
|
if timeoutSeconds.Valid {
|
|
timeout := int(timeoutSeconds.Int64)
|
|
task.TimeoutSeconds = &timeout
|
|
}
|
|
if errorMessage.Valid {
|
|
task.ErrorMessage = errorMessage.String
|
|
}
|
|
if currentStep.Valid {
|
|
task.CurrentStep = currentStep.String
|
|
}
|
|
if outputPath.Valid {
|
|
task.OutputPath = outputPath.String
|
|
}
|
|
|
|
tasks = append(tasks, task)
|
|
}
|
|
|
|
s.respondJSON(w, http.StatusOK, tasks)
|
|
}
|
|
|
|
// handleGetTaskLogs retrieves logs for a specific task
|
|
func (s *Manager) handleGetTaskLogs(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
taskID, err := parseID(r, "taskId")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
if !isAdmin {
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
} else {
|
|
// Admin: verify job exists
|
|
var exists bool
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?)", jobID).Scan(&exists)
|
|
})
|
|
if err != nil || !exists {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Verify task belongs to job
|
|
var taskJobID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT job_id FROM tasks WHERE id = ?", taskID).Scan(&taskJobID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Task not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify task: %v", err))
|
|
return
|
|
}
|
|
if taskJobID != jobID {
|
|
s.respondError(w, http.StatusBadRequest, "Task does not belong to this job")
|
|
return
|
|
}
|
|
|
|
// Get query parameters for filtering
|
|
stepName := r.URL.Query().Get("step_name")
|
|
logLevel := r.URL.Query().Get("log_level")
|
|
sinceIDStr := r.URL.Query().Get("since_id")
|
|
limitStr := r.URL.Query().Get("limit")
|
|
limit := 100 // default
|
|
unlimited := false
|
|
if limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil {
|
|
if l == 0 {
|
|
unlimited = true // explicit no-limit mode
|
|
} else if l > 0 && l <= 10000 {
|
|
limit = l
|
|
} else if l > 10000 {
|
|
limit = 10000
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build query
|
|
query := `SELECT id, task_id, runner_id, log_level, message, step_name, created_at
|
|
FROM task_logs WHERE task_id = ?`
|
|
args := []interface{}{taskID}
|
|
|
|
// Add since_id filter for incremental updates
|
|
if sinceIDStr != "" {
|
|
if sinceID, err := strconv.ParseInt(sinceIDStr, 10, 64); err == nil && sinceID > 0 {
|
|
query += " AND id > ?"
|
|
args = append(args, sinceID)
|
|
}
|
|
}
|
|
|
|
if stepName != "" {
|
|
query += " AND step_name = ?"
|
|
args = append(args, stepName)
|
|
}
|
|
if logLevel != "" {
|
|
query += " AND log_level = ?"
|
|
args = append(args, logLevel)
|
|
}
|
|
query += " ORDER BY id ASC"
|
|
if !unlimited {
|
|
query += " LIMIT ?"
|
|
args = append(args, limit)
|
|
}
|
|
|
|
var rows *sql.Rows
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
var err error
|
|
rows, err = conn.Query(query, args...)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query logs: %v", err))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
logs := []types.TaskLog{}
|
|
for rows.Next() {
|
|
var log types.TaskLog
|
|
var runnerID sql.NullInt64
|
|
err := rows.Scan(
|
|
&log.ID, &log.TaskID, &runnerID, &log.LogLevel, &log.Message,
|
|
&log.StepName, &log.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan log: %v", err))
|
|
return
|
|
}
|
|
if runnerID.Valid {
|
|
log.RunnerID = &runnerID.Int64
|
|
}
|
|
logs = append(logs, log)
|
|
}
|
|
|
|
// Return last_id for next incremental fetch
|
|
lastID := int64(0)
|
|
if len(logs) > 0 {
|
|
lastID = logs[len(logs)-1].ID
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"logs": logs,
|
|
"last_id": lastID,
|
|
"limit": func() int {
|
|
if unlimited {
|
|
return 0
|
|
}
|
|
return limit
|
|
}(),
|
|
}
|
|
s.respondJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleGetTaskSteps retrieves step timeline for a specific task
|
|
func (s *Manager) handleGetTaskSteps(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
taskID, err := parseID(r, "taskId")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
if !isAdmin {
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
} else {
|
|
// Admin: verify job exists
|
|
var exists bool
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?)", jobID).Scan(&exists)
|
|
})
|
|
if err != nil || !exists {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Verify task belongs to job
|
|
var taskJobID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT job_id FROM tasks WHERE id = ?", taskID).Scan(&taskJobID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Task not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify task: %v", err))
|
|
return
|
|
}
|
|
if taskJobID != jobID {
|
|
s.respondError(w, http.StatusBadRequest, "Task does not belong to this job")
|
|
return
|
|
}
|
|
|
|
var rows *sql.Rows
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
var err error
|
|
rows, err = conn.Query(
|
|
`SELECT id, task_id, step_name, status, started_at, completed_at, duration_ms, error_message
|
|
FROM task_steps WHERE task_id = ? ORDER BY started_at ASC`,
|
|
taskID,
|
|
)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query steps: %v", err))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
steps := []types.TaskStep{}
|
|
for rows.Next() {
|
|
var step types.TaskStep
|
|
var startedAt, completedAt sql.NullTime
|
|
var durationMs sql.NullInt64
|
|
var errorMessage sql.NullString
|
|
err := rows.Scan(
|
|
&step.ID, &step.TaskID, &step.StepName, &step.Status,
|
|
&startedAt, &completedAt, &durationMs, &errorMessage,
|
|
)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan step: %v", err))
|
|
return
|
|
}
|
|
if startedAt.Valid {
|
|
step.StartedAt = &startedAt.Time
|
|
}
|
|
if completedAt.Valid {
|
|
step.CompletedAt = &completedAt.Time
|
|
}
|
|
if durationMs.Valid {
|
|
duration := int(durationMs.Int64)
|
|
step.DurationMs = &duration
|
|
}
|
|
if errorMessage.Valid {
|
|
step.ErrorMessage = errorMessage.String
|
|
}
|
|
steps = append(steps, step)
|
|
}
|
|
|
|
s.respondJSON(w, http.StatusOK, steps)
|
|
}
|
|
|
|
// handleRetryTask retries a failed task
|
|
func (s *Manager) handleRetryTask(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
s.respondError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
taskID, err := parseID(r, "taskId")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
|
|
// Verify task belongs to job and is in a retryable state
|
|
var taskJobID int64
|
|
var taskStatus string
|
|
var retryCount, maxRetries int
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow(
|
|
"SELECT job_id, status, retry_count, max_retries FROM tasks WHERE id = ?",
|
|
taskID,
|
|
).Scan(&taskJobID, &taskStatus, &retryCount, &maxRetries)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Task not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify task: %v", err))
|
|
return
|
|
}
|
|
if taskJobID != jobID {
|
|
s.respondError(w, http.StatusBadRequest, "Task does not belong to this job")
|
|
return
|
|
}
|
|
|
|
if taskStatus != string(types.TaskStatusFailed) {
|
|
s.respondError(w, http.StatusBadRequest, "Task is not in failed state")
|
|
return
|
|
}
|
|
|
|
if retryCount >= maxRetries {
|
|
s.respondError(w, http.StatusBadRequest, "Maximum retries exceeded")
|
|
return
|
|
}
|
|
|
|
// Reset task to pending
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
_, err := conn.Exec(
|
|
`UPDATE tasks SET status = ?, runner_id = NULL, current_step = NULL,
|
|
error_message = NULL, started_at = NULL, completed_at = NULL
|
|
WHERE id = ?`,
|
|
types.TaskStatusPending, taskID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Clear steps and logs for fresh retry
|
|
_, err = conn.Exec(`DELETE FROM task_steps WHERE task_id = ?`, taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = conn.Exec(`DELETE FROM task_logs WHERE task_id = ?`, taskID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to retry task: %v", err))
|
|
return
|
|
}
|
|
|
|
// Broadcast task reset to clients (includes steps_cleared and logs_cleared flags)
|
|
s.broadcastTaskUpdate(jobID, taskID, "task_reset", map[string]interface{}{
|
|
"status": types.TaskStatusPending,
|
|
"runner_id": nil,
|
|
"current_step": nil,
|
|
"error_message": nil,
|
|
"steps_cleared": true,
|
|
"logs_cleared": true,
|
|
})
|
|
|
|
s.respondJSON(w, http.StatusOK, map[string]string{"message": "Task queued for retry"})
|
|
}
|
|
|
|
// handleStreamTaskLogsWebSocket streams task logs via WebSocket
|
|
// Note: This is called after auth middleware, so userID is already verified
|
|
func (s *Manager) handleStreamTaskLogsWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
taskID, err := parseID(r, "taskId")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
|
|
// Verify task belongs to job
|
|
var taskJobID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT job_id FROM tasks WHERE id = ?", taskID).Scan(&taskJobID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Task not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify task: %v", err))
|
|
return
|
|
}
|
|
if taskJobID != jobID {
|
|
s.respondError(w, http.StatusBadRequest, "Task does not belong to this job")
|
|
return
|
|
}
|
|
|
|
// Upgrade to WebSocket
|
|
conn, err := s.wsUpgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
log.Printf("Failed to upgrade WebSocket: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
key := fmt.Sprintf("%d:%d", jobID, taskID)
|
|
s.frontendConnsMu.Lock()
|
|
s.frontendConns[key] = conn
|
|
s.frontendConnsMu.Unlock()
|
|
|
|
// Create a write mutex for this connection
|
|
s.frontendConnsWriteMuMu.Lock()
|
|
s.frontendConnsWriteMu[key] = &sync.Mutex{}
|
|
writeMu := s.frontendConnsWriteMu[key]
|
|
s.frontendConnsWriteMuMu.Unlock()
|
|
|
|
defer func() {
|
|
s.frontendConnsMu.Lock()
|
|
delete(s.frontendConns, key)
|
|
s.frontendConnsMu.Unlock()
|
|
s.frontendConnsWriteMuMu.Lock()
|
|
delete(s.frontendConnsWriteMu, key)
|
|
s.frontendConnsWriteMuMu.Unlock()
|
|
}()
|
|
|
|
// Send initial connection message
|
|
writeMu.Lock()
|
|
err = conn.WriteJSON(map[string]interface{}{
|
|
"type": "connected",
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
writeMu.Unlock()
|
|
if err != nil {
|
|
log.Printf("Failed to send initial connection message: %v", err)
|
|
return
|
|
}
|
|
|
|
// Get last log ID to start streaming from
|
|
lastIDStr := r.URL.Query().Get("last_id")
|
|
lastID := int64(0)
|
|
if lastIDStr != "" {
|
|
if id, err := strconv.ParseInt(lastIDStr, 10, 64); err == nil {
|
|
lastID = id
|
|
}
|
|
}
|
|
|
|
// Send existing logs
|
|
// Order by id ASC to ensure consistent ordering and avoid race conditions
|
|
var rows *sql.Rows
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
var err error
|
|
rows, err = conn.Query(
|
|
`SELECT id, task_id, runner_id, log_level, message, step_name, created_at
|
|
FROM task_logs WHERE task_id = ? AND id > ? ORDER BY id ASC LIMIT 100`,
|
|
taskID, lastID,
|
|
)
|
|
return err
|
|
})
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var log types.TaskLog
|
|
var runnerID sql.NullInt64
|
|
err := rows.Scan(
|
|
&log.ID, &log.TaskID, &runnerID, &log.LogLevel, &log.Message,
|
|
&log.StepName, &log.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if runnerID.Valid {
|
|
log.RunnerID = &runnerID.Int64
|
|
}
|
|
// Always update lastID to the highest ID we've seen
|
|
if log.ID > lastID {
|
|
lastID = log.ID
|
|
}
|
|
|
|
// Serialize writes to prevent concurrent write panics
|
|
writeMu.Lock()
|
|
writeErr := conn.WriteJSON(map[string]interface{}{
|
|
"type": "log",
|
|
"data": log,
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
writeMu.Unlock()
|
|
if writeErr != nil {
|
|
// Connection closed, exit the loop
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Poll for new logs and send them
|
|
// Use shorter interval for more responsive updates, but order by id for consistency
|
|
ticker := time.NewTicker(500 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
ctx := r.Context()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
var logs []types.TaskLog
|
|
err := s.db.With(func(dbConn *sql.DB) error {
|
|
rows, err := dbConn.Query(
|
|
`SELECT id, task_id, runner_id, log_level, message, step_name, created_at
|
|
FROM task_logs WHERE task_id = ? AND id > ? ORDER BY id ASC LIMIT 100`,
|
|
taskID, lastID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var log types.TaskLog
|
|
var runnerID sql.NullInt64
|
|
err := rows.Scan(
|
|
&log.ID, &log.TaskID, &runnerID, &log.LogLevel, &log.Message,
|
|
&log.StepName, &log.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if runnerID.Valid {
|
|
log.RunnerID = &runnerID.Int64
|
|
}
|
|
lastID = log.ID
|
|
logs = append(logs, log)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// Send logs to client (outside With callback to access websocket conn)
|
|
for _, log := range logs {
|
|
msg := map[string]interface{}{
|
|
"type": "log",
|
|
"task_id": taskID,
|
|
"data": log,
|
|
"timestamp": time.Now().Unix(),
|
|
}
|
|
writeMu.Lock()
|
|
writeErr := conn.WriteJSON(msg)
|
|
writeMu.Unlock()
|
|
if writeErr != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleClientWebSocket handles the unified client WebSocket connection with subscription protocol
|
|
func (s *Manager) handleClientWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Check if user is admin
|
|
isAdmin := isAdminUser(r)
|
|
|
|
// Upgrade to WebSocket
|
|
conn, err := s.wsUpgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
log.Printf("Failed to upgrade WebSocket: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Generate unique connection ID for this tab/connection
|
|
connNum := atomic.AddUint64(&s.connIDCounter, 1)
|
|
connID := fmt.Sprintf("%d:%d", userID, connNum)
|
|
|
|
// Create client connection
|
|
clientConn := &ClientConnection{
|
|
Conn: conn,
|
|
UserID: userID,
|
|
ConnID: connID,
|
|
IsAdmin: isAdmin,
|
|
Subscriptions: make(map[string]bool),
|
|
WriteMu: &sync.Mutex{},
|
|
}
|
|
|
|
// Register connection (no need to close old - multiple connections per user are allowed)
|
|
s.clientConnsMu.Lock()
|
|
s.clientConns[connID] = clientConn
|
|
s.clientConnsMu.Unlock()
|
|
log.Printf("handleClientWebSocket: Registered client connection %s for user %d", connID, userID)
|
|
|
|
defer func() {
|
|
s.clientConnsMu.Lock()
|
|
delete(s.clientConns, connID)
|
|
s.clientConnsMu.Unlock()
|
|
log.Printf("handleClientWebSocket: Removed client connection %s for user %d", connID, userID)
|
|
}()
|
|
|
|
// Send initial connection message
|
|
clientConn.WriteMu.Lock()
|
|
err = conn.WriteJSON(map[string]interface{}{
|
|
"type": "connected",
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
clientConn.WriteMu.Unlock()
|
|
if err != nil {
|
|
log.Printf("Failed to send initial connection message: %v", err)
|
|
return
|
|
}
|
|
|
|
// Set up ping/pong
|
|
conn.SetReadDeadline(time.Now().Add(WSReadDeadline))
|
|
conn.SetPongHandler(func(string) error {
|
|
conn.SetReadDeadline(time.Now().Add(WSReadDeadline)) // Reset deadline on pong
|
|
return nil
|
|
})
|
|
|
|
// Start ping ticker
|
|
ticker := time.NewTicker(WSPingInterval)
|
|
defer ticker.Stop()
|
|
|
|
// Message handling channel - increased buffer size to prevent blocking
|
|
messageChan := make(chan map[string]interface{}, 100)
|
|
|
|
// Read messages in background
|
|
readDone := make(chan struct{})
|
|
go func() {
|
|
defer close(readDone)
|
|
for {
|
|
conn.SetReadDeadline(time.Now().Add(WSReadDeadline)) // Increased timeout
|
|
messageType, message, err := conn.ReadMessage()
|
|
if err != nil {
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
log.Printf("WebSocket read error for client %d: %v", userID, err)
|
|
} else {
|
|
log.Printf("WebSocket read error for client %d (expected close): %v", userID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle control frames (pong, ping, close)
|
|
if messageType == websocket.PongMessage {
|
|
// Pong received - connection is alive, reset deadline
|
|
conn.SetReadDeadline(time.Now().Add(WSReadDeadline))
|
|
continue
|
|
}
|
|
if messageType == websocket.PingMessage {
|
|
// Respond to ping with pong
|
|
conn.SetWriteDeadline(time.Now().Add(WSWriteDeadline))
|
|
conn.WriteMessage(websocket.PongMessage, message)
|
|
conn.SetReadDeadline(time.Now().Add(WSReadDeadline))
|
|
continue
|
|
}
|
|
if messageType != websocket.TextMessage {
|
|
// Skip non-text messages
|
|
continue
|
|
}
|
|
|
|
// Parse JSON message
|
|
var msg map[string]interface{}
|
|
if err := json.Unmarshal(message, &msg); err != nil {
|
|
log.Printf("Failed to parse JSON message from client %d: %v", userID, err)
|
|
continue
|
|
}
|
|
messageChan <- msg
|
|
conn.SetReadDeadline(time.Now().Add(WSReadDeadline))
|
|
}
|
|
}()
|
|
|
|
ctx := r.Context()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Printf("handleClientWebSocket: Context cancelled for user %d", userID)
|
|
return
|
|
case <-readDone:
|
|
log.Printf("handleClientWebSocket: Read done for user %d", userID)
|
|
return
|
|
case msg := <-messageChan:
|
|
s.handleClientMessage(clientConn, msg)
|
|
case <-ticker.C:
|
|
// Reset read deadline before sending ping to ensure we can receive pong
|
|
conn.SetReadDeadline(time.Now().Add(WSReadDeadline))
|
|
clientConn.WriteMu.Lock()
|
|
// Use WriteControl for ping frames (control frames)
|
|
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(WSWriteDeadline)); err != nil {
|
|
log.Printf("handleClientWebSocket: Ping failed for user %d: %v", userID, err)
|
|
clientConn.WriteMu.Unlock()
|
|
return
|
|
}
|
|
clientConn.WriteMu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleClientMessage processes messages from client WebSocket
|
|
func (s *Manager) handleClientMessage(clientConn *ClientConnection, msg map[string]interface{}) {
|
|
msgType, ok := msg["type"].(string)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
switch msgType {
|
|
case "subscribe":
|
|
channel, ok := msg["channel"].(string)
|
|
if !ok {
|
|
// Send error for invalid channel format
|
|
clientConn.WriteMu.Lock()
|
|
if err := clientConn.Conn.WriteJSON(map[string]interface{}{
|
|
"type": "subscription_error",
|
|
"channel": channel,
|
|
"error": "Invalid channel format",
|
|
}); err != nil {
|
|
log.Printf("Failed to send subscription_error to client %d: %v", clientConn.UserID, err)
|
|
}
|
|
clientConn.WriteMu.Unlock()
|
|
return
|
|
}
|
|
// Check if already subscribed
|
|
clientConn.SubsMu.Lock()
|
|
alreadySubscribed := clientConn.Subscriptions[channel]
|
|
clientConn.SubsMu.Unlock()
|
|
|
|
if alreadySubscribed {
|
|
// Already subscribed - just send confirmation, don't send initial state again
|
|
if s.verboseWSLogging {
|
|
log.Printf("Client %d already subscribed to channel: %s (skipping initial state)", clientConn.UserID, channel)
|
|
}
|
|
clientConn.WriteMu.Lock()
|
|
if err := clientConn.Conn.WriteJSON(map[string]interface{}{
|
|
"type": "subscribed",
|
|
"channel": channel,
|
|
}); err != nil {
|
|
log.Printf("Failed to send subscribed confirmation to client %d: %v", clientConn.UserID, err)
|
|
}
|
|
clientConn.WriteMu.Unlock()
|
|
return
|
|
}
|
|
|
|
// Validate channel access
|
|
if s.canSubscribe(clientConn, channel) {
|
|
clientConn.SubsMu.Lock()
|
|
clientConn.Subscriptions[channel] = true
|
|
clientConn.SubsMu.Unlock()
|
|
if s.verboseWSLogging {
|
|
log.Printf("Client %d subscribed to channel: %s", clientConn.UserID, channel)
|
|
}
|
|
// Send success confirmation
|
|
clientConn.WriteMu.Lock()
|
|
if err := clientConn.Conn.WriteJSON(map[string]interface{}{
|
|
"type": "subscribed",
|
|
"channel": channel,
|
|
}); err != nil {
|
|
log.Printf("Failed to send subscribed confirmation to client %d: %v", clientConn.UserID, err)
|
|
clientConn.WriteMu.Unlock()
|
|
return
|
|
}
|
|
clientConn.WriteMu.Unlock()
|
|
// Send initial state for the subscribed channel (only on first subscription)
|
|
go s.sendInitialState(clientConn, channel)
|
|
} else {
|
|
// Subscription failed - send error to client
|
|
log.Printf("Client %d failed to subscribe to channel: %s (job may not exist or access denied)", clientConn.UserID, channel)
|
|
clientConn.WriteMu.Lock()
|
|
if err := clientConn.Conn.WriteJSON(map[string]interface{}{
|
|
"type": "subscription_error",
|
|
"channel": channel,
|
|
"error": "Channel not found or access denied",
|
|
}); err != nil {
|
|
log.Printf("Failed to send subscription_error to client %d: %v", clientConn.UserID, err)
|
|
}
|
|
clientConn.WriteMu.Unlock()
|
|
}
|
|
case "unsubscribe":
|
|
channel, ok := msg["channel"].(string)
|
|
if !ok {
|
|
return
|
|
}
|
|
clientConn.SubsMu.Lock()
|
|
delete(clientConn.Subscriptions, channel)
|
|
clientConn.SubsMu.Unlock()
|
|
if s.verboseWSLogging {
|
|
log.Printf("Client %d unsubscribed from channel: %s", clientConn.UserID, channel)
|
|
}
|
|
}
|
|
}
|
|
|
|
// canSubscribe checks if a client can subscribe to a channel
|
|
func (s *Manager) canSubscribe(clientConn *ClientConnection, channel string) bool {
|
|
// Always allow jobs channel (always broadcasted, but subscription doesn't hurt)
|
|
if channel == "jobs" {
|
|
return true
|
|
}
|
|
|
|
// Check channel format
|
|
if strings.HasPrefix(channel, "job:") {
|
|
// Extract job ID
|
|
jobIDStr := strings.TrimPrefix(channel, "job:")
|
|
jobID, err := strconv.ParseInt(jobIDStr, 10, 64)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// Verify job belongs to user (unless admin)
|
|
if clientConn.IsAdmin {
|
|
var exists bool
|
|
err := s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?)", jobID).Scan(&exists)
|
|
})
|
|
return err == nil && exists
|
|
}
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
return err == nil && jobUserID == clientConn.UserID
|
|
}
|
|
|
|
if strings.HasPrefix(channel, "logs:") {
|
|
// Format: logs:jobId:taskId
|
|
parts := strings.Split(channel, ":")
|
|
if len(parts) != 3 {
|
|
return false
|
|
}
|
|
jobID, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// Verify job belongs to user (unless admin)
|
|
if clientConn.IsAdmin {
|
|
var exists bool
|
|
err := s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?)", jobID).Scan(&exists)
|
|
})
|
|
return err == nil && exists
|
|
}
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
return err == nil && jobUserID == clientConn.UserID
|
|
}
|
|
|
|
if strings.HasPrefix(channel, "upload:") {
|
|
// Format: upload:sessionId
|
|
sessionID := strings.TrimPrefix(channel, "upload:")
|
|
s.uploadSessionsMu.RLock()
|
|
session, exists := s.uploadSessions[sessionID]
|
|
s.uploadSessionsMu.RUnlock()
|
|
// Verify session belongs to user
|
|
return exists && session.UserID == clientConn.UserID
|
|
}
|
|
|
|
if channel == "runners" {
|
|
// Only admins can subscribe to runners
|
|
return clientConn.IsAdmin
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// sendInitialState sends the current state when a client subscribes to a channel
|
|
func (s *Manager) sendInitialState(clientConn *ClientConnection, channel string) {
|
|
// Use a shorter write deadline for initial state to avoid blocking too long
|
|
// If the connection is slow/dead, we want to fail fast
|
|
writeTimeout := 5 * time.Second
|
|
|
|
// Check if connection is still valid before starting
|
|
clientConn.WriteMu.Lock()
|
|
// Set a reasonable write deadline
|
|
clientConn.Conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
|
clientConn.WriteMu.Unlock()
|
|
|
|
if strings.HasPrefix(channel, "job:") {
|
|
// Send initial job state
|
|
jobIDStr := strings.TrimPrefix(channel, "job:")
|
|
jobID, err := strconv.ParseInt(jobIDStr, 10, 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Get job from database
|
|
var job types.Job
|
|
var jobType string
|
|
var startedAt, completedAt sql.NullTime
|
|
var blendMetadataJSON sql.NullString
|
|
var errorMessage sql.NullString
|
|
var frameStart, frameEnd sql.NullInt64
|
|
var outputFormat sql.NullString
|
|
query := "SELECT id, user_id, job_type, name, status, progress, frame_start, frame_end, output_format, blend_metadata, created_at, started_at, completed_at, error_message FROM jobs WHERE id = ?"
|
|
if !clientConn.IsAdmin {
|
|
query += " AND user_id = ?"
|
|
}
|
|
|
|
var err2 error
|
|
err2 = s.db.With(func(conn *sql.DB) error {
|
|
if clientConn.IsAdmin {
|
|
return conn.QueryRow(query, jobID).Scan(
|
|
&job.ID, &job.UserID, &jobType, &job.Name, &job.Status, &job.Progress,
|
|
&frameStart, &frameEnd, &outputFormat,
|
|
&blendMetadataJSON, &job.CreatedAt, &startedAt, &completedAt, &errorMessage,
|
|
)
|
|
} else {
|
|
return conn.QueryRow(query, jobID, clientConn.UserID).Scan(
|
|
&job.ID, &job.UserID, &jobType, &job.Name, &job.Status, &job.Progress,
|
|
&frameStart, &frameEnd, &outputFormat,
|
|
&blendMetadataJSON, &job.CreatedAt, &startedAt, &completedAt, &errorMessage,
|
|
)
|
|
}
|
|
})
|
|
|
|
if err2 != nil {
|
|
return
|
|
}
|
|
|
|
if frameStart.Valid {
|
|
fs := int(frameStart.Int64)
|
|
job.FrameStart = &fs
|
|
}
|
|
if frameEnd.Valid {
|
|
fe := int(frameEnd.Int64)
|
|
job.FrameEnd = &fe
|
|
}
|
|
if outputFormat.Valid {
|
|
of := outputFormat.String
|
|
job.OutputFormat = &of
|
|
}
|
|
if startedAt.Valid {
|
|
job.StartedAt = &startedAt.Time
|
|
}
|
|
if completedAt.Valid {
|
|
job.CompletedAt = &completedAt.Time
|
|
}
|
|
if errorMessage.Valid {
|
|
job.ErrorMessage = errorMessage.String
|
|
}
|
|
|
|
// Send job_update with full job data
|
|
clientConn.WriteMu.Lock()
|
|
clientConn.Conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
|
writeErr := clientConn.Conn.WriteJSON(map[string]interface{}{
|
|
"type": "job_update",
|
|
"channel": channel,
|
|
"job_id": jobID,
|
|
"data": job,
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
clientConn.WriteMu.Unlock()
|
|
if writeErr != nil {
|
|
log.Printf("Failed to send initial job_update to client %d: %v", clientConn.UserID, writeErr)
|
|
return
|
|
}
|
|
|
|
// Get and send tasks (no limit - send all)
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
rows, err2 := conn.Query(
|
|
`SELECT id, job_id, runner_id, frame, frame_end, status, task_type,
|
|
current_step, retry_count, max_retries, output_path, created_at, started_at,
|
|
completed_at, error_message, timeout_seconds
|
|
FROM tasks WHERE job_id = ? ORDER BY frame ASC`,
|
|
jobID,
|
|
)
|
|
if err2 != nil {
|
|
return err2
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var task types.Task
|
|
var runnerID sql.NullInt64
|
|
var frameEnd sql.NullInt64
|
|
var startedAt, completedAt sql.NullTime
|
|
var timeoutSeconds sql.NullInt64
|
|
var errorMessage sql.NullString
|
|
var currentStep sql.NullString
|
|
var outputPath sql.NullString
|
|
|
|
err := rows.Scan(
|
|
&task.ID, &task.JobID, &runnerID, &task.Frame, &frameEnd,
|
|
&task.Status, &task.TaskType, ¤tStep, &task.RetryCount,
|
|
&task.MaxRetries, &outputPath, &task.CreatedAt, &startedAt,
|
|
&completedAt, &errorMessage, &timeoutSeconds,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if runnerID.Valid {
|
|
task.RunnerID = &runnerID.Int64
|
|
}
|
|
if frameEnd.Valid {
|
|
fe := int(frameEnd.Int64)
|
|
task.FrameEnd = &fe
|
|
}
|
|
if startedAt.Valid {
|
|
task.StartedAt = &startedAt.Time
|
|
}
|
|
if completedAt.Valid {
|
|
task.CompletedAt = &completedAt.Time
|
|
}
|
|
if timeoutSeconds.Valid {
|
|
timeout := int(timeoutSeconds.Int64)
|
|
task.TimeoutSeconds = &timeout
|
|
}
|
|
if errorMessage.Valid {
|
|
task.ErrorMessage = errorMessage.String
|
|
}
|
|
if currentStep.Valid {
|
|
task.CurrentStep = currentStep.String
|
|
}
|
|
if outputPath.Valid {
|
|
task.OutputPath = outputPath.String
|
|
}
|
|
|
|
// Send task_update
|
|
clientConn.WriteMu.Lock()
|
|
clientConn.Conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
|
writeErr := clientConn.Conn.WriteJSON(map[string]interface{}{
|
|
"type": "task_update",
|
|
"channel": channel,
|
|
"job_id": jobID,
|
|
"task_id": task.ID,
|
|
"data": task,
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
clientConn.WriteMu.Unlock()
|
|
if writeErr != nil {
|
|
log.Printf("Failed to send initial task_update to client %d: %v", clientConn.UserID, writeErr)
|
|
// Connection is likely closed, stop sending more messages
|
|
break
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
} else if strings.HasPrefix(channel, "logs:") {
|
|
// Send initial logs for the task
|
|
parts := strings.Split(channel, ":")
|
|
if len(parts) != 3 {
|
|
return
|
|
}
|
|
jobID, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
taskID, err := strconv.ParseInt(parts[2], 10, 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Get existing logs (no limit - send all)
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
rows, err2 := conn.Query(
|
|
`SELECT id, task_id, runner_id, log_level, message, step_name, created_at
|
|
FROM task_logs WHERE task_id = ? ORDER BY id ASC`,
|
|
taskID,
|
|
)
|
|
if err2 != nil {
|
|
return err2
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var taskLog types.TaskLog
|
|
var runnerID sql.NullInt64
|
|
err := rows.Scan(
|
|
&taskLog.ID, &taskLog.TaskID, &runnerID, &taskLog.LogLevel, &taskLog.Message,
|
|
&taskLog.StepName, &taskLog.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if runnerID.Valid {
|
|
taskLog.RunnerID = &runnerID.Int64
|
|
}
|
|
|
|
// Send log
|
|
clientConn.WriteMu.Lock()
|
|
clientConn.Conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
|
writeErr := clientConn.Conn.WriteJSON(map[string]interface{}{
|
|
"type": "log",
|
|
"channel": channel,
|
|
"task_id": taskID,
|
|
"job_id": jobID,
|
|
"data": taskLog,
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
clientConn.WriteMu.Unlock()
|
|
if writeErr != nil {
|
|
log.Printf("Failed to send initial log to client %d: %v", clientConn.UserID, writeErr)
|
|
// Connection is likely closed, stop sending more messages
|
|
break
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
} else if channel == "runners" {
|
|
// Send initial runner list (only for admins)
|
|
if !clientConn.IsAdmin {
|
|
return
|
|
}
|
|
|
|
s.db.With(func(conn *sql.DB) error {
|
|
rows, err2 := conn.Query(
|
|
`SELECT id, name, hostname, ip_address, status, last_heartbeat, capabilities, priority, created_at
|
|
FROM runners ORDER BY id ASC`,
|
|
)
|
|
if err2 != nil {
|
|
return err2
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var runner types.Runner
|
|
err := rows.Scan(
|
|
&runner.ID, &runner.Name, &runner.Hostname, &runner.IPAddress,
|
|
&runner.Status, &runner.LastHeartbeat, &runner.Capabilities,
|
|
&runner.Priority, &runner.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Send runner_status
|
|
clientConn.WriteMu.Lock()
|
|
clientConn.Conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
|
writeErr := clientConn.Conn.WriteJSON(map[string]interface{}{
|
|
"type": "runner_status",
|
|
"channel": channel,
|
|
"runner_id": runner.ID,
|
|
"data": runner,
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
clientConn.WriteMu.Unlock()
|
|
if writeErr != nil {
|
|
log.Printf("Failed to send initial runner_status to client %d: %v", clientConn.UserID, writeErr)
|
|
// Connection is likely closed, stop sending more messages
|
|
break
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
} else if strings.HasPrefix(channel, "upload:") {
|
|
// Send initial upload session state
|
|
sessionID := strings.TrimPrefix(channel, "upload:")
|
|
s.uploadSessionsMu.RLock()
|
|
session, exists := s.uploadSessions[sessionID]
|
|
s.uploadSessionsMu.RUnlock()
|
|
|
|
if exists && session.UserID == clientConn.UserID {
|
|
msgType := "upload_progress"
|
|
if session.Status != "uploading" {
|
|
msgType = "processing_status"
|
|
}
|
|
|
|
clientConn.WriteMu.Lock()
|
|
clientConn.Conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
|
writeErr := clientConn.Conn.WriteJSON(map[string]interface{}{
|
|
"type": msgType,
|
|
"channel": channel,
|
|
"session_id": sessionID,
|
|
"data": map[string]interface{}{
|
|
"progress": session.Progress,
|
|
"status": session.Status,
|
|
"message": session.Message,
|
|
},
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
clientConn.WriteMu.Unlock()
|
|
if writeErr != nil {
|
|
log.Printf("Failed to send initial upload state to client %d: %v", clientConn.UserID, writeErr)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleJobsWebSocket handles WebSocket connection for job list updates
|
|
func (s *Manager) handleJobsWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Upgrade to WebSocket
|
|
conn, err := s.wsUpgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
log.Printf("Failed to upgrade WebSocket: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Register connection
|
|
s.jobListConnsMu.Lock()
|
|
// Close existing connection if any
|
|
if oldConn, exists := s.jobListConns[userID]; exists && oldConn != nil {
|
|
oldConn.Close()
|
|
}
|
|
s.jobListConns[userID] = conn
|
|
s.jobListConnsMu.Unlock()
|
|
|
|
defer func() {
|
|
s.jobListConnsMu.Lock()
|
|
delete(s.jobListConns, userID)
|
|
s.jobListConnsMu.Unlock()
|
|
}()
|
|
|
|
// Send initial connection message
|
|
err = conn.WriteJSON(map[string]interface{}{
|
|
"type": "connected",
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
if err != nil {
|
|
log.Printf("Failed to send initial connection message: %v", err)
|
|
return
|
|
}
|
|
|
|
// Keep connection alive and handle ping/pong
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
conn.SetPongHandler(func(string) error {
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
return nil
|
|
})
|
|
|
|
// Start ping ticker
|
|
ticker := time.NewTicker(WSPingInterval)
|
|
defer ticker.Stop()
|
|
|
|
// Read messages in background to keep connection alive and handle pongs
|
|
readDone := make(chan struct{})
|
|
go func() {
|
|
defer close(readDone)
|
|
for {
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
_, _, err := conn.ReadMessage()
|
|
if err != nil {
|
|
// Connection closed or error - exit read loop
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
log.Printf("WebSocket read error for job list: %v", err)
|
|
}
|
|
return
|
|
}
|
|
// Reset read deadline after successful read (pong received)
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
}
|
|
}()
|
|
|
|
ctx := r.Context()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-readDone:
|
|
// Read loop exited, close connection
|
|
return
|
|
case <-ticker.C:
|
|
// Reset read deadline before sending ping to ensure we can receive pong
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
// Use WriteControl for ping frames (control frames)
|
|
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(WSWriteDeadline)); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleJobWebSocket handles WebSocket connection for single job updates
|
|
func (s *Manager) handleJobWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := getUserID(r)
|
|
if err != nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
jobID, err := parseID(r, "id")
|
|
if err != nil {
|
|
s.respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify job belongs to user (unless admin)
|
|
isAdmin := isAdminUser(r)
|
|
if !isAdmin {
|
|
var jobUserID int64
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&jobUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify job: %v", err))
|
|
return
|
|
}
|
|
if jobUserID != userID {
|
|
s.respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
} else {
|
|
var exists bool
|
|
err = s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM jobs WHERE id = ?)", jobID).Scan(&exists)
|
|
})
|
|
if err != nil || !exists {
|
|
s.respondError(w, http.StatusNotFound, "Job not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Upgrade to WebSocket
|
|
conn, err := s.wsUpgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
log.Printf("Failed to upgrade WebSocket: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
key := fmt.Sprintf("%d:%d", userID, jobID)
|
|
s.jobConnsMu.Lock()
|
|
// Close existing connection if any
|
|
if oldConn, exists := s.jobConns[key]; exists && oldConn != nil {
|
|
oldConn.Close()
|
|
}
|
|
s.jobConns[key] = conn
|
|
s.jobConnsMu.Unlock()
|
|
|
|
// Create a write mutex for this connection
|
|
s.jobConnsWriteMuMu.Lock()
|
|
s.jobConnsWriteMu[key] = &sync.Mutex{}
|
|
writeMu := s.jobConnsWriteMu[key]
|
|
s.jobConnsWriteMuMu.Unlock()
|
|
|
|
defer func() {
|
|
s.jobConnsMu.Lock()
|
|
delete(s.jobConns, key)
|
|
s.jobConnsMu.Unlock()
|
|
s.jobConnsWriteMuMu.Lock()
|
|
delete(s.jobConnsWriteMu, key)
|
|
s.jobConnsWriteMuMu.Unlock()
|
|
}()
|
|
|
|
// Send initial connection message
|
|
writeMu.Lock()
|
|
err = conn.WriteJSON(map[string]interface{}{
|
|
"type": "connected",
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
writeMu.Unlock()
|
|
if err != nil {
|
|
log.Printf("Failed to send initial connection message: %v", err)
|
|
return
|
|
}
|
|
|
|
// Keep connection alive and handle ping/pong
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
conn.SetPongHandler(func(string) error {
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
return nil
|
|
})
|
|
|
|
// Start ping ticker
|
|
ticker := time.NewTicker(WSPingInterval)
|
|
defer ticker.Stop()
|
|
|
|
// Read messages in background to keep connection alive and handle pongs
|
|
readDone := make(chan struct{})
|
|
go func() {
|
|
defer close(readDone)
|
|
for {
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
_, _, err := conn.ReadMessage()
|
|
if err != nil {
|
|
// Connection closed or error - exit read loop
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
log.Printf("WebSocket read error for job %d: %v", jobID, err)
|
|
}
|
|
return
|
|
}
|
|
// Reset read deadline after successful read (pong received)
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
}
|
|
}()
|
|
|
|
ctx := r.Context()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-readDone:
|
|
// Read loop exited, close connection
|
|
return
|
|
case <-ticker.C:
|
|
// Reset read deadline before sending ping to ensure we can receive pong
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
// Use WriteControl for ping frames (control frames)
|
|
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(WSWriteDeadline)); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// broadcastJobUpdate broadcasts job update to connected clients
|
|
func (s *Manager) broadcastJobUpdate(jobID int64, updateType string, data interface{}) {
|
|
// Get user_id from job
|
|
var userID int64
|
|
err := s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&userID)
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
msg := map[string]interface{}{
|
|
"type": updateType,
|
|
"job_id": jobID,
|
|
"data": data,
|
|
"timestamp": time.Now().Unix(),
|
|
}
|
|
|
|
// Always broadcast to jobs channel (all clients receive this)
|
|
if updateType == "job_update" || updateType == "job_created" {
|
|
// For job_update, only send status and progress to jobs channel
|
|
if updateType == "job_update" {
|
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
|
// Only include status and progress for jobs channel
|
|
jobsData := map[string]interface{}{}
|
|
if status, ok := dataMap["status"]; ok {
|
|
jobsData["status"] = status
|
|
}
|
|
if progress, ok := dataMap["progress"]; ok {
|
|
jobsData["progress"] = progress
|
|
}
|
|
jobsMsg := map[string]interface{}{
|
|
"type": updateType,
|
|
"job_id": jobID,
|
|
"data": jobsData,
|
|
"timestamp": time.Now().Unix(),
|
|
}
|
|
s.broadcastToAllClients("jobs", jobsMsg)
|
|
}
|
|
} else {
|
|
// job_created - send full data to all clients
|
|
s.broadcastToAllClients("jobs", msg)
|
|
}
|
|
}
|
|
|
|
// Only broadcast if client is connected
|
|
if s.isClientConnected(userID) {
|
|
// Broadcast to client WebSocket if subscribed to job:{id}
|
|
channel := fmt.Sprintf("job:%d", jobID)
|
|
s.broadcastToClient(userID, channel, msg)
|
|
}
|
|
|
|
// Also broadcast to old WebSocket connections (for backwards compatibility during migration)
|
|
s.jobListConnsMu.RLock()
|
|
if conn, exists := s.jobListConns[userID]; exists && conn != nil {
|
|
s.jobListConnsMu.RUnlock()
|
|
conn.SetWriteDeadline(time.Now().Add(WSWriteDeadline))
|
|
conn.WriteJSON(msg)
|
|
} else {
|
|
s.jobListConnsMu.RUnlock()
|
|
}
|
|
|
|
// Broadcast to single job connection
|
|
key := fmt.Sprintf("%d:%d", userID, jobID)
|
|
s.jobConnsMu.RLock()
|
|
conn, exists := s.jobConns[key]
|
|
s.jobConnsMu.RUnlock()
|
|
|
|
if exists && conn != nil {
|
|
s.jobConnsWriteMuMu.RLock()
|
|
writeMu, hasMu := s.jobConnsWriteMu[key]
|
|
s.jobConnsWriteMuMu.RUnlock()
|
|
|
|
if hasMu && writeMu != nil {
|
|
writeMu.Lock()
|
|
conn.SetWriteDeadline(time.Now().Add(WSWriteDeadline))
|
|
err := conn.WriteJSON(msg)
|
|
writeMu.Unlock()
|
|
if err != nil {
|
|
log.Printf("Failed to broadcast %s to job %d WebSocket: %v", updateType, jobID, err)
|
|
}
|
|
} else {
|
|
conn.SetWriteDeadline(time.Now().Add(WSWriteDeadline))
|
|
conn.WriteJSON(msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// broadcastTaskUpdate broadcasts task update to connected clients
|
|
func (s *Manager) broadcastTaskUpdate(jobID int64, taskID int64, updateType string, data interface{}) {
|
|
// Get user_id from job
|
|
var userID int64
|
|
err := s.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT user_id FROM jobs WHERE id = ?", jobID).Scan(&userID)
|
|
})
|
|
if err != nil {
|
|
log.Printf("broadcastTaskUpdate: Failed to get user_id for job %d: %v", jobID, err)
|
|
return
|
|
}
|
|
|
|
msg := map[string]interface{}{
|
|
"type": updateType,
|
|
"job_id": jobID,
|
|
"data": data,
|
|
"timestamp": time.Now().Unix(),
|
|
}
|
|
// Always include task_id if it's provided (even if 0, for consistency)
|
|
// For bulk operations like "tasks_added", task_id will be 0
|
|
if taskID > 0 {
|
|
msg["task_id"] = taskID
|
|
// Also include task_id in data for convenience
|
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
|
dataMap["task_id"] = taskID
|
|
}
|
|
}
|
|
|
|
// Only broadcast if client is connected
|
|
if !s.isClientConnected(userID) {
|
|
if s.verboseWSLogging {
|
|
log.Printf("broadcastTaskUpdate: Client %d not connected, skipping broadcast for task %d (job %d)", userID, taskID, jobID)
|
|
}
|
|
// Still broadcast to old WebSocket connections for backwards compatibility
|
|
} else {
|
|
// Broadcast to client WebSocket if subscribed to job:{id}
|
|
channel := fmt.Sprintf("job:%d", jobID)
|
|
if s.verboseWSLogging {
|
|
log.Printf("broadcastTaskUpdate: Broadcasting %s for task %d (job %d, user %d) on channel %s, data=%+v", updateType, taskID, jobID, userID, channel, data)
|
|
}
|
|
s.broadcastToClient(userID, channel, msg)
|
|
}
|
|
|
|
// Also broadcast to old WebSocket connection (for backwards compatibility during migration)
|
|
key := fmt.Sprintf("%d:%d", userID, jobID)
|
|
s.jobConnsMu.RLock()
|
|
conn, exists := s.jobConns[key]
|
|
s.jobConnsMu.RUnlock()
|
|
|
|
if exists && conn != nil {
|
|
s.jobConnsWriteMuMu.RLock()
|
|
writeMu, hasMu := s.jobConnsWriteMu[key]
|
|
s.jobConnsWriteMuMu.RUnlock()
|
|
|
|
if hasMu && writeMu != nil {
|
|
writeMu.Lock()
|
|
conn.SetWriteDeadline(time.Now().Add(WSWriteDeadline))
|
|
conn.WriteJSON(msg)
|
|
writeMu.Unlock()
|
|
} else {
|
|
conn.SetWriteDeadline(time.Now().Add(WSWriteDeadline))
|
|
conn.WriteJSON(msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// isClientConnected checks if a user has at least one active connection
|
|
func (s *Manager) isClientConnected(userID int64) bool {
|
|
s.clientConnsMu.RLock()
|
|
defer s.clientConnsMu.RUnlock()
|
|
for _, clientConn := range s.clientConns {
|
|
if clientConn != nil && clientConn.UserID == userID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getClientConnections returns all connections for a specific user
|
|
func (s *Manager) getClientConnections(userID int64) []*ClientConnection {
|
|
s.clientConnsMu.RLock()
|
|
defer s.clientConnsMu.RUnlock()
|
|
var conns []*ClientConnection
|
|
for _, clientConn := range s.clientConns {
|
|
if clientConn != nil && clientConn.UserID == userID {
|
|
conns = append(conns, clientConn)
|
|
}
|
|
}
|
|
return conns
|
|
}
|
|
|
|
// broadcastToClient sends a message to all connections for a specific user
|
|
func (s *Manager) broadcastToClient(userID int64, channel string, msg map[string]interface{}) {
|
|
conns := s.getClientConnections(userID)
|
|
|
|
if len(conns) == 0 {
|
|
// Client not connected - this is normal, don't log it (only log at verbose level)
|
|
if s.verboseWSLogging {
|
|
log.Printf("broadcastToClient: Client %d not connected (channel: %s)", userID, channel)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Add channel to message
|
|
msg["channel"] = channel
|
|
|
|
sentCount := 0
|
|
var deadConns []string
|
|
for _, clientConn := range conns {
|
|
// Check if client is subscribed to this channel (jobs channel is always sent)
|
|
if channel != "jobs" {
|
|
clientConn.SubsMu.RLock()
|
|
subscribed := clientConn.Subscriptions[channel]
|
|
clientConn.SubsMu.RUnlock()
|
|
if !subscribed {
|
|
continue
|
|
}
|
|
}
|
|
|
|
clientConn.WriteMu.Lock()
|
|
clientConn.Conn.SetWriteDeadline(time.Now().Add(WSWriteDeadline))
|
|
if err := clientConn.Conn.WriteJSON(msg); err != nil {
|
|
// Mark connection for removal - don't spam logs, just remove dead connections
|
|
deadConns = append(deadConns, clientConn.ConnID)
|
|
} else {
|
|
sentCount++
|
|
}
|
|
clientConn.WriteMu.Unlock()
|
|
}
|
|
|
|
// Remove dead connections
|
|
if len(deadConns) > 0 {
|
|
s.clientConnsMu.Lock()
|
|
for _, connID := range deadConns {
|
|
if conn, exists := s.clientConns[connID]; exists {
|
|
log.Printf("Removing dead connection %s for user %d (write failed)", connID, conn.UserID)
|
|
conn.Conn.Close()
|
|
delete(s.clientConns, connID)
|
|
}
|
|
}
|
|
s.clientConnsMu.Unlock()
|
|
}
|
|
|
|
if s.verboseWSLogging {
|
|
log.Printf("broadcastToClient: Sent to %d/%d connections for user %d on channel %s: type=%v",
|
|
sentCount, len(conns), userID, channel, msg["type"])
|
|
}
|
|
}
|
|
|
|
// broadcastToAllClients sends a message to all connected clients (for jobs channel)
|
|
func (s *Manager) broadcastToAllClients(channel string, msg map[string]interface{}) {
|
|
msg["channel"] = channel
|
|
|
|
s.clientConnsMu.RLock()
|
|
clients := make([]*ClientConnection, 0, len(s.clientConns))
|
|
for _, clientConn := range s.clientConns {
|
|
clients = append(clients, clientConn)
|
|
}
|
|
s.clientConnsMu.RUnlock()
|
|
|
|
var deadConns []string
|
|
for _, clientConn := range clients {
|
|
clientConn.WriteMu.Lock()
|
|
clientConn.Conn.SetWriteDeadline(time.Now().Add(WSWriteDeadline))
|
|
if err := clientConn.Conn.WriteJSON(msg); err != nil {
|
|
deadConns = append(deadConns, clientConn.ConnID)
|
|
}
|
|
clientConn.WriteMu.Unlock()
|
|
}
|
|
|
|
// Remove dead connections
|
|
if len(deadConns) > 0 {
|
|
s.clientConnsMu.Lock()
|
|
for _, connID := range deadConns {
|
|
if conn, exists := s.clientConns[connID]; exists {
|
|
log.Printf("Removing dead connection %s for user %d (write failed)", connID, conn.UserID)
|
|
conn.Conn.Close()
|
|
delete(s.clientConns, connID)
|
|
}
|
|
}
|
|
s.clientConnsMu.Unlock()
|
|
}
|
|
}
|
|
|
|
// broadcastUploadProgress broadcasts upload/processing progress to subscribed clients
|
|
// This function updates the session synchronously (quick operation) but broadcasts
|
|
// asynchronously to avoid blocking the upload handler on slow WebSocket writes.
|
|
func (s *Manager) broadcastUploadProgress(sessionID string, progress float64, status, message string) {
|
|
userID, exists := s.updateUploadSessionState(sessionID, progress, status, message)
|
|
if !exists {
|
|
return
|
|
}
|
|
|
|
// Broadcast asynchronously to avoid blocking upload handler on slow WebSocket writes
|
|
// This prevents the entire HTTP server from freezing during large file uploads
|
|
go func() {
|
|
// Determine message type
|
|
msgType := "upload_progress"
|
|
if status != "uploading" {
|
|
msgType = "processing_status"
|
|
}
|
|
|
|
msg := map[string]interface{}{
|
|
"type": msgType,
|
|
"session_id": sessionID,
|
|
"data": map[string]interface{}{
|
|
"progress": progress,
|
|
"status": status,
|
|
"phase": uploadSessionPhase(status),
|
|
"message": message,
|
|
},
|
|
"timestamp": time.Now().Unix(),
|
|
}
|
|
|
|
// Only broadcast if client is connected
|
|
if s.isClientConnected(userID) {
|
|
channel := fmt.Sprintf("upload:%s", sessionID)
|
|
s.broadcastToClient(userID, channel, msg)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// broadcastUploadProgressSync sends upload progress synchronously (for completion messages)
|
|
// This ensures the message is sent immediately and not lost
|
|
func (s *Manager) broadcastUploadProgressSync(userID int64, sessionID string, progress float64, status, message string) {
|
|
s.updateUploadSessionState(sessionID, progress, status, message)
|
|
|
|
// Determine message type
|
|
msgType := "upload_progress"
|
|
if status != "uploading" {
|
|
msgType = "processing_status"
|
|
}
|
|
|
|
msg := map[string]interface{}{
|
|
"type": msgType,
|
|
"session_id": sessionID,
|
|
"data": map[string]interface{}{
|
|
"progress": progress,
|
|
"status": status,
|
|
"phase": uploadSessionPhase(status),
|
|
"message": message,
|
|
},
|
|
"timestamp": time.Now().Unix(),
|
|
}
|
|
|
|
// Send synchronously to ensure delivery
|
|
if s.isClientConnected(userID) {
|
|
channel := fmt.Sprintf("upload:%s", sessionID)
|
|
s.broadcastToClient(userID, channel, msg)
|
|
}
|
|
}
|
|
|
|
// truncateString truncates a string to a maximum length, appending "..." if truncated
|
|
func truncateString(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
if maxLen <= 3 {
|
|
return "..."
|
|
}
|
|
return s[:maxLen-3] + "..."
|
|
}
|