| namespace App\Services; | |
| use Illuminate\Http\Client\ConnectionException; | |
| use Illuminate\Http\Client\Response; | |
| use Illuminate\Support\Facades\Http; | |
| use Illuminate\Support\Str; | |
| use RuntimeException; | |
| class FreepikStockContentService | |
| { | |
| public function search(string $query, string $type = 'all', string $order = 'relevance', int $page = 1): array | |
| { | |
| $apiKey = trim((string) config('services.freepik.api_key', '')); | |
| $cleanQuery = trim($query); | |
| $type = $this->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'; | |
| } | |
| } | |