Initial commit: Animexe Laravel platform

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 00:01:48 +03:00
co-authored by Claude Opus 4.8
commit a63515cfc6
366 changed files with 74773 additions and 0 deletions
@@ -0,0 +1,375 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ImportJob;
use App\Models\Episode;
use App\Models\Season;
use App\Models\Subtitle;
use App\Models\VideoSource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ImportController extends Controller
{
public function index()
{
$jobs = ImportJob::latest()->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;
}
}