Initial commit: Animexe Laravel platform

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 00:01:48 +03:00
co-authored by Claude Opus 4.8
commit a63515cfc6
366 changed files with 74773 additions and 0 deletions
@@ -0,0 +1,372 @@
<?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];
}
}