diff --git a/.env.example b/.env.example index d96ae48..82d98f5 100644 --- a/.env.example +++ b/.env.example @@ -72,12 +72,16 @@ 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= +# Ana kanal (@handle) — Videolar sekmesi YOUTUBE_CHANNEL_ID= -# İsteğe bağlı: Releases playlist ID'sini manuel belirtmek için (UC->UU dönüşümünü atlar) +# Topic kanalı — Yayınlananlar sekmesi (DistroKid art track). Boş bırakılırsa otomatik bulunur. +# Örnek: Ümit Reva kanalı için UCEGzDgiExoGrwWEnpIdOGRA +YOUTUBE_TOPIC_CHANNEL_ID= +# Sunucu/cron senkronizasyonu için KISITLAMASIZ veya IP kısıtlı ayrı bir anahtar kullanın. +YOUTUBE_API_KEY= +# İsteğe bağlı: playlist ID doğrudan (Topic uploads: UU + topic channel ID'den sonraki kısım) YOUTUBE_PLAYLIST_ID= -# Sadece referrer kısıtlı web anahtarı kullanıyorsanız, whitelist'teki tam URL'yi girin +# İsteğe bağlı: yalnızca DistroKid yayınları için açıklama filtresi +# YOUTUBE_DISTRIBUTOR_FILTER=Provided to YouTube by DistroKid +# Sadece referrer kısıtlı web anahtarı kullanıyorsanız: # YOUTUBE_API_REFERER=https://truncgil.com diff --git a/app/Console/Commands/SyncYoutubeReleases.php b/app/Console/Commands/SyncYoutubeReleases.php index e353214..e5f7c5b 100644 --- a/app/Console/Commands/SyncYoutubeReleases.php +++ b/app/Console/Commands/SyncYoutubeReleases.php @@ -12,7 +12,8 @@ use Illuminate\Support\Str; class SyncYoutubeReleases extends Command { protected $signature = 'youtube:sync-releases - {--channel= : Override YouTube channel ID} + {--channel= : Override YouTube main channel ID} + {--topic= : Override YouTube Topic channel ID (Yayınlananlar / art track)} {--playlist= : Override Releases playlist ID} {--dry-run : Show changes without writing to database}'; @@ -20,9 +21,10 @@ class SyncYoutubeReleases extends Command public function handle(YouTubeService $youTubeService): int { - if ($this->option('channel') || $this->option('playlist')) { + if ($this->option('channel') || $this->option('topic') || $this->option('playlist')) { $youTubeService = new YouTubeService( channelId: $this->option('channel') ?? config('services.youtube.channel_id'), + topicChannelId: $this->option('topic') ?? config('services.youtube.topic_channel_id'), playlistId: $this->option('playlist') ?? config('services.youtube.playlist_id'), ); } @@ -34,6 +36,9 @@ class SyncYoutubeReleases extends Command } $this->info(__('music_productions.youtube_sync_started')); + $this->line(__('music_productions.youtube_sync_source', [ + 'source' => $youTubeService->getReleasesSourceLabel(), + ])); $this->line('Playlist ID: ' . $youTubeService->resolveReleasesPlaylistId()); try { diff --git a/app/Services/YouTubeService.php b/app/Services/YouTubeService.php index 026c08a..010c93b 100644 --- a/app/Services/YouTubeService.php +++ b/app/Services/YouTubeService.php @@ -10,39 +10,87 @@ use Illuminate\Support\Str; class YouTubeService { + protected ?string $resolvedTopicChannelId = null; + + protected ?string $resolvedReleasesSource = null; + public function __construct( protected ?string $apiKey = null, protected ?string $channelId = null, + protected ?string $topicChannelId = null, protected ?string $playlistId = null, ) { $this->apiKey = $apiKey ?? config('services.youtube.api_key'); $this->channelId = $channelId ?? config('services.youtube.channel_id'); + $this->topicChannelId = $topicChannelId ?? config('services.youtube.topic_channel_id'); $this->playlistId = $playlistId ?? config('services.youtube.playlist_id'); } public function isConfigured(): bool { return filled($this->apiKey) - && (filled($this->channelId) || filled($this->playlistId)); + && (filled($this->channelId) || filled($this->topicChannelId) || filled($this->playlistId)); + } + + public function getReleasesSourceLabel(): string + { + $this->resolveReleasesPlaylistId(); + + return $this->resolvedReleasesSource ?? '-'; } public function resolveReleasesPlaylistId(): ?string { if (filled($this->playlistId)) { + $this->resolvedReleasesSource = 'playlist:' . $this->playlistId; + return $this->playlistId; } - $channelId = $this->channelId; + $topicChannelId = $this->resolveTopicChannelId(); + + if (filled($topicChannelId)) { + $playlistId = $this->channelToUploadsPlaylistId($topicChannelId); + $this->resolvedReleasesSource = 'topic:' . $topicChannelId; + + return $playlistId; + } + + if (filled($this->channelId)) { + Log::warning('YouTube Topic channel not found, falling back to main channel uploads', [ + 'channel_id' => $this->channelId, + ]); + + $playlistId = $this->channelToUploadsPlaylistId($this->channelId); + $this->resolvedReleasesSource = 'main:' . $this->channelId; + + return $playlistId; + } + + return null; + } + + public function resolveTopicChannelId(): ?string + { + if ($this->resolvedTopicChannelId !== null) { + return $this->resolvedTopicChannelId ?: null; + } + + if (filled($this->topicChannelId)) { + $this->resolvedTopicChannelId = $this->topicChannelId; + + return $this->resolvedTopicChannelId; + } + + if (blank($this->channelId)) { + $this->resolvedTopicChannelId = ''; - if (blank($channelId)) { return null; } - if (str_starts_with($channelId, 'UC')) { - return 'UU' . substr($channelId, 2); - } + $this->resolvedTopicChannelId = $this->discoverTopicChannelId($this->channelId) ?? ''; - return $channelId; + return $this->resolvedTopicChannelId ?: null; } /** @@ -58,6 +106,7 @@ class YouTubeService $releases = []; $pageToken = null; + $distributorFilter = config('services.youtube.distributor_filter'); do { $params = [ @@ -76,6 +125,7 @@ class YouTubeService if ($response->failed()) { Log::error('YouTube releases fetch failed', [ 'playlist_id' => $playlistId, + 'source' => $this->resolvedReleasesSource, 'status' => $response->status(), 'body' => $response->body(), ]); @@ -90,7 +140,7 @@ class YouTubeService $items = $data['items'] ?? []; foreach ($items as $item) { - $normalized = $this->normalizeReleaseItem($item); + $normalized = $this->normalizeReleaseItem($item, $distributorFilter); if ($normalized) { $releases[$normalized['video_id']] = $normalized; @@ -103,15 +153,97 @@ class YouTubeService return array_values($releases); } + protected function discoverTopicChannelId(string $mainChannelId): ?string + { + $channel = $this->getChannel($mainChannelId); + + if (! $channel) { + return null; + } + + $artistTitle = trim($channel['snippet']['title'] ?? ''); + + if (blank($artistTitle)) { + return null; + } + + $expectedTopicTitle = $artistTitle . ' - Topic'; + + $response = $this->client()->get('https://www.googleapis.com/youtube/v3/search', [ + 'key' => $this->apiKey, + 'part' => 'snippet', + 'type' => 'channel', + 'q' => $expectedTopicTitle, + 'maxResults' => 5, + ]); + + if ($response->failed()) { + Log::warning('YouTube topic channel search failed', [ + 'channel_id' => $mainChannelId, + 'query' => $expectedTopicTitle, + 'status' => $response->status(), + ]); + + return null; + } + + foreach ($response->json('items') ?? [] as $item) { + $channelId = $item['snippet']['channelId'] ?? $item['id']['channelId'] ?? null; + $title = trim($item['snippet']['title'] ?? ''); + + if (blank($channelId)) { + continue; + } + + if ($title === $expectedTopicTitle || str_ends_with($title, ' - Topic')) { + return $channelId; + } + } + + return null; + } + + /** + * @return array|null + */ + protected function getChannel(string $channelId): ?array + { + $response = $this->client()->get('https://www.googleapis.com/youtube/v3/channels', [ + 'key' => $this->apiKey, + 'id' => $channelId, + 'part' => 'snippet,contentDetails', + ]); + + if ($response->failed()) { + return null; + } + + return $response->json('items.0'); + } + + protected function channelToUploadsPlaylistId(string $channelId): string + { + if (str_starts_with($channelId, 'UU') || str_starts_with($channelId, 'PL')) { + return $channelId; + } + + if (str_starts_with($channelId, 'UC')) { + return 'UU' . substr($channelId, 2); + } + + return $channelId; + } + /** * @param array $item * @return array|null */ - protected function normalizeReleaseItem(array $item): ?array + protected function normalizeReleaseItem(array $item, ?string $distributorFilter = null): ?array { $snippet = $item['snippet'] ?? []; $videoId = $snippet['resourceId']['videoId'] ?? null; $title = trim($snippet['title'] ?? ''); + $description = trim($snippet['description'] ?? ''); if (blank($videoId) || blank($title)) { return null; @@ -121,6 +253,10 @@ class YouTubeService return null; } + if (filled($distributorFilter) && ! str_contains($description, $distributorFilter)) { + return null; + } + $thumbnails = $snippet['thumbnails'] ?? []; $coverUrl = $thumbnails['maxres']['url'] ?? $thumbnails['standard']['url'] @@ -132,7 +268,7 @@ class YouTubeService return [ 'video_id' => $videoId, 'title' => $title, - 'description' => trim($snippet['description'] ?? ''), + 'description' => $description, 'cover_url' => $coverUrl, 'published_at' => $snippet['publishedAt'] ?? null, 'youtube_url' => 'https://www.youtube.com/watch?v=' . $videoId, diff --git a/config/services.php b/config/services.php index 606b1dd..6ced60b 100644 --- a/config/services.php +++ b/config/services.php @@ -53,8 +53,10 @@ return [ 'youtube' => [ 'api_key' => env('YOUTUBE_API_KEY'), 'channel_id' => env('YOUTUBE_CHANNEL_ID'), + 'topic_channel_id' => env('YOUTUBE_TOPIC_CHANNEL_ID'), 'playlist_id' => env('YOUTUBE_PLAYLIST_ID'), 'api_referer' => env('YOUTUBE_API_REFERER'), + 'distributor_filter' => env('YOUTUBE_DISTRIBUTOR_FILTER'), ], ]; diff --git a/lang/en/music_productions.php b/lang/en/music_productions.php index 095e169..65ae969 100644 --- a/lang/en/music_productions.php +++ b/lang/en/music_productions.php @@ -113,6 +113,7 @@ return [ '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_source' => 'Source: :source (topic = Releases tab, main = Videos tab)', '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.', diff --git a/lang/tr/music_productions.php b/lang/tr/music_productions.php index ac8fe2c..a9460d3 100644 --- a/lang/tr/music_productions.php +++ b/lang/tr/music_productions.php @@ -113,6 +113,7 @@ return [ '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_source' => 'Kaynak: :source (topic = Yayınlananlar, main = Videolar)', '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.',