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; } } }