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

373 lines
16 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\Frontend;
use App\Http\Controllers\Controller;
use App\Models\Anime;
use App\Models\Season;
use App\Models\Episode;
use App\Models\Setting;
use App\Models\Comment;
use App\Services\BunnyCdnSigner;
use App\Services\AniSkipService;
class PlayerController extends Controller
{
// Public wrapper so API controller can call it
public static function resolveDubSourcesPublic(?string $m3u8Url, ?array $availableDubs = null): array
{
return self::resolveDubSourcesFromM3u8($m3u8Url, $availableDubs);
}
public function watch(Anime $anime, int $season, int $episode)
{
abort_unless($anime->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ı — CDNdeki .../{720p|1080p}-{dub}[/master.m3u8] kalıbından türet
// Embed modda URL video_urlde olabilir (m3u8_url null) — video_urle 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 URLlerini 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).
// anizium.co + aniziumserver.* CDN'leri doğrudan yükle (tarayıcı üzerinden).
$isOwn = fn(?string $u) => !$u || str_contains($u, 'b-cdn.net') || str_contains($u, 'animexe.com')
|| str_contains($u, 'anizium.co') || str_contains($u, 'aniziumserver.sbs');
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,
];
})
->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<int, array{key:string,label:string,url:string,active:bool}>, 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];
}
}