diff --git a/.env.example b/.env.example index 5150bcb..d96ae48 100644 --- a/.env.example +++ b/.env.example @@ -70,3 +70,14 @@ SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= SPOTIFY_ARTIST_ID= SPOTIFY_MARKET=TR + +# YouTube Data API v3 (Müzik Prodüksiyonları / Art Track senkronizasyonu) +# Kanal ID'si UC ile başlar; Releases playlist ID otomatik olarak UU ile türetilir. +# Sunucu/cron senkronizasyonu için KISITLAMASIZ veya IP kısıtlı ayrı bir anahtar kullanın. +# Web sitesi için referrer kısıtlı anahtar burada ÇALIŞMAZ. +YOUTUBE_API_KEY= +YOUTUBE_CHANNEL_ID= +# İsteğe bağlı: Releases playlist ID'sini manuel belirtmek için (UC->UU dönüşümünü atlar) +YOUTUBE_PLAYLIST_ID= +# Sadece referrer kısıtlı web anahtarı kullanıyorsanız, whitelist'teki tam URL'yi girin +# YOUTUBE_API_REFERER=https://truncgil.com diff --git a/app/Console/Commands/SyncYoutubeReleases.php b/app/Console/Commands/SyncYoutubeReleases.php new file mode 100644 index 0000000..e353214 --- /dev/null +++ b/app/Console/Commands/SyncYoutubeReleases.php @@ -0,0 +1,176 @@ +option('channel') || $this->option('playlist')) { + $youTubeService = new YouTubeService( + channelId: $this->option('channel') ?? config('services.youtube.channel_id'), + playlistId: $this->option('playlist') ?? config('services.youtube.playlist_id'), + ); + } + + if (! $youTubeService->isConfigured()) { + $this->error(__('music_productions.youtube_not_configured')); + + return Command::FAILURE; + } + + $this->info(__('music_productions.youtube_sync_started')); + $this->line('Playlist ID: ' . $youTubeService->resolveReleasesPlaylistId()); + + try { + $releases = $youTubeService->getReleases(); + } catch (\Throwable $exception) { + $this->error($exception->getMessage()); + + return Command::FAILURE; + } + + if (empty($releases)) { + $this->warn(__('music_productions.youtube_no_releases')); + + return Command::SUCCESS; + } + + $created = 0; + $updated = 0; + $dryRun = (bool) $this->option('dry-run'); + + foreach ($releases as $index => $release) { + $videoId = $release['video_id'] ?? null; + + if (blank($videoId)) { + continue; + } + + $title = $release['title'] ?? __('music_productions.youtube_untitled_release'); + $releaseDate = filled($release['published_at'] ?? null) + ? Carbon::parse($release['published_at']) + : null; + $youtubeUrl = $release['youtube_url'] ?? null; + $coverUrl = $release['cover_url'] ?? null; + $description = $release['description'] ?? ''; + + $production = $this->findMatchingProduction($videoId, $title); + + $action = $production ? 'update' : 'create'; + $this->line(sprintf( + '[%d/%d] %s: %s (%s)', + $index + 1, + count($releases), + $action === 'create' ? 'Yeni' : 'Güncelle', + $title, + $releaseDate?->format('Y-m-d') ?? '-' + )); + + if ($dryRun) { + continue; + } + + $attributes = [ + 'youtube_video_id' => $videoId, + 'youtube_url' => $youtubeUrl, + 'youtube_cover_url' => $coverUrl, + 'youtube_description' => $description, + 'youtube_synced_at' => now(), + ]; + + if (! $production) { + $attributes['title'] = $title; + $attributes['slug'] = YouTubeService::uniqueSlug($title); + $attributes['production_date'] = $releaseDate; + $attributes['content'] = $youTubeService->buildDefaultContent($release); + $attributes['is_active'] = true; + $attributes['sort_order'] = 0; + + $coverPath = $youTubeService->downloadCoverImage($coverUrl, $videoId); + if ($coverPath) { + $attributes['cover_image'] = $coverPath; + } + + MusicProduction::create($attributes); + $created++; + + continue; + } + + if (blank($production->title)) { + $attributes['title'] = $title; + } + + if (blank($production->production_date) && $releaseDate) { + $attributes['production_date'] = $releaseDate; + } + + if (blank($production->content)) { + $attributes['content'] = $youTubeService->buildDefaultContent($release); + } + + if (blank($production->cover_image) && filled($coverUrl)) { + $coverPath = $youTubeService->downloadCoverImage($coverUrl, $videoId); + if ($coverPath) { + $attributes['cover_image'] = $coverPath; + } + } + + $production->update($attributes); + $updated++; + + usleep(150000); + } + + $message = __('music_productions.youtube_sync_completed', [ + 'created' => $created, + 'updated' => $updated, + 'total' => count($releases), + ]); + + $this->info($message); + Log::info('YouTube releases sync completed', [ + 'created' => $created, + 'updated' => $updated, + 'total' => count($releases), + 'dry_run' => $dryRun, + ]); + + return Command::SUCCESS; + } + + protected function findMatchingProduction(string $videoId, string $title): ?MusicProduction + { + $byYoutubeId = MusicProduction::withTrashed() + ->where('youtube_video_id', $videoId) + ->first(); + + if ($byYoutubeId) { + return $byYoutubeId->trashed() ? null : $byYoutubeId; + } + + $slug = Str::slug($title); + + $bySlug = MusicProduction::where('slug', '=', $slug, 'and')->first(); + if ($bySlug) { + return $bySlug; + } + + return MusicProduction::whereRaw('LOWER(title) = ?', [Str::lower($title)], 'and')->first(); + } +} diff --git a/app/Filament/Admin/Resources/MusicProductions/Pages/ListMusicProductions.php b/app/Filament/Admin/Resources/MusicProductions/Pages/ListMusicProductions.php index 634ffb4..1abb3a1 100644 --- a/app/Filament/Admin/Resources/MusicProductions/Pages/ListMusicProductions.php +++ b/app/Filament/Admin/Resources/MusicProductions/Pages/ListMusicProductions.php @@ -3,8 +3,10 @@ namespace App\Filament\Admin\Resources\MusicProductions\Pages; use App\Console\Commands\SyncSpotifyMusicProductions; +use App\Console\Commands\SyncYoutubeReleases; use App\Filament\Admin\Resources\MusicProductions\MusicProductionResource; use App\Services\SpotifyService; +use App\Services\YouTubeService; use Filament\Actions\Action; use Filament\Actions\CreateAction; use Filament\Notifications\Notification; @@ -48,6 +50,31 @@ class ListMusicProductions extends ListRecords ->success() ->send(); }), + Action::make('syncYoutube') + ->label(__('music_productions.sync_youtube')) + ->icon('heroicon-o-play') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('music_productions.sync_youtube_heading')) + ->modalDescription(__('music_productions.sync_youtube_description')) + ->visible(fn (): bool => app(YouTubeService::class)->isConfigured()) + ->action(function (): void { + $exitCode = Artisan::call(SyncYoutubeReleases::class); + + if ($exitCode !== 0) { + Notification::make() + ->title(__('music_productions.youtube_sync_failed')) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title(__('music_productions.youtube_sync_success')) + ->success() + ->send(); + }), CreateAction::make() ->label(__('music_productions.create')), ]; diff --git a/app/Filament/Admin/Resources/MusicProductions/Schemas/MusicProductionForm.php b/app/Filament/Admin/Resources/MusicProductions/Schemas/MusicProductionForm.php index e363812..c68391e 100644 --- a/app/Filament/Admin/Resources/MusicProductions/Schemas/MusicProductionForm.php +++ b/app/Filament/Admin/Resources/MusicProductions/Schemas/MusicProductionForm.php @@ -123,6 +123,33 @@ class MusicProductionForm ->collapsible(true) ->collapsed(true) ->visible(fn (?MusicProduction $record): bool => filled($record?->spotify_album_id)), + + Section::make(__('music_productions.youtube_section')) + ->schema([ + Placeholder::make('youtube_video_id_display') + ->label(__('music_productions.youtube_video_id_field')) + ->content(fn (?MusicProduction $record): string => $record?->youtube_video_id ?? '-'), + + Placeholder::make('youtube_url_display') + ->label(__('music_productions.youtube_url_field')) + ->content(fn (?MusicProduction $record): string => $record?->youtube_url ?? '-'), + + Placeholder::make('youtube_description_display') + ->label(__('music_productions.youtube_description_field')) + ->content(fn (?MusicProduction $record): string => filled($record?->youtube_description) + ? \Illuminate\Support\Str::limit($record->youtube_description, 500) + : '-'), + + Placeholder::make('youtube_synced_at_display') + ->label(__('music_productions.youtube_synced_at_field')) + ->content(fn (?MusicProduction $record): string => $record?->youtube_synced_at + ?->timezone(config('app.timezone')) + ->format('d.m.Y H:i') ?? '-'), + ]) + ->columnSpanFull() + ->collapsible(true) + ->collapsed(true) + ->visible(fn (?MusicProduction $record): bool => filled($record?->youtube_video_id)), // Alt Kısım - Galeri Görselleri Section::make(__('music_productions.gallery_field')) diff --git a/app/Models/MusicProduction.php b/app/Models/MusicProduction.php index 41f6672..f32cffc 100644 --- a/app/Models/MusicProduction.php +++ b/app/Models/MusicProduction.php @@ -21,6 +21,11 @@ class MusicProduction extends Model 'spotify_cover_url', 'spotify_type', 'spotify_synced_at', + 'youtube_video_id', + 'youtube_url', + 'youtube_cover_url', + 'youtube_description', + 'youtube_synced_at', 'cover_image', 'content', 'client_name', @@ -42,6 +47,7 @@ class MusicProduction extends Model protected $casts = [ 'production_date' => 'date', 'spotify_synced_at' => 'datetime', + 'youtube_synced_at' => 'datetime', 'gallery' => 'array', 'is_active' => 'boolean', 'sort_order' => 'integer', @@ -61,7 +67,11 @@ class MusicProduction extends Model if ($this->spotify_cover_url) { return $this->spotify_cover_url; } - + + if ($this->youtube_cover_url) { + return $this->youtube_cover_url; + } + return null; } diff --git a/app/Services/YouTubeService.php b/app/Services/YouTubeService.php new file mode 100644 index 0000000..026c08a --- /dev/null +++ b/app/Services/YouTubeService.php @@ -0,0 +1,249 @@ +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; + } +} diff --git a/config/services.php b/config/services.php index 989a2ef..606b1dd 100644 --- a/config/services.php +++ b/config/services.php @@ -50,4 +50,11 @@ return [ 'market' => env('SPOTIFY_MARKET', 'TR'), ], + 'youtube' => [ + 'api_key' => env('YOUTUBE_API_KEY'), + 'channel_id' => env('YOUTUBE_CHANNEL_ID'), + 'playlist_id' => env('YOUTUBE_PLAYLIST_ID'), + 'api_referer' => env('YOUTUBE_API_REFERER'), + ], + ]; diff --git a/database/migrations/2026_05_20_193131_add_youtube_fields_to_music_productions_table.php b/database/migrations/2026_05_20_193131_add_youtube_fields_to_music_productions_table.php new file mode 100644 index 0000000..e4c0b18 --- /dev/null +++ b/database/migrations/2026_05_20_193131_add_youtube_fields_to_music_productions_table.php @@ -0,0 +1,41 @@ +string('youtube_video_id')->nullable()->unique()->after('spotify_synced_at'); + $table->string('youtube_url')->nullable()->after('youtube_video_id'); + $table->string('youtube_cover_url')->nullable()->after('youtube_url'); + $table->text('youtube_description')->nullable()->after('youtube_cover_url'); + $table->timestamp('youtube_synced_at')->nullable()->after('youtube_description'); + + $table->index('youtube_video_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('music_productions', function (Blueprint $table) { + $table->dropIndex(['youtube_video_id']); + $table->dropColumn([ + 'youtube_video_id', + 'youtube_url', + 'youtube_cover_url', + 'youtube_description', + 'youtube_synced_at', + ]); + }); + } +}; diff --git a/lang/en/music_productions.php b/lang/en/music_productions.php index b590c55..095e169 100644 --- a/lang/en/music_productions.php +++ b/lang/en/music_productions.php @@ -101,4 +101,25 @@ return [ 'spotify_premium_required' => 'The Spotify account that owns the Developer app needs an active Premium subscription. Sign in to Spotify Developer Dashboard with that account, upgrade to Premium, and wait a few hours. Detail: :detail', 'spotify_no_albums' => 'No albums or singles found on the Spotify artist account.', 'spotify_untitled_album' => 'Untitled Album', + + // YouTube + 'youtube_section' => 'YouTube Information', + 'youtube_video_id_field' => 'YouTube Video ID', + 'youtube_url_field' => 'YouTube URL', + 'youtube_description_field' => 'YouTube Description', + 'youtube_synced_at_field' => 'Last YouTube Sync', + 'youtube_watch_on_youtube' => 'Watch on YouTube', + 'sync_youtube' => 'Sync from YouTube', + 'sync_youtube_heading' => 'YouTube Releases Sync', + 'sync_youtube_description' => 'Art track releases from your channel\'s Releases playlist will be synced to music productions. Existing records are updated by title match, new records are created.', + 'youtube_sync_started' => 'Starting YouTube Releases sync...', + 'youtube_sync_completed' => 'Sync completed. :created created, :updated updated (:total total).', + 'youtube_sync_success' => 'YouTube sync completed successfully.', + 'youtube_sync_failed' => 'YouTube sync failed. Check the logs.', + 'youtube_not_configured' => 'YouTube API credentials are missing. Set YOUTUBE_API_KEY and YOUTUBE_CHANNEL_ID (or YOUTUBE_PLAYLIST_ID) in your .env file.', + 'youtube_releases_fetch_failed' => 'Failed to fetch YouTube Releases list.', + 'youtube_referrer_blocked' => 'YouTube API key rejected the server request. Your current key is likely restricted to HTTP referrers for the website. Fix: Google Cloud Console → Credentials → Create API key → Application restrictions: "None" (or your server IP) → API restrictions: YouTube Data API v3 → set the new key as YOUTUBE_API_KEY in .env. Detail: :detail', + 'youtube_playlist_not_resolved' => 'Could not resolve YouTube Releases playlist ID. Set YOUTUBE_CHANNEL_ID or YOUTUBE_PLAYLIST_ID.', + 'youtube_no_releases' => 'No releases found in the YouTube Releases playlist.', + 'youtube_untitled_release' => 'Untitled Release', ]; diff --git a/lang/tr/music_productions.php b/lang/tr/music_productions.php index 244a443..ac8fe2c 100644 --- a/lang/tr/music_productions.php +++ b/lang/tr/music_productions.php @@ -101,4 +101,25 @@ return [ 'spotify_premium_required' => 'Spotify Developer uygulamasını oluşturan hesapta aktif Premium abonelik gerekli. Spotify Developer Dashboard\'da uygulamayı oluşturan hesapla giriş yapın, Premium\'a geçin ve birkaç saat bekleyin. Detay: :detail', 'spotify_no_albums' => 'Spotify sanatçı hesabında albüm veya single bulunamadı.', 'spotify_untitled_album' => 'İsimsiz Albüm', + + // YouTube + 'youtube_section' => 'YouTube Bilgileri', + 'youtube_video_id_field' => 'YouTube Video ID', + 'youtube_url_field' => 'YouTube URL', + 'youtube_description_field' => 'YouTube Açıklaması', + 'youtube_synced_at_field' => 'Son YouTube Senkronizasyonu', + 'youtube_watch_on_youtube' => 'YouTube\'da İzle', + 'sync_youtube' => 'YouTube\'dan Senkronize Et', + 'sync_youtube_heading' => 'YouTube Releases Senkronizasyonu', + 'sync_youtube_description' => 'Kanalınızın Releases (Yayınlar) listesindeki art track kayıtları müzik prodüksiyonlarına aktarılacak. Mevcut kayıtlar başlık eşleşmesine göre güncellenir, yeni kayıtlar oluşturulur.', + 'youtube_sync_started' => 'YouTube Releases senkronizasyonu başlıyor...', + 'youtube_sync_completed' => 'Senkronizasyon tamamlandı. :created yeni, :updated güncellendi (toplam :total).', + 'youtube_sync_success' => 'YouTube senkronizasyonu başarıyla tamamlandı.', + 'youtube_sync_failed' => 'YouTube senkronizasyonu başarısız oldu. Logları kontrol edin.', + 'youtube_not_configured' => 'YouTube API bilgileri eksik. .env dosyasında YOUTUBE_API_KEY ve YOUTUBE_CHANNEL_ID (veya YOUTUBE_PLAYLIST_ID) değerlerini tanımlayın.', + 'youtube_releases_fetch_failed' => 'YouTube Releases listesi alınamadı.', + 'youtube_referrer_blocked' => 'YouTube API anahtarı sunucu isteğini reddetti. Mevcut anahtar muhtemelen web sitesi için "HTTP referrer" ile kısıtlı. Çözüm: Google Cloud Console → Credentials → Create API key → Application restrictions: "None" (veya sunucu IP\'niz) → API restrictions: YouTube Data API v3 → yeni anahtarı YOUTUBE_API_KEY olarak .env\'ye yazın. Detay: :detail', + 'youtube_playlist_not_resolved' => 'YouTube Releases playlist ID\'si çözümlenemedi. YOUTUBE_CHANNEL_ID veya YOUTUBE_PLAYLIST_ID tanımlayın.', + 'youtube_no_releases' => 'YouTube Releases listesinde kayıt bulunamadı.', + 'youtube_untitled_release' => 'İsimsiz Yayın', ]; diff --git a/routes/console.php b/routes/console.php index 5553bbe..639bc87 100644 --- a/routes/console.php +++ b/routes/console.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Schedule; use App\Console\Commands\GenerateBlogAssistant; use App\Console\Commands\SyncSpotifyMusicProductions; +use App\Console\Commands\SyncYoutubeReleases; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); @@ -16,3 +17,6 @@ Schedule::command(GenerateBlogAssistant::class)->dailyAt('09:00'); // Spotify müzik prodüksiyonlarını her gece senkronize et Schedule::command(SyncSpotifyMusicProductions::class)->dailyAt('03:00'); + +// YouTube Releases (Art Track) kayıtlarını her gece senkronize et +Schedule::command(SyncYoutubeReleases::class)->dailyAt('03:30');