338 lines
12 KiB
PHP
338 lines
12 KiB
PHP
<?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;
|
||
}
|
||
}
|
||
}
|