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 ' . nl2br(e($description)) . '