first(); if ($anime) return $anime; } // 2. slug $slug = Str::slug($data['title'] ?? ''); if ($slug) { $anime = Anime::where('slug', $slug)->first(); if ($anime) { // mal_id eksikse güncelle if (!empty($data['mal_id']) && !$anime->mal_id) { $anime->update(['mal_id' => $data['mal_id']]); } return $anime; } } // 3. Büyük/küçük harf duyarsız başlık + title_en eşleşmesi $lowerTitle = strtolower(trim($data['title'] ?? '')); if ($lowerTitle) { $anime = Anime::whereRaw('LOWER(title) = ?', [$lowerTitle]) ->orWhereRaw('LOWER(title_en) = ?', [$lowerTitle]) ->first(); if ($anime) { if (!empty($data['mal_id']) && !$anime->mal_id) { $anime->update(['mal_id' => $data['mal_id']]); } return $anime; } } // 4. Bulunamadı → yeni oluştur return Anime::create([ 'title' => $data['title'], 'title_en' => $data['title_en'] ?? '', 'slug' => $slug ?: Str::slug($data['title'] ?? 'anime-' . uniqid()), 'type' => $data['type'] ?? 'series', 'status' => 'ongoing', 'is_published' => false, 'cover_image' => $data['cover'] ?? null, 'mal_id' => $data['mal_id'] ?? null, ]); } // ── Auto-Import API'leri ───────────────────────────────────────────────── /** * Programatik job oluşturma. * AnimeCix ve Anizium her iki kaynak için tek endpoint. */ public function createJob(Request $request) { $source = $request->input('source', 'anizium'); // ── AnimeCix job ────────────────────────────────────────────────────── if ($source === 'animecix') { if ($request->has('year') && $request->year !== null) { $request->merge(['year' => (string) $request->year]); } $data = $request->validate([ 'animecix_title_id' => 'required|string|max:50', 'slug' => 'required|string|max:300', 'title' => 'required|string|max:300', 'title_en' => 'nullable|string|max:300', 'cover' => 'nullable|string|max:500', 'episode_count' => 'nullable|integer', 'type' => 'nullable|string|max:30', 'year' => 'nullable|string|max:10', 'genres' => 'nullable|array', 'mal_id' => 'nullable|integer', 'priority' => 'nullable|integer|min:0|max:2', ]); // Dedup — aynı title zaten aktif/bitti mi? $existing = ImportJob::where('source', 'animecix') ->where('animecix_title_id', $data['animecix_title_id']) ->whereIn('status', ['pending', 'fetching', 'done']) ->latest()->first(); if ($existing) { return response()->json([ 'job_id' => $existing->id, 'status' => 'existing', ]); } // Anime bul veya oluştur (unified matcher) $anime = $this->findOrCreateAnime([ 'mal_id' => $data['mal_id'] ?? null, 'title' => $data['title'], 'title_en' => $data['title_en'] ?? '', 'slug' => Str::slug($data['title']), 'type' => $data['type'] ?: 'series', 'cover' => $data['cover'] ?? null, ]); // Priority: request'ten geliyorsa kullan, yoksa otomatik hesapla $priority = (int) ($data['priority'] ?? ImportJob::PRIORITY_NEW); if (!isset($data['priority']) && !$anime->wasRecentlyCreated) { $hasAnizium = VideoSource::whereHas('episode', fn($q) => $q->where('anime_id', $anime->id)) ->where('source', 'anizium')->exists(); $priority = $hasAnizium ? ImportJob::PRIORITY_CROSSFILL : ImportJob::PRIORITY_NEW; } $job = ImportJob::create([ 'source' => 'animecix', 'animecix_title_id' => $data['animecix_title_id'], 'animecix_slug' => $data['slug'], 'anime_title' => $data['title'], 'anime_id' => $anime->id, 'status' => 'pending', 'priority' => $priority, ]); // AniList resimlerini arka planda doldur if (empty($anime->cover_image) || empty($anime->banner_image)) { dispatch(function () use ($anime) { try { (new \App\Services\AniListService())->fillImages($anime->fresh()); } catch (\Throwable) {} })->afterResponse(); } return response()->json(['job_id' => $job->id, 'status' => 'created', 'anime_id' => $anime->id], 201); } // ── Anizium job ─────────────────────────────────────────────────────── $data = $request->validate([ 'source_url' => 'required|string|max:500', 'anime_title' => 'required|string|max:300', 'watch_id' => 'required|string|max:50', 'season_ranges' => 'nullable|array', 'season_ranges.*.season' => 'required_with:season_ranges|integer|min:1', 'season_ranges.*.from' => 'required_with:season_ranges|integer|min:1', 'season_ranges.*.to' => 'required_with:season_ranges|integer|min:1', 'priority' => 'nullable|integer|min:0|max:2', ]); // Aktif job var mı? $active = ImportJob::where('watch_id', $data['watch_id']) ->whereIn('status', ['pending', 'fetching', 'downloading', 'uploading']) ->latest()->first(); if ($active) { return response()->json(['job_id' => $active->id, 'status' => 'existing', 'msg' => 'Aktif job zaten var.']); } // Daha önce bitti mi? (anime_id'yi al) $prevDone = ImportJob::where('watch_id', $data['watch_id']) ->where('status', 'done')->whereNotNull('anime_id')->latest()->first(); // Anime eşleştir (watch_id'den tanınan anime_id varsa kullan, yoksa title arama) $anime = null; if ($prevDone?->anime_id) { $anime = Anime::find($prevDone->anime_id); } if (!$anime) { // Başlık ile mevcut anime bul (farklı kaynaktan yüklenmiş olabilir) $slug = Str::slug($data['anime_title']); $lower = strtolower(trim($data['anime_title'])); $anime = Anime::where('slug', $slug) ->orWhereRaw('LOWER(title) = ?', [$lower]) ->orWhereRaw('LOWER(title_en) = ?', [$lower]) ->first(); } // Priority: request'ten geliyorsa kullan, yoksa otomatik hesapla $aniziumPriority = (int) ($data['priority'] ?? ImportJob::PRIORITY_NEW); if (!isset($data['priority']) && $anime?->id) { $hasAnimecix = VideoSource::whereHas('episode', fn($q) => $q->where('anime_id', $anime->id)) ->where('source', 'animecix')->exists(); $aniziumPriority = $hasAnimecix ? ImportJob::PRIORITY_CROSSFILL : ImportJob::PRIORITY_NEW; } $job = ImportJob::create([ 'source' => 'anizium', 'source_url' => $data['source_url'], 'anime_title' => $data['anime_title'], 'watch_id' => $data['watch_id'], 'status' => 'pending', 'anime_id' => $anime?->id, 'season_ranges' => $data['season_ranges'] ?? null, 'priority' => $aniziumPriority, ]); return response()->json(['job_id' => $job->id, 'status' => 'created', 'priority' => $aniziumPriority], 201); } // ── Anime arama endpoint'i — Python botları için ────────────────────────── // GET /api/import/anime/lookup?mal_id=xxx OR ?title=yyy OR ?slug=zzz public function animeLookup(Request $request) { // 1. mal_id if ($mal_id = $request->input('mal_id')) { $anime = Anime::where('mal_id', (int) $mal_id)->first(); if ($anime) { return response()->json([ 'found' => true, 'anime_id' => $anime->id, 'title' => $anime->title, 'mal_id' => $anime->mal_id, ]); } } // 2. Slug veya başlık if ($title = $request->input('title')) { $slug = Str::slug($title); $lower = strtolower(trim($title)); $anime = Anime::where('slug', $slug) ->orWhereRaw('LOWER(title) = ?', [$lower]) ->orWhereRaw('LOWER(title_en) = ?', [$lower]) ->first(); if ($anime) { return response()->json([ 'found' => true, 'anime_id' => $anime->id, 'title' => $anime->title, 'mal_id' => $anime->mal_id, ]); } } return response()->json(['found' => false, 'anime_id' => null]); } // ── Animecix: bekleyen job listesi ──────────────────────────────────────── public function animecixPendingJobs() { try { $jobs = ImportJob::where('source', 'animecix') ->where('status', 'pending') ->orderByDesc('priority') ->orderBy('id') ->limit(20) ->get(['id', 'animecix_title_id', 'animecix_slug', 'anime_title', 'anime_id', 'priority']); } catch (\Throwable) { $jobs = ImportJob::where('source', 'animecix') ->where('status', 'pending') ->orderBy('id') ->limit(20) ->get(['id', 'animecix_title_id', 'animecix_slug', 'anime_title', 'anime_id']); } return response()->json(['jobs' => $jobs, 'count' => $jobs->count()]); } // ── Animecix: episode'lara video kaynakları kaydet ─────────────────────── public function saveVideoSources(Request $request, Episode $episode) { $data = $request->validate([ 'sources' => 'required|array|min:1', 'sources.*.label' => 'nullable|string|max:120', 'sources.*.url' => 'required|string|max:2000', 'sources.*.type' => 'nullable|in:mp4,hls,embed', 'sources.*.quality' => 'nullable|string|max:20', 'sources.*.translator_id' => 'nullable|string|max:60', 'sources.*.sort' => 'nullable|integer', ]); // Önceki AnimeCix kaynaklarını sil (idempotent yeniden çalıştırma) VideoSource::where('episode_id', $episode->id)->where('source', 'animecix')->delete(); // AnimeCix kaynakları eklendiğinde Anizium '4K' kaynağını secondary yap VideoSource::where('episode_id', $episode->id) ->where('source', 'anizium') ->update(['is_default' => false, 'sort_order' => 99]); $isDefault = true; foreach ($data['sources'] as $idx => $src) { VideoSource::create([ 'episode_id' => $episode->id, 'label' => $src['label'] ?? '', 'url' => $src['url'], 'type' => $src['type'] ?? 'mp4', 'quality' => $src['quality'] ?? '', 'translator_id' => $src['translator_id'] ?? null, 'sort_order' => $src['sort'] ?? $idx, 'is_default' => $isDefault, 'source' => 'animecix', ]); $isDefault = false; // İlk AnimeCix kaynağını episode video_url olarak da kaydet if ($idx === 0) { $url = $src['url']; $type = $src['type'] ?? 'mp4'; if ($type === 'mp4') { $episode->update(['video_url' => $url, 'source' => 'animecix']); } elseif ($type === 'hls') { $episode->update(['m3u8_url' => $url, 'source' => 'animecix']); } } } // Anime yayınla (ilk bölüm geldiğinde) if ($episode->anime_id) { Anime::where('id', $episode->anime_id)->where('is_published', false)->update(['is_published' => true]); } return response()->json(['ok' => true, 'saved' => count($data['sources'])]); } /** * Import edilmiş tüm watch_id'leri döndürür (discover.py karşılaştırması için). * NOT: Artık sadece failed-olmayanlar "mevcut" sayılır. */ public function importedIds() { $ids = ImportJob::whereNotNull('watch_id') ->where('watch_id', '!=', '') ->whereIn('status', ['pending', 'fetching', 'downloading', 'uploading', 'done']) ->pluck('watch_id') ->map(fn($id) => (string) $id) ->unique() ->values(); return response()->json(['watch_ids' => $ids, 'count' => $ids->count()]); } /** * Animexe'deki tüm yayınlanan anime başlıklarını döndürür. */ public function importedTitles() { $titles = Anime::where('is_published', true) ->pluck('title') ->filter() ->unique() ->values(); return response()->json(['titles' => $titles, 'count' => $titles->count()]); } /** * Bot 3 güncelleme botu için: Anizium watch_id'si olan TÜM animeleri döndür. * ongoing/completed/finished fark etmez — her anime eksik bölüm kontrolüne tabi. * Her animenin mevcut sezon/bölüm durumu da dahil. */ public function allAniziumAnimes() { $animes = ImportJob::where('import_jobs.status', 'done') ->whereNotNull('import_jobs.watch_id') ->whereNotNull('import_jobs.anime_id') ->join('animes', 'animes.id', '=', 'import_jobs.anime_id') ->select( 'import_jobs.watch_id', 'import_jobs.anime_title', 'import_jobs.anime_id', 'animes.status as anime_status' ) ->groupBy('import_jobs.watch_id', 'import_jobs.anime_title', 'import_jobs.anime_id', 'animes.status') ->get(); $result = $animes->map(function ($a) { $seasonData = \DB::table('episodes') ->join('seasons', 'seasons.id', '=', 'episodes.season_id') ->where('episodes.anime_id', $a->anime_id) ->where('episodes.is_published', true) ->select( 'seasons.season_number', \DB::raw('MAX(episodes.episode_number) as max_episode'), \DB::raw('COUNT(*) as episode_count') ) ->groupBy('seasons.season_number') ->orderBy('seasons.season_number') ->get(); $seasons = []; foreach ($seasonData as $s) { $seasons[(string) $s->season_number] = [ 'count' => (int) $s->episode_count, 'max' => (int) $s->max_episode, ]; } return [ 'watch_id' => $a->watch_id, 'anime_title' => $a->anime_title, 'anime_id' => (int) $a->anime_id, 'anime_status' => $a->anime_status, 'seasons' => $seasons, ]; }); return response()->json(['animes' => $result, 'count' => $result->count()]); } // ── Bot ayarlarını döndür ───────────────────────────────────────────────── public function settings() { $get = fn($key) => \App\Models\Setting::where('key', $key)->value('value') ?? ''; return response()->json([ 'bunnycdn' => [ 'zone' => $get('bunnycdn_zone'), 'api_key' => $get('bunnycdn_api_key'), 'pull_url' => $get('bunnycdn_pull_url'), ], ]); } // Bağlantı testi public function test() { $pendingBySource = ImportJob::where('status', 'pending') ->selectRaw('COALESCE(source, "anizium") as source, COUNT(*) as cnt') ->groupBy('source') ->pluck('cnt', 'source'); return response()->json([ 'ok' => true, 'message' => 'Laravel API erişilebilir', 'db' => \DB::connection()->getDatabaseName(), 'pending' => ImportJob::where('status', 'pending')->count(), 'pending_anizium' => (int) ($pendingBySource['anizium'] ?? 0), 'pending_animecix' => (int) ($pendingBySource['animecix'] ?? 0), 'total' => ImportJob::count(), 'timestamp' => now()->toDateTimeString(), ]); } // Dashboard istatistikleri public function stats() { $counts = ImportJob::selectRaw('status, COUNT(*) as cnt') ->groupBy('status') ->pluck('cnt', 'status'); $byStatus = [ 'pending' => (int) ($counts['pending'] ?? 0), 'fetching' => (int) ($counts['fetching'] ?? 0), 'downloading' => (int) ($counts['downloading'] ?? 0), 'uploading' => (int) ($counts['uploading'] ?? 0), 'done' => (int) ($counts['done'] ?? 0), 'failed' => (int) ($counts['failed'] ?? 0), ]; $active = ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading']) ->latest()->first(); $ongoingCount = ImportJob::where('import_jobs.status', 'done') ->whereNotNull('import_jobs.anime_id') ->join('animes', 'animes.id', '=', 'import_jobs.anime_id') ->where('animes.status', 'ongoing') ->distinct('import_jobs.watch_id') ->count('import_jobs.watch_id'); return response()->json([ 'total' => array_sum($byStatus), 'by_status' => $byStatus, 'ongoing_count' => $ongoingCount, 'active_job' => $active ? [ 'id' => $active->id, 'title' => $active->anime_title, 'status' => $active->status, 'current_step' => $active->current_step, 'total_episodes' => (int) ($active->total_episodes ?? 0), 'done_episodes' => (int) ($active->done_episodes ?? 0), 'progress_pct' => $active->progress_percent, ] : null, ]); } // Python: belirli bir job'u al public function getJob(ImportJob $job) { return response()->json(['job' => $job]); } /** * Python: bekleyen job var mı? — DB lock ile atomik al. * Her kaynak kendi job'larını alır; çapraz engelleme KALDIRILDI. * AnimeCix kendi kuyruğunu /animecix/pending ile alıyor. * Bu endpoint sadece Anizium (ve untagged legacy) job'larını döndürür. */ public function nextJob() { $job = \DB::transaction(function () { // Priority sırası: 2 (cross-fill) → 1 (ongoing) → 0 (yeni keşif) // priority kolonu henüz yoksa (migration çalıştırılmadıysa) sadece id sıralaması try { $job = ImportJob::where('status', 'pending') ->where(fn($q) => $q->where('source', 'anizium') ->orWhere('source', '') ->orWhereNull('source') ) ->orderByDesc('priority') ->orderByRaw('CASE WHEN season_ranges IS NOT NULL THEN 1 ELSE 0 END DESC') ->orderBy('id') ->lockForUpdate() ->first(); } catch (\Throwable) { $job = ImportJob::where('status', 'pending') ->where(fn($q) => $q->where('source', 'anizium') ->orWhere('source', '') ->orWhereNull('source') ) ->orderByRaw('CASE WHEN season_ranges IS NOT NULL THEN 1 ELSE 0 END DESC') ->orderBy('id') ->lockForUpdate() ->first(); } if ($job) { $job->update(['status' => 'fetching']); } return $job; }); return response()->json(['job' => $job]); } // Daemon: import edilmiş ongoing animeleri döndür (yeni bölüm kontrolü için) public function ongoingAnimes() { $animes = ImportJob::where('import_jobs.status', 'done') ->whereNotNull('import_jobs.watch_id') ->whereNotNull('import_jobs.anime_id') ->join('animes', 'animes.id', '=', 'import_jobs.anime_id') ->where('animes.status', 'ongoing') ->select( 'import_jobs.watch_id', 'import_jobs.anime_title', 'import_jobs.anime_id' ) ->groupBy('import_jobs.watch_id', 'import_jobs.anime_title', 'import_jobs.anime_id') ->get(); $result = $animes->map(function ($a) { $seasonData = \DB::table('episodes') ->join('seasons', 'seasons.id', '=', 'episodes.season_id') ->where('episodes.anime_id', $a->anime_id) ->where('episodes.is_published', true) ->select( 'seasons.season_number', \DB::raw('MAX(episodes.episode_number) as max_episode'), \DB::raw('COUNT(*) as episode_count') ) ->groupBy('seasons.season_number') ->orderBy('seasons.season_number') ->get(); $seasons = []; foreach ($seasonData as $s) { $seasons[(string) $s->season_number] = [ 'count' => (int) $s->episode_count, 'max' => (int) $s->max_episode, ]; } return [ 'watch_id' => $a->watch_id, 'anime_title' => $a->anime_title, 'anime_id' => (int) $a->anime_id, 'seasons' => $seasons, ]; }); return response()->json(['animes' => $result, 'count' => $result->count()]); } // Python: job durumunu güncelle public function updateStatus(Request $request, ImportJob $job) { $data = $request->validate([ 'status' => 'sometimes|in:pending,fetching,downloading,uploading,done,failed', 'current_step' => 'nullable|string', 'total_episodes' => 'nullable|integer', 'done_episodes' => 'nullable|integer', 'failed_episodes' => 'nullable|integer', 'error_log' => 'nullable|string', ]); $update = array_intersect_key($data, array_flip(array_keys($request->all()))); if (!empty($update)) { $job->update($update); } return response()->json(['ok' => true]); } // Python: bir bölüm tamamlandı, DB'ye kaydet public function saveEpisode(Request $request, ImportJob $job) { $data = $request->validate([ 'season' => 'required|integer|min:1', 'episode' => 'required|integer|min:0', 'title' => 'nullable|string', 'description' => 'nullable|string', 'duration' => 'nullable|integer', 'video_url' => 'nullable|string', 'm3u8_url' => 'nullable|string', 'bunny_video_id' => 'nullable|string', 'source_url' => 'nullable|string', 'thumbnail' => 'nullable|string', 'available_dubs' => 'nullable|array', 'available_dubs.*'=> 'string|max:32', 'embed_source' => 'nullable|string|max:32', 'extra_sources' => 'nullable|array', 'extra_sources.*.url' => 'required|string|max:2000', 'extra_sources.*.quality' => 'nullable|string|max:20', 'extra_sources.*.label' => 'nullable|string|max:60', ]); $isAnizium = in_array($job->source ?? 'anizium', ['anizium', '', null], true) || is_null($job->source); // Anime bul veya oluştur if ($job->anime_id) { $anime = Anime::find($job->anime_id); } else { $baseTitle = $job->anime_title ?: "Anime CDN-{$job->cdn_id}"; $slug = Str::slug($baseTitle) . '-' . ($job->cdn_id ?: $job->id); $anime = Anime::firstOrCreate( ['slug' => $slug], [ 'title' => $baseTitle, 'type' => 'series', 'status' => 'ongoing', 'is_published' => true, ] ); $job->update(['anime_id' => $anime->id]); // Auto-fetch MAL ID (fire-and-forget) if (!$anime->mal_id) { dispatch(function () use ($anime) { try { $jikan = new JikanService(); $malId = $jikan->searchMalId($anime->title, $anime->title_en, $anime->title_jp); if ($malId) { $anime->update(['mal_id' => $malId]); $chain = $jikan->fetchSeasonMalIds($malId); foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) { if (!$season->mal_id && isset($chain[$i])) { $season->update(['mal_id' => $chain[$i]]); } } (new \App\Services\AniListService())->fillImages($anime->fresh()); } } catch (\Throwable) {} })->afterResponse(); } if (empty($anime->cover_image) || empty($anime->banner_image)) { dispatch(function () use ($anime) { try { (new \App\Services\AniListService())->fillImages($anime->fresh()); } catch (\Throwable) {} })->afterResponse(); } if (Setting::get('ai_auto_seo') === '1' && empty($anime->seo_title)) { dispatch(function () use ($anime) { try { $ai = new \App\Services\DeepSeekService(); $result = $ai->generateAnimeSeoMeta($anime->fresh(['genres'])); if ($result) { $anime->update([ 'seo_title' => $result['seo_title'] ?? null, 'seo_meta_desc' => $result['seo_meta_desc'] ?? null, 'seo_keywords' => $result['seo_keywords'] ?? null, ]); } } catch (\Throwable) {} })->afterResponse(); } } // Sezon bul/oluştur $season = Season::firstOrCreate( ['anime_id' => $anime->id, 'season_number' => $data['season']], ['is_published' => true] ); if (!$season->mal_id && $anime->mal_id) { dispatch(function () use ($anime, $season) { try { $chain = (new JikanService())->fetchSeasonMalIds($anime->mal_id); $idx = $season->season_number - 1; if (isset($chain[$idx])) $season->update(['mal_id' => $chain[$idx]]); } catch (\Throwable) {} })->afterResponse(); } // Mevcut episode var mı? (başka kaynaktan yüklenmiş olabilir) $existingEpisode = Episode::where('season_id', $season->id) ->where('episode_number', $data['episode']) ->first(); // Başlık stratejisi: // Anizium → her zaman başlığı set eder (kullanıcı isteği: başlık aniziumdan gelsin) // AnimeCix → sadece mevcut başlık boşsa set eder $titleValue = $data['title'] ?? null; if (!$isAnizium && $existingEpisode && $existingEpisode->title) { $titleValue = $existingEpisode->title; // AnimeCix mevcut başlığı ezip geçmez } $episodeValues = [ 'anime_id' => $anime->id, 'title' => $titleValue, 'description' => $data['description'] ?? null, 'duration' => $data['duration'] ?? null, 'source_url' => $data['source_url'] ?? null, 'thumbnail' => $data['thumbnail'] ?? null, 'status' => 'published', 'is_published' => true, ]; // video_url / m3u8_url sadece Anizium koyar (AnimeCix video_sources'tan gider) if ($isAnizium) { $episodeValues['video_url'] = $data['video_url'] ?? null; $episodeValues['m3u8_url'] = $data['m3u8_url'] ?? null; $episodeValues['bunny_video_id'] = $data['bunny_video_id'] ?? null; $episodeValues['available_dubs'] = isset($data['available_dubs']) ? json_encode($data['available_dubs']) : null; $episodeValues['source'] = isset($data['bunny_video_id']) ? 'bunnycdn' : ($data['embed_source'] ?? 'anizium'); } try { Episode::updateOrCreate( ['season_id' => $season->id, 'episode_number' => $data['episode']], $episodeValues ); } catch (\Illuminate\Database\QueryException $e) { if (str_contains($e->getMessage(), 'available_dubs')) { unset($episodeValues['available_dubs']); Episode::updateOrCreate( ['season_id' => $season->id, 'episode_number' => $data['episode']], $episodeValues ); } else { throw $e; } } $savedEp = Episode::where('season_id', $season->id) ->where('episode_number', $data['episode']) ->first(); // ── Anizium bölümlerini video_sources tablosuna kaydet ── if ($isAnizium && $savedEp) { $vsUrl = $data['video_url'] ?? $data['m3u8_url'] ?? null; $vsType = (!empty($data['video_url'])) ? 'mp4' : (!empty($data['m3u8_url']) ? 'hls' : null); if ($vsUrl && $vsType) { $hasAnimecix = VideoSource::where('episode_id', $savedEp->id) ->where('source', 'animecix') ->exists(); // Mevcut anizium kaynaklarını temizle, yeniden ekle VideoSource::where('episode_id', $savedEp->id) ->where('source', 'anizium') ->delete(); // Ana kaynak (en yüksek kalite) VideoSource::create([ 'episode_id' => $savedEp->id, 'source' => 'anizium', 'label' => '4K', 'url' => $vsUrl, 'type' => $vsType, 'quality' => '4K', 'sort_order' => $hasAnimecix ? 99 : 0, 'is_default' => !$hasAnimecix, ]); // Yedek kaliteler (720p, 480p vs. — HEVC failse browser bunları dener) foreach (($data['extra_sources'] ?? []) as $idx => $src) { VideoSource::create([ 'episode_id' => $savedEp->id, 'source' => 'anizium', 'label' => $src['label'] ?? ($src['quality'] ?? 'Yedek'), 'url' => $src['url'], 'type' => 'hls', 'quality' => $src['quality'] ?? null, 'sort_order' => ($hasAnimecix ? 99 : 0) + $idx + 1, 'is_default' => false, ]); } } } $anime->update(['episode_count' => $anime->episodes()->count()]); $job->increment('done_episodes'); // Anime yayınla Anime::where('id', $anime->id)->where('is_published', false)->update(['is_published' => true]); // Auto açıklama üretimi (Anizium için) if ($isAnizium && Setting::get('ai_auto_description') === '1' && $savedEp && empty($savedEp->description)) { $ai = new DeepSeekService(); $desc = $ai->generateEpisodeDescription($anime->title, $data['episode'], $data['title'] ?? ''); if ($desc) $savedEp->update(['description' => $desc]); } return response()->json(['ok' => true, 'anime_id' => $anime->id, 'episode_id' => $savedEp?->id]); } /** * Python: job için tamamlanmış bölümleri döndür (resume desteği). * * Her kaynak sadece KENDİ kaydettiği bölümleri "done" sayar. * Anizium → video_sources.source='anizium' olan bölümler * AnimeCix → video_sources.source='animecix' olan bölümler * Legacy (source belirsiz) → eski davranış (is_published=true) */ public function doneEpisodes(ImportJob $job) { if (!$job->anime_id) { return response()->json(['done' => (object)[]]); } $source = $job->source ?? 'anizium'; if (in_array($source, ['anizium', 'animecix'], true)) { // Kaynak bazlı: sadece bu kaynağın video_sources kayıtları olan bölümler $rows = \DB::table('video_sources') ->join('episodes', 'episodes.id', '=', 'video_sources.episode_id') ->join('seasons', 'seasons.id', '=', 'episodes.season_id') ->where('episodes.anime_id', $job->anime_id) ->where('video_sources.source', $source) ->select('seasons.season_number as s', 'episodes.episode_number as e') ->distinct() ->get(); } else { // Legacy: is_published=true olan tüm bölümler $rows = \DB::table('episodes') ->join('seasons', 'seasons.id', '=', 'episodes.season_id') ->where('episodes.anime_id', $job->anime_id) ->where('episodes.is_published', true) ->select('seasons.season_number as s', 'episodes.episode_number as e') ->get(); } $done = []; foreach ($rows as $r) { $done[(string)$r->s][(string)$r->e] = true; } return response()->json(['done' => $done ?: (object)[]]); } // Python: bir bölüme altyazı kaydet public function saveSubtitle(Request $request, ImportJob $job) { $data = $request->validate([ 'season' => 'required|integer|min:1', 'episode' => 'required|integer|min:1', 'language' => 'required|string|max:10', 'label' => 'required|string|max:50', 'url' => 'required|string', 'is_default' => 'boolean', ]); $season = Season::where('anime_id', $job->anime_id) ->where('season_number', $data['season'])->first(); if (!$season) { return response()->json(['ok' => false, 'msg' => 'Season bulunamadı'], 404); } $episode = Episode::where('season_id', $season->id) ->where('episode_number', $data['episode'])->first(); if (!$episode) { return response()->json(['ok' => false, 'msg' => 'Episode bulunamadı'], 404); } Subtitle::updateOrCreate( ['episode_id' => $episode->id, 'language' => $data['language']], ['label' => $data['label'], 'url' => $data['url'], 'is_default' => $data['is_default'] ?? false] ); return response()->json(['ok' => true]); } // Anizium kaynaklı tüm bölümleri watch_id bazında döndür (altyazı yenileme için) // ?only_missing_subs=1 → subtitles tablosunda kaydı olmayan bölümler // ?fix_anizium_subs=1 → altyazısı var ama URL'i hâlâ ham Anizium linki olan bölümler (b-cdn.net değil) public function aniziumEpisodes(Request $request) { $onlyMissing = $request->boolean('only_missing_subs', false); $fixAniziumSubs = $request->boolean('fix_anizium_subs', false); $query = \DB::table('episodes') ->join('seasons', 'seasons.id', '=', 'episodes.season_id') ->whereNotNull('episodes.source_url') ->where('episodes.source_url', 'like', '%anizium.co/watch/%') ->select( 'episodes.id as episode_id', 'seasons.season_number as season', 'episodes.episode_number as episode', 'episodes.source_url', 'episodes.view_count' ) ->orderBy('episodes.id'); if ($onlyMissing) { $query->whereNotExists(function ($sub) { $sub->select(\DB::raw(1)) ->from('subtitles') ->whereColumn('subtitles.episode_id', 'episodes.id'); }); } elseif ($fixAniziumSubs) { // Altyazısı var ama en az bir URL b-cdn.net içermiyor (ham Anizium linki) $query->whereExists(function ($sub) { $sub->select(\DB::raw(1)) ->from('subtitles') ->whereColumn('subtitles.episode_id', 'episodes.id') ->where('subtitles.url', 'not like', '%b-cdn.net%'); }); } $rows = $query->get(); $grouped = []; $viewCounts = []; foreach ($rows as $r) { if (!preg_match('#anizium\.co/watch/(\w+)#', $r->source_url, $m)) continue; $wid = $m[1]; if (!isset($grouped[$wid])) { $grouped[$wid] = []; $viewCounts[$wid] = 0; } $grouped[$wid][] = [ 'episode_id' => $r->episode_id, 'season' => $r->season, 'episode' => $r->episode, ]; $viewCounts[$wid] += (int) ($r->view_count ?? 0); } // Popularity'e göre sırala (en çok izlenen önce) $sortedAnimes = []; foreach ($grouped as $wid => $eps) { $sortedAnimes[$wid] = [ 'episodes' => $eps, 'view_count' => $viewCounts[$wid], ]; } uasort($sortedAnimes, fn($a, $b) => $b['view_count'] - $a['view_count']); return response()->json([ 'total' => $rows->count(), 'animes' => $sortedAnimes, ]); } // Sağlık kontrolü: Anizium kaynaklı anime + bölüm URL'leri public function aniziumHealthData() { $rows = \DB::table('episodes') ->join('seasons', 'seasons.id', '=', 'episodes.season_id') ->join('animes', 'animes.id', '=', 'episodes.anime_id') ->join('import_jobs', function ($j) { $j->on('import_jobs.anime_id', '=', 'animes.id') ->where('import_jobs.source', 'anizium') ->where('import_jobs.status', 'done'); }) ->where('episodes.is_published', true) ->whereNotNull('episodes.source_url') ->where('episodes.source_url', 'like', '%anizium.co/watch/%') ->select( 'animes.id as anime_id', 'animes.title', 'animes.slug', 'import_jobs.watch_id', 'episodes.id as episode_id', 'seasons.season_number as season', 'episodes.episode_number as episode', 'episodes.m3u8_url', 'episodes.video_url' ) ->orderBy('animes.id') ->orderBy('seasons.season_number') ->orderBy('episodes.episode_number') ->get(); $animes = []; foreach ($rows as $r) { $aid = $r->anime_id; if (!isset($animes[$aid])) { $animes[$aid] = [ 'anime_id' => $aid, 'title' => $r->title, 'slug' => $r->slug, 'watch_id' => $r->watch_id, 'episodes' => [], ]; } $url = $r->m3u8_url ?: $r->video_url; if ($url) { $animes[$aid]['episodes'][] = [ 'episode_id' => $r->episode_id, 'season' => $r->season, 'episode' => $r->episode, 'url' => $url, ]; } } return response()->json([ 'anime_count' => count($animes), 'episode_count' => $rows->count(), 'animes' => array_values($animes), ]); } // Anime'yi inaktife al (Anizium bozuk, yeniden import edilecek) public function deactivateAnime(Request $request) { $data = $request->validate(['anime_id' => 'required|integer|exists:animes,id']); Anime::where('id', $data['anime_id'])->update(['is_published' => false]); ImportJob::where('anime_id', $data['anime_id']) ->where('source', 'anizium') ->update(['status' => 'failed', 'error_log' => 'CDN URL broken — replaced']); return response()->json(['ok' => true]); } // Altyazıyı doğrudan episode_id ile kaydet public function saveSubtitleDirect(Request $request) { $data = $request->validate([ 'episode_id' => 'required|integer|exists:episodes,id', 'language' => 'required|string|max:10', 'label' => 'required|string|max:50', 'url' => 'required|string', 'is_default' => 'boolean', ]); Subtitle::updateOrCreate( ['episode_id' => $data['episode_id'], 'language' => $data['language']], ['label' => $data['label'], 'url' => $data['url'], 'is_default' => $data['is_default'] ?? false] ); return response()->json(['ok' => true]); } // ── Çapraz re-import: yayınlanan animeler için tamamlanmış job'ları döndür ─ // Kullanım: cross_reimport.py scripti bu endpoint'i çağırır public function publishedAnimesWithJobs() { $animes = Anime::where('is_published', true) ->with(['importJobs' => fn($q) => $q->where('status', 'done')->select( 'id', 'anime_id', 'source', 'watch_id', 'animecix_title_id', 'animecix_slug', 'status' )]) ->select('id', 'title', 'title_en', 'slug', 'mal_id', 'type', 'status') ->get() ->map(function ($anime) { $jobs = $anime->importJobs; return [ 'anime_id' => $anime->id, 'title' => $anime->title, 'title_en' => $anime->title_en, 'slug' => $anime->slug, 'mal_id' => $anime->mal_id, 'type' => $anime->type, 'status' => $anime->status, 'has_anizium' => $jobs->where('source', 'anizium')->isNotEmpty(), 'has_animecix' => $jobs->where('source', 'animecix')->isNotEmpty(), 'anizium_watch_ids' => $jobs->where('source', 'anizium')->pluck('watch_id')->filter()->unique()->values(), 'animecix_title_ids' => $jobs->where('source', 'animecix')->pluck('animecix_title_id')->filter()->unique()->values(), 'animecix_slugs' => $jobs->where('source', 'animecix')->pluck('animecix_slug')->filter()->unique()->values(), ]; }); return response()->json(['animes' => $animes, 'count' => $animes->count()]); } }