package api import ( "database/sql" "encoding/json" "fmt" "io" "net/http" "time" "fuego/pkg/types" ) // handleCreateJob creates a new job func (s *Server) 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, "Invalid request body") return } if req.Name == "" { s.respondError(w, http.StatusBadRequest, "Job name is required") return } if req.FrameStart < 0 || req.FrameEnd < req.FrameStart { s.respondError(w, http.StatusBadRequest, "Invalid frame range") return } if req.OutputFormat == "" { req.OutputFormat = "PNG" } result, err := s.db.Exec( `INSERT INTO jobs (user_id, name, status, progress, frame_start, frame_end, output_format) VALUES (?, ?, ?, ?, ?, ?, ?)`, userID, req.Name, types.JobStatusPending, 0.0, req.FrameStart, req.FrameEnd, req.OutputFormat, ) if err != nil { s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create job: %v", err)) return } jobID, _ := result.LastInsertId() // Create tasks for the job (one task per frame for simplicity, could be batched) for frame := req.FrameStart; frame <= req.FrameEnd; frame++ { _, err = s.db.Exec( `INSERT INTO tasks (job_id, frame_start, frame_end, status) VALUES (?, ?, ?, ?)`, jobID, frame, frame, types.TaskStatusPending, ) if err != nil { s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create tasks: %v", err)) return } } job := types.Job{ ID: jobID, UserID: userID, Name: req.Name, Status: types.JobStatusPending, Progress: 0.0, FrameStart: req.FrameStart, FrameEnd: req.FrameEnd, OutputFormat: req.OutputFormat, CreatedAt: time.Now(), } s.respondJSON(w, http.StatusCreated, job) } // handleListJobs lists jobs for the current user func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) { userID, err := getUserID(r) if err != nil { s.respondError(w, http.StatusUnauthorized, err.Error()) return } rows, err := s.db.Query( `SELECT id, user_id, name, status, progress, frame_start, frame_end, output_format, created_at, started_at, completed_at, error_message FROM jobs WHERE user_id = ? ORDER BY created_at DESC`, userID, ) 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 startedAt, completedAt sql.NullTime err := rows.Scan( &job.ID, &job.UserID, &job.Name, &job.Status, &job.Progress, &job.FrameStart, &job.FrameEnd, &job.OutputFormat, &job.CreatedAt, &startedAt, &completedAt, &job.ErrorMessage, ) if err != nil { s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan job: %v", err)) return } if startedAt.Valid { job.StartedAt = &startedAt.Time } if completedAt.Valid { job.CompletedAt = &completedAt.Time } jobs = append(jobs, job) } s.respondJSON(w, http.StatusOK, jobs) } // handleGetJob gets a specific job func (s *Server) 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 startedAt, completedAt sql.NullTime err = s.db.QueryRow( `SELECT id, user_id, name, status, progress, frame_start, frame_end, output_format, created_at, started_at, completed_at, error_message FROM jobs WHERE id = ? AND user_id = ?`, jobID, userID, ).Scan( &job.ID, &job.UserID, &job.Name, &job.Status, &job.Progress, &job.FrameStart, &job.FrameEnd, &job.OutputFormat, &job.CreatedAt, &startedAt, &completedAt, &job.ErrorMessage, ) 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 startedAt.Valid { job.StartedAt = &startedAt.Time } if completedAt.Valid { job.CompletedAt = &completedAt.Time } s.respondJSON(w, http.StatusOK, job) } // handleCancelJob cancels a job func (s *Server) 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 } result, err := s.db.Exec( `UPDATE jobs SET status = ? WHERE id = ? AND user_id = ?`, types.JobStatusCancelled, jobID, userID, ) if err != nil { s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to cancel job: %v", err)) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { s.respondError(w, http.StatusNotFound, "Job not found") return } // Cancel pending tasks _, err = s.db.Exec( `UPDATE tasks SET status = ? WHERE job_id = ? AND status = ?`, types.TaskStatusFailed, jobID, types.TaskStatusPending, ) if err != nil { s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to cancel tasks: %v", err)) return } s.respondJSON(w, http.StatusOK, map[string]string{"message": "Job cancelled"}) } // handleUploadJobFile handles file upload for a job func (s *Server) 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.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 err = r.ParseMultipartForm(100 << 20) // 100 MB if err != nil { s.respondError(w, http.StatusBadRequest, "Failed to parse form") return } file, header, err := r.FormFile("file") if err != nil { s.respondError(w, http.StatusBadRequest, "No file provided") return } defer file.Close() // Save file filePath, err := s.storage.SaveUpload(jobID, header.Filename, file) if err != nil { s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to save file: %v", err)) return } // Record in database result, err := s.db.Exec( `INSERT INTO job_files (job_id, file_type, file_path, file_name, file_size) VALUES (?, ?, ?, ?, ?)`, jobID, types.JobFileTypeInput, filePath, header.Filename, header.Size, ) if err != nil { s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to record file: %v", err)) return } fileID, _ := result.LastInsertId() s.respondJSON(w, http.StatusCreated, map[string]interface{}{ "id": fileID, "file_name": header.Filename, "file_path": filePath, "file_size": header.Size, }) } // handleListJobFiles lists files for a job func (s *Server) 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 var jobUserID int64 err = s.db.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 jobUserID != userID { s.respondError(w, http.StatusForbidden, "Access denied") return } rows, err := s.db.Query( `SELECT id, job_id, file_type, file_path, file_name, file_size, created_at FROM job_files WHERE job_id = ? ORDER BY created_at DESC`, jobID, ) 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) } s.respondJSON(w, http.StatusOK, files) } // handleDownloadJobFile downloads a job file func (s *Server) 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 var jobUserID int64 err = s.db.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 jobUserID != userID { s.respondError(w, http.StatusForbidden, "Access denied") return } // Get file info var filePath, fileName string err = s.db.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() // Set headers w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) w.Header().Set("Content-Type", "application/octet-stream") // Stream file io.Copy(w, file) } // handleStreamVideo streams MP4 video file with range support func (s *Server) 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 var jobUserID int64 var outputFormat string err = s.db.QueryRow("SELECT user_id, output_format FROM jobs WHERE id = ?", jobID).Scan(&jobUserID, &outputFormat) if err == sql.ErrNoRows { s.respondError(w, http.StatusNotFound, "Job not found") return } if jobUserID != userID { s.respondError(w, http.StatusForbidden, "Access denied") return } // Find MP4 file var filePath, fileName string err = s.db.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) } }