paginate(20); // Araçlar paneli için istatistikler $stats = [ 'total_animes' => \App\Models\Anime::where('is_published', true)->count(), 'anizium_done' => ImportJob::where('source', 'anizium')->where('status', 'done')->count(), 'animecix_done' => ImportJob::where('source', 'animecix')->where('status', 'done')->count(), 'video_sources_total' => VideoSource::count(), 'anizium_sources' => VideoSource::where('source', 'anizium')->count(), 'animecix_sources' => VideoSource::where('source', 'animecix')->count(), 'subtitle_mismatch' => $this->countSubtitleMismatch(), ]; return view('admin.import.index', compact('jobs', 'stats')); } public function store(Request $request) { $request->validate([ 'source_url' => 'required|url|max:500', 'cdn_id' => 'nullable|string|max:50', 'anime_title' => 'nullable|string|max:255', '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', ]); $watchId = null; if ($request->source_url) { preg_match('/\/(?:anime|watch)\/(\d+)/', $request->source_url, $m); $watchId = $m[1] ?? null; } $ranges = null; if ($request->filled('season_ranges')) { $ranges = []; foreach ($request->season_ranges as $r) { if (empty($r['season']) || empty($r['from']) || empty($r['to'])) continue; $from = (int) $r['from']; $to = (int) $r['to']; if ($from > $to) [$from, $to] = [$to, $from]; $ranges[] = ['season' => (int)$r['season'], 'from' => $from, 'to' => $to]; } if (empty($ranges)) $ranges = null; } $job = ImportJob::create([ 'source_url' => $request->source_url, 'watch_id' => $watchId, 'cdn_id' => $request->cdn_id ? trim($request->cdn_id) : null, 'anime_title' => $request->anime_title, 'season_ranges' => $ranges, 'status' => 'pending', ]); return redirect()->route('admin.import.show', $job) ->with('success', "Import job #{$job->id} oluşturuldu. Python script'i başlatın."); } public function show(ImportJob $import) { return view('admin.import.show', compact('import')); } public function destroy(ImportJob $import) { $import->delete(); return redirect()->route('admin.import.index')->with('success', 'Job silindi.'); } public function destroyFailed() { $count = ImportJob::where('status', 'failed')->count(); ImportJob::where('status', 'failed')->delete(); return redirect()->route('admin.import.index')->with('success', "{$count} hatalı job silindi."); } public function destroyPending() { $count = ImportJob::where('status', 'pending')->count(); ImportJob::where('status', 'pending')->delete(); return redirect()->route('admin.import.index')->with('success', "{$count} bekleyen job silindi."); } public function destroyStuck() { // fetching/downloading/uploading ama 2 saatten fazladır güncellenmemiş = takılı kalmış $cutoff = now()->subHours(2); $count = ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading']) ->where('updated_at', '<', $cutoff) ->count(); ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading']) ->where('updated_at', '<', $cutoff) ->delete(); return redirect()->route('admin.import.index')->with('success', "{$count} takılı kalmış job silindi."); } public function bulkCounts() { $cutoff = now()->subHours(2); return response()->json([ 'failed' => ImportJob::where('status', 'failed')->count(), 'pending' => ImportJob::where('status', 'pending')->count(), 'stuck' => ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading']) ->where('updated_at', '<', $cutoff) ->count(), ]); } public function destroyByStatus(Request $request) { $statuses = $request->input('statuses', []); $hours = (int) $request->input('stuck_hours', 2); $allowed = ['pending', 'failed', 'fetching', 'downloading', 'uploading']; $statuses = array_intersect($statuses, $allowed); if (empty($statuses)) { return response()->json(['ok' => false, 'message' => 'Geçerli status seçilmedi.'], 422); } $query = ImportJob::whereIn('status', $statuses); // Aktif statüler için sadece belirtilen saatten eskilerini sil $activeStatuses = array_intersect($statuses, ['fetching', 'downloading', 'uploading']); if (!empty($activeStatuses) && count($activeStatuses) === count($statuses)) { $query->where('updated_at', '<', now()->subHours($hours)); } $count = $query->count(); $query->delete(); return response()->json(['ok' => true, 'deleted' => $count]); } // ── ARAÇLAR: Terminal gerektirmez, admin panelden çalışır ───────────────── /** * Altyazı uyuşmazlığı düzelt (Anizium episode-1 cache bug). * Subtitle URL'sindeki name=s1_b1_XX yanlış bölümü işaret edenleri siler. */ public function fixSubtitles(Request $request) { $dryRun = $request->boolean('dry_run', false); $animeId = $request->input('anime_id'); $query = Subtitle::query() ->join('episodes', 'subtitles.episode_id', '=', 'episodes.id') ->join('seasons', 'seasons.id', '=', 'episodes.season_id') ->whereNotNull('subtitles.url') ->where('subtitles.url', 'like', '%anizium%') ->select( 'subtitles.id as subtitle_id', 'subtitles.language', 'subtitles.url', 'seasons.season_number', 'episodes.episode_number', 'episodes.anime_id', ); if ($animeId) { $query->where('episodes.anime_id', (int) $animeId); } $subtitles = $query->get(); $mismatchIds = []; $details = []; foreach ($subtitles as $sub) { $parsed = parse_url($sub->url); if (!isset($parsed['query'])) continue; parse_str($parsed['query'], $params); $name = $params['name'] ?? ''; if (!$name) continue; $expectedPrefix = "s{$sub->season_number}_b{$sub->episode_number}_"; if (!str_starts_with($name, $expectedPrefix)) { $mismatchIds[] = $sub->subtitle_id; $details[] = [ 'anime_id' => $sub->anime_id, 'season' => $sub->season_number, 'episode' => $sub->episode_number, 'lang' => $sub->language, 'name' => $name, 'expected' => $expectedPrefix . $sub->language, ]; } } $deleted = 0; if (!$dryRun && !empty($mismatchIds)) { $deleted = Subtitle::whereIn('id', $mismatchIds)->delete(); } return response()->json([ 'ok' => true, 'dry_run' => $dryRun, 'checked' => $subtitles->count(), 'mismatch' => count($mismatchIds), 'deleted' => $deleted, 'details' => array_slice($details, 0, 30), ]); } /** * Anizium done job'larını yeniden pending yap (yeni video_sources eklemek için). * Her job'ın done_episodes sıfırlanır; Anizium bot yeniden çalışınca * doneEpisodes() artık source='anizium' kontrolü yaptığından * sadece video_sources'ta anizium kaydı OLMAYAN bölümleri yeniden işler. */ public function requeueAnizium(Request $request) { $limit = (int) $request->input('limit', 50); $animeId = $request->input('anime_id'); $query = ImportJob::where('source', 'anizium') ->where('status', 'done') ->whereNotNull('watch_id') ->latest(); if ($animeId) { $query->where('anime_id', (int) $animeId); } $jobs = $query->limit($limit)->get(); $requeued = 0; foreach ($jobs as $job) { // Zaten pending/işleniyor olan var mı? $active = ImportJob::where('watch_id', $job->watch_id) ->where('source', 'anizium') ->whereIn('status', ['pending', 'fetching', 'downloading', 'uploading']) ->exists(); if (!$active) { $job->update([ 'status' => 'pending', 'done_episodes'=> 0, 'error_log' => null, 'current_step' => 'Çapraz re-import — video_sources yenileme', ]); $requeued++; } } return response()->json([ 'ok' => true, 'checked' => $jobs->count(), 'requeued' => $requeued, ]); } /** * AnimeCix done job'larını yeniden pending yap. */ public function requeueAnimecix(Request $request) { $limit = (int) $request->input('limit', 50); $animeId = $request->input('anime_id'); $query = ImportJob::where('source', 'animecix') ->where('status', 'done') ->whereNotNull('animecix_title_id') ->latest(); if ($animeId) { $query->where('anime_id', (int) $animeId); } $jobs = $query->limit($limit)->get(); $requeued = 0; foreach ($jobs as $job) { $active = ImportJob::where('animecix_title_id', $job->animecix_title_id) ->where('source', 'animecix') ->whereIn('status', ['pending', 'fetching']) ->exists(); if (!$active) { $job->update([ 'status' => 'pending', 'done_episodes'=> 0, 'error_log' => null, 'current_step' => 'Çapraz re-import — video_sources yenileme', ]); $requeued++; } } return response()->json([ 'ok' => true, 'checked' => $jobs->count(), 'requeued' => $requeued, ]); } /** * video_sources istatistikleri (AJAX için). */ public function sourceStats() { $animeCount = \App\Models\Anime::where('is_published', true)->count(); $episodesWithBoth = DB::table('episodes') ->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium')) ->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix')) ->where('is_published', true) ->count(); $episodesOnlyAnizium = DB::table('episodes') ->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium')) ->whereNotExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix')) ->where('is_published', true) ->count(); $episodesOnlyAnimecix = DB::table('episodes') ->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix')) ->whereNotExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium')) ->where('is_published', true) ->count(); return response()->json([ 'anime_count' => $animeCount, 'episodes_with_both' => $episodesWithBoth, 'episodes_only_anizium' => $episodesOnlyAnizium, 'episodes_only_animecix' => $episodesOnlyAnimecix, 'subtitle_mismatch' => $this->countSubtitleMismatch(), 'anizium_pending_jobs' => ImportJob::where('source', 'anizium')->where('status', 'pending')->count(), 'animecix_pending_jobs' => ImportJob::where('source', 'animecix')->where('status', 'pending')->count(), ]); } // ── Yardımcı ───────────────────────────────────────────────────────────── private function countSubtitleMismatch(): int { $rows = Subtitle::query() ->join('episodes', 'subtitles.episode_id', '=', 'episodes.id') ->join('seasons', 'seasons.id', '=', 'episodes.season_id') ->whereNotNull('subtitles.url') ->where('subtitles.url', 'like', '%anizium%') ->select('subtitles.url', 'seasons.season_number', 'episodes.episode_number') ->get(); $count = 0; foreach ($rows as $r) { $parsed = parse_url($r->url); if (!isset($parsed['query'])) continue; parse_str($parsed['query'], $params); $name = $params['name'] ?? ''; if ($name && !str_starts_with($name, "s{$r->season_number}_b{$r->episode_number}_")) { $count++; } } return $count; } }