request->getJSON(true) ?: $this->request->getPost(); $slug = (string) ($payload['tool'] ?? ''); $tool = service('finder')->tool($slug); if ($tool === null) { return response()->setStatusCode(400)->setJSON(['ok' => false, 'error' => 'unknown_tool', 'message' => 'Unknown tool slug.']); } // URL-kind tools fetch their input server-side (SSRF-guarded). $params = array_intersect_key($payload, array_flip([ 'url', 'format', 'bitrate', 'quality', 'width', 'height', 'start', 'end', 'x', 'y', 'crop_width', 'crop_height', 'degrees', 'direction', 'factor', 'fps', 'gain_db', 'level', 'preset', 'pages', '_operation', ])); $params['_operation'] = $params['_operation'] ?? $tool['operation']; if ($tool['kind'] === 'upload') { return response()->setStatusCode(400)->setJSON(['ok' => false, 'error' => 'upload_required', 'message' => 'This tool requires a file upload via POST /upload first.']); } if ($tool['kind'] === 'url') { $videoId = YoutubeLib::extractId((string) ($params['url'] ?? '')); if ($videoId === null) { service('analytics')->track('error', ['tool' => $slug]); return response()->setStatusCode(422)->setJSON(['ok' => false, 'error' => 'invalid_url', 'message' => 'Invalid YouTube URL.']); } // canonicalize to a plain watch URL $params['url'] = 'https://www.youtube.com/watch?v=' . $videoId; } try { $jobId = service('pipeline')->enqueue($tool, $params, $isApiV1 ? 'api' : 'web'); } catch (\RuntimeException $e) { return response()->setStatusCode(429)->setJSON(['ok' => false, 'error' => 'limit_reached', 'message' => $e->getMessage()]); } model(\App\Models\ToolModel::class)->bumpUseCount((int) $tool['id']); service('analytics')->track('tool_start', ['tool' => $slug]); return response()->setStatusCode(202)->setJSON([ 'ok' => true, 'job_id' => $jobId, 'status' => 'queued', 'poll_url' => base_url('/api/jobs/' . $jobId), ]); } public function show(string $id): \CodeIgniter\HTTP\ResponseInterface { $job = model(JobModel::class)->find($id); if ($job === null || ! $this->owns($job)) { return response()->setStatusCode(404)->setJSON(['ok' => false, 'error' => 'not_found']); } $body = [ 'job_id' => $job['id'], 'status' => $job['status'], 'stage' => $job['stage'], 'progress' => (int) $job['progress'], 'created_at' => $job['created_at'], 'expires_at' => $job['expires_at'], ]; if ($job['status'] === 'completed') { $body['download_url'] = '/download/' . $job['id']; $body['preview_url'] = '/preview/' . $job['id'] . '.' . pathinfo((string) $job['output_file'], PATHINFO_EXTENSION); $body['output_name'] = $job['output_name']; $body['output_size'] = (int) $job['output_size']; } if ($job['status'] === 'failed') { $body['message'] = self::friendlyError((string) ($job['error'] ?? 'Processing failed.')); } return response()->setJSON(['ok' => true] + $body); } public function delete(string $id): \CodeIgniter\HTTP\ResponseInterface { $jobs = model(JobModel::class); $job = $jobs->find($id); if ($job === null || ! $this->owns($job)) { return response()->setStatusCode(404)->setJSON(['ok' => false, 'error' => 'not_found']); } foreach (['input_file', 'output_file'] as $field) { if (! empty($job[$field])) { @unlink(Pipeline::storageDir() . '/' . $job[$field]); @unlink(Pipeline::incomingDir() . '/' . $job[$field]); } } if (in_array($job['status'], ['queued'], true)) { $jobs->update($id, ['status' => 'cancelled', 'completed_at' => gmdate('Y-m-d H:i:s')]); } else { $jobs->delete($id); } return response()->setJSON(['ok' => true, 'deleted' => true]); } /** Session ownership for web callers; API-token jobs are keyed by session hash too. */ private function owns(array $job): bool { return $job['session_id'] === service('analytics')->sessionHash(); } private static function friendlyError(string $raw): string { $known = [ 'Invalid YouTube URL.' => 'That does not look like a valid YouTube link. Paste the full URL.', 'disabled on this server' => 'This capability is currently disabled on this server.', 'exceeds' => 'The file is too large for this tool.', ]; foreach ($known as $needle => $friendly) { if (str_contains($raw, $needle)) { return $friendly; } } return 'This file could not be processed. Please check the format and try again.'; } }