1101 lines
46 KiB
PHP
1101 lines
46 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\Anime;
|
||
use App\Models\Episode;
|
||
use App\Models\ImportJob;
|
||
use App\Models\Season;
|
||
use App\Models\Setting;
|
||
use App\Models\Subtitle;
|
||
use App\Models\VideoSource;
|
||
use App\Services\DeepSeekService;
|
||
use App\Services\JikanService;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Str;
|
||
|
||
class ImportApiController extends Controller
|
||
{
|
||
// ── Anime eşleştirme yardımcısı ──────────────────────────────────────────
|
||
// Her iki kaynak da bu helper'ı kullanır: mal_id → slug → normalize → oluştur
|
||
|
||
private function findOrCreateAnime(array $data): Anime
|
||
{
|
||
// 1. mal_id — en güvenilir
|
||
if (!empty($data['mal_id'])) {
|
||
$anime = Anime::where('mal_id', $data['mal_id'])->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()]);
|
||
}
|
||
}
|