373 lines
16 KiB
PHP
373 lines
16 KiB
PHP
<?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ı — 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).
|
||
// 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];
|
||
}
|
||
}
|