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,412 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Anime;
use App\Models\Season;
use App\Models\ContentPermission;
use App\Models\Genre;
use App\Models\PermissionSetting;
use App\Services\JikanService;
use App\Support\ImageOptimizer;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class AnimeController extends Controller
{
public function index(Request $request)
{
$query = Anime::with('genres')->latest();
if ($request->search) {
$query->where('title', 'like', '%' . $request->search . '%');
}
if ($request->status) {
$query->where('status', $request->status);
}
if ($request->type) {
$query->where('type', $request->type);
}
if ($request->no_episodes) {
$query->whereDoesntHave('episodes');
}
$animes = $query->paginate(20)->withQueryString();
$zeroEpisodeCount = Anime::whereDoesntHave('episodes')->count();
return view('admin.animes.index', compact('animes', 'zeroEpisodeCount'));
}
public function destroyZeroEpisodes()
{
$animes = Anime::whereDoesntHave('episodes')->get();
$count = $animes->count();
foreach ($animes as $anime) {
$anime->delete();
}
return response()->json(['success' => true, 'count' => $count]);
}
public function create()
{
$genres = Genre::where('is_active', true)->get();
$permissions = PermissionSetting::all();
return view('admin.animes.create', compact('genres', 'permissions'));
}
public function store(Request $request)
{
$data = $request->validate([
'title' => 'required|string|max:255',
'title_en' => 'nullable|string|max:255',
'title_jp' => 'nullable|string|max:255',
'description' => 'nullable|string',
'type' => 'required|in:series,movie,ova,ona,special',
'status' => 'required|in:ongoing,completed,upcoming',
'release_year' => 'nullable|integer|min:1900|max:2099',
'studio' => 'nullable|string|max:255',
'rating' => 'nullable|numeric|min:0|max:10',
'mal_id' => 'nullable|string|max:50',
'trailer_url' => 'nullable|url',
'is_featured' => 'boolean',
'is_published' => 'boolean',
'is_dubbed' => 'boolean',
]);
$data['slug'] = Str::slug($data['title']);
$data['is_featured'] = $request->boolean('is_featured');
$data['is_published'] = $request->boolean('is_published');
$data['is_dubbed'] = $request->boolean('is_dubbed');
// Auto-fetch MAL ID if not provided
if (empty($data['mal_id'])) {
try {
$data['mal_id'] = (new JikanService())->searchMalId(
$data['title'],
$data['title_en'] ?? null,
$data['title_jp'] ?? null,
$data['type'] ?? null,
);
} catch (\Throwable) {}
}
if ($request->hasFile('cover_image')) {
$data['cover_image'] = ImageOptimizer::store($request->file('cover_image'), 'covers', 'cover');
}
if ($request->hasFile('banner_image')) {
$data['banner_image'] = ImageOptimizer::store($request->file('banner_image'), 'banners', 'banner');
}
$anime = Anime::create($data);
if ($request->genres) {
$anime->genres()->sync($request->genres);
}
// Auto-fill season MAL IDs if mal_id was found
if ($anime->mal_id) {
dispatch(function () use ($anime) {
try {
$chain = (new JikanService())->fetchSeasonMalIds($anime->mal_id);
foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) {
if (isset($chain[$i])) $season->update(['mal_id' => $chain[$i]]);
}
} catch (\Throwable) {}
})->afterResponse();
}
return redirect()->route('admin.animes.show', $anime)->with('success', 'Anime eklendi.');
}
public function show(Anime $anime)
{
$anime->load(['genres', 'seasons.episodes']);
$permissions = PermissionSetting::all();
$contentPerms = ContentPermission::where('content_type', 'anime')
->where('content_id', $anime->id)
->pluck('required_membership', 'permission_key');
return view('admin.animes.show', compact('anime', 'permissions', 'contentPerms'));
}
public function edit(Anime $anime)
{
$genres = Genre::where('is_active', true)->get();
$permissions = PermissionSetting::all();
$contentPerms = ContentPermission::where('content_type', 'anime')
->where('content_id', $anime->id)
->pluck('required_membership', 'permission_key');
return view('admin.animes.edit', compact('anime', 'genres', 'permissions', 'contentPerms'));
}
public function update(Request $request, Anime $anime)
{
$data = $request->validate([
'title' => 'required|string|max:255',
'title_en' => 'nullable|string|max:255',
'title_jp' => 'nullable|string|max:255',
'description' => 'nullable|string',
'type' => 'required|in:series,movie,ova,ona,special',
'status' => 'required|in:ongoing,completed,upcoming',
'release_year' => 'nullable|integer|min:1900|max:2099',
'studio' => 'nullable|string|max:255',
'rating' => 'nullable|numeric|min:0|max:10',
'mal_id' => 'nullable|string|max:50',
'trailer_url' => 'nullable|url',
'is_featured' => 'boolean',
'is_published' => 'boolean',
'is_dubbed' => 'boolean',
]);
$data['is_featured'] = $request->boolean('is_featured');
$data['is_published'] = $request->boolean('is_published');
$data['is_dubbed'] = $request->boolean('is_dubbed');
// Auto-fetch MAL ID if not provided and anime doesn't already have one
if (empty($data['mal_id']) && empty($anime->mal_id)) {
try {
$data['mal_id'] = (new JikanService())->searchMalId(
$data['title'],
$data['title_en'] ?? null,
$data['title_jp'] ?? null,
);
} catch (\Throwable) {}
}
if ($request->hasFile('cover_image')) {
ImageOptimizer::delete($anime->cover_image);
$data['cover_image'] = ImageOptimizer::store($request->file('cover_image'), 'covers', 'cover');
}
if ($request->hasFile('banner_image')) {
ImageOptimizer::delete($anime->banner_image);
$data['banner_image'] = ImageOptimizer::store($request->file('banner_image'), 'banners', 'banner');
}
$anime->update($data);
if ($request->has('genres')) {
$anime->genres()->sync($request->genres ?? []);
}
// MAL ID değiştiyse: AniSkip cache'lerini temizle + sezon MAL ID'lerini doldur
if ($anime->mal_id) {
dispatch(function () use ($anime) {
try {
// AniSkip null cache'lerini temizle (tüm bölümler için)
foreach ($anime->seasons as $s) {
if ($s->mal_id) {
foreach ($anime->episodes()->where('season_id', $s->id)->pluck('episode_number') as $epNum) {
\Illuminate\Support\Facades\Cache::forget("aniskip_{$s->mal_id}_{$epNum}");
}
}
}
// S1 için doğrudan anime.mal_id kullan
$s1 = $anime->seasons()->where('season_number', 1)->first();
if ($s1 && !$s1->mal_id) {
$s1->update(['mal_id' => $anime->mal_id]);
foreach ($anime->episodes()->where('season_id', $s1->id)->pluck('episode_number') as $epNum) {
\Illuminate\Support\Facades\Cache::forget("aniskip_{$anime->mal_id}_{$epNum}");
}
}
// S2+ için Jikan chain
$chain = (new JikanService())->fetchSeasonMalIds($anime->mal_id);
\Illuminate\Support\Facades\Cache::put("jikan_chain_{$anime->mal_id}", $chain, 60 * 60 * 24 * 7);
foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) {
if (!$season->mal_id && isset($chain[$i])) {
$season->update(['mal_id' => $chain[$i]]);
}
}
} catch (\Throwable) {}
})->afterResponse();
}
return redirect()->route('admin.animes.show', $anime)->with('success', 'Anime güncellendi.');
}
public function destroy(Anime $anime)
{
// CDN klasörü için örnek bir video_url al (anime_XXXXX/ path'ini çıkarmak için)
$sampleVideoUrl = $anime->episodes()->whereNotNull('video_url')->value('video_url');
$anime->delete();
// CDN'den tüm anime klasörünü arka planda sil (anime_XXXXX/season_X/...)
dispatch(function () use ($sampleVideoUrl) {
\App\Services\BunnyCdnStorage::deleteAnimeFolder($sampleVideoUrl);
})->afterResponse();
return redirect()->route('admin.animes.index')->with('success', 'Anime silindi.');
}
public function updatePermissions(Request $request, Anime $anime)
{
$permissions = $request->permissions ?? [];
// Mevcut override'ları sil
ContentPermission::where('content_type', 'anime')
->where('content_id', $anime->id)
->delete();
// Yeni override'ları kaydet
foreach ($permissions as $key => $value) {
if (in_array($value, ['free', 'premium'])) {
ContentPermission::create([
'content_type' => 'anime',
'content_id' => $anime->id,
'permission_key' => $key,
'required_membership' => $value,
]);
}
}
return back()->with('success', 'İzinler güncellendi.');
}
/**
* POST admin/animes/{anime}/fetch-mal-seasons
* Walks the Jikan sequel chain and fills seasons.mal_id automatically.
*/
public function fetchMalSeasons(Request $request, Anime $anime)
{
// Formdan gelen mal_id varsa önce güncelle
if ($request->filled('mal_id')) {
$anime->update(['mal_id' => $request->input('mal_id')]);
}
if (!$anime->mal_id) {
return response()->json(['error' => 'MAL ID girilmemiş. MyAnimeList.net\'ten anime sayfasını açıp URL\'deki numarayı gir.'], 422);
}
$jikan = new JikanService();
$chain = $jikan->fetchSeasonMalIds($anime->mal_id);
if (empty($chain)) {
return response()->json(['error' => 'Jikan API\'den veri alınamadı.'], 502);
}
$seasons = Season::where('anime_id', $anime->id)
->orderBy('season_number')
->get();
$updated = [];
foreach ($seasons as $index => $season) {
$malId = $chain[$index] ?? null;
if ($malId) {
$season->update(['mal_id' => $malId]);
$updated[] = [
'season' => $season->season_number,
'mal_id' => $malId,
];
}
}
// If anime has more seasons than chain entries, remaining seasons stay null
return response()->json([
'success' => true,
'chain' => $chain,
'updated' => $updated,
'message' => count($updated) . ' sezon güncellendi.',
]);
}
/**
* POST admin/animes/{anime}/fetch-mal
* Tek bir anime için MAL ID arar ve kaydeder.
*/
public function fetchMalSingle(Anime $anime)
{
try {
$malId = (new JikanService())->searchMalId(
$anime->title, $anime->title_en, $anime->title_jp, $anime->type
);
if ($malId) {
$anime->update(['mal_id' => $malId]);
$s1 = $anime->seasons()->where('season_number', 1)->first();
if ($s1 && !$s1->mal_id) $s1->update(['mal_id' => $malId]);
return response()->json(['found' => true, 'mal_id' => $malId]);
}
return response()->json(['found' => false]);
} catch (\Throwable $e) {
return response()->json(['found' => false, 'error' => $e->getMessage()], 500);
}
}
public function bulkDestroy(Request $request)
{
if ($request->boolean('all')) {
$query = Anime::query();
$f = $request->input('filters', []);
if (!empty($f['search'])) $query->where('title', 'like', '%'.$f['search'].'%');
if (!empty($f['status'])) $query->where('status', $f['status']);
if (!empty($f['type'])) $query->where('type', $f['type']);
$animes = $query->get();
} else {
$request->validate(['ids' => 'required|array', 'ids.*' => 'integer']);
$animes = Anime::whereIn('id', $request->ids)->get();
}
$sampleUrls = [];
foreach ($animes as $anime) {
$url = $anime->episodes()->whereNotNull('video_url')->value('video_url');
if ($url) $sampleUrls[] = $url;
$anime->delete();
}
dispatch(function () use ($sampleUrls) {
foreach ($sampleUrls as $url) {
\App\Services\BunnyCdnStorage::deleteAnimeFolder($url);
}
})->afterResponse();
return response()->json(['success' => true, 'deleted' => count($animes)]);
}
/**
* POST admin/animes/bulk-find-mal
* MAL ID'si olmayan animeleri Jikan title search ile toplu doldurur.
* Her seferinde 1 anime işler (AJAX loop), Jikan rate limit aşılmaz.
*/
public function bulkFindMal(Request $request)
{
$skipIds = $request->input('skip_ids', []);
$anime = Anime::where(fn($q) => $q->whereNull('mal_id')->orWhere('mal_id', ''))
->when($skipIds, fn($q) => $q->whereNotIn('id', $skipIds))
->orderBy('id')
->first();
if (!$anime) {
return response()->json(['done' => true, 'message' => 'Tüm animelerin MAL ID\'si dolu!']);
}
$jikan = new JikanService();
$malId = $jikan->searchMalId($anime->title, $anime->title_en, $anime->title_jp, $anime->type);
if ($malId) {
$anime->update(['mal_id' => $malId]);
// S1 için season.mal_id de doldur
$s1 = $anime->seasons()->where('season_number', 1)->first();
if ($s1 && !$s1->mal_id) $s1->update(['mal_id' => $malId]);
return response()->json([
'done' => false,
'found' => true,
'anime' => $anime->title,
'mal_id' => $malId,
'remaining' => Anime::whereNull('mal_id')->orWhere('mal_id', '')->count(),
]);
}
// Bulunamadı — bir sonrakine geç (geçici olarak dummy değer koy, sonra temizle)
return response()->json([
'done' => false,
'found' => false,
'anime' => $anime->title,
'mal_id' => null,
'remaining' => Anime::whereNull('mal_id')->orWhere('mal_id', '')->count() - 1,
'skipped_id' => $anime->id,
]);
}
}