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

535 lines
23 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\Genre;
use App\Models\Episode;
use App\Models\Banner;
use App\Models\ContinueWatching;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AnimeApiController extends Controller
{
// GET /api/home
public function home(Request $request)
{
$featured = Anime::where('is_featured', true)->where('is_published', true)
->with('genres', 'seasons', 'episodes')
->latest()->take(5)->get();
if ($featured->isEmpty()) {
$featured = Anime::where('is_published', true)->with('genres', 'seasons', 'episodes')
->where('rating', '>=', 1)->orderByDesc('rating')->take(5)->get();
}
$latest = Anime::where('is_published', true)->latest()->take(20)->get();
$topRated = Anime::where('is_published', true)->where('rating', '>=', 7)
->orderByDesc('rating')->take(12)->get();
try {
$manualTrending = Anime::where('is_trending', true)->where('is_published', true)
->orderBy('trending_order')->take(12)->get();
if ($manualTrending->count() >= 6) {
$trending = $manualTrending->take(12);
} else {
$autoIds = $manualTrending->pluck('id')->toArray();
$autoFill = Anime::where('is_published', true)
->whereNotIn('id', $autoIds)
->withSum(['episodes as recent_views' => fn($q) =>
$q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30))
], 'view_count')
->orderByDesc('recent_views')
->take(12 - $manualTrending->count())->get();
$trending = $manualTrending->concat($autoFill);
}
} catch (\Throwable) {
$trending = collect();
}
if ($trending->isEmpty()) $trending = $latest->take(12);
$newEpisodes = Episode::with(['anime', 'season'])
->where('is_published', true)->latest()->take(12)->get()
->filter(fn($e) => $e->anime && $e->season)->values();
$genres = Genre::where('is_active', true)->take(16)->get();
$continueWatching = collect();
$recommended = collect();
$authUser = auth('sanctum')->user();
if ($authUser) {
try {
$continueWatching = ContinueWatching::where('user_id', $authUser->id)
->with('anime:id,title,slug,cover_image')
->where('percent_complete', '>=', 5)
->where('percent_complete', '<', 95)
->orderByDesc('updated_at')->limit(10)->get();
$watchedIds = ContinueWatching::where('user_id', $authUser->id)->pluck('anime_id');
if ($watchedIds->isNotEmpty()) {
$topGenreIds = DB::table('anime_genre')
->whereIn('anime_id', $watchedIds)
->select('genre_id', DB::raw('count(*) as cnt'))
->groupBy('genre_id')->orderByDesc('cnt')->limit(3)->pluck('genre_id');
if ($topGenreIds->isNotEmpty()) {
$recommended = Anime::where('is_published', true)
->whereNotIn('id', $watchedIds)
->whereHas('genres', fn($q) => $q->whereIn('genres.id', $topGenreIds))
->where('rating', '>=', 6)->inRandomOrder()->take(12)->get();
}
}
} catch (\Throwable $e) {}
}
return response()->json([
'featured' => $featured->map(fn($a) => $this->animeResource($a, true)),
'trending' => $trending->values()->map(fn($a) => $this->animeResource($a)),
'latest' => $latest->map(fn($a) => $this->animeResource($a)),
'top_rated' => $topRated->map(fn($a) => $this->animeResource($a)),
'new_episodes' => $newEpisodes->map(fn($e) => $this->episodeCardResource($e)),
'genres' => $genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug]),
'continue_watching'=> $continueWatching->map(fn($cw) => $this->continueWatchingResource($cw)),
'recommended' => $recommended->map(fn($a) => $this->animeResource($a)),
]);
}
// GET /api/animes
public function index(Request $request)
{
$q = $request->input('q', '');
$genre = $request->input('genre');
$type = $request->input('type');
$status = $request->input('status');
$year = $request->input('year');
$sort = $request->input('sort', 'latest'); // latest|rating|views
$query = Anime::where('is_published', true)->with('genres');
if ($q) {
$query->where(function ($qb) use ($q) {
$qb->where('title', 'like', "%$q%")
->orWhere('title_en', 'like', "%$q%")
->orWhere('title_jp', 'like', "%$q%");
});
}
if ($genre) $query->whereHas('genres', fn($qb) => $qb->where('slug', $genre));
if ($type) $query->where('type', $type);
if ($status) $query->where('status', $status);
if ($year) $query->where('release_year', $year);
match ($sort) {
'rating' => $query->orderByDesc('rating'),
default => $query->latest(),
};
$results = $query->paginate(24)->withQueryString();
return response()->json([
'data' => collect($results->items())->map(fn($a) => $this->animeResource($a)),
'total' => $results->total(),
'per_page' => $results->perPage(),
'current_page'=> $results->currentPage(),
'last_page' => $results->lastPage(),
]);
}
// GET /api/animes/{slug}
public function show(Request $request, string $slug)
{
$anime = Anime::where('slug', $slug)->where('is_published', true)
->with(['genres', 'seasons', 'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number')])
->firstOrFail();
$userId = $request->user()?->id;
$inWatchlist = false;
$userRating = null;
$isFollowing = false;
if ($userId) {
$wl = \App\Models\Watchlist::where('user_id', $userId)->where('anime_id', $anime->id)->first();
$inWatchlist = $wl !== null;
$watchlistStatus = $wl?->status;
$userRating = \App\Models\AnimeRating::where('user_id', $userId)->where('anime_id', $anime->id)->value('rating');
$isFollowing = \App\Models\AnimeFollow::where('user_id', $userId)->where('anime_id', $anime->id)->exists();
}
$seasons = $anime->seasons->map(function ($season) {
return [
'id' => $season->id,
'season_number' => $season->season_number,
'title' => $season->title,
'episodes' => $season->episodes->map(fn($ep) => $this->episodeResource($ep)),
];
});
return response()->json([
'anime' => $this->animeResource($anime, true),
'seasons' => $seasons,
'in_watchlist' => $inWatchlist,
'watchlist_status'=> $watchlistStatus ?? null,
'user_rating' => $userRating,
'is_following' => $isFollowing,
]);
}
// GET /api/genres/{slug}
public function genre(Request $request, string $slug)
{
$genre = Genre::where('slug', $slug)->where('is_active', true)->firstOrFail();
$animes = $genre->animes()->where('is_published', true)->latest()->paginate(24);
return response()->json([
'genre' => ['id'=>$genre->id,'name'=>$genre->name,'slug'=>$genre->slug],
'data' => collect($animes->items())->map(fn($a) => $this->animeResource($a)),
'total' => $animes->total(),
'last_page' => $animes->lastPage(),
'current_page' => $animes->currentPage(),
]);
}
// GET /api/genres
public function genres()
{
$genres = Genre::where('is_active', true)->orderBy('name')->get();
return response()->json($genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug]));
}
// GET /api/watch/{slug}/{season}/{episode}
public function watch(Request $request, string $slug, int $season, int $episode)
{
$anime = Anime::where('slug', $slug)->where('is_published', true)->firstOrFail();
$seasonModel = $anime->seasons()->where('season_number', $season)->firstOrFail();
$ep = $seasonModel->episodes()->where('episode_number', $episode)->where('is_published', true)->firstOrFail();
$ep->increment('view_count');
$prev = $seasonModel->episodes()->where('episode_number', '<', $episode)->where('is_published', true)->orderByDesc('episode_number')->first();
$next = $seasonModel->episodes()->where('episode_number', '>', $episode)->where('is_published', true)->orderBy('episode_number')->first();
// Cross-season next
if (!$next) {
$nextSeason = $anime->seasons()->where('season_number', $season + 1)->first();
if ($nextSeason) {
$next = $nextSeason->episodes()->where('episode_number', 1)->where('is_published', true)->first();
}
}
// Subtitles
$subtitles = [];
if (method_exists($ep, 'subtitles')) {
$subtitles = $ep->subtitles()->get()->map(fn($s) => [
'label' => $s->label,
'lang' => $s->language,
'url' => $s->url,
'is_default' => (bool)($s->is_default ?? false),
])->values()->toArray();
}
// Dub sources from m3u8 URL
$dubSources = [];
if ($ep->m3u8_url) {
$rawDubs = $ep->available_dubs ?? null;
$availableDubs = is_string($rawDubs) ? json_decode($rawDubs, true) : (is_array($rawDubs) ? $rawDubs : null);
[$dubSources] = \App\Http\Controllers\Frontend\PlayerController::resolveDubSourcesPublic(
$ep->m3u8_url, $availableDubs
);
}
// Quality sources (legacy — episode.video_url / m3u8_url)
$sources = collect();
if ($ep->m3u8_url) $sources->push(['quality'=>'Auto (HLS)','url'=>$ep->m3u8_url,'type'=>'hls']);
if ($ep->video_url) {
$isHls = str_ends_with($ep->video_url, '.m3u8') || str_contains($ep->video_url, 'master.m3u8');
$type = $isHls ? 'hls' : 'mp4';
$sources->push(['quality'=>'Auto','url'=>$ep->video_url,'type'=>$type]);
}
if ($ep->video_url_1080 ?? null) $sources->push(['quality'=>'1080p','url'=>$ep->video_url_1080,'type'=>'mp4']);
if ($ep->video_url_720 ?? null) $sources->push(['quality'=>'720p','url'=>$ep->video_url_720,'type'=>'mp4']);
if ($ep->video_url_480 ?? null) $sources->push(['quality'=>'480p','url'=>$ep->video_url_480,'type'=>'mp4']);
// Çok kaynak desteği (video_sources tablosu)
// Her translator/kaynak bir grup → [{key, label, url, type, quality}]
$multiSources = \App\Models\VideoSource::where('episode_id', $ep->id)
->orderBy('sort_order')
->get()
->groupBy(fn($vs) => $vs->translator_id ?: $vs->label)
->map(function ($group) {
$default = $group->firstWhere('is_default', true) ?? $group->first();
// Tüm kaliteler (1080p, 720p, vb.)
$qualities = $group->map(fn($vs) => [
'quality' => $vs->quality ?: 'Auto',
'url' => $vs->url,
'type' => $vs->type ?? 'mp4',
])->values()->toArray();
return [
'key' => $default->translator_id
?: \Illuminate\Support\Str::slug($default->label ?? 'kaynak'),
'label' => $default->label ?: 'Kaynak',
'url' => $default->url,
'type' => $default->type ?? 'mp4',
'quality' => $default->quality ?: 'Auto',
'source' => $default->source ?? 'animecix',
'qualities' => $qualities,
'is_default'=> (bool) $default->is_default,
];
})
->values()
->toArray();
// Player settings
$skipSeconds = (int) \App\Models\Setting::get('main_video_skip_seconds', 10);
$wmCoverSeconds = (int) \App\Models\Setting::get('watermark_cover_seconds', 11);
$introEnabled = \App\Models\Setting::get('intro_enabled') == '1';
$introUrl = $introEnabled ? (\App\Models\Setting::get('intro_video_url') ?: null) : null;
$introSkipAfter = (int) \App\Models\Setting::get('intro_skip_after', 5);
// AniSkip is fetched via separate /api/aniskip endpoint to avoid blocking video load
$aniSkipData = null;
// All episodes list for in-player episode switcher
$allEpisodes = $seasonModel->episodes()->where('is_published', true)->orderBy('episode_number')
->get()->map(fn($e) => [
'id' => $e->id,
'episode_number' => $e->episode_number,
'season_number' => $season,
'title' => $e->title,
'thumbnail_url' => $e->thumbnail_url ?? null,
]);
return response()->json([
'anime' => ['id'=>$anime->id,'title'=>$anime->title,'slug'=>$anime->slug,'cover_url'=>$anime->coverUrl],
'season' => ['id'=>$seasonModel->id,'season_number'=>$seasonModel->season_number,'title'=>$seasonModel->title],
'episode' => $this->episodeResource($ep),
'sources' => $sources->values(),
'multi_sources'=> $multiSources, // Çok kaynak (Anizium "4K" + AnimeCix çevirmenler)
'dub_sources' => $dubSources,
'subtitles' => $subtitles,
'episodes' => $allEpisodes,
'prev_episode' => $prev ? ['season'=>$prev->season?->season_number ?? $season,'episode'=>$prev->episode_number] : null,
'next_episode' => $next ? ['season'=>$next->season?->season_number ?? $season,'episode'=>$next->episode_number] : null,
'settings' => [
'skip_seconds' => $skipSeconds,
'wm_cover_seconds' => $wmCoverSeconds,
'intro_url' => $introUrl,
'intro_skip_after' => $introSkipAfter,
// AniSkip timestamps (null if not available)
'aniskip' => $aniSkipData, // {'op':{'start':X,'end':Y}, 'ed':{'start':X,'end':Y}}
],
]);
}
// ── Resources ─────────────────────────────────────────────────────────────
private function animeResource(Anime $a, bool $full = false): array
{
$base = [
'id' => $a->id,
'title' => $a->title,
'title_en' => $a->title_en,
'title_jp' => $a->title_jp,
'slug' => $a->slug,
'cover_url' => $a->coverUrl,
'banner_url' => $a->bannerUrl,
'type' => $a->type,
'status' => $a->status,
'rating' => $a->rating ? (float)$a->rating : null,
'release_year' => $a->release_year,
'episode_count' => $a->episode_count,
'genres' => $a->relationLoaded('genres')
? $a->genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug])->values()
: [],
];
if ($full) {
$base['description'] = $a->description;
$base['studio'] = $a->studio ?? null;
$base['duration'] = $a->duration ?? null;
$base['is_featured'] = $a->is_featured;
// First episode for "watch now" button
if ($a->relationLoaded('seasons') && $a->seasons->isNotEmpty()) {
$firstSeason = $a->seasons->first();
$eps = $a->relationLoaded('episodes') ? $a->episodes : $firstSeason->episodes;
$firstEp = $eps->where('season_id', $firstSeason->id)->where('is_published', true)->sortBy('episode_number')->first();
$base['first_watch'] = ($firstSeason && $firstEp) ? [
'season' => $firstSeason->season_number,
'episode' => $firstEp->episode_number,
] : null;
}
}
return $base;
}
private function episodeResource(Episode $ep): array
{
return [
'id' => $ep->id,
'episode_number' => $ep->episode_number,
'title' => $ep->title,
'thumbnail_url' => $ep->thumbnailUrl ?? null,
'duration' => $ep->duration,
'view_count' => $ep->view_count,
'created_at' => $ep->created_at?->toISOString(),
];
}
private function episodeCardResource(Episode $ep): array
{
return [
'id' => $ep->id,
'episode_number' => $ep->episode_number,
'season_number' => $ep->season?->season_number,
'title' => $ep->title,
'thumbnail_url' => $ep->thumbnailUrl ?? null,
'created_at' => $ep->created_at?->diffForHumans(),
'anime' => [
'id' => $ep->anime->id,
'title' => $ep->anime->title,
'slug' => $ep->anime->slug,
'cover_url' => $ep->anime->coverUrl,
'rating' => $ep->anime->rating ? (float)$ep->anime->rating : null,
'description' => $ep->anime->description,
],
];
}
// ── AniSkip endpoint ─────────────────────────────────────────────────────
// GET /api/aniskip/{slug}/{season}/{episode}
// Fully automatic: finds MAL ID by title if missing, caches everything
public function aniSkip(string $slug, int $season, int $episode)
{
$anime = Anime::where('slug', $slug)->first();
if (!$anime) return response()->json(['aniskip' => null]);
$seasonModel = $anime->seasons()->where('season_number', $season)->first();
if (!$seasonModel) return response()->json(['aniskip' => null]);
$seasonMalId = $seasonModel->mal_id;
try {
// anime.mal_id yoksa title search (bir kez, cache'lenir)
if (!$anime->mal_id) {
$found = (new \App\Services\JikanService())->searchMalId($anime->title, $anime->title_en, $anime->title_jp);
if ($found) $anime->update(['mal_id' => $found]);
}
if (!$seasonMalId && $anime->mal_id) {
// S1 için anime.mal_id direkt kullan — Jikan'a gitme
if ($season === 1) {
$seasonMalId = $anime->mal_id;
$seasonModel->update(['mal_id' => $seasonMalId]);
} else {
// Diğer sezonlar: sadece cache'ten bak, yoksa null dön (page load bloke olmasın)
$chain = \Illuminate\Support\Facades\Cache::get("jikan_chain_{$anime->mal_id}");
if ($chain) {
$seasonMalId = $chain[$season - 1] ?? $chain[0] ?? null;
if ($seasonMalId) $seasonModel->update(['mal_id' => $seasonMalId]);
}
}
}
if ($seasonMalId) {
$data = (new \App\Services\AniSkipService())->getSkipTimes((string)$seasonMalId, $episode);
return response()->json(['aniskip' => $data]);
}
} catch (\Throwable) {}
return response()->json(['aniskip' => null]);
}
// ── Skip segments ─────────────────────────────────────────────────────────
// POST /api/episodes/{episode}/skip-event (anonim OK, rate-limited)
public function recordSkipEvent(Request $request, Episode $episode)
{
$from = (int) $request->input('from_sec', 0);
$to = (int) $request->input('to_sec', 0);
// Basic sanity: must skip forward at least 5s, not more than 10 min
if ($to <= $from + 4 || ($to - $from) > 600) {
return response()->json(['ok' => false]);
}
DB::table('episode_skip_events')->insert([
'episode_id' => $episode->id,
'from_sec' => $from,
'to_sec' => $to,
'created_at' => now(),
]);
return response()->json(['ok' => true]);
}
// GET /api/episodes/{episode}/skip-segments
// Returns segments where >= 10 users skipped from within a 30-second window
public function skipSegments(Episode $episode)
{
// İntro tespiti: ilk 3 dakika içinde 45-150sn ileri atlama = intro skip
// Kümeleme: 20sn bucket, to_sec standart sapması <= 15sn, en az 2 farklı kullanıcı
$rows = DB::table('episode_skip_events')
->where('episode_id', $episode->id)
->where('from_sec', '<', 180)
->whereRaw('(to_sec - from_sec) BETWEEN 45 AND 150')
->selectRaw('
FLOOR(from_sec / 20) * 20 AS bucket_start,
AVG(to_sec) AS avg_to,
STDDEV_POP(to_sec) AS stddev_to,
COUNT(*) AS cnt
')
->groupByRaw('FLOOR(from_sec / 20) * 20')
->havingRaw('cnt >= 2 AND (STDDEV_POP(to_sec) <= 15 OR cnt = 1)')
->orderBy('cnt', 'desc')
->limit(1)
->get();
$segments = $rows->map(fn($r) => [
'from' => (int) $r->bucket_start,
'to' => (int) round($r->avg_to),
'count'=> (int) $r->cnt,
])->values();
return response()->json(['segments' => $segments]);
}
private function continueWatchingResource($cw): array
{
return [
'id' => $cw->id,
'season_number' => $cw->season_number,
'episode_number' => $cw->episode_number,
'percent_complete' => $cw->percent_complete,
'anime' => $cw->anime ? [
'id' => $cw->anime->id,
'title' => $cw->anime->title,
'slug' => $cw->anime->slug,
'cover_url' => \App\Support\MediaUrl::fromStoragePath($cw->anime->cover_image),
] : null,
];
}
// POST /api/sources/flag-hevc
// Player tarafından çağrılır: HEVC hatası alınan kaynak URL'sini DB'ye işler
public function flagHevc(Request $request)
{
$url = $request->input('url');
if (!$url) return response()->json(['ok' => false]);
\App\Models\VideoSource::where('url', $url)->update([
'is_hevc' => true,
'hevc_checked_at' => now(),
]);
return response()->json(['ok' => true]);
}
}