Files
animexe/app/Http/Controllers/Api/ImportApiController.php
T
2026-07-14 00:01:48 +03:00

1101 lines
46 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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()]);
}
}