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

338 lines
12 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\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;
}
}
}