is_published, 404); $seasonModel = Season::where('anime_id', $anime->id) ->where('season_number', $season) ->firstOrFail(); $ep = Episode::where('season_id', $seasonModel->id) ->where('episode_number', $episode) ->where('is_published', true) ->firstOrFail(); $ep->increment('view_count'); $ep->load('subtitles'); // Tüm sezonlar + bölümler (playlist için) + türler (bilgi paneli) $anime->load([ 'seasons' => fn($q) => $q->orderBy('season_number'), 'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number'), 'genres', ]); // Önceki / sonraki bölüm $prev = Episode::with('season') ->where('season_id', $seasonModel->id) ->where('episode_number', $episode - 1) ->where('is_published', true) ->first(); $next = Episode::with('season') ->where('season_id', $seasonModel->id) ->where('episode_number', $episode + 1) ->where('is_published', true) ->first(); // Sonraki bölüm yoksa bir sonraki sezona geç if (!$next) { $nextSeason = Season::where('anime_id', $anime->id) ->where('season_number', $season + 1) ->first(); if ($nextSeason) { $next = Episode::where('season_id', $nextSeason->id) ->where('episode_number', 1) ->where('is_published', true) ->first(); } } // JSON-safe subtitle data (proxy URL for CORS bypass) $subtitlesData = $ep->subtitles->map(fn($s) => [ 'label' => $s->label, 'lang' => $s->language, 'url' => route('vtt.proxy', ['url' => $s->url]), 'is_default' => (bool) $s->is_default, ])->values()->toArray(); // Dublaj kaynakları — CDN’deki .../{720p|1080p}-{dub}[/master.m3u8] kalıbından türet // Embed modda URL video_url’de olabilir (m3u8_url null) — video_url’e fallback $raw = $ep->available_dubs; $availableDubs = is_array($raw) ? $raw : null; // null = unknown (show all), array = restrict to listed $sourceForDubs = $ep->m3u8_url ?: $ep->video_url; [$dubSources, $activeDub] = self::resolveDubSourcesFromM3u8($sourceForDubs, $availableDubs); // Tüm video URL’lerini imzala (BunnyCDN Token Auth) BunnyCdnSigner::signAll($dubSources); $ep->m3u8_url = BunnyCdnSigner::sign($ep->m3u8_url); $ep->video_url = BunnyCdnSigner::sign($ep->video_url); // Proxy all external HLS streams through our server (CORS + SSL bypass). // aniziumserver.* (sbs/site) CORS izni vermiyor, bu yüzden hepsi proxy'den geçmeli. $isOwn = fn(?string $u) => !$u || str_contains($u, 'b-cdn.net') || str_contains($u, 'animexe.com'); if ($ep->m3u8_url && !$isOwn($ep->m3u8_url)) { $ep->m3u8_url = route("stream.proxy", ["u" => base64_encode((string) $ep->m3u8_url)]); } // video_url — embed modda anizium HLS URL olabilir; HLS ise proxy'den geçir, MP4 ise bırak. if ($ep->video_url && !$isOwn($ep->video_url)) { if (str_ends_with((string) $ep->video_url, '.m3u8')) { $ep->video_url = route("stream.proxy", ["u" => base64_encode((string) $ep->video_url)]); } } foreach (array_keys($dubSources) as $idx) { $dubUrl = (string) ($dubSources[$idx]["url"] ?? ""); if ($dubUrl && !$isOwn($dubUrl)) { $dubSources[$idx]["url"] = route("stream.proxy", ["u" => base64_encode($dubUrl)]); } } // Video sources — Anizium 1080p → 720p → 4K/diğer, sonra AnimeCix $videoSourcesData = \App\Models\VideoSource::where('episode_id', $ep->id) ->orderBy('sort_order') ->get(['id', 'label', 'url', 'type', 'quality', 'translator_id', 'is_default', 'source', 'sort_order', 'is_hevc']) ->groupBy(fn($vs) => $vs->translator_id ?: $vs->label) ->map(function ($group) { $default = $group->firstWhere('is_default', true) ?? $group->first(); return [ 'id' => $default->id, 'key' => $default->translator_id ?: \Illuminate\Support\Str::slug($default->label), 'label' => $default->label ?: 'Kaynak', 'url' => $default->url, 'type' => $default->type ?? 'hls', 'source' => $default->source ?? 'animecix', 'quality' => $default->quality ?? '', 'sort_order' => $default->sort_order ?? 99, 'is_hevc' => (bool) $default->is_hevc, ]; }) ->map(function ($item) use ($isOwn) { if (!empty($item['url']) && !$isOwn($item['url'])) { $item['url'] = route("stream.proxy", ["u" => base64_encode((string) $item['url'])]); } return $item; }) ->sortBy(function ($item) { $isAnizium = ($item['source'] === 'anizium'); $q = strtolower($item['quality'] ?? ''); if ($isAnizium) { if (str_contains($q, '1080')) return 0; if (str_contains($q, '720')) return 1; return 1000; // 4K / H.265 / diğer → en sona } return 10 + ($item['sort_order'] ?? 99); // AnimeCix }) ->values() ->toArray(); // Sonraki bölüm URL'si $nextUrl = null; if ($next) { $nextSeasonNum = $next->season?->season_number ?? $seasonModel->season_number; if (!$next->season) { $nextSeason2 = Season::find($next->season_id); $nextSeasonNum = $nextSeason2?->season_number ?? $seasonModel->season_number; } $nextUrl = route('watch', [$anime->slug, $nextSeasonNum, $next->episode_number]); } // Intro video ayarları $introUrl = Setting::get('intro_enabled') == '1' ? (Setting::get('intro_video_url') ?: null) : null; $introSkipAfter = (int) Setting::get('intro_skip_after', 5); $mainVideoSkipSec = (int) Setting::get('main_video_skip_seconds', 10); $wmCoverSeconds = (int) Setting::get('watermark_cover_seconds', 11); // İntro atla: önce bölüme elle girilmiş zamanlar, yoksa AniSkip API $aniSkip = null; if ($ep->intro_start !== null && $ep->intro_end !== null && $ep->intro_end > $ep->intro_start) { // Manuel giriş — en güvenilir $aniSkip = ['op' => ['start' => (float)$ep->intro_start, 'end' => (float)$ep->intro_end]]; } else { $seasonMalId = $seasonModel->mal_id; // season.mal_id yoksa akıllı fallback (Jikan'a gitme, bloke olur) if (!$seasonMalId && $anime->mal_id) { // Sezon 1 için anime.mal_id direkt kullanılabilir // Diğer sezonlar için background job yerine cache'li Jikan if ($seasonModel->season_number === 1) { $seasonMalId = $anime->mal_id; $seasonModel->update(['mal_id' => $seasonMalId]); } else { // Sequel chain'i sadece cache'li olarak dene (timeout kısa, bloke etmez) try { $cacheKey = "jikan_chain_{$anime->mal_id}"; $chain = \Illuminate\Support\Facades\Cache::get($cacheKey); if (!$chain) { // Cache yoksa arka planda doldur, bu istek için atla dispatch(function () use ($anime) { $chain = (new \App\Services\JikanService())->fetchSeasonMalIds($anime->mal_id); if ($chain) { \Illuminate\Support\Facades\Cache::put("jikan_chain_{$anime->mal_id}", $chain, 60 * 60 * 24 * 7); foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $s) { if (!$s->mal_id && isset($chain[$i])) $s->update(['mal_id' => $chain[$i]]); } } })->afterResponse(); } else { $idx = $seasonModel->season_number - 1; $seasonMalId = $chain[$idx] ?? $chain[0] ?? null; if ($seasonMalId) $seasonModel->update(['mal_id' => $seasonMalId]); } } catch (\Throwable) {} } } // anime.mal_id de yoksa Jikan title search (sadece bir kez, cache'lenir) if (!$seasonMalId && !$anime->mal_id) { try { $found = (new AniSkipService())->searchByTitle($anime->title, $anime->title_en, $anime->title_jp); if ($found) { $anime->update(['mal_id' => $found]); $seasonMalId = $found; if ($seasonModel->season_number === 1) $seasonModel->update(['mal_id' => $found]); } } catch (\Throwable) {} } if ($seasonMalId) { try { $aniSkip = (new AniSkipService())->getSkipTimes((string)$seasonMalId, $ep->episode_number); } catch (\Throwable) {} } } // İzleme ilerlemeleri (sidebar progress bar için) $watchProgress = []; if (auth()->check()) { $progRows = \App\Models\ContinueWatching::where('user_id', auth()->id()) ->where('anime_id', $anime->id) ->get(['episode_id', 'percent_complete']); foreach ($progRows as $row) { $watchProgress[$row->episode_id] = (int) $row->percent_complete; } } // Premium kullanıcı HİÇBİR reklam görmez (hem eski VAST hem yeni MP4 sistemi) $isPremiumUser = auth()->check() && auth()->user()->isPremium(); $adsConfig = [ 'enabled' => !$isPremiumUser && Setting::get('ads_enabled', '0') === '1', 'vast_url' => Setting::get('ads_vast_url', ''), 'freq_episodes' => (int) Setting::get('ads_freq_episodes', 4), 'freq_minutes' => (int) Setting::get('ads_freq_minutes', 10), ]; // ── Kendi MP4 pre-roll reklam sistemi ──────────────────────────────── // Premium kullanıcı reklam görmez. mode: 'ad' | 'upsell' | null $vadConfig = ['mode' => null]; if (!$isPremiumUser && Setting::get('vad_enabled', '0') === '1') { $upsellPercent = (int) Setting::get('vad_upsell_percent', 20); $ad = null; $mode = null; if (random_int(1, 100) <= $upsellPercent) { $mode = 'upsell'; } else { $ad = \App\Models\Ad::pickVideo(); if ($ad && $ad->media_url) { $mode = 'ad'; } elseif ($upsellPercent > 0) { $mode = 'upsell'; // hiç video reklam yoksa upsell göster } } $vadConfig = [ 'mode' => $mode, 'ad' => $mode === 'ad' ? [ 'id' => $ad->id, 'url' => $ad->media_url, 'click_url' => $ad->click_url, 'skip_after' => (int) $ad->skip_after, ] : null, 'freq_episodes' => (int) Setting::get('vad_freq_episodes', 2), 'freq_minutes' => (int) Setting::get('vad_freq_minutes', 5), 'premium_url' => route('premium.plans'), ]; } // Kendi reklamımız gösterilecekse IMA/VAST devreye girmesin if (!empty($vadConfig['mode'])) { $adsConfig['enabled'] = false; } return response() ->view('frontend.player', compact( 'anime', 'ep', 'seasonModel', 'prev', 'next', 'subtitlesData', 'nextUrl', 'dubSources', 'activeDub', 'introUrl', 'introSkipAfter', 'mainVideoSkipSec', 'wmCoverSeconds', 'aniSkip', 'watchProgress', 'videoSourcesData', 'adsConfig', 'vadConfig' )) ->header('Cache-Control', 'private, no-store, no-cache, must-revalidate') ->header('Pragma', 'no-cache') ->header('X-Player-Version', '3'); } /** * m3u8 URL içinden kalite+dublaj klasörünü bulup diğer dublaj varyantlarının URL'lerini üretir. * Örnekler: * - https://f.aniziumserver.sbs/85937/1/1/1080p-original/master.m3u8 * - https://host/cdn/x/1/01/720p-trdub/ * - https://xxx.b-cdn.net/.../1080p_endub/index.m3u8 * * @return array{0: array, 1: ?string} */ protected static function resolveDubSourcesFromM3u8(?string $m3u8Url, ?array $availableDubs = null): array { if (!$m3u8Url || ! is_string($m3u8Url)) { return [[], null]; } $u = rtrim(preg_replace('/[?#].*$/', '', trim($m3u8Url)), '/'); if ($u === '') { return [[], null]; } // Sondaki playlist dosyasını çıkar (master.m3u8, index.m3u8, video.m3u8, …) if (preg_match('#/[^/]+\.m3u8$#i', $u)) { $u = rtrim(preg_replace('#/[^/]+\.m3u8$#i', '', $u), '/'); } // Son segment: 720p-original, 1080p_trdub, 480p-endub if (! preg_match('#^(.*)/(\d{3,4}p)([-_])([a-zA-Z0-9_-]+)$#', $u, $m)) { return [[], null]; } $parent = $m[1]; $quality = $m[2]; $sep = $m[3]; $activeDub = strtolower($m[4]); $dubLabels = [ 'trdub' => 'Türkçe Dublaj', 'original' => 'Japonca (Orijinal)', 'endub' => 'İngilizce Dublaj', // Dynamic: any unrecognised key gets a generic label below ]; $raw = rtrim(preg_replace('/[?#].*$/', '', trim($m3u8Url)), '/'); $suffix = ''; if (preg_match('#/(\d{3,4}p)([-_])([a-zA-Z0-9_-]+)(/.*)$#i', $raw, $tail)) { $suffix = $tail[4]; } // Which dub keys to include: // • null → column not yet migrated (legacy): show all standard dubs // • [] empty array → no dub info, show only active // • ['trdub','original',...] → restrict to listed keys if ($availableDubs === null) { // Bilinmiyor: aktif dub trdub/endub ise orijinal (JP) de büyük ihtimalle var. // Aktif dub zaten original ise başka dub olmadığını varsay (false positive önle). $keys = $activeDub !== 'original' ? [$activeDub, 'original'] : [$activeDub]; } elseif (count($availableDubs) === 0) { // Bot açıkça "dub bilgisi yok" dedi — sadece aktif $keys = [$activeDub]; } else { $keys = $availableDubs; } // Tekrarları at, aktif dub'ı öne al $seen = []; $sources = []; // Aktif dub her zaman ilk sıraya if (!in_array($activeDub, $keys)) array_unshift($keys, $activeDub); foreach ($keys as $key) { if (isset($seen[$key])) continue; $seen[$key] = true; $label = $dubLabels[$key] ?? ucfirst($key) . ' Dublaj'; $url = $parent . '/' . $quality . $sep . $key . $suffix; $sources[] = [ 'key' => $key, 'label' => $label, 'url' => $url, 'active' => $key === $activeDub, ]; } return [$sources, $activeDub]; } }