normalizeType($type); $order = in_array($order, ['relevance', 'recent'], true) ? $order : 'relevance'; $page = max(1, $page); if ($apiKey === '') { throw new RuntimeException($this->missingApiKeyMessage()); } if ($cleanQuery === '') { return [ 'results' => [ 'images' => [], 'videos' => [], 'icons' => [], 'audio' => [], ], 'errors' => [], 'summary' => [ 'images' => 0, 'videos' => 0, 'icons' => 0, 'audio' => 0, ], ]; } $targets = $type === 'all' ? ['images', 'videos', 'icons', 'audio'] : [$type]; $results = [ 'images' => [], 'videos' => [], 'icons' => [], 'audio' => [], ]; $errors = []; foreach ($targets as $target) { try { $results[$target] = match ($target) { 'images' => $this->searchResources($apiKey, $cleanQuery, $order, $page), 'videos' => $this->searchVideos($apiKey, $cleanQuery, $order, $page), 'icons' => $this->searchIcons($apiKey, $cleanQuery, $order, $page), 'audio' => $this->searchMusic($apiKey, $cleanQuery, $page), default => [], }; } catch (\Throwable $error) { $errors[$target] = $error->getMessage(); } } return [ 'results' => $results, 'errors' => $errors, 'summary' => [ 'images' => count($results['images']), 'videos' => count($results['videos']), 'icons' => count($results['icons']), 'audio' => count($results['audio']), ], ]; } private function searchResources(string $apiKey, string $query, string $order, int $page): array { $response = $this->client($apiKey) ->get($this->endpoint('/v1/resources'), [ 'term' => $query, 'page' => $page, 'limit' => 8, 'order' => $order, ]); $items = $this->extractList($response, 'Freepik stock image search gagal'); return collect($items)->map(function (array $item) { return [ 'id' => (int) ($item['id'] ?? 0), 'kind' => 'image', 'title' => (string) ($item['title'] ?? 'Untitled asset'), 'url' => (string) ($item['url'] ?? ''), 'thumb' => (string) data_get($item, 'image.source.url', ''), 'subtitle' => trim(implode(' • ', array_filter([ strtoupper((string) data_get($item, 'image.type', 'asset')), ucfirst((string) data_get($item, 'image.orientation', '')), (string) data_get($item, 'author.name', ''), ]))), 'badge' => strtoupper((string) data_get($item, 'image.type', 'image')), 'meta_line' => trim(implode(' • ', array_filter([ (string) data_get($item, 'meta.published_at', ''), 'Likes '.(string) data_get($item, 'stats.likes', 0), ]))), ]; })->filter(fn (array $item) => $item['thumb'] !== '')->values()->all(); } private function searchVideos(string $apiKey, string $query, string $order, int $page): array { $response = $this->client($apiKey) ->get($this->endpoint('/v1/videos'), [ 'term' => $query, 'page' => $page, 'order' => $order, ]); $items = array_slice($this->extractList($response, 'Freepik video search gagal'), 0, 8); return collect($items)->map(function (array $item) { return [ 'id' => (int) ($item['id'] ?? 0), 'kind' => 'video', 'title' => (string) ($item['name'] ?? 'Untitled video'), 'url' => (string) ($item['url'] ?? ''), 'thumb' => (string) data_get($item, 'thumbnails.1.url', data_get($item, 'thumbnails.0.url', '')), 'preview_url' => (string) data_get($item, 'previews.0.url', ''), 'subtitle' => trim(implode(' • ', array_filter([ strtoupper((string) ($item['quality'] ?? 'video')), (string) ($item['duration'] ?? ''), (string) data_get($item, 'author.name', ''), ]))), 'badge' => 'VIDEO', 'meta_line' => trim(implode(' • ', array_filter([ (string) ($item['aspect_ratio'] ?? ''), (string) ($item['item_subtype'] ?? ''), ]))), ]; })->filter(fn (array $item) => $item['thumb'] !== '')->values()->all(); } private function searchIcons(string $apiKey, string $query, string $order, int $page): array { $response = $this->client($apiKey) ->get($this->endpoint('/v1/icons'), [ 'term' => $query, 'page' => $page, 'per_page' => 8, 'order' => $order, 'thumbnail_size' => 256, ]); $items = $this->extractList($response, 'Freepik icon search gagal'); return collect($items)->map(function (array $item) { $slug = trim((string) ($item['slug'] ?? '')); $id = (int) ($item['id'] ?? 0); return [ 'id' => $id, 'kind' => 'icon', 'title' => (string) ($item['name'] ?? 'Untitled icon'), 'url' => $slug !== '' && $id > 0 ? 'https://www.freepik.com/icon/'.$slug.'_'.$id : '', 'thumb' => (string) data_get($item, 'thumbnails.0.url', ''), 'subtitle' => trim(implode(' • ', array_filter([ (string) data_get($item, 'style.name', ''), (string) data_get($item, 'author.name', ''), ]))), 'badge' => 'ICON', 'meta_line' => collect(data_get($item, 'tags', [])) ->take(3) ->pluck('name') ->filter() ->implode(' • '), ]; })->filter(fn (array $item) => $item['thumb'] !== '')->values()->all(); } private function searchMusic(string $apiKey, string $query, int $page): array { $response = $this->client($apiKey) ->get($this->endpoint('/v1/music'), [ 'q' => $query, 'limit' => 6, 'offset' => ($page - 1) * 6, ]); $json = $response->json(); if (! $response->successful()) { throw new RuntimeException('Freepik music search gagal: '.$response->status().' - '.$this->responseError($response)); } $items = data_get($json, 'results', []); if (! is_array($items)) { return []; } return collect($items)->map(function (array $item) { $genres = collect(data_get($item, 'genres', []))->pluck('name')->filter()->take(2)->implode(' • '); return [ 'id' => (int) ($item['id'] ?? 0), 'kind' => 'audio', 'title' => (string) ($item['title'] ?? 'Untitled track'), 'url' => (string) ($item['preview_url'] ?? ''), 'thumb' => (string) ($item['cover_url'] ?? ''), 'subtitle' => trim(implode(' • ', array_filter([ (string) ($item['time'] ?? ''), (string) data_get($item, 'artist.name', ''), ]))), 'badge' => 'AUDIO', 'meta_line' => trim(implode(' • ', array_filter([ $genres, (bool) ($item['is_premium'] ?? false) ? 'Premium' : 'Free', ]))), ]; })->filter(fn (array $item) => $item['thumb'] !== '')->values()->all(); } private function client(string $apiKey) { return Http::connectTimeout(15) ->timeout(45) ->retry(3, 500, fn ($exception) => $exception instanceof ConnectionException, throw: false) ->acceptJson() ->withOptions([ 'force_ip_resolve' => 'v4', ]) ->withHeaders([ 'x-freepik-api-key' => $apiKey, 'User-Agent' => 'TeknoMedia/1.0 (+Laravel Freepik Search Client)', ]); } private function endpoint(string $path): string { return rtrim($this->apiOrigin(), '/').$path; } private function apiOrigin(): string { $configured = trim((string) config('services.freepik.base_url', 'https://api.freepik.com/v1/ai/text-to-image')); $scheme = parse_url($configured, PHP_URL_SCHEME) ?: 'https'; $host = parse_url($configured, PHP_URL_HOST); $port = parse_url($configured, PHP_URL_PORT); if (! is_string($host) || $host === '') { return 'https://api.freepik.com'; } return $scheme.'://'.$host.($port ? ':'.$port : ''); } private function extractList(Response $response, string $prefix): array { if (! $response->successful()) { throw new RuntimeException($prefix.': '.$response->status().' - '.$this->responseError($response)); } $items = data_get($response->json(), 'data', []); return is_array($items) ? $items : []; } private function responseError(Response $response): string { if ($response->status() === 429) { return 'Kuota Freepik API habis (HTTP 429). Isi billing/upgrade paket di dashboard Freepik Developers.'; } $message = data_get($response->json(), 'message') ?? data_get($response->json(), 'error.message') ?? data_get($response->json(), 'detail') ?? $response->body(); return Str::limit(trim((string) $message), 240); } private function normalizeType(string $type): string { return match ($type) { 'image', 'images' => 'images', 'video', 'videos' => 'videos', 'audio', 'music' => 'audio', 'icon', 'icons', 'other', 'others' => 'icons', default => 'all', }; } private function missingApiKeyMessage(): string { $value = strtolower(trim((string) ( config('services.huggingface.hf_only') ?? $_ENV['TOOLS_HF_ONLY'] ?? $_SERVER['TOOLS_HF_ONLY'] ?? getenv('TOOLS_HF_ONLY') ?? 'false' ))); if (in_array($value, ['1', 'true', 'yes', 'on'], true)) { return 'Browser stock Freepik dimatikan di mode Hugging Face-only.'; } return 'FREEPIK_API_KEY belum diisi di file .env'; } }