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,337 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Anime;
use App\Models\ContentPermission;
use App\Models\Episode;
use App\Models\PermissionSetting;
use App\Models\Season;
use App\Models\VideoSource;
use App\Support\ImageOptimizer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class EpisodeController extends Controller
{
public function index(Request $request)
{
$query = Episode::with(['anime', 'season'])->latest();
if ($request->anime_id) {
$query->where('anime_id', $request->anime_id);
}
if ($request->status) {
$query->where('status', $request->status);
}
if ($request->search) {
$query->where('title', 'like', '%' . $request->search . '%');
}
$episodes = $query->paginate(30)->withQueryString();
$animes = Anime::orderBy('title')->get();
return view('admin.episodes.index', compact('episodes', 'animes'));
}
public function create(Request $request)
{
$animes = Anime::orderBy('title')->get();
$seasons = [];
$selectedAnime = null;
if ($request->anime_id) {
$selectedAnime = Anime::find($request->anime_id);
$seasons = Season::where('anime_id', $request->anime_id)->get();
}
$permissions = PermissionSetting::all();
return view('admin.episodes.create', compact('animes', 'seasons', 'selectedAnime', 'permissions'));
}
public function store(Request $request)
{
$data = $request->validate([
'anime_id' => 'required|exists:animes,id',
'season_id' => 'required|exists:seasons,id',
'episode_number' => 'required|integer|min:1',
'title' => 'nullable|string|max:255',
'description' => 'nullable|string',
'duration' => 'nullable|integer',
'source_url' => 'nullable|string',
'video_url' => 'nullable|string',
'm3u8_url' => 'nullable|string',
'source' => 'required|in:bunnycdn,external,direct',
'is_published' => 'boolean',
]);
$data['status'] = $data['is_published'] ? 'published' : 'pending';
$data['is_published'] = $request->boolean('is_published');
if ($request->hasFile('thumbnail')) {
$data['thumbnail'] = ImageOptimizer::store($request->file('thumbnail'), 'thumbnails', 'thumbnail');
}
$episode = Episode::create($data);
// İzin override'ları
$this->savePermissions($episode, $request->permissions ?? []);
// Takipçilere bildirim gönder
if ($episode->is_published) {
$this->notifyFollowers($episode);
}
return redirect()->route('admin.episodes.index', ['anime_id' => $episode->anime_id])
->with('success', 'Bölüm eklendi.');
}
public function show(Episode $episode)
{
return redirect()->route('admin.episodes.edit', $episode);
}
public function edit(Episode $episode)
{
$animes = Anime::orderBy('title')->get();
$seasons = Season::where('anime_id', $episode->anime_id)->get();
$permissions = PermissionSetting::all();
$contentPerms = ContentPermission::where('content_type', 'episode')
->where('content_id', $episode->id)
->pluck('required_membership', 'permission_key');
return view('admin.episodes.edit', compact('episode', 'animes', 'seasons', 'permissions', 'contentPerms'));
}
public function update(Request $request, Episode $episode)
{
$data = $request->validate([
'anime_id' => 'required|exists:animes,id',
'season_id' => 'required|exists:seasons,id',
'episode_number' => 'required|integer|min:1',
'title' => 'nullable|string|max:255',
'description' => 'nullable|string',
'duration' => 'nullable|integer',
'intro_start' => 'nullable|integer|min:0',
'intro_end' => 'nullable|integer|min:0',
'source_url' => 'nullable|string',
'video_url' => 'nullable|string',
'm3u8_url' => 'nullable|string',
'source' => 'required|in:bunnycdn,external,direct',
'is_published' => 'boolean',
]);
$wasPublished = $episode->is_published;
$data['is_published'] = $request->boolean('is_published');
$data['status'] = $data['is_published'] ? 'published' : 'pending';
if ($request->hasFile('thumbnail')) {
$data['thumbnail'] = ImageOptimizer::store($request->file('thumbnail'), 'thumbnails', 'thumbnail');
}
$episode->update($data);
$this->savePermissions($episode, $request->permissions ?? []);
// Sadece yeni yayınlandıysa bildirim gönder (zaten yayındaysa tekrar gönderme)
if (!$wasPublished && $episode->is_published) {
$this->notifyFollowers($episode);
}
return redirect()->route('admin.episodes.edit', $episode)->with('success', 'Bölüm güncellendi.');
}
public function destroy(Episode $episode)
{
$animeId = $episode->anime_id;
$videoUrl = $episode->video_url;
$subUrls = $episode->subtitles()->pluck('url')->all();
$episode->delete();
// CDN'den dosyaları arka planda sil
dispatch(function () use ($videoUrl, $subUrls) {
\App\Services\BunnyCdnStorage::deleteFile($videoUrl);
foreach ($subUrls as $url) {
\App\Services\BunnyCdnStorage::deleteFile($url);
}
})->afterResponse();
return redirect()->route('admin.episodes.index', ['anime_id' => $animeId])
->with('success', 'Bölüm silindi.');
}
private function notifyFollowers(Episode $episode): void
{
$anime = Anime::find($episode->anime_id);
$season = Season::find($episode->season_id);
if (!$anime) return;
$followers = \App\Models\AnimeFollow::where('anime_id', $episode->anime_id)
->join('users', 'users.id', '=', 'anime_follows.user_id')
->select('users.id as user_id', 'users.fcm_token')
->get();
if ($followers->isEmpty()) return;
$seasonNum = $season?->season_number ?? 1;
$notifData = json_encode([
'anime_id' => $anime->id,
'anime_title' => $anime->title,
'anime_slug' => $anime->slug,
'episode_number' => $episode->episode_number,
'season_number' => $seasonNum,
'episode_title' => $episode->title,
]);
$rows = [];
$now = now();
foreach ($followers as $follower) {
$rows[] = [
'user_id' => $follower->user_id,
'type' => 'episode',
'data' => $notifData,
'created_at' => $now,
];
}
\App\Models\UserNotification::insert($rows);
// FCM Push
$fcmTokens = $followers->pluck('fcm_token')->filter()->values()->toArray();
if (!empty($fcmTokens)) {
$title = $anime->title . ' — Yeni Bölüm!';
$body = "Sezon {$seasonNum}, {$episode->episode_number}. Bölüm"
. ($episode->title ? ' — ' . $episode->title : '') . ' eklendi.';
$fcm = new \App\Services\FcmService();
$fcm->sendToTokens($fcmTokens, $title, $body, [
'type' => 'episode',
'anime_slug' => $anime->slug,
'season_number' => (string)$seasonNum,
'episode_number' => (string)$episode->episode_number,
]);
}
}
private function savePermissions(Episode $episode, array $permissions): void
{
ContentPermission::where('content_type', 'episode')
->where('content_id', $episode->id)
->delete();
foreach ($permissions as $key => $value) {
if (in_array($value, ['free', 'premium'])) {
ContentPermission::create([
'content_type' => 'episode',
'content_id' => $episode->id,
'permission_key' => $key,
'required_membership' => $value,
]);
}
}
}
public function bulkDestroy(Request $request)
{
if ($request->boolean('all')) {
$query = Episode::query();
$f = $request->input('filters', []);
if (!empty($f['anime_id'])) $query->where('anime_id', $f['anime_id']);
if (!empty($f['status'])) $query->where('status', $f['status']);
if (!empty($f['search'])) $query->where('title', 'like', '%'.$f['search'].'%');
$episodes = $query->get();
} else {
$request->validate(['ids' => 'required|array', 'ids.*' => 'integer']);
$episodes = Episode::whereIn('id', $request->ids)->get();
}
$videoUrls = [];
$subUrls = [];
foreach ($episodes as $ep) {
if ($ep->video_url) $videoUrls[] = $ep->video_url;
foreach ($ep->subtitles()->pluck('url') as $u) $subUrls[] = $u;
$ep->delete();
}
dispatch(function () use ($videoUrls, $subUrls) {
foreach ($videoUrls as $url) \App\Services\BunnyCdnStorage::deleteFile($url);
foreach ($subUrls as $url) \App\Services\BunnyCdnStorage::deleteFile($url);
})->afterResponse();
return response()->json(['success' => true, 'deleted' => count($episodes)]);
}
public function bulkIntro(Request $request)
{
$request->validate([
'anime_id' => 'required|exists:animes,id',
'season' => 'required|integer|min:0',
'intro_start' => 'required|integer|min:0',
'intro_end' => 'required|integer|min:1',
]);
$query = Episode::where('anime_id', $request->anime_id);
if ((int)$request->season > 0) {
$season = \App\Models\Season::where('anime_id', $request->anime_id)
->where('season_number', $request->season)->first();
if ($season) $query->where('season_id', $season->id);
}
$updated = $query->update([
'intro_start' => $request->intro_start,
'intro_end' => $request->intro_end,
]);
return response()->json(['ok' => true, 'updated' => $updated]);
}
// POST /admin/episodes/{episode}/scan-hevc
// Admin panelinden bölümün HLS kaynaklarını sunucu tarafında tarar, HEVC olanları işaretler
public function scanHevc(Episode $episode)
{
$sources = VideoSource::where('episode_id', $episode->id)
->where('type', 'hls')
->get();
$results = [];
foreach ($sources as $src) {
$isHevc = $this->probeM3u8ForHevc($src->url);
$src->update(['is_hevc' => $isHevc, 'hevc_checked_at' => now()]);
$results[] = [
'id' => $src->id,
'label' => $src->label,
'quality' => $src->quality,
'is_hevc' => $isHevc,
];
}
return response()->json(['ok' => true, 'results' => $results]);
}
private function probeM3u8ForHevc(string $url): bool
{
try {
$response = Http::timeout(8)->withHeaders(['User-Agent' => 'Mozilla/5.0'])->get($url);
if (!$response->ok()) return false;
$text = $response->body();
preg_match_all('/#EXT-X-STREAM-INF:([^\n]+)/i', $text, $matches);
if (empty($matches[1])) return false;
$isHevcCodec = fn($attrs) => (bool) preg_match('/CODECS="[^"]*(?:hev1|hvc1|dvh1)[^"]*"/i', $attrs);
foreach ($matches[1] as $attrs) {
// CODECS tag yoksa bilinmiyor — H.264 uyumlu say, HEVC değil
if (!str_contains(strtoupper($attrs), 'CODECS=')) return false;
if (!$isHevcCodec($attrs)) return false;
}
return true; // tüm stream'ler HEVC
} catch (\Throwable) {
return false;
}
}
}