apiKey = $apiKey ?? config('services.youtube.api_key'); $this->channelId = $channelId ?? config('services.youtube.channel_id'); $this->playlistId = $playlistId ?? config('services.youtube.playlist_id'); } public function isConfigured(): bool { return filled($this->apiKey) && (filled($this->channelId) || filled($this->playlistId)); } public function resolveReleasesPlaylistId(): ?string { if (filled($this->playlistId)) { return $this->playlistId; } $channelId = $this->channelId; if (blank($channelId)) { return null; } if (str_starts_with($channelId, 'UC')) { return 'UU' . substr($channelId, 2); } return $channelId; } /** * @return array> */ public function getReleases(): array { $playlistId = $this->resolveReleasesPlaylistId(); if (blank($playlistId)) { throw new \RuntimeException(__('music_productions.youtube_playlist_not_resolved')); } $releases = []; $pageToken = null; do { $params = [ 'key' => $this->apiKey, 'playlistId' => $playlistId, 'part' => 'snippet', 'maxResults' => 50, ]; if ($pageToken) { $params['pageToken'] = $pageToken; } $response = $this->client()->get('https://www.googleapis.com/youtube/v3/playlistItems', $params); if ($response->failed()) { Log::error('YouTube releases fetch failed', [ 'playlist_id' => $playlistId, 'status' => $response->status(), 'body' => $response->body(), ]); throw new \RuntimeException($this->formatApiError( __('music_productions.youtube_releases_fetch_failed'), $response )); } $data = $response->json(); $items = $data['items'] ?? []; foreach ($items as $item) { $normalized = $this->normalizeReleaseItem($item); if ($normalized) { $releases[$normalized['video_id']] = $normalized; } } $pageToken = $data['nextPageToken'] ?? null; } while ($pageToken); return array_values($releases); } /** * @param array $item * @return array|null */ protected function normalizeReleaseItem(array $item): ?array { $snippet = $item['snippet'] ?? []; $videoId = $snippet['resourceId']['videoId'] ?? null; $title = trim($snippet['title'] ?? ''); if (blank($videoId) || blank($title)) { return null; } if (in_array($title, ['Private video', 'Deleted video', 'Gizli video', 'Silinmiş video'], true)) { return null; } $thumbnails = $snippet['thumbnails'] ?? []; $coverUrl = $thumbnails['maxres']['url'] ?? $thumbnails['standard']['url'] ?? $thumbnails['high']['url'] ?? $thumbnails['medium']['url'] ?? $thumbnails['default']['url'] ?? null; return [ 'video_id' => $videoId, 'title' => $title, 'description' => trim($snippet['description'] ?? ''), 'cover_url' => $coverUrl, 'published_at' => $snippet['publishedAt'] ?? null, 'youtube_url' => 'https://www.youtube.com/watch?v=' . $videoId, ]; } public function downloadCoverImage(?string $imageUrl, string $videoId): ?string { if (blank($imageUrl)) { return null; } try { $response = Http::timeout(30)->get($imageUrl); if ($response->failed()) { return null; } $extension = $this->guessImageExtension($response->header('Content-Type')); $path = 'music-productions/covers/youtube-' . $videoId . '.' . $extension; Storage::disk('public')->put($path, $response->body()); return $path; } catch (\Throwable $exception) { Log::warning('YouTube cover download failed', [ 'video_id' => $videoId, 'message' => $exception->getMessage(), ]); return null; } } public function buildDefaultContent(array $release): string { $title = e($release['title'] ?? ''); $youtubeUrl = e($release['youtube_url'] ?? ''); $description = trim($release['description'] ?? ''); $listenLabel = e(__('music_productions.youtube_watch_on_youtube')); $descriptionHtml = filled($description) ? '

' . nl2br(e($description)) . '

' : ''; return <<{$title}

{$descriptionHtml}

{$listenLabel}

HTML; } protected function client(): PendingRequest { $request = Http::acceptJson()->timeout(30); $referer = config('services.youtube.api_referer'); if (filled($referer)) { $request = $request->withHeaders([ 'Referer' => rtrim($referer, '/') . '/', ]); } return $request; } protected function formatApiError(string $fallback, \Illuminate\Http\Client\Response $response): string { $message = $response->json('error.message') ?? trim($response->body()); if (blank($message)) { return $fallback; } if ($response->status() === 403 && str_contains(strtolower($message), 'referer')) { return __('music_productions.youtube_referrer_blocked', [ 'detail' => $message, ]); } return $fallback . ' (HTTP ' . $response->status() . ': ' . $message . ')'; } protected function guessImageExtension(?string $contentType): string { return match ($contentType) { 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif', default => 'jpg', }; } public static function uniqueSlug(string $title, ?int $ignoreId = null): string { $baseSlug = Str::slug($title) ?: 'youtube-release'; $slug = $baseSlug; $counter = 1; while ( \App\Models\MusicProduction::withTrashed() ->when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId)) ->where('slug', $slug) ->exists() ) { $slug = $baseSlug . '-' . $counter; $counter++; } return $slug; } }