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]); } }