Initial commit: Animexe Laravel platform
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivationCode;
|
||||
use App\Models\MembershipPlan;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ActivationCodeController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = ActivationCode::with(['plan', 'usedBy', 'createdBy'])->latest();
|
||||
|
||||
if ($request->filled('plan_id')) {
|
||||
$query->where('plan_id', $request->plan_id);
|
||||
}
|
||||
|
||||
if ($request->filled('batch')) {
|
||||
$query->where('batch', $request->batch);
|
||||
}
|
||||
|
||||
match ($request->status) {
|
||||
'used' => $query->whereNotNull('used_at'),
|
||||
'unused' => $query->whereNull('used_at'),
|
||||
default => null,
|
||||
};
|
||||
|
||||
$codes = $query->paginate(50)->withQueryString();
|
||||
$plans = MembershipPlan::where('is_active', true)->orderBy('sort_order')->get();
|
||||
$batches = ActivationCode::select('batch')->whereNotNull('batch')
|
||||
->distinct()->orderBy('batch', 'desc')->pluck('batch');
|
||||
|
||||
$stats = [
|
||||
'total' => ActivationCode::count(),
|
||||
'used' => ActivationCode::whereNotNull('used_at')->count(),
|
||||
'unused' => ActivationCode::whereNull('used_at')->count(),
|
||||
];
|
||||
|
||||
return view('admin.activation-codes.index', compact('codes', 'plans', 'stats', 'batches'));
|
||||
}
|
||||
|
||||
public function generate(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'plan_id' => 'required|exists:membership_plans,id',
|
||||
'quantity' => 'required|integer|min:1|max:500',
|
||||
'expires_at' => 'nullable|date|after:today',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
'batch' => 'nullable|string|max:64',
|
||||
]);
|
||||
|
||||
$batch = $request->batch ?: 'toplu-' . now()->format('Ymd-His');
|
||||
$generated = [];
|
||||
|
||||
DB::transaction(function () use ($request, $batch, &$generated) {
|
||||
for ($i = 0; $i < $request->quantity; $i++) {
|
||||
$code = ActivationCode::create([
|
||||
'code' => ActivationCode::generateCode(),
|
||||
'plan_id' => $request->plan_id,
|
||||
'expires_at' => $request->expires_at ?: null,
|
||||
'batch' => $batch,
|
||||
'notes' => $request->notes,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
$generated[] = $code->code;
|
||||
}
|
||||
});
|
||||
|
||||
return back()
|
||||
->with('generated_codes', $generated)
|
||||
->with('success', count($generated) . ' adet aktivasyon kodu oluşturuldu. (Batch: ' . $batch . ')');
|
||||
}
|
||||
|
||||
public function destroy(ActivationCode $activationCode)
|
||||
{
|
||||
if ($activationCode->isUsed()) {
|
||||
return back()->withErrors(['error' => 'Kullanılmış kodlar silinemez.']);
|
||||
}
|
||||
|
||||
$activationCode->delete();
|
||||
|
||||
return back()->with('success', 'Aktivasyon kodu silindi.');
|
||||
}
|
||||
|
||||
public function destroyBatch(Request $request)
|
||||
{
|
||||
$request->validate(['batch' => 'required|string|max:64']);
|
||||
|
||||
$count = ActivationCode::where('batch', $request->batch)
|
||||
->whereNull('used_at')
|
||||
->delete();
|
||||
|
||||
return back()->with('success', $count . ' adet kullanılmamış kod silindi.');
|
||||
}
|
||||
|
||||
public function destroySelected(Request $request)
|
||||
{
|
||||
$request->validate(['ids' => 'required|array|min:1', 'ids.*' => 'integer|exists:activation_codes,id']);
|
||||
|
||||
$count = ActivationCode::whereIn('id', $request->ids)
|
||||
->whereNull('used_at')
|
||||
->delete();
|
||||
|
||||
return back()->with('success', $count . ' adet aktivasyon kodu silindi.');
|
||||
}
|
||||
|
||||
public function export(Request $request)
|
||||
{
|
||||
$query = ActivationCode::with('plan')->whereNull('used_at');
|
||||
|
||||
if ($request->filled('plan_id')) {
|
||||
$query->where('plan_id', $request->plan_id);
|
||||
}
|
||||
|
||||
if ($request->filled('batch')) {
|
||||
$query->where('batch', $request->batch);
|
||||
}
|
||||
|
||||
$codes = $query->orderBy('batch')->orderBy('created_at')->get();
|
||||
|
||||
$csv = "\xEF\xBB\xBF"; // UTF-8 BOM (Excel için)
|
||||
$csv .= "Kod,Plan,Batch,Son Kullanma,Oluşturulma\n";
|
||||
|
||||
foreach ($codes as $code) {
|
||||
$csv .= implode(',', [
|
||||
$code->code,
|
||||
'"' . str_replace('"', '""', $code->plan->name) . '"',
|
||||
$code->batch ?? '-',
|
||||
$code->expires_at?->format('d.m.Y') ?? '-',
|
||||
$code->created_at->format('d.m.Y H:i'),
|
||||
]) . "\n";
|
||||
}
|
||||
|
||||
return response($csv, 200, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="aktivasyon-kodlari-' . now()->format('Ymd') . '.csv"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Ad;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AdController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$ads = Ad::orderByDesc('created_at')->get();
|
||||
|
||||
$settings = [
|
||||
'vad_enabled' => Setting::get('vad_enabled', '0'),
|
||||
'vad_freq_episodes' => Setting::get('vad_freq_episodes', 2),
|
||||
'vad_freq_minutes' => Setting::get('vad_freq_minutes', 5),
|
||||
'vad_upsell_percent' => Setting::get('vad_upsell_percent', 20),
|
||||
'banner_ads_enabled' => Setting::get('banner_ads_enabled', '0'),
|
||||
];
|
||||
|
||||
$stats = [
|
||||
'total_impressions' => $ads->sum('impressions'),
|
||||
'total_clicks' => $ads->sum('clicks'),
|
||||
'avg_ctr' => $ads->sum('impressions') > 0
|
||||
? round($ads->sum('clicks') / $ads->sum('impressions') * 100, 2) : 0,
|
||||
'active_count' => $ads->where('is_active', true)->count(),
|
||||
];
|
||||
|
||||
return view('admin.ads.index', compact('ads', 'settings', 'stats'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $this->validateAd($request);
|
||||
|
||||
if ($request->hasFile('media_file')) {
|
||||
$data['file_path'] = $this->storeMedia($request->file('media_file'));
|
||||
}
|
||||
|
||||
unset($data['media_file']);
|
||||
Ad::create($data);
|
||||
|
||||
return back()->with('success', 'Reklam eklendi.');
|
||||
}
|
||||
|
||||
public function edit(Ad $ad)
|
||||
{
|
||||
return view('admin.ads.edit', compact('ad'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Ad $ad)
|
||||
{
|
||||
$data = $this->validateAd($request, $ad);
|
||||
|
||||
if ($request->hasFile('media_file')) {
|
||||
$newPath = $this->storeMedia($request->file('media_file'));
|
||||
if ($ad->file_path) Storage::disk('public')->delete($ad->file_path);
|
||||
$data['file_path'] = $newPath;
|
||||
}
|
||||
|
||||
unset($data['media_file']);
|
||||
$ad->update($data);
|
||||
|
||||
return redirect()->route('admin.ads.index')->with('success', 'Reklam güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(Ad $ad)
|
||||
{
|
||||
if ($ad->file_path) Storage::disk('public')->delete($ad->file_path);
|
||||
$ad->delete();
|
||||
|
||||
return back()->with('success', 'Reklam silindi.');
|
||||
}
|
||||
|
||||
public function toggle(Ad $ad)
|
||||
{
|
||||
$ad->update(['is_active' => !$ad->is_active]);
|
||||
return back()->with('success', $ad->is_active ? 'Reklam aktifleştirildi.' : 'Reklam durduruldu.');
|
||||
}
|
||||
|
||||
public function saveSettings(Request $request)
|
||||
{
|
||||
Setting::set('vad_enabled', $request->boolean('vad_enabled') ? '1' : '0', 'ads');
|
||||
Setting::set('vad_freq_episodes', max(1, (int) $request->input('vad_freq_episodes', 2)), 'ads');
|
||||
Setting::set('vad_freq_minutes', max(1, (int) $request->input('vad_freq_minutes', 5)), 'ads');
|
||||
Setting::set('vad_upsell_percent', min(100, max(0, (int) $request->input('vad_upsell_percent', 20))), 'ads');
|
||||
Setting::set('banner_ads_enabled', $request->boolean('banner_ads_enabled') ? '1' : '0', 'ads');
|
||||
|
||||
return back()->with('success', 'Reklam ayarları kaydedildi.');
|
||||
}
|
||||
|
||||
private function validateAd(Request $request, ?Ad $existing = null): array
|
||||
{
|
||||
$type = $request->input('type', 'video');
|
||||
|
||||
// Sunucu upload limitini aşan dosya: PHP boş/bozuk upload gönderir.
|
||||
// Sessizce medyasız reklam kaydetmek yerine net hata ver.
|
||||
$this->guardUploadError($request);
|
||||
|
||||
// Yüklenmiş dosya da dış URL de yoksa reklam gösterilemez (media_url null olur).
|
||||
// Düzenlemede mevcut dosya varsa yeniden yükleme zorunlu değil.
|
||||
$hasExisting = $existing?->file_path || $existing?->external_url;
|
||||
$needsMedia = !$request->hasFile('media_file') && !$hasExisting;
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:120',
|
||||
'type' => 'required|in:video,banner',
|
||||
'placement' => 'required|in:preroll,home_mid,home_bottom',
|
||||
'media_file' => [
|
||||
'nullable', 'file',
|
||||
$type === 'video' ? 'mimes:mp4,m4v' : 'mimes:jpg,jpeg,png,webp,gif',
|
||||
$type === 'video' ? 'max:102400' : 'max:20480', // video 100MB, görsel/gif 20MB
|
||||
],
|
||||
'external_url' => [$needsMedia ? 'required' : 'nullable', 'nullable', 'url', 'max:2000'],
|
||||
'click_url' => 'nullable|url|max:2000',
|
||||
'skip_after' => 'required|integer|min:0|max:60',
|
||||
'weight' => 'required|integer|min:1|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'starts_at' => 'nullable|date',
|
||||
'ends_at' => 'nullable|date|after:starts_at',
|
||||
], [
|
||||
'external_url.required' => 'Bir medya dosyası yükleyin veya dış URL girin. '
|
||||
. 'Dosya seçtiyseniz sunucu yükleme limitini aşmış olabilir (maks. '
|
||||
. ini_get('upload_max_filesize') . ').',
|
||||
'media_file.mimes' => $type === 'video'
|
||||
? 'Video dosyası MP4 formatında olmalı.'
|
||||
: 'Görsel JPG, PNG, WebP veya GIF formatında olmalı.',
|
||||
'media_file.max' => 'Dosya çok büyük.',
|
||||
]);
|
||||
|
||||
// Checkbox işaretli değilse request'te hiç gelmez — açıkça boolean'a çevir
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** PHP upload hatalarını (limit aşımı, kısmi yükleme) net mesajla yüzeye çıkar. */
|
||||
private function guardUploadError(Request $request): void
|
||||
{
|
||||
$file = $request->file('media_file');
|
||||
if (!$file || $file->isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$msg = match ($file->getError()) {
|
||||
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE =>
|
||||
'Dosya sunucunun yükleme limitini aşıyor (maks. ' . ini_get('upload_max_filesize')
|
||||
. '). Daha küçük bir dosya seçin veya hosting limitini yükseltin.',
|
||||
UPLOAD_ERR_PARTIAL => 'Dosya yalnızca kısmen yüklendi, lütfen tekrar deneyin.',
|
||||
UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE =>
|
||||
'Sunucu dosyayı geçici klasöre yazamadı. Hosting sağlayıcınıza bildirin.',
|
||||
default => 'Dosya yüklenemedi (hata kodu: ' . $file->getError() . ').',
|
||||
};
|
||||
|
||||
throw \Illuminate\Validation\ValidationException::withMessages(['media_file' => $msg]);
|
||||
}
|
||||
|
||||
/** Dosyayı public diske yaz ve tam yazıldığını doğrula. */
|
||||
private function storeMedia(\Illuminate\Http\UploadedFile $file): string
|
||||
{
|
||||
// NOT: klasör adı bilerek nötr ('ads' değil) — adblocker /media/ads/ yolunu
|
||||
// ERR_BLOCKED_BY_CLIENT ile engelliyor. 'content' engellenmez.
|
||||
$expected = $file->getSize();
|
||||
$path = $file->store('content', 'public');
|
||||
|
||||
if (!$path || !Storage::disk('public')->exists($path)) {
|
||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
||||
'media_file' => 'Dosya sunucuya kaydedilemedi. storage/app/public klasörünün yazma izni olduğundan emin olun.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Kısmi yazma (disk dolu / kesilen upload) sessizce bozuk reklam bırakmasın
|
||||
$written = Storage::disk('public')->size($path);
|
||||
if ($expected > 0 && $written !== $expected) {
|
||||
Storage::disk('public')->delete($path);
|
||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
||||
'media_file' => "Dosya eksik yüklendi ({$written}/{$expected} byte). Tekrar deneyin.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Setting;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AiController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /admin/ai/anime-meta — toplu anime meta doldurma sayfası.
|
||||
*/
|
||||
public function animeMetaPage()
|
||||
{
|
||||
$total = Anime::count();
|
||||
|
||||
$missing = Anime::where(function ($q) {
|
||||
$q->whereNull('description')->orWhere('description', '')
|
||||
->orWhereNull('release_year')
|
||||
->orWhereNull('studio')->orWhere('studio', '')
|
||||
->orWhereNull('type')->orWhere('type', '')
|
||||
->orWhereNull('status')->orWhere('status', '');
|
||||
})->count();
|
||||
|
||||
$noGenres = Anime::doesntHave('genres')->count();
|
||||
|
||||
return view('admin.ai.anime-meta', compact('total', 'missing', 'noGenres'));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/ai/anime-meta-ids — eksik animelerin ID listesini döndür.
|
||||
*/
|
||||
public function animeMetaIds(Request $request)
|
||||
{
|
||||
$force = $request->boolean('force');
|
||||
|
||||
$query = Anime::with('genres:id')->select('id', 'title', 'description', 'release_year', 'studio', 'type', 'status', 'rating', 'title_en', 'title_jp');
|
||||
|
||||
if (!$force) {
|
||||
$query->where(function ($q) {
|
||||
$q->whereNull('description')->orWhere('description', '')
|
||||
->orWhereNull('release_year')
|
||||
->orWhereNull('studio')->orWhere('studio', '')
|
||||
->orWhereNull('type')->orWhere('type', '')
|
||||
->orWhereNull('status')->orWhere('status', '');
|
||||
})->orDoesntHave('genres');
|
||||
}
|
||||
|
||||
$animes = $query->orderBy('id')->get()->map(function ($a) {
|
||||
$missing = [];
|
||||
if (empty($a->description)) $missing[] = 'açıklama';
|
||||
if (empty($a->release_year)) $missing[] = 'yıl';
|
||||
if (empty($a->studio)) $missing[] = 'stüdyo';
|
||||
if (empty($a->type)) $missing[] = 'tür';
|
||||
if (empty($a->status)) $missing[] = 'durum';
|
||||
if (!$a->rating) $missing[] = 'puan';
|
||||
if ($a->genres->isEmpty()) $missing[] = 'kategoriler';
|
||||
return ['id' => $a->id, 'title' => $a->title, 'missing' => $missing];
|
||||
});
|
||||
|
||||
return response()->json(['animes' => $animes]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/ai/fill-anime-meta — tek anime için meta doldur ve kaydet.
|
||||
*/
|
||||
public function fillAnimeMeta(Request $request)
|
||||
{
|
||||
$anime = Anime::with('genres:id,name')->findOrFail($request->anime_id);
|
||||
$force = $request->boolean('force');
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? '');
|
||||
|
||||
if (!$meta) {
|
||||
Log::channel('daily')->warning("[FillAnimeMeta-UI] HATA [{$anime->id}] {$anime->title}");
|
||||
return response()->json(['error' => 'DeepSeek boş yanıt döndürdü.'], 500);
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
$fillIfEmpty = function (string $field, $value) use ($anime, $force, &$updates) {
|
||||
if ($value === null || $value === '') return;
|
||||
if ($force || empty($anime->$field)) $updates[$field] = $value;
|
||||
};
|
||||
|
||||
$fillIfEmpty('description', $meta['description'] ?? null);
|
||||
$fillIfEmpty('release_year', $meta['release_year'] ?? null);
|
||||
$fillIfEmpty('studio', $meta['studio'] ?? null);
|
||||
$fillIfEmpty('type', $meta['type'] ?? null);
|
||||
$fillIfEmpty('status', $meta['status'] ?? null);
|
||||
$fillIfEmpty('title_en', $meta['title_en'] ?? null);
|
||||
$fillIfEmpty('title_jp', $meta['title_jp'] ?? null);
|
||||
if (!empty($meta['rating']) && ($force || !$anime->rating)) {
|
||||
$updates['rating'] = min(10, max(0, (float) $meta['rating']));
|
||||
}
|
||||
|
||||
if (!empty($updates)) $anime->update($updates);
|
||||
|
||||
$syncedGenres = [];
|
||||
if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) {
|
||||
$ids = [];
|
||||
foreach ($meta['genres'] as $name) {
|
||||
$g = Genre::firstOrCreate(['name' => $name], ['slug' => Str::slug($name)]);
|
||||
$ids[] = $g->id;
|
||||
}
|
||||
if ($ids) {
|
||||
$force ? $anime->genres()->sync($ids) : $anime->genres()->syncWithoutDetaching($ids);
|
||||
$syncedGenres = $meta['genres'];
|
||||
}
|
||||
}
|
||||
|
||||
$filled = array_keys($updates);
|
||||
if ($syncedGenres) $filled[] = 'kategoriler';
|
||||
|
||||
Log::channel('daily')->info("[FillAnimeMeta-UI] OK [{$anime->id}] {$anime->title} → " . implode(', ', $filled));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'filled' => $filled,
|
||||
'meta' => array_merge($updates, ['genres' => $syncedGenres]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toplu açıklama yazma sayfası.
|
||||
*/
|
||||
public function descriptionsPage()
|
||||
{
|
||||
$animes = Anime::orderBy('title')
|
||||
->withCount(['episodes as total_eps' => fn($q) => $q->whereNull('description')->orWhere('description', '')])
|
||||
->get()
|
||||
->filter(fn($a) => $a->total_eps > 0);
|
||||
|
||||
$totalMissing = Episode::where(fn($q) => $q->whereNull('description')->orWhere('description', ''))->count();
|
||||
|
||||
return view('admin.ai.descriptions', compact('animes', 'totalMissing'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Açıklaması olmayan bölüm ID'lerini döndür (JS için).
|
||||
* POST { anime_id: 0=tümü }
|
||||
*/
|
||||
public function episodeIds(Request $request)
|
||||
{
|
||||
$query = Episode::where(fn($q) => $q->whereNull('description')->orWhere('description', ''));
|
||||
|
||||
if ($request->anime_id && $request->anime_id != '0') {
|
||||
$query->where('anime_id', $request->anime_id);
|
||||
}
|
||||
|
||||
$ids = $query->with('anime:id,title')->get()->map(fn($ep) => [
|
||||
'id' => $ep->id,
|
||||
'label' => ($ep->anime->title ?? '?') . ' — ' . $ep->episode_number . '. Bölüm' . ($ep->title ? ' — '.$ep->title : ''),
|
||||
]);
|
||||
|
||||
return response()->json(['episodes' => $ids]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tek bir bölüme açıklama yaz ve kaydet.
|
||||
* POST { episode_id }
|
||||
*/
|
||||
public function fillOne(Request $request)
|
||||
{
|
||||
$episode = Episode::with('anime:id,title')->findOrFail($request->episode_id);
|
||||
|
||||
if (!empty($episode->description)) {
|
||||
return response()->json(['ok' => true, 'skipped' => true, 'description' => $episode->description]);
|
||||
}
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$desc = $ai->generateEpisodeDescription(
|
||||
$episode->anime->title ?? 'Bilinmeyen',
|
||||
$episode->episode_number,
|
||||
$episode->title ?? ''
|
||||
);
|
||||
|
||||
if (!$desc) {
|
||||
return response()->json(['error' => 'DeepSeek boş yanıt döndürdü veya hata oluştu.'], 500);
|
||||
}
|
||||
|
||||
$episode->update(['description' => $desc]);
|
||||
return response()->json(['ok' => true, 'description' => $desc]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anime için tüm meta verileri AI ile doldur.
|
||||
* POST { anime_id, title?, title_jp? }
|
||||
* Döner: { description, release_year, studio, type, status, rating, title_en, title_jp, genres[] }
|
||||
*/
|
||||
public function animeMeta(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$title = trim($request->input('title', ''));
|
||||
$titleJp = trim($request->input('title_jp', ''));
|
||||
|
||||
if (!$title) {
|
||||
return response()->json(['error' => 'Başlık boş olamaz.'], 422);
|
||||
}
|
||||
|
||||
$meta = $ai->generateAnimeMeta($title, $titleJp);
|
||||
|
||||
if (!$meta) {
|
||||
return response()->json(['error' => 'DeepSeek boş yanıt döndürdü veya hata oluştu.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'meta' => $meta]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek ile Türkçe açıklama üret.
|
||||
* POST body: { type: 'anime'|'episode', title, title_jp?, anime_title?, episode_number?, genres? }
|
||||
*/
|
||||
public function generate(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar > DeepSeek bölümüne ekleyin.'], 422);
|
||||
}
|
||||
|
||||
$type = $request->input('type', 'anime');
|
||||
$title = trim($request->input('title', ''));
|
||||
if (!$title) {
|
||||
return response()->json(['error' => 'Başlık boş olamaz.'], 422);
|
||||
}
|
||||
|
||||
if ($type === 'episode') {
|
||||
$desc = $ai->generateEpisodeDescription(
|
||||
trim($request->input('anime_title', $title)),
|
||||
(int) $request->input('episode_number', 1),
|
||||
trim($request->input('episode_title', ''))
|
||||
);
|
||||
} else {
|
||||
$desc = $ai->generateAnimeDescription(
|
||||
$title,
|
||||
trim($request->input('title_jp', '')),
|
||||
trim($request->input('genres', ''))
|
||||
);
|
||||
}
|
||||
|
||||
if (!$desc) {
|
||||
return response()->json(['error' => 'DeepSeek boş yanıt döndürdü veya bağlantı hatası.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['description' => $desc]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\PageView;
|
||||
use App\Models\Analytics\WatchEvent;
|
||||
use App\Models\Analytics\AiQuery;
|
||||
use App\Models\Analytics\VisitorSession;
|
||||
use App\Models\Anime;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$period = $request->input('period', '7d');
|
||||
$from = match ($period) {
|
||||
'today' => now()->startOfDay(),
|
||||
'30d' => now()->subDays(30),
|
||||
'90d' => now()->subDays(90),
|
||||
default => now()->subDays(7),
|
||||
};
|
||||
|
||||
$cacheKey = 'admin_analytics_' . $period;
|
||||
$cached = Cache::remember($cacheKey, 300, function () use ($from, $period) {
|
||||
return $this->buildAnalytics($from, $period);
|
||||
});
|
||||
extract($cached);
|
||||
|
||||
// Gerçek zamanlı veriler (cache'lenmiyor)
|
||||
$recentViews = PageView::with('user:id,name')
|
||||
->where('created_at', '>=', $from)
|
||||
->orderByDesc('id')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
$blockedIps = collect();
|
||||
$recentBots = collect();
|
||||
try {
|
||||
$blockedIps = DB::table('blocked_ips')->orderByDesc('blocked_at')->limit(20)->get();
|
||||
$recentBots = DB::table('analytics_bot_logs')->orderByDesc('id')->limit(30)->get();
|
||||
} catch (\Exception) {}
|
||||
|
||||
return view('admin.analytics.index', compact(
|
||||
'period', 'from',
|
||||
'totalViews', 'uniqueVisitors', 'watchSeconds', 'aiTotal', 'newUsers',
|
||||
'viewsDelta', 'todayViews', 'yesterdayViews',
|
||||
'trendLabels', 'trendData', 'watchTrendData',
|
||||
'hourlyData',
|
||||
'topAnimes',
|
||||
'topEpisodes',
|
||||
'deviceStats', 'browserStats', 'pageTypeStats',
|
||||
'geoStats',
|
||||
'activeUsers',
|
||||
'aiByType', 'aiTopQuestions', 'aiTopUsers',
|
||||
'recentViews',
|
||||
'referrerStats', 'directTraffic',
|
||||
'botViews', 'humanViews', 'botRatio', 'botTopIps', 'botByName',
|
||||
'blockedIps', 'recentBots',
|
||||
'sessions', 'avgSessionTime', 'avgPages',
|
||||
));
|
||||
}
|
||||
|
||||
private function buildAnalytics($from, string $period): array
|
||||
{
|
||||
// ── Özet kartlar ──────────────────────────────────────────────────────
|
||||
$totalViews = PageView::where('created_at', '>=', $from)->count();
|
||||
$uniqueVisitors = PageView::where('created_at', '>=', $from)->distinct('session_id')->count('session_id');
|
||||
$watchSeconds = WatchEvent::where('created_at', '>=', $from)->sum('seconds_watched');
|
||||
$aiTotal = AiQuery::where('created_at', '>=', $from)->count();
|
||||
$newUsers = User::where('created_at', '>=', $from)->count();
|
||||
|
||||
$yesterday = now()->subDay();
|
||||
$todayViews = PageView::where('created_at', '>=', now()->startOfDay())->count();
|
||||
$yesterdayViews = PageView::whereBetween('created_at', [$yesterday->startOfDay(), $yesterday->endOfDay()])->count();
|
||||
$viewsDelta = $yesterdayViews > 0 ? round(($todayViews - $yesterdayViews) / $yesterdayViews * 100) : 0;
|
||||
|
||||
// ── Görüntüleme trendi (gün bazlı) ────────────────────────────────────
|
||||
$viewsByDay = PageView::where('created_at', '>=', $from)
|
||||
->selectRaw('DATE(created_at) as date, COUNT(*) as cnt')
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->pluck('cnt', 'date');
|
||||
|
||||
$trendLabels = [];
|
||||
$trendData = [];
|
||||
$cur = clone $from;
|
||||
while ($cur->lte(now())) {
|
||||
$key = $cur->format('Y-m-d');
|
||||
$trendLabels[] = $cur->format($period === 'today' ? 'H:i' : 'd M');
|
||||
$trendData[] = $viewsByDay[$key] ?? 0;
|
||||
$cur->addDay();
|
||||
}
|
||||
|
||||
// ── Saatlik dağılım (bugün) ───────────────────────────────────────────
|
||||
$hourlyRaw = PageView::where('created_at', '>=', now()->startOfDay())
|
||||
->selectRaw('HOUR(created_at) as hour, COUNT(*) as cnt')
|
||||
->groupBy('hour')
|
||||
->pluck('cnt', 'hour');
|
||||
$hourlyData = array_map(fn($h) => $hourlyRaw[$h] ?? 0, range(0, 23));
|
||||
|
||||
// ── İzleme süresi trendi ─────────────────────────────────────────────
|
||||
$watchByDay = WatchEvent::where('created_at', '>=', $from)
|
||||
->selectRaw('DATE(created_at) as date, ROUND(SUM(seconds_watched)/3600, 1) as hours')
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->pluck('hours', 'date');
|
||||
$watchTrendData = array_map(fn($k) => (float)($watchByDay[$k] ?? 0), array_keys(array_flip($trendLabels)));
|
||||
|
||||
// ── Top 10 anime ─────────────────────────────────────────────────────
|
||||
$topAnimeIds = PageView::where('created_at', '>=', $from)
|
||||
->whereNotNull('anime_id')
|
||||
->selectRaw('anime_id, COUNT(*) as cnt')
|
||||
->groupBy('anime_id')
|
||||
->orderByDesc('cnt')
|
||||
->limit(10)
|
||||
->pluck('cnt', 'anime_id');
|
||||
|
||||
$topAnimes = Anime::whereIn('id', $topAnimeIds->keys())
|
||||
->get(['id', 'title', 'cover_image'])
|
||||
->map(fn($a) => [
|
||||
'title' => $a->title,
|
||||
'views' => $topAnimeIds[$a->id] ?? 0,
|
||||
'cover' => $a->cover_url,
|
||||
'slug' => $a->slug,
|
||||
])
|
||||
->sortByDesc('views')
|
||||
->values();
|
||||
|
||||
// ── Top bölümler ─────────────────────────────────────────────────────
|
||||
$topEpisodes = WatchEvent::where('analytics_watch_events.created_at', '>=', $from)
|
||||
->selectRaw('anime_id, season_number, episode_number, episode_id,
|
||||
SUM(seconds_watched) as total_sec,
|
||||
COUNT(*) as plays,
|
||||
ROUND(AVG(percent_complete), 0) as avg_pct')
|
||||
->groupBy('anime_id', 'season_number', 'episode_number', 'episode_id')
|
||||
->orderByDesc('total_sec')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$epAnimes = Anime::whereIn('id', $topEpisodes->pluck('anime_id')->unique())->pluck('title', 'id');
|
||||
$topEpisodes = $topEpisodes->map(fn($e) => [
|
||||
'anime' => $epAnimes[$e->anime_id] ?? 'Bilinmiyor',
|
||||
'label' => "S{$e->season_number}E{$e->episode_number}",
|
||||
'plays' => $e->plays,
|
||||
'hours' => round($e->total_sec / 3600, 1),
|
||||
'avg_pct' => $e->avg_pct,
|
||||
]);
|
||||
|
||||
// ── Cihaz / tarayıcı / sayfa türü ────────────────────────────────────
|
||||
$deviceStats = PageView::where('created_at', '>=', $from)
|
||||
->selectRaw('device, COUNT(*) as cnt')
|
||||
->groupBy('device')
|
||||
->pluck('cnt', 'device');
|
||||
|
||||
$browserStats = PageView::where('created_at', '>=', $from)
|
||||
->selectRaw('browser, COUNT(*) as cnt')
|
||||
->groupBy('browser')
|
||||
->orderByDesc('cnt')
|
||||
->pluck('cnt', 'browser');
|
||||
|
||||
$pageTypeStats = PageView::where('created_at', '>=', $from)
|
||||
->selectRaw('page_type, COUNT(*) as cnt')
|
||||
->groupBy('page_type')
|
||||
->orderByDesc('cnt')
|
||||
->pluck('cnt', 'page_type');
|
||||
|
||||
// ── Coğrafi dağılım ───────────────────────────────────────────────────
|
||||
$geoStats = PageView::where('created_at', '>=', $from)
|
||||
->whereNotNull('city')
|
||||
->selectRaw('city, country, COUNT(*) as cnt')
|
||||
->groupBy('city', 'country')
|
||||
->orderByDesc('cnt')
|
||||
->limit(15)
|
||||
->get(['city', 'country', DB::raw('COUNT(*) as cnt')]);
|
||||
|
||||
// ── En aktif kullanıcılar ─────────────────────────────────────────────
|
||||
$activeUserIds = PageView::where('created_at', '>=', $from)
|
||||
->whereNotNull('user_id')
|
||||
->selectRaw('user_id, COUNT(*) as views, COUNT(DISTINCT DATE(created_at)) as days')
|
||||
->groupBy('user_id')
|
||||
->orderByDesc('views')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$activeUserList = User::whereIn('id', $activeUserIds->pluck('user_id'))
|
||||
->get(['id', 'name', 'email', 'created_at'])
|
||||
->keyBy('id');
|
||||
|
||||
$activeUsers = $activeUserIds->map(fn($r) => [
|
||||
'user' => $activeUserList[$r->user_id] ?? null,
|
||||
'views' => $r->views,
|
||||
'days' => $r->days,
|
||||
])->filter(fn($r) => $r['user']);
|
||||
|
||||
// ── AI istatistikleri ─────────────────────────────────────────────────
|
||||
$aiByType = AiQuery::where('created_at', '>=', $from)
|
||||
->selectRaw('query_type, COUNT(*) as cnt')
|
||||
->groupBy('query_type')
|
||||
->orderByDesc('cnt')
|
||||
->pluck('cnt', 'query_type');
|
||||
|
||||
$aiTopQuestions = AiQuery::where('created_at', '>=', $from)
|
||||
->where('query_type', 'chat')
|
||||
->whereNotNull('query_text')
|
||||
->selectRaw('query_text, COUNT(*) as cnt')
|
||||
->groupBy('query_text')
|
||||
->orderByDesc('cnt')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$aiByUser = AiQuery::where('created_at', '>=', $from)
|
||||
->whereNotNull('user_id')
|
||||
->selectRaw('user_id, COUNT(*) as cnt')
|
||||
->groupBy('user_id')
|
||||
->orderByDesc('cnt')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
$aiUserList = User::whereIn('id', $aiByUser->pluck('user_id'))->pluck('name', 'id');
|
||||
$aiTopUsers = $aiByUser->map(fn($r) => [
|
||||
'name' => $aiUserList[$r->user_id] ?? 'Bilinmiyor',
|
||||
'cnt' => $r->cnt,
|
||||
]);
|
||||
|
||||
// ── Referrer ─────────────────────────────────────────────────────────
|
||||
$referrerRaw = PageView::where('created_at', '>=', $from)
|
||||
->whereNotNull('referrer')
|
||||
->where('referrer', '!=', '')
|
||||
->selectRaw('referrer, COUNT(*) as cnt')
|
||||
->groupBy('referrer')
|
||||
->orderByDesc('cnt')
|
||||
->limit(30)
|
||||
->pluck('cnt', 'referrer');
|
||||
|
||||
$referrerStats = collect();
|
||||
foreach ($referrerRaw as $url => $cnt) {
|
||||
try {
|
||||
$parsed = parse_url($url);
|
||||
$domain = $parsed['host'] ?? $url;
|
||||
$domain = preg_replace('/^www\./', '', $domain);
|
||||
} catch (\Throwable) {
|
||||
$domain = $url;
|
||||
}
|
||||
if ($referrerStats->has($domain)) {
|
||||
$referrerStats[$domain] += $cnt;
|
||||
} else {
|
||||
$referrerStats[$domain] = $cnt;
|
||||
}
|
||||
}
|
||||
$referrerStats = $referrerStats->sortDesc()->take(15);
|
||||
|
||||
$directTraffic = PageView::where('created_at', '>=', $from)
|
||||
->where(fn($q) => $q->whereNull('referrer')->orWhere('referrer', ''))
|
||||
->count();
|
||||
|
||||
// ── Bot istatistikleri ────────────────────────────────────────────────
|
||||
$botViews = 0;
|
||||
$humanViews = 0;
|
||||
$botRatio = 0;
|
||||
$botTopIps = collect();
|
||||
$botByName = collect();
|
||||
|
||||
try {
|
||||
$botViews = PageView::where('created_at', '>=', $from)->where('is_bot', 1)->count();
|
||||
$humanViews = PageView::where('created_at', '>=', $from)->where('is_bot', 0)->count();
|
||||
$botRatio = ($botViews + $humanViews) > 0 ? round($botViews / ($botViews + $humanViews) * 100) : 0;
|
||||
|
||||
$botTopIps = DB::table('analytics_bot_logs')
|
||||
->where('created_at', '>=', $from)
|
||||
->selectRaw('ip, COUNT(*) as cnt, MAX(user_agent) as ua, MAX(action) as action')
|
||||
->groupBy('ip')
|
||||
->orderByDesc('cnt')
|
||||
->limit(15)
|
||||
->get();
|
||||
|
||||
$botByName = DB::table('analytics_bot_logs')
|
||||
->where('created_at', '>=', $from)
|
||||
->selectRaw('bot_name, COUNT(*) as cnt, action')
|
||||
->groupBy('bot_name', 'action')
|
||||
->orderByDesc('cnt')
|
||||
->limit(20)
|
||||
->get();
|
||||
} catch (\Exception) {}
|
||||
|
||||
// ── Oturum istatistikleri ─────────────────────────────────────────────
|
||||
$sessions = collect();
|
||||
$avgSessionTime = 0;
|
||||
$avgPages = 0;
|
||||
|
||||
try {
|
||||
$avgSessionTime = (int) DB::table('analytics_sessions')
|
||||
->where('started_at', '>=', $from)
|
||||
->where('is_bot', 0)
|
||||
->avg('total_seconds');
|
||||
|
||||
$avgPages = round((float) DB::table('analytics_sessions')
|
||||
->where('started_at', '>=', $from)
|
||||
->where('is_bot', 0)
|
||||
->avg('pages_visited'), 1);
|
||||
|
||||
$sessions = DB::table('analytics_sessions')
|
||||
->where('started_at', '>=', $from)
|
||||
->orderByDesc('started_at')
|
||||
->limit(30)
|
||||
->get();
|
||||
} catch (\Exception) {}
|
||||
|
||||
return compact(
|
||||
'totalViews', 'uniqueVisitors', 'watchSeconds', 'aiTotal', 'newUsers',
|
||||
'viewsDelta', 'todayViews', 'yesterdayViews',
|
||||
'trendLabels', 'trendData', 'watchTrendData',
|
||||
'hourlyData',
|
||||
'topAnimes', 'topEpisodes',
|
||||
'deviceStats', 'browserStats', 'pageTypeStats',
|
||||
'geoStats',
|
||||
'activeUsers',
|
||||
'aiByType', 'aiTopQuestions', 'aiTopUsers',
|
||||
'referrerStats', 'directTraffic',
|
||||
'botViews', 'humanViews', 'botRatio', 'botTopIps', 'botByName',
|
||||
'sessions', 'avgSessionTime', 'avgPages'
|
||||
);
|
||||
}
|
||||
|
||||
public function blockIp(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'ip' => 'required|ip',
|
||||
'reason' => 'nullable|string|max:255',
|
||||
'expires_at' => 'nullable|date|after:now',
|
||||
]);
|
||||
|
||||
DB::table('blocked_ips')->updateOrInsert(
|
||||
['ip' => $data['ip']],
|
||||
[
|
||||
'reason' => $data['reason'] ?? 'Manuel engel',
|
||||
'auto_blocked' => 0,
|
||||
'blocked_at' => now(),
|
||||
'expires_at' => $data['expires_at'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
\Illuminate\Support\Facades\Cache::forget('blocked_ip_' . $data['ip']);
|
||||
return back()->with('success', $data['ip'] . ' engellendi.');
|
||||
}
|
||||
|
||||
public function unblockIp(Request $request)
|
||||
{
|
||||
$ip = $request->input('ip');
|
||||
DB::table('blocked_ips')->where('ip', $ip)->delete();
|
||||
\Illuminate\Support\Facades\Cache::forget('blocked_ip_' . $ip);
|
||||
return back()->with('success', $ip . ' engeli kaldırıldı.');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AnimeRequest;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AnimeRequestController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$status = $request->input('status', 'pending');
|
||||
|
||||
$requests = AnimeRequest::with('user:id,name,email')
|
||||
->when($status !== 'all', fn($q) => $q->where('status', $status))
|
||||
->orderByDesc('vote_count')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(30);
|
||||
|
||||
$counts = AnimeRequest::selectRaw('status, COUNT(*) as cnt')
|
||||
->groupBy('status')
|
||||
->pluck('cnt', 'status');
|
||||
|
||||
return view('admin.anime-requests.index', compact('requests', 'counts', 'status'));
|
||||
}
|
||||
|
||||
public function update(Request $request, AnimeRequest $animeRequest)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'status' => 'required|in:pending,approved,rejected,added',
|
||||
'admin_note' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$animeRequest->update($data);
|
||||
|
||||
return back()->with('success', 'İstek güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(AnimeRequest $animeRequest)
|
||||
{
|
||||
$animeRequest->delete();
|
||||
return back()->with('success', 'İstek silindi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin()
|
||||
{
|
||||
if (Auth::check() && Auth::user()->isAdmin()) {
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
return view('admin.auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
if (Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) {
|
||||
if (!Auth::user()->isAdmin() && !Auth::user()->isModerator()) {
|
||||
Auth::logout();
|
||||
return back()->withErrors(['email' => 'Bu hesabın yönetici yetkisi yok.']);
|
||||
}
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
|
||||
return back()->withErrors(['email' => 'E-posta veya şifre hatalı.']);
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Banner;
|
||||
use App\Support\ImageOptimizer;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BannerController extends Controller
|
||||
{
|
||||
public function create() { return redirect()->route('admin.banners.index'); }
|
||||
public function show(Banner $banner) { return redirect()->route('admin.banners.index'); }
|
||||
public function edit(Banner $banner) { return redirect()->route('admin.banners.index'); }
|
||||
|
||||
public function index()
|
||||
{
|
||||
$banners = Banner::orderBy('sort_order')->get();
|
||||
return view('admin.banners.index', compact('banners'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'link' => 'nullable|url',
|
||||
'sort_order' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
$data['image'] = ImageOptimizer::store($request->file('image'), 'banners', 'site_banner');
|
||||
} else {
|
||||
return back()->withErrors(['image' => 'Görsel zorunludur.']);
|
||||
}
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
Banner::create($data);
|
||||
return back()->with('success', 'Banner eklendi.');
|
||||
}
|
||||
|
||||
public function update(Request $request, Banner $banner)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'link' => 'nullable|url',
|
||||
'sort_order' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
$data['image'] = ImageOptimizer::store($request->file('image'), 'banners', 'site_banner');
|
||||
}
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$banner->update($data);
|
||||
return back()->with('success', 'Banner güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(Banner $banner)
|
||||
{
|
||||
$banner->delete();
|
||||
return back()->with('success', 'Banner silindi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\BlogPost;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BlogController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$q = $request->get('q');
|
||||
$posts = BlogPost::with('anime')
|
||||
->when($q, fn($query) => $query->where('title', 'like', "%{$q}%"))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
$stats = [
|
||||
'total' => BlogPost::count(),
|
||||
'published' => BlogPost::where('status', 'published')->count(),
|
||||
'draft' => BlogPost::where('status', 'draft')->count(),
|
||||
'ai' => BlogPost::where('ai_generated', true)->count(),
|
||||
];
|
||||
|
||||
return view('admin.blog.index', compact('posts', 'stats', 'q'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$animes = Anime::where('is_published', true)->orderBy('title')->get(['id', 'title']);
|
||||
$post = new BlogPost();
|
||||
return view('admin.blog.edit', compact('post', 'animes'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $this->validated($request);
|
||||
$data['slug'] = BlogPost::generateSlug($data['title']);
|
||||
$data['published_at'] = $data['status'] === 'published' ? now() : null;
|
||||
BlogPost::create($data);
|
||||
return redirect()->route('admin.blog.index')->with('success', 'Blog yazısı oluşturuldu.');
|
||||
}
|
||||
|
||||
public function edit(BlogPost $blog)
|
||||
{
|
||||
$animes = Anime::where('is_published', true)->orderBy('title')->get(['id', 'title']);
|
||||
return view('admin.blog.edit', compact('blog', 'animes'));
|
||||
}
|
||||
|
||||
public function update(Request $request, BlogPost $blog)
|
||||
{
|
||||
$data = $this->validated($request);
|
||||
if ($data['status'] === 'published' && !$blog->published_at) {
|
||||
$data['published_at'] = now();
|
||||
}
|
||||
$blog->update($data);
|
||||
return redirect()->route('admin.blog.index')->with('success', 'Blog yazısı güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(BlogPost $blog)
|
||||
{
|
||||
$blog->delete();
|
||||
return redirect()->route('admin.blog.index')->with('success', 'Blog yazısı silindi.');
|
||||
}
|
||||
|
||||
public function generateAi(Request $request, DeepSeekService $deepseek)
|
||||
{
|
||||
$request->validate(['anime_id' => 'required|exists:animes,id']);
|
||||
|
||||
if (!$deepseek->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API anahtarı ayarlanmamış. Admin > Ayarlar > deepseek_api_key'], 422);
|
||||
}
|
||||
|
||||
set_time_limit(120);
|
||||
|
||||
$anime = Anime::with('genres')->findOrFail($request->anime_id);
|
||||
$genreIds = $anime->genres->pluck('id');
|
||||
$related = Anime::where('is_published', true)
|
||||
->where('id', '!=', $anime->id)
|
||||
->whereHas('genres', fn($q) => $q->whereIn('genres.id', $genreIds))
|
||||
->orderByDesc('rating')
|
||||
->limit(5)
|
||||
->get(['id', 'title', 'slug'])
|
||||
->map(fn($a) => ['slug' => $a->slug, 'title' => $a->title])
|
||||
->toArray();
|
||||
|
||||
$data = $deepseek->generateBlogPost($anime, $related);
|
||||
|
||||
if (!$data || empty($data['content'])) {
|
||||
return response()->json(['error' => 'AI içerik üretemedi: ' . $deepseek->lastError], 422);
|
||||
}
|
||||
|
||||
$content = preg_replace_callback(
|
||||
'/\[LINK:([^\]]+)\]([^\[]*)\[\/LINK\]/',
|
||||
function ($m) {
|
||||
$slug = trim($m[1]);
|
||||
$label = trim($m[2]);
|
||||
try {
|
||||
return '<a href="' . route('anime.show', $slug) . '">' . $label . '</a>';
|
||||
} catch (\Exception $e) {
|
||||
return $label;
|
||||
}
|
||||
},
|
||||
$data['content']
|
||||
);
|
||||
|
||||
$linkedIds = [];
|
||||
if (!empty($data['linked_slugs'])) {
|
||||
$linkedIds = Anime::whereIn('slug', $data['linked_slugs'])->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'title' => $data['title'] ?? '',
|
||||
'excerpt' => $data['excerpt'] ?? '',
|
||||
'content' => $content,
|
||||
'focus_keyword' => $data['focus_keyword'] ?? $anime->title,
|
||||
'meta_description' => $data['meta_description'] ?? '',
|
||||
'faq' => $data['faq'] ?? [],
|
||||
'linked_anime_ids' => $linkedIds,
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkGenerate(Request $request)
|
||||
{
|
||||
$count = min(5, (int) $request->get('count', 3));
|
||||
set_time_limit(300);
|
||||
try {
|
||||
\Artisan::call('animexe:generate-blogs', ['--count' => $count, '--force' => false]);
|
||||
$output = \Artisan::output();
|
||||
return redirect()->route('admin.blog.index')->with('success', 'AI blog üretimi tamamlandı: ' . trim($output));
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('admin.blog.index')->with('error', 'Hata: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function validated(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'excerpt' => 'nullable|string',
|
||||
'content' => 'nullable|string',
|
||||
'cover_image' => 'nullable|string|max:500',
|
||||
'focus_keyword' => 'nullable|string|max:255',
|
||||
'meta_title' => 'nullable|string|max:255',
|
||||
'meta_description' => 'nullable|string',
|
||||
'meta_keywords' => 'nullable|string',
|
||||
'status' => 'required|in:draft,published',
|
||||
'anime_id' => 'nullable|exists:animes,id',
|
||||
'reading_time' => 'nullable|integer|min:1|max:60',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Comment;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Comment::with([
|
||||
'user',
|
||||
'commentable' => fn(MorphTo $m) => $m->constrain([
|
||||
\App\Models\Episode::class => fn($q) => $q->with('season.anime'),
|
||||
\App\Models\Anime::class => fn($q) => $q,
|
||||
]),
|
||||
])->latest();
|
||||
|
||||
if ($request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
if ($request->search) {
|
||||
$query->where('content', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
if ($request->user_id) {
|
||||
$query->where('user_id', $request->user_id);
|
||||
}
|
||||
|
||||
$comments = $query->paginate(30)->withQueryString();
|
||||
return view('admin.comments.index', compact('comments'));
|
||||
}
|
||||
|
||||
public function show(Comment $comment)
|
||||
{
|
||||
$comment->load(['user', 'replies.user', 'parent.user']);
|
||||
return view('admin.comments.show', compact('comment'));
|
||||
}
|
||||
|
||||
public function approve(Comment $comment)
|
||||
{
|
||||
$comment->update(['status' => 'approved']);
|
||||
return back()->with('success', 'Yorum onaylandı.');
|
||||
}
|
||||
|
||||
public function reject(Comment $comment)
|
||||
{
|
||||
$comment->update(['status' => 'rejected']);
|
||||
return back()->with('success', 'Yorum reddedildi.');
|
||||
}
|
||||
|
||||
public function pin(Comment $comment)
|
||||
{
|
||||
$comment->update(['is_pinned' => !$comment->is_pinned]);
|
||||
$msg = $comment->is_pinned ? 'Yorum sabitlendi.' : 'Yorum sabit kaldırıldı.';
|
||||
return back()->with('success', $msg);
|
||||
}
|
||||
|
||||
public function destroy(Comment $comment)
|
||||
{
|
||||
$comment->delete();
|
||||
return back()->with('success', 'Yorum silindi.');
|
||||
}
|
||||
|
||||
public function reply(Request $request, Comment $comment)
|
||||
{
|
||||
$data = $request->validate(['content' => 'required|string|max:2000']);
|
||||
|
||||
Comment::create([
|
||||
'user_id' => auth()->id(),
|
||||
'commentable_type' => $comment->commentable_type,
|
||||
'commentable_id' => $comment->commentable_id,
|
||||
'parent_id' => $comment->id,
|
||||
'content' => $data['content'],
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Yanıt gönderildi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Season;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ContentStatsController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// ── Özet sayılar ──────────────────────────────────────────────────────
|
||||
$totalAnimes = Anime::count();
|
||||
$publishedAnimes= Anime::where('is_published', true)->count();
|
||||
$totalEpisodes = Episode::count();
|
||||
$publishedEps = Episode::where('is_published', true)->count();
|
||||
$totalSeasons = Season::count();
|
||||
|
||||
// ── Son 365 gün — günlük bölüm yükleme (ısı haritası için) ───────────
|
||||
$epsByDay = Episode::selectRaw('DATE(created_at) as day, COUNT(*) as cnt')
|
||||
->where('created_at', '>=', now()->subYear())
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->pluck('cnt', 'day');
|
||||
|
||||
// ── Son 365 gün — günlük anime yükleme ───────────────────────────────
|
||||
$animesByDay = Anime::selectRaw('DATE(created_at) as day, COUNT(*) as cnt')
|
||||
->where('created_at', '>=', now()->subYear())
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->pluck('cnt', 'day');
|
||||
|
||||
// ── Son 90 gün trend (chart için) ─────────────────────────────────────
|
||||
$from90 = now()->subDays(89)->startOfDay();
|
||||
$trendLabels = [];
|
||||
$epTrendData = [];
|
||||
$animeTrendData = [];
|
||||
$cur = clone $from90;
|
||||
while ($cur->lte(now())) {
|
||||
$key = $cur->format('Y-m-d');
|
||||
$trendLabels[] = $cur->format('d M');
|
||||
$epTrendData[] = (int)($epsByDay[$key] ?? 0);
|
||||
$animeTrendData[] = (int)($animesByDay[$key] ?? 0);
|
||||
$cur->addDay();
|
||||
}
|
||||
|
||||
// ── Saatlik yükleme dağılımı (tüm zamanlar) ──────────────────────────
|
||||
$hourlyEps = Episode::selectRaw('HOUR(created_at) as hour, COUNT(*) as cnt')
|
||||
->groupBy('hour')
|
||||
->pluck('cnt', 'hour');
|
||||
$hourlyEpsData = array_map(fn($h) => (int)($hourlyEps[$h] ?? 0), range(0, 23));
|
||||
|
||||
// ── Haftanın günlerine göre dağılım ───────────────────────────────────
|
||||
$weekdayEps = Episode::selectRaw('DAYOFWEEK(created_at) as dow, COUNT(*) as cnt')
|
||||
->groupBy('dow')
|
||||
->pluck('cnt', 'dow');
|
||||
// MySQL DAYOFWEEK: 1=Pazar, 2=Pazartesi, ..., 7=Cumartesi
|
||||
$weekdayLabels = ['Paz', 'Pzt', 'Sal', 'Çar', 'Per', 'Cum', 'Cmt'];
|
||||
$weekdayData = array_map(fn($d) => (int)($weekdayEps[$d] ?? 0), range(1, 7));
|
||||
|
||||
// ── Aylık dağılım (son 24 ay) ─────────────────────────────────────────
|
||||
$monthlyEps = Episode::selectRaw('DATE_FORMAT(created_at, "%Y-%m") as mon, COUNT(*) as cnt')
|
||||
->where('created_at', '>=', now()->subMonths(24))
|
||||
->groupBy('mon')
|
||||
->orderBy('mon')
|
||||
->pluck('cnt', 'mon');
|
||||
|
||||
$monthLabels = [];
|
||||
$monthData = [];
|
||||
$mCur = now()->subMonths(23)->startOfMonth();
|
||||
while ($mCur->lte(now())) {
|
||||
$key = $mCur->format('Y-m');
|
||||
$monthLabels[] = $mCur->format('M y');
|
||||
$monthData[] = (int)($monthlyEps[$key] ?? 0);
|
||||
$mCur->addMonth();
|
||||
}
|
||||
|
||||
// ── Top 10 en fazla bölüm olan anime ──────────────────────────────────
|
||||
$topByEpisodes = Anime::withCount('episodes')
|
||||
->orderByDesc('episodes_count')
|
||||
->limit(10)
|
||||
->get(['id', 'title', 'slug', 'cover_image', 'status', 'type']);
|
||||
|
||||
// ── Son eklenen 20 bölüm ───────────────────────────────────────────────
|
||||
$recentEpisodes = Episode::with(['anime:id,title,slug', 'season:id,season_number'])
|
||||
->orderByDesc('created_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// ── Son eklenen 10 anime ───────────────────────────────────────────────
|
||||
$recentAnimes = Anime::orderByDesc('created_at')
|
||||
->limit(10)
|
||||
->get(['id', 'title', 'slug', 'cover_image', 'type', 'status', 'is_published', 'created_at']);
|
||||
|
||||
// ── Isı haritası verisi (52 hafta × 7 gün) ────────────────────────────
|
||||
$heatStart = now()->subWeeks(51)->startOfWeek(\Carbon\Carbon::MONDAY);
|
||||
$heatData = [];
|
||||
for ($w = 0; $w < 52; $w++) {
|
||||
$week = [];
|
||||
for ($d = 0; $d < 7; $d++) {
|
||||
$day = $heatStart->copy()->addDays($w * 7 + $d);
|
||||
$key = $day->format('Y-m-d');
|
||||
$week[] = [
|
||||
'date' => $key,
|
||||
'cnt' => (int)($epsByDay[$key] ?? 0),
|
||||
];
|
||||
}
|
||||
$heatData[] = $week;
|
||||
}
|
||||
|
||||
// ── Tür bazlı bölüm sayısı ────────────────────────────────────────────
|
||||
$genreEpStats = DB::table('anime_genre')
|
||||
->join('genres', 'genres.id', '=', 'anime_genre.genre_id')
|
||||
->join('episodes', 'episodes.anime_id', '=', 'anime_genre.anime_id')
|
||||
->select('genres.name', DB::raw('COUNT(episodes.id) as ep_count'))
|
||||
->groupBy('genres.id', 'genres.name')
|
||||
->orderByDesc('ep_count')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
return view('admin.stats.index', compact(
|
||||
'totalAnimes', 'publishedAnimes', 'totalEpisodes', 'publishedEps', 'totalSeasons',
|
||||
'trendLabels', 'epTrendData', 'animeTrendData',
|
||||
'hourlyEpsData',
|
||||
'weekdayLabels', 'weekdayData',
|
||||
'monthLabels', 'monthData',
|
||||
'topByEpisodes',
|
||||
'recentEpisodes', 'recentAnimes',
|
||||
'heatData',
|
||||
'genreEpStats',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$stats = [
|
||||
'total_users' => User::count(),
|
||||
'premium_users' => User::where('membership', 'premium')->count(),
|
||||
'total_animes' => Anime::count(),
|
||||
'total_episodes' => Episode::count(),
|
||||
'total_comments' => Comment::count(),
|
||||
'pending_comments' => Comment::where('status', 'pending')->count(),
|
||||
'active_subs' => Subscription::where('status', 'active')->count(),
|
||||
];
|
||||
|
||||
$recent_users = User::latest()->take(5)->get();
|
||||
$recent_comments = Comment::with('user')->latest()->take(5)->get();
|
||||
$recent_animes = Anime::latest()->take(5)->get();
|
||||
|
||||
return view('admin.dashboard', compact('stats', 'recent_users', 'recent_comments', 'recent_animes'));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Genre;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenreController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$genres = Genre::withCount('animes')->get();
|
||||
return view('admin.genres.index', compact('genres'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'color' => 'nullable|string|max:7',
|
||||
]);
|
||||
$data['slug'] = Str::slug($data['name']);
|
||||
Genre::create($data);
|
||||
return back()->with('success', 'Tür eklendi.');
|
||||
}
|
||||
|
||||
public function update(Request $request, Genre $genre)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'color' => 'nullable|string|max:7',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$genre->update($data);
|
||||
return back()->with('success', 'Tür güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(Genre $genre)
|
||||
{
|
||||
$genre->delete();
|
||||
return back()->with('success', 'Tür silindi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class HealthController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// 1. Duplike anime — aynı MAL ID'ye sahip birden fazla anime
|
||||
$malDuplicates = DB::table('animes')
|
||||
->whereNotNull('mal_id')
|
||||
->where('mal_id', '>', 0)
|
||||
->select('mal_id', DB::raw('COUNT(*) as cnt'))
|
||||
->groupBy('mal_id')
|
||||
->having('cnt', '>', 1)
|
||||
->get()
|
||||
->map(function ($row) {
|
||||
$animes = Anime::where('mal_id', $row->mal_id)
|
||||
->withCount('episodes')
|
||||
->get(['id', 'title', 'slug', 'mal_id', 'created_at']);
|
||||
return ['mal_id' => $row->mal_id, 'animes' => $animes];
|
||||
});
|
||||
|
||||
// 2. Karışık kaynak — aynı anime içinde hem animecix hem anizium bölüm var
|
||||
$mixedSources = DB::table('episodes')
|
||||
->whereIn('source', ['anizium', 'animecix'])
|
||||
->whereNotNull('anime_id')
|
||||
->select('anime_id', 'source', DB::raw('COUNT(*) as cnt'))
|
||||
->groupBy('anime_id', 'source')
|
||||
->get()
|
||||
->groupBy('anime_id')
|
||||
->filter(fn($group) => $group->pluck('source')->unique()->count() > 1)
|
||||
->map(function ($group) {
|
||||
$anime = Anime::find($group->first()->anime_id, ['id', 'title', 'slug']);
|
||||
if (!$anime) return null;
|
||||
$sources = $group->mapWithKeys(fn($r) => [$r->source => $r->cnt]);
|
||||
return ['anime' => $anime, 'sources' => $sources];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
// 3. Eksik bölümler — episode_count > gerçek bölüm sayısı
|
||||
$missingEpisodes = Anime::whereNotNull('episode_count')
|
||||
->where('episode_count', '>', 0)
|
||||
->withCount('episodes')
|
||||
->get(['id', 'title', 'slug', 'episode_count'])
|
||||
->filter(fn($a) => $a->episodes_count < $a->episode_count)
|
||||
->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'slug' => $a->slug,
|
||||
'expected' => $a->episode_count,
|
||||
'actual' => $a->episodes_count,
|
||||
'missing' => $a->episode_count - $a->episodes_count,
|
||||
])
|
||||
->sortByDesc('missing')
|
||||
->values();
|
||||
|
||||
// 4. Harici CDN bölümler — BunnyCDN'e taşınmamış, Anizium CDN'de kalan
|
||||
$externalCount = Episode::whereNull('video_url')
|
||||
->where(function ($q) {
|
||||
$q->where('m3u8_url', 'like', '%aniziumserver%')
|
||||
->orWhere('m3u8_url', 'like', '%anizium%');
|
||||
})
|
||||
->count();
|
||||
|
||||
$externalSample = Episode::whereNull('video_url')
|
||||
->where(function ($q) {
|
||||
$q->where('m3u8_url', 'like', '%aniziumserver%')
|
||||
->orWhere('m3u8_url', 'like', '%anizium%');
|
||||
})
|
||||
->with('anime:id,title,slug')
|
||||
->select('id', 'anime_id', 'season_id', 'episode_number', 'm3u8_url', 'source')
|
||||
->orderByDesc('id')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
// 5. Sıfır bölümlü animeler
|
||||
$zeroEpisodeAnimes = Anime::whereDoesntHave('episodes')
|
||||
->get(['id', 'title', 'slug', 'created_at']);
|
||||
|
||||
return view('admin.health.index', compact(
|
||||
'malDuplicates',
|
||||
'mixedSources',
|
||||
'missingEpisodes',
|
||||
'externalCount',
|
||||
'externalSample',
|
||||
'zeroEpisodeAnimes'
|
||||
));
|
||||
}
|
||||
|
||||
// ── Sistem Temizliği ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Depolama istatistiklerini döndür — inode tüketimini gösterir.
|
||||
*/
|
||||
public function storageStats()
|
||||
{
|
||||
$dirs = [
|
||||
'seg_cache' => storage_path('app/seg_cache'),
|
||||
'cache_data' => storage_path('framework/cache/data'),
|
||||
'sessions' => storage_path('framework/sessions'),
|
||||
'views' => storage_path('framework/views'),
|
||||
'logs' => storage_path('logs'),
|
||||
'app_public' => storage_path('app/public'),
|
||||
];
|
||||
|
||||
$stats = [];
|
||||
foreach ($dirs as $key => $path) {
|
||||
if (!is_dir($path)) {
|
||||
$stats[$key] = ['count' => 0, 'size' => 0, 'path' => $path];
|
||||
continue;
|
||||
}
|
||||
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS));
|
||||
$count = 0;
|
||||
$size = 0;
|
||||
foreach ($files as $f) {
|
||||
$count++;
|
||||
$size += $f->getSize();
|
||||
}
|
||||
$stats[$key] = ['count' => $count, 'size' => $size, 'path' => $path];
|
||||
}
|
||||
|
||||
return response()->json(['stats' => $stats, 'total_files' => array_sum(array_column($stats, 'count'))]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Belirtilen depolama dizinini temizle.
|
||||
*/
|
||||
public function cleanupStorage(Request $request)
|
||||
{
|
||||
$target = $request->input('target');
|
||||
$allowed = [
|
||||
'seg_cache' => storage_path('app/seg_cache'),
|
||||
'cache_data' => storage_path('framework/cache/data'),
|
||||
'sessions' => storage_path('framework/sessions'),
|
||||
'views' => storage_path('framework/views'),
|
||||
'old_logs' => storage_path('logs'),
|
||||
];
|
||||
|
||||
if (!array_key_exists($target, $allowed)) {
|
||||
return response()->json(['error' => 'Geçersiz hedef.'], 422);
|
||||
}
|
||||
|
||||
$path = $allowed[$target];
|
||||
$deleted = 0;
|
||||
|
||||
if (!is_dir($path)) {
|
||||
return response()->json(['ok' => true, 'deleted' => 0, 'message' => 'Dizin yok.']);
|
||||
}
|
||||
|
||||
if ($target === 'old_logs') {
|
||||
// Logları tamamen silme — sadece 7 günden eskilerini sil
|
||||
foreach (glob($path . '/*.log') ?: [] as $f) {
|
||||
if (filemtime($f) < time() - 604800) { // 7 gün
|
||||
@unlink($f);
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
// Laravel her gün yeni log açar, bugünküne dokunma
|
||||
} else {
|
||||
// Diğer dizinler: tümünü temizle
|
||||
$files = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($files as $f) {
|
||||
if ($f->isFile()) {
|
||||
@unlink($f->getRealPath());
|
||||
$deleted++;
|
||||
} elseif ($f->isDir()) {
|
||||
@rmdir($f->getRealPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Laravel cache'i PHP seviyesinde de temizle
|
||||
if ($target === 'cache_data') {
|
||||
try { \Illuminate\Support\Facades\Cache::flush(); } catch (\Throwable) {}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'deleted' => $deleted,
|
||||
'message' => "{$deleted} dosya silindi.",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Session driver bilgisi + önerisi.
|
||||
*/
|
||||
public function sessionInfo()
|
||||
{
|
||||
$driver = config('session.driver', 'file');
|
||||
$sessionPath = storage_path('framework/sessions');
|
||||
$sessionCount = is_dir($sessionPath) ? count(glob($sessionPath . '/*') ?: []) : 0;
|
||||
|
||||
return response()->json([
|
||||
'driver' => $driver,
|
||||
'session_files' => $sessionCount,
|
||||
'recommendation'=> $driver === 'file'
|
||||
? 'SESSION_DRIVER=database veya cookie kullanmanız önerilir (inode tasarrufu).'
|
||||
: 'Session sürücüsü inode-dostu.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteAnime(Request $request, Anime $anime)
|
||||
{
|
||||
$title = $anime->title;
|
||||
$anime->delete();
|
||||
return back()->with('success', "\"$title\" silindi.");
|
||||
}
|
||||
|
||||
public function deleteSourceEpisodes(Request $request, Anime $anime)
|
||||
{
|
||||
$source = $request->validate(['source' => 'required|in:anizium,animecix'])['source'];
|
||||
$count = Episode::where('anime_id', $anime->id)->where('source', $source)->count();
|
||||
Episode::where('anime_id', $anime->id)->where('source', $source)->delete();
|
||||
return back()->with('success', "$anime->title — $source kaynağından $count bölüm silindi.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ImportJob;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Season;
|
||||
use App\Models\Subtitle;
|
||||
use App\Models\VideoSource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ImportController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$jobs = ImportJob::latest()->paginate(20);
|
||||
|
||||
// Araçlar paneli için istatistikler
|
||||
$stats = [
|
||||
'total_animes' => \App\Models\Anime::where('is_published', true)->count(),
|
||||
'anizium_done' => ImportJob::where('source', 'anizium')->where('status', 'done')->count(),
|
||||
'animecix_done' => ImportJob::where('source', 'animecix')->where('status', 'done')->count(),
|
||||
'video_sources_total' => VideoSource::count(),
|
||||
'anizium_sources' => VideoSource::where('source', 'anizium')->count(),
|
||||
'animecix_sources' => VideoSource::where('source', 'animecix')->count(),
|
||||
'subtitle_mismatch' => $this->countSubtitleMismatch(),
|
||||
];
|
||||
|
||||
return view('admin.import.index', compact('jobs', 'stats'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'source_url' => 'required|url|max:500',
|
||||
'cdn_id' => 'nullable|string|max:50',
|
||||
'anime_title' => 'nullable|string|max:255',
|
||||
'season_ranges' => 'nullable|array',
|
||||
'season_ranges.*.season' => 'required_with:season_ranges|integer|min:1',
|
||||
'season_ranges.*.from' => 'required_with:season_ranges|integer|min:1',
|
||||
'season_ranges.*.to' => 'required_with:season_ranges|integer|min:1',
|
||||
]);
|
||||
|
||||
$watchId = null;
|
||||
if ($request->source_url) {
|
||||
preg_match('/\/(?:anime|watch)\/(\d+)/', $request->source_url, $m);
|
||||
$watchId = $m[1] ?? null;
|
||||
}
|
||||
|
||||
$ranges = null;
|
||||
if ($request->filled('season_ranges')) {
|
||||
$ranges = [];
|
||||
foreach ($request->season_ranges as $r) {
|
||||
if (empty($r['season']) || empty($r['from']) || empty($r['to'])) continue;
|
||||
$from = (int) $r['from'];
|
||||
$to = (int) $r['to'];
|
||||
if ($from > $to) [$from, $to] = [$to, $from];
|
||||
$ranges[] = ['season' => (int)$r['season'], 'from' => $from, 'to' => $to];
|
||||
}
|
||||
if (empty($ranges)) $ranges = null;
|
||||
}
|
||||
|
||||
$job = ImportJob::create([
|
||||
'source_url' => $request->source_url,
|
||||
'watch_id' => $watchId,
|
||||
'cdn_id' => $request->cdn_id ? trim($request->cdn_id) : null,
|
||||
'anime_title' => $request->anime_title,
|
||||
'season_ranges' => $ranges,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.import.show', $job)
|
||||
->with('success', "Import job #{$job->id} oluşturuldu. Python script'i başlatın.");
|
||||
}
|
||||
|
||||
public function show(ImportJob $import)
|
||||
{
|
||||
return view('admin.import.show', compact('import'));
|
||||
}
|
||||
|
||||
public function destroy(ImportJob $import)
|
||||
{
|
||||
$import->delete();
|
||||
return redirect()->route('admin.import.index')->with('success', 'Job silindi.');
|
||||
}
|
||||
|
||||
public function destroyFailed()
|
||||
{
|
||||
$count = ImportJob::where('status', 'failed')->count();
|
||||
ImportJob::where('status', 'failed')->delete();
|
||||
return redirect()->route('admin.import.index')->with('success', "{$count} hatalı job silindi.");
|
||||
}
|
||||
|
||||
public function destroyPending()
|
||||
{
|
||||
$count = ImportJob::where('status', 'pending')->count();
|
||||
ImportJob::where('status', 'pending')->delete();
|
||||
return redirect()->route('admin.import.index')->with('success', "{$count} bekleyen job silindi.");
|
||||
}
|
||||
|
||||
public function destroyStuck()
|
||||
{
|
||||
// fetching/downloading/uploading ama 2 saatten fazladır güncellenmemiş = takılı kalmış
|
||||
$cutoff = now()->subHours(2);
|
||||
$count = ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading'])
|
||||
->where('updated_at', '<', $cutoff)
|
||||
->count();
|
||||
ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading'])
|
||||
->where('updated_at', '<', $cutoff)
|
||||
->delete();
|
||||
return redirect()->route('admin.import.index')->with('success', "{$count} takılı kalmış job silindi.");
|
||||
}
|
||||
|
||||
public function bulkCounts()
|
||||
{
|
||||
$cutoff = now()->subHours(2);
|
||||
return response()->json([
|
||||
'failed' => ImportJob::where('status', 'failed')->count(),
|
||||
'pending' => ImportJob::where('status', 'pending')->count(),
|
||||
'stuck' => ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading'])
|
||||
->where('updated_at', '<', $cutoff)
|
||||
->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroyByStatus(Request $request)
|
||||
{
|
||||
$statuses = $request->input('statuses', []);
|
||||
$hours = (int) $request->input('stuck_hours', 2);
|
||||
|
||||
$allowed = ['pending', 'failed', 'fetching', 'downloading', 'uploading'];
|
||||
$statuses = array_intersect($statuses, $allowed);
|
||||
|
||||
if (empty($statuses)) {
|
||||
return response()->json(['ok' => false, 'message' => 'Geçerli status seçilmedi.'], 422);
|
||||
}
|
||||
|
||||
$query = ImportJob::whereIn('status', $statuses);
|
||||
|
||||
// Aktif statüler için sadece belirtilen saatten eskilerini sil
|
||||
$activeStatuses = array_intersect($statuses, ['fetching', 'downloading', 'uploading']);
|
||||
if (!empty($activeStatuses) && count($activeStatuses) === count($statuses)) {
|
||||
$query->where('updated_at', '<', now()->subHours($hours));
|
||||
}
|
||||
|
||||
$count = $query->count();
|
||||
$query->delete();
|
||||
|
||||
return response()->json(['ok' => true, 'deleted' => $count]);
|
||||
}
|
||||
|
||||
// ── ARAÇLAR: Terminal gerektirmez, admin panelden çalışır ─────────────────
|
||||
|
||||
/**
|
||||
* Altyazı uyuşmazlığı düzelt (Anizium episode-1 cache bug).
|
||||
* Subtitle URL'sindeki name=s1_b1_XX yanlış bölümü işaret edenleri siler.
|
||||
*/
|
||||
public function fixSubtitles(Request $request)
|
||||
{
|
||||
$dryRun = $request->boolean('dry_run', false);
|
||||
$animeId = $request->input('anime_id');
|
||||
|
||||
$query = Subtitle::query()
|
||||
->join('episodes', 'subtitles.episode_id', '=', 'episodes.id')
|
||||
->join('seasons', 'seasons.id', '=', 'episodes.season_id')
|
||||
->whereNotNull('subtitles.url')
|
||||
->where('subtitles.url', 'like', '%anizium%')
|
||||
->select(
|
||||
'subtitles.id as subtitle_id',
|
||||
'subtitles.language',
|
||||
'subtitles.url',
|
||||
'seasons.season_number',
|
||||
'episodes.episode_number',
|
||||
'episodes.anime_id',
|
||||
);
|
||||
|
||||
if ($animeId) {
|
||||
$query->where('episodes.anime_id', (int) $animeId);
|
||||
}
|
||||
|
||||
$subtitles = $query->get();
|
||||
$mismatchIds = [];
|
||||
$details = [];
|
||||
|
||||
foreach ($subtitles as $sub) {
|
||||
$parsed = parse_url($sub->url);
|
||||
if (!isset($parsed['query'])) continue;
|
||||
parse_str($parsed['query'], $params);
|
||||
$name = $params['name'] ?? '';
|
||||
if (!$name) continue;
|
||||
|
||||
$expectedPrefix = "s{$sub->season_number}_b{$sub->episode_number}_";
|
||||
if (!str_starts_with($name, $expectedPrefix)) {
|
||||
$mismatchIds[] = $sub->subtitle_id;
|
||||
$details[] = [
|
||||
'anime_id' => $sub->anime_id,
|
||||
'season' => $sub->season_number,
|
||||
'episode' => $sub->episode_number,
|
||||
'lang' => $sub->language,
|
||||
'name' => $name,
|
||||
'expected' => $expectedPrefix . $sub->language,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$deleted = 0;
|
||||
if (!$dryRun && !empty($mismatchIds)) {
|
||||
$deleted = Subtitle::whereIn('id', $mismatchIds)->delete();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'dry_run' => $dryRun,
|
||||
'checked' => $subtitles->count(),
|
||||
'mismatch' => count($mismatchIds),
|
||||
'deleted' => $deleted,
|
||||
'details' => array_slice($details, 0, 30),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anizium done job'larını yeniden pending yap (yeni video_sources eklemek için).
|
||||
* Her job'ın done_episodes sıfırlanır; Anizium bot yeniden çalışınca
|
||||
* doneEpisodes() artık source='anizium' kontrolü yaptığından
|
||||
* sadece video_sources'ta anizium kaydı OLMAYAN bölümleri yeniden işler.
|
||||
*/
|
||||
public function requeueAnizium(Request $request)
|
||||
{
|
||||
$limit = (int) $request->input('limit', 50);
|
||||
$animeId = $request->input('anime_id');
|
||||
|
||||
$query = ImportJob::where('source', 'anizium')
|
||||
->where('status', 'done')
|
||||
->whereNotNull('watch_id')
|
||||
->latest();
|
||||
|
||||
if ($animeId) {
|
||||
$query->where('anime_id', (int) $animeId);
|
||||
}
|
||||
|
||||
$jobs = $query->limit($limit)->get();
|
||||
$requeued = 0;
|
||||
|
||||
foreach ($jobs as $job) {
|
||||
// Zaten pending/işleniyor olan var mı?
|
||||
$active = ImportJob::where('watch_id', $job->watch_id)
|
||||
->where('source', 'anizium')
|
||||
->whereIn('status', ['pending', 'fetching', 'downloading', 'uploading'])
|
||||
->exists();
|
||||
|
||||
if (!$active) {
|
||||
$job->update([
|
||||
'status' => 'pending',
|
||||
'done_episodes'=> 0,
|
||||
'error_log' => null,
|
||||
'current_step' => 'Çapraz re-import — video_sources yenileme',
|
||||
]);
|
||||
$requeued++;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'checked' => $jobs->count(),
|
||||
'requeued' => $requeued,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AnimeCix done job'larını yeniden pending yap.
|
||||
*/
|
||||
public function requeueAnimecix(Request $request)
|
||||
{
|
||||
$limit = (int) $request->input('limit', 50);
|
||||
$animeId = $request->input('anime_id');
|
||||
|
||||
$query = ImportJob::where('source', 'animecix')
|
||||
->where('status', 'done')
|
||||
->whereNotNull('animecix_title_id')
|
||||
->latest();
|
||||
|
||||
if ($animeId) {
|
||||
$query->where('anime_id', (int) $animeId);
|
||||
}
|
||||
|
||||
$jobs = $query->limit($limit)->get();
|
||||
$requeued = 0;
|
||||
|
||||
foreach ($jobs as $job) {
|
||||
$active = ImportJob::where('animecix_title_id', $job->animecix_title_id)
|
||||
->where('source', 'animecix')
|
||||
->whereIn('status', ['pending', 'fetching'])
|
||||
->exists();
|
||||
|
||||
if (!$active) {
|
||||
$job->update([
|
||||
'status' => 'pending',
|
||||
'done_episodes'=> 0,
|
||||
'error_log' => null,
|
||||
'current_step' => 'Çapraz re-import — video_sources yenileme',
|
||||
]);
|
||||
$requeued++;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'checked' => $jobs->count(),
|
||||
'requeued' => $requeued,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* video_sources istatistikleri (AJAX için).
|
||||
*/
|
||||
public function sourceStats()
|
||||
{
|
||||
$animeCount = \App\Models\Anime::where('is_published', true)->count();
|
||||
|
||||
$episodesWithBoth = DB::table('episodes')
|
||||
->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium'))
|
||||
->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix'))
|
||||
->where('is_published', true)
|
||||
->count();
|
||||
|
||||
$episodesOnlyAnizium = DB::table('episodes')
|
||||
->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium'))
|
||||
->whereNotExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix'))
|
||||
->where('is_published', true)
|
||||
->count();
|
||||
|
||||
$episodesOnlyAnimecix = DB::table('episodes')
|
||||
->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix'))
|
||||
->whereNotExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium'))
|
||||
->where('is_published', true)
|
||||
->count();
|
||||
|
||||
return response()->json([
|
||||
'anime_count' => $animeCount,
|
||||
'episodes_with_both' => $episodesWithBoth,
|
||||
'episodes_only_anizium' => $episodesOnlyAnizium,
|
||||
'episodes_only_animecix' => $episodesOnlyAnimecix,
|
||||
'subtitle_mismatch' => $this->countSubtitleMismatch(),
|
||||
'anizium_pending_jobs' => ImportJob::where('source', 'anizium')->where('status', 'pending')->count(),
|
||||
'animecix_pending_jobs' => ImportJob::where('source', 'animecix')->where('status', 'pending')->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Yardımcı ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function countSubtitleMismatch(): int
|
||||
{
|
||||
$rows = Subtitle::query()
|
||||
->join('episodes', 'subtitles.episode_id', '=', 'episodes.id')
|
||||
->join('seasons', 'seasons.id', '=', 'episodes.season_id')
|
||||
->whereNotNull('subtitles.url')
|
||||
->where('subtitles.url', 'like', '%anizium%')
|
||||
->select('subtitles.url', 'seasons.season_number', 'episodes.episode_number')
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
foreach ($rows as $r) {
|
||||
$parsed = parse_url($r->url);
|
||||
if (!isset($parsed['query'])) continue;
|
||||
parse_str($parsed['query'], $params);
|
||||
$name = $params['name'] ?? '';
|
||||
if ($name && !str_starts_with($name, "s{$r->season_number}_b{$r->episode_number}_")) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotification;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MobileAppController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// App settings
|
||||
$settings = [
|
||||
'mobile_min_version' => Setting::get('mobile_min_version', '1.0.0'),
|
||||
'mobile_current_version' => Setting::get('mobile_current_version', '1.0.0'),
|
||||
'mobile_apk_url' => Setting::get('mobile_apk_url', ''),
|
||||
'mobile_maintenance_mode' => Setting::get('mobile_maintenance_mode', '0'),
|
||||
'mobile_maintenance_message'=> Setting::get('mobile_maintenance_message', 'Uygulama şu anda bakımda. Lütfen daha sonra tekrar deneyin.'),
|
||||
'mobile_force_update_msg' => Setting::get('mobile_force_update_msg', 'Uygulamayı kullanmaya devam etmek için lütfen güncelleyin.'),
|
||||
];
|
||||
|
||||
// Stats
|
||||
$stats = [
|
||||
'total_users' => User::count(),
|
||||
'fcm_tokens' => User::whereNotNull('fcm_token')->where('fcm_token', '!=', '')->count(),
|
||||
'notifications_sent'=> UserNotification::count(),
|
||||
'notifs_today' => UserNotification::whereDate('created_at', today())->count(),
|
||||
'notifs_unread' => UserNotification::whereNull('read_at')->count(),
|
||||
];
|
||||
|
||||
// Active users (logged in last 30 days, via tokens)
|
||||
try {
|
||||
$stats['active_30d'] = DB::table('personal_access_tokens')
|
||||
->where('tokenable_type', User::class)
|
||||
->where('last_used_at', '>=', now()->subDays(30))
|
||||
->distinct('tokenable_id')
|
||||
->count('tokenable_id');
|
||||
} catch (\Throwable $e) {
|
||||
$stats['active_30d'] = '–';
|
||||
}
|
||||
|
||||
return view('admin.mobile.index', compact('settings', 'stats'));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'mobile_min_version' => 'required|string|max:20',
|
||||
'mobile_current_version' => 'required|string|max:20',
|
||||
'mobile_apk_url' => 'nullable|url|max:500',
|
||||
'mobile_maintenance_mode' => 'boolean',
|
||||
'mobile_maintenance_message' => 'required|string|max:300',
|
||||
'mobile_force_update_msg' => 'required|string|max:300',
|
||||
]);
|
||||
|
||||
// Checkbox absent = unchecked → force '0'
|
||||
$data['mobile_maintenance_mode'] = $request->boolean('mobile_maintenance_mode') ? '1' : '0';
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
Setting::set($key, $value ?? '', 'mobile');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Mobil uygulama ayarları güncellendi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ModeratorPermission;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ModeratorController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$moderators = User::where('role', 'moderator')
|
||||
->withCount('moderatorPermissions')
|
||||
->with('moderatorPermissions:user_id,permission')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.moderators.index', [
|
||||
'moderators' => $moderators,
|
||||
'groups' => ModeratorPermission::$groups,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(User $user)
|
||||
{
|
||||
abort_if($user->isAdmin(), 403);
|
||||
|
||||
$permissions = ModeratorPermission::where('user_id', $user->id)
|
||||
->pluck('permission')
|
||||
->flip() // key = permission, value = true for fast lookup
|
||||
->all();
|
||||
|
||||
return view('admin.moderators.edit', [
|
||||
'moderator' => $user,
|
||||
'groups' => ModeratorPermission::$groups,
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Admin assigns a user the moderator role */
|
||||
public function promote(Request $request)
|
||||
{
|
||||
$request->validate(['user_id' => 'required|exists:users,id']);
|
||||
|
||||
$user = User::findOrFail($request->user_id);
|
||||
abort_if($user->isAdmin(), 403, 'Admin kullanıcı düzenlenemez.');
|
||||
|
||||
$user->update(['role' => 'moderator']);
|
||||
|
||||
return back()->with('success', "{$user->name} moderatör yapıldı.");
|
||||
}
|
||||
|
||||
/** Remove moderator role */
|
||||
public function demote(User $user)
|
||||
{
|
||||
abort_if($user->isAdmin(), 403);
|
||||
$user->update(['role' => 'user']);
|
||||
ModeratorPermission::where('user_id', $user->id)->delete();
|
||||
$user->flushPermCache();
|
||||
|
||||
return back()->with('success', "{$user->name} moderatörlükten çıkarıldı.");
|
||||
}
|
||||
|
||||
/** Save permission checkboxes */
|
||||
public function savePermissions(Request $request, User $user)
|
||||
{
|
||||
abort_if($user->isAdmin(), 403);
|
||||
abort_if($user->role !== 'moderator', 422, 'Kullanıcı moderatör değil.');
|
||||
|
||||
$allKeys = ModeratorPermission::allKeys();
|
||||
$submitted = array_intersect($request->input('permissions', []), $allKeys);
|
||||
|
||||
// Delete old, insert new
|
||||
ModeratorPermission::where('user_id', $user->id)->delete();
|
||||
foreach ($submitted as $perm) {
|
||||
ModeratorPermission::create([
|
||||
'user_id' => $user->id,
|
||||
'permission' => $perm,
|
||||
'granted_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
$user->flushPermCache();
|
||||
|
||||
return back()->with('success', 'İzinler kaydedildi. (' . count($submitted) . ' izin aktif)');
|
||||
}
|
||||
|
||||
/** Quick permission toggle via AJAX */
|
||||
public function togglePermission(Request $request, User $user)
|
||||
{
|
||||
abort_if($user->isAdmin(), 403);
|
||||
$perm = $request->input('permission');
|
||||
abort_unless(in_array($perm, ModeratorPermission::allKeys()), 422);
|
||||
|
||||
$existing = ModeratorPermission::where('user_id', $user->id)
|
||||
->where('permission', $perm)->first();
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$active = false;
|
||||
} else {
|
||||
ModeratorPermission::create(['user_id' => $user->id, 'permission' => $perm, 'granted_by' => auth()->id()]);
|
||||
$active = true;
|
||||
}
|
||||
|
||||
$user->flushPermCache();
|
||||
|
||||
return response()->json(['active' => $active]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotification;
|
||||
use App\Services\FcmService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$recent = UserNotification::with('user')
|
||||
->orderByDesc('created_at')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
$stats = [
|
||||
'total' => UserNotification::count(),
|
||||
'unread' => UserNotification::whereNull('read_at')->count(),
|
||||
'users' => User::count(),
|
||||
'today' => UserNotification::whereDate('created_at', today())->count(),
|
||||
];
|
||||
|
||||
return view('admin.notifications.index', compact('recent', 'stats'));
|
||||
}
|
||||
|
||||
public function send(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:100',
|
||||
'body' => 'required|string|max:500',
|
||||
'url' => 'nullable|url|max:300',
|
||||
'target' => 'required|in:all,premium,free',
|
||||
'icon' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$query = User::query();
|
||||
|
||||
if ($data['target'] === 'premium') {
|
||||
$query->where('membership', 'premium')
|
||||
->where(fn($q) => $q->whereNull('premium_expires_at')->orWhere('premium_expires_at', '>', now()));
|
||||
} elseif ($data['target'] === 'free') {
|
||||
$query->where(fn($q) => $q->where('membership', '!=', 'premium')->orWhere('premium_expires_at', '<=', now()));
|
||||
}
|
||||
|
||||
$users = $query->select('id', 'fcm_token')->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
return back()->with('error', 'Hedef kullanıcı bulunamadı.');
|
||||
}
|
||||
|
||||
$notifData = json_encode([
|
||||
'title' => $data['title'],
|
||||
'body' => $data['body'],
|
||||
'url' => $data['url'] ?? null,
|
||||
'icon' => $data['icon'] ?? 'bi-megaphone-fill',
|
||||
'admin' => true,
|
||||
]);
|
||||
|
||||
$now = now();
|
||||
$rows = $users->map(fn($u) => [
|
||||
'user_id' => $u->id,
|
||||
'type' => 'admin',
|
||||
'data' => $notifData,
|
||||
'created_at' => $now,
|
||||
])->toArray();
|
||||
|
||||
// In-app notifications
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
UserNotification::insert($chunk);
|
||||
}
|
||||
|
||||
// FCM Push notifications
|
||||
$fcmTokens = $users->pluck('fcm_token')->filter()->values()->toArray();
|
||||
if (!empty($fcmTokens)) {
|
||||
$fcm = new FcmService();
|
||||
$fcm->sendToTokens($fcmTokens, $data['title'], $data['body'], [
|
||||
'type' => 'admin',
|
||||
'url' => $data['url'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', count($rows) . ' kullanıcıya bildirim gönderildi' . (!empty($fcmTokens) ? ' (' . count($fcmTokens) . ' push)' : '') . '.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PermissionSetting;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PermissionController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$permissions = PermissionSetting::all();
|
||||
return view('admin.permissions.index', compact('permissions'));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$permissions = $request->permissions ?? [];
|
||||
|
||||
foreach ($permissions as $key => $value) {
|
||||
if (in_array($value, ['free', 'premium'])) {
|
||||
PermissionSetting::where('key', $key)->update(['required_membership' => $value]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'Global izinler güncellendi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MembershipPlan;
|
||||
use App\Services\PremiumFeatures;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PlanController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$plans = MembershipPlan::orderBy('sort_order')->get();
|
||||
return view('admin.plans.index', compact('plans'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$allPerks = PremiumFeatures::grouped();
|
||||
return view('admin.plans.create', compact('allPerks'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'purchase_link' => 'nullable|url|max:1000',
|
||||
'duration_days' => 'required|integer|min:1',
|
||||
'trial_days' => 'nullable|integer|min:0',
|
||||
'badge_label' => 'nullable|string|max:32',
|
||||
'accent_color' => 'nullable|string|max:16',
|
||||
'features' => 'nullable|array',
|
||||
'features.*' => 'string',
|
||||
'perks' => 'nullable|array',
|
||||
'is_active' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'visible_until' => 'nullable|date',
|
||||
'sort_order' => 'integer',
|
||||
]);
|
||||
|
||||
$data['slug'] = Str::slug($data['name']);
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$data['is_public'] = $request->boolean('is_public', true);
|
||||
$data['trial_days'] = (int) ($request->input('trial_days', 0));
|
||||
$data['purchase_link'] = $request->filled('purchase_link') ? $request->input('purchase_link') : null;
|
||||
$data['badge_label'] = $request->filled('badge_label') ? $request->input('badge_label') : null;
|
||||
$data['accent_color'] = $request->filled('accent_color') ? $request->input('accent_color') : null;
|
||||
$data['visible_until'] = $request->filled('visible_until') ? $request->input('visible_until') : null;
|
||||
$data['features'] = array_values(array_filter($request->features ?? []));
|
||||
|
||||
$perks = [];
|
||||
foreach (array_keys(PremiumFeatures::ALL) as $key) {
|
||||
$perks[$key] = in_array($key, $request->input('perks', []));
|
||||
}
|
||||
$data['perks'] = $perks;
|
||||
|
||||
MembershipPlan::create($data);
|
||||
return redirect()->route('admin.plans.index')->with('success', 'Plan eklendi.');
|
||||
}
|
||||
|
||||
public function edit(MembershipPlan $plan)
|
||||
{
|
||||
$allPerks = PremiumFeatures::grouped();
|
||||
return view('admin.plans.edit', compact('plan', 'allPerks'));
|
||||
}
|
||||
|
||||
public function update(Request $request, MembershipPlan $plan)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'purchase_link' => 'nullable|url|max:1000',
|
||||
'duration_days' => 'required|integer|min:1',
|
||||
'trial_days' => 'nullable|integer|min:0',
|
||||
'badge_label' => 'nullable|string|max:32',
|
||||
'accent_color' => 'nullable|string|max:16',
|
||||
'features' => 'nullable|array',
|
||||
'features.*' => 'string',
|
||||
'perks' => 'nullable|array',
|
||||
'is_active' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'visible_until' => 'nullable|date',
|
||||
'sort_order' => 'integer',
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$data['is_public'] = $request->boolean('is_public', true);
|
||||
$data['trial_days'] = (int) ($request->input('trial_days', 0));
|
||||
$data['purchase_link'] = $request->filled('purchase_link') ? $request->input('purchase_link') : null;
|
||||
$data['badge_label'] = $request->filled('badge_label') ? $request->input('badge_label') : null;
|
||||
$data['accent_color'] = $request->filled('accent_color') ? $request->input('accent_color') : null;
|
||||
$data['visible_until'] = $request->filled('visible_until') ? $request->input('visible_until') : null;
|
||||
$data['features'] = array_values(array_filter($request->features ?? []));
|
||||
|
||||
$perks = [];
|
||||
foreach (array_keys(PremiumFeatures::ALL) as $key) {
|
||||
$perks[$key] = in_array($key, $request->input('perks', []));
|
||||
}
|
||||
$data['perks'] = $perks;
|
||||
|
||||
$plan->update($data);
|
||||
return redirect()->route('admin.plans.index')->with('success', 'Plan güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(MembershipPlan $plan)
|
||||
{
|
||||
$plan->delete();
|
||||
return redirect()->route('admin.plans.index')->with('success', 'Plan silindi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Season;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SeasonController extends Controller
|
||||
{
|
||||
public function index(Anime $anime) { return redirect()->route('admin.animes.show', $anime); }
|
||||
public function create(Anime $anime) { return redirect()->route('admin.animes.show', $anime); }
|
||||
public function show(Season $season) { return redirect()->route('admin.animes.show', $season->anime_id); }
|
||||
|
||||
public function store(Request $request, Anime $anime)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'season_number' => 'required|integer|min:1',
|
||||
'title' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'release_year' => 'nullable|integer|min:1900|max:2099',
|
||||
'is_published' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['anime_id'] = $anime->id;
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
|
||||
Season::create($data);
|
||||
return redirect()->route('admin.animes.show', $anime)->with('success', 'Sezon eklendi.');
|
||||
}
|
||||
|
||||
public function edit(Season $season)
|
||||
{
|
||||
return view('admin.seasons.edit', compact('season'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Season $season)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'season_number' => 'required|integer|min:1',
|
||||
'title' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'release_year' => 'nullable|integer|min:1900|max:2099',
|
||||
'is_published' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
$season->update($data);
|
||||
return redirect()->route('admin.animes.show', $season->anime_id)->with('success', 'Sezon güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(Season $season)
|
||||
{
|
||||
$animeId = $season->anime_id;
|
||||
$season->delete();
|
||||
return redirect()->route('admin.animes.show', $animeId)->with('success', 'Sezon silindi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Setting;
|
||||
use App\Models\SeoKeyword;
|
||||
use App\Models\SeoRedirect;
|
||||
use App\Models\Episode;
|
||||
use App\Models\User;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Watchlist;
|
||||
use App\Models\AnimeRating;
|
||||
use App\Models\BlogPost;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SeoController extends Controller
|
||||
{
|
||||
private array $defaults = [
|
||||
'seo_site_name' => 'Animexe',
|
||||
'seo_title_template' => '%s — Animexe | Türkçe Anime İzle',
|
||||
'seo_home_title' => 'Animexe — Türkçe Anime İzle | Ücretsiz HD',
|
||||
'seo_home_description' => 'Animexe\'de binlerce anime dizisi ve filmini Türkçe altyazılı veya dublajlı, ücretsiz ve yüksek kalitede izleyin.',
|
||||
'seo_home_keywords' => 'anime izle, türkçe anime, anime dizi, anime film, ücretsiz anime izle, hd anime, türkçe altyazılı anime, türkçe dublajlı anime',
|
||||
'seo_og_image' => '/logo.jpg',
|
||||
'seo_twitter_site' => '',
|
||||
'seo_facebook_app_id' => '',
|
||||
'seo_canonical_domain' => '',
|
||||
'seo_google_analytics' => '',
|
||||
'seo_gtm_id' => '',
|
||||
'seo_gsc_verification' => '',
|
||||
'seo_bing_verification' => '',
|
||||
'seo_yandex_verification' => '',
|
||||
'seo_enable_schema' => '1',
|
||||
'seo_enable_breadcrumb' => '1',
|
||||
'seo_noindex_search' => '1',
|
||||
'seo_noindex_profile' => '1',
|
||||
'seo_noindex_watch' => '0',
|
||||
'seo_org_logo' => '/logo.jpg',
|
||||
'seo_org_twitter' => '',
|
||||
'seo_org_facebook' => '',
|
||||
'seo_org_instagram' => '',
|
||||
'seo_robots_custom' => '',
|
||||
'seo_pagespeed_api_key' => '',
|
||||
'seo_looker_embed_url' => '',
|
||||
'seo_enable_faq_schema' => '1',
|
||||
'seo_enable_video_schema' => '1',
|
||||
];
|
||||
|
||||
public function index()
|
||||
{
|
||||
$settings = Setting::where('key', 'like', 'seo_%')->pluck('value', 'key')->toArray();
|
||||
foreach ($this->defaults as $key => $val) {
|
||||
if (!array_key_exists($key, $settings)) {
|
||||
$settings[$key] = $val;
|
||||
}
|
||||
}
|
||||
|
||||
$robotsPath = public_path('robots.txt');
|
||||
$robotsTxt = File::exists($robotsPath) ? File::get($robotsPath) : '';
|
||||
$audit = $this->runAudit();
|
||||
|
||||
$sitemapStats = [
|
||||
'anime_count' => Anime::where('is_published', true)->count(),
|
||||
'genre_count' => Genre::where('is_active', true)->count(),
|
||||
'static_count' => 2,
|
||||
'last_updated' => Setting::get('seo_sitemap_generated_at', null),
|
||||
];
|
||||
|
||||
// Keyword tracker
|
||||
$keywords = SeoKeyword::orderBy('keyword')->get();
|
||||
|
||||
// Redirect manager
|
||||
$redirects = SeoRedirect::orderByDesc('hits')->paginate(25, ['*'], 'rpage');
|
||||
|
||||
// Bulk SEO — animelerin SEO verileri (seo_title veya seo_meta_desc eksik olanlar önce)
|
||||
$animes = Anime::where('is_published', true)
|
||||
->orderByRaw('(seo_title IS NULL OR seo_title = "") DESC')
|
||||
->orderBy('title')
|
||||
->select('id', 'title', 'slug', 'description', 'seo_title', 'seo_meta_desc', 'seo_keywords')
|
||||
->paginate(30, ['*'], 'apage');
|
||||
|
||||
$animeSeoCoverage = [
|
||||
'total' => Anime::where('is_published', true)->count(),
|
||||
'has_seo_title'=> Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count(),
|
||||
'has_seo_desc' => Anime::where('is_published', true)->whereNotNull('seo_meta_desc')->where('seo_meta_desc', '!=', '')->count(),
|
||||
];
|
||||
|
||||
// Image alt audit — animes with cover
|
||||
$missingAlt = Anime::where('is_published', true)
|
||||
->whereNotNull('cover_image')->where('cover_image', '!=', '')
|
||||
->whereNull('title')->count(); // titles serve as alt text, so just check no-title
|
||||
|
||||
// Duplicate descriptions
|
||||
$dupDesc = DB::table('animes')
|
||||
->select('description', DB::raw('COUNT(*) as cnt'))
|
||||
->where('is_published', true)
|
||||
->whereNotNull('description')
|
||||
->where('description', '!=', '')
|
||||
->groupBy('description')
|
||||
->having('cnt', '>', 1)
|
||||
->count();
|
||||
|
||||
// ── Analytics stats for Google tab ───────────────────────────────────
|
||||
$analyticsStats = [
|
||||
'total_anime' => Anime::where('is_published', true)->count(),
|
||||
'total_episodes' => class_exists(Episode::class) ? Episode::count() : 0,
|
||||
'total_users' => User::count(),
|
||||
'total_genres' => Genre::where('is_active', true)->count(),
|
||||
'total_comments' => class_exists(Comment::class) ? Comment::count() : 0,
|
||||
'total_watchlists' => class_exists(Watchlist::class) ? Watchlist::count() : 0,
|
||||
'total_ratings' => class_exists(AnimeRating::class) ? AnimeRating::count() : 0,
|
||||
'total_blog_posts' => class_exists(BlogPost::class) ? BlogPost::count() : 0,
|
||||
'total_redirects' => SeoRedirect::where('is_active', true)->count(),
|
||||
'total_redirect_hits'=> SeoRedirect::sum('hits'),
|
||||
'seo_title_pct' => $sitemapStats['anime_count'] > 0
|
||||
? round($animeSeoCoverage['has_seo_title'] / $sitemapStats['anime_count'] * 100)
|
||||
: 0,
|
||||
'seo_desc_pct' => $sitemapStats['anime_count'] > 0
|
||||
? round($animeSeoCoverage['has_seo_desc'] / $sitemapStats['anime_count'] * 100)
|
||||
: 0,
|
||||
'new_anime_this_month' => Anime::where('is_published', true)
|
||||
->where('created_at', '>=', now()->startOfMonth())->count(),
|
||||
'new_users_this_month' => User::where('created_at', '>=', now()->startOfMonth())->count(),
|
||||
];
|
||||
|
||||
// Integration status
|
||||
$integrations = [
|
||||
'ga4' => !empty($settings['seo_google_analytics'] ?? ''),
|
||||
'gtm' => !empty($settings['seo_gtm_id'] ?? ''),
|
||||
'gsc' => !empty($settings['seo_gsc_verification'] ?? ''),
|
||||
'bing' => !empty($settings['seo_bing_verification'] ?? ''),
|
||||
'yandex' => !empty($settings['seo_yandex_verification'] ?? ''),
|
||||
];
|
||||
|
||||
return view('admin.seo.index', compact(
|
||||
'settings', 'robotsTxt', 'audit', 'sitemapStats',
|
||||
'keywords', 'redirects', 'animes', 'animeSeoCoverage', 'dupDesc',
|
||||
'analyticsStats', 'integrations'
|
||||
));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
// Tüm alanlar opsiyonel — her tab kendi alanlarını gönderir (partial update)
|
||||
$rules = [
|
||||
'seo_site_name' => 'nullable|string|max:100',
|
||||
'seo_title_template' => 'nullable|string|max:200',
|
||||
'seo_home_title' => 'nullable|string|max:200',
|
||||
'seo_home_description' => 'nullable|string|max:500',
|
||||
'seo_home_keywords' => 'nullable|string|max:500',
|
||||
'seo_og_image' => 'nullable|string|max:500',
|
||||
'seo_twitter_site' => 'nullable|string|max:100',
|
||||
'seo_facebook_app_id' => 'nullable|string|max:100',
|
||||
'seo_canonical_domain' => 'nullable|url|max:200',
|
||||
'seo_google_analytics' => 'nullable|string|max:50',
|
||||
'seo_gtm_id' => 'nullable|string|max:50',
|
||||
'seo_gsc_verification' => 'nullable|string|max:200',
|
||||
'seo_bing_verification' => 'nullable|string|max:200',
|
||||
'seo_yandex_verification' => 'nullable|string|max:200',
|
||||
'seo_org_logo' => 'nullable|string|max:500',
|
||||
'seo_org_twitter' => 'nullable|string|max:200',
|
||||
'seo_org_facebook' => 'nullable|string|max:200',
|
||||
'seo_org_instagram' => 'nullable|string|max:200',
|
||||
'seo_pagespeed_api_key' => 'nullable|string|max:100',
|
||||
'seo_looker_embed_url' => 'nullable|string|max:500',
|
||||
];
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
|
||||
// Checkbox alanları: sadece request'te varsa güncelle
|
||||
$checkboxes = [
|
||||
'seo_enable_schema', 'seo_enable_breadcrumb', 'seo_noindex_search',
|
||||
'seo_noindex_profile', 'seo_noindex_watch', 'seo_enable_faq_schema', 'seo_enable_video_schema',
|
||||
];
|
||||
foreach ($checkboxes as $key) {
|
||||
if ($request->has($key) || $request->has('_seo_section')) {
|
||||
$value = $request->input($key);
|
||||
$validated[$key] = ($value === '1' || $value === 'on') ? '1' : '0';
|
||||
}
|
||||
}
|
||||
|
||||
// Sadece gönderilen (non-null) alanları kaydet
|
||||
foreach ($validated as $key => $value) {
|
||||
if ($value !== null) {
|
||||
Setting::set($key, $value, 'seo');
|
||||
}
|
||||
}
|
||||
|
||||
cache()->forget('seo_settings');
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['ok' => true, 'message' => 'SEO ayarları kaydedildi.']);
|
||||
}
|
||||
return back()->with('success', 'SEO ayarları başarıyla kaydedildi.');
|
||||
}
|
||||
|
||||
public function updateRobots(Request $request)
|
||||
{
|
||||
$request->validate(['robots_txt' => 'required|string|max:10000']);
|
||||
File::put(public_path('robots.txt'), $request->input('robots_txt'));
|
||||
return back()->with('success', 'robots.txt güncellendi.');
|
||||
}
|
||||
|
||||
public function pingSearchEngines(Request $request)
|
||||
{
|
||||
$domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/');
|
||||
$sitemapUrl = urlencode($domain . '/sitemap.xml');
|
||||
$results = [];
|
||||
|
||||
foreach (['google' => "https://www.google.com/ping?sitemap={$sitemapUrl}", 'bing' => "https://www.bing.com/ping?sitemap={$sitemapUrl}"] as $engine => $url) {
|
||||
try {
|
||||
$r = Http::timeout(5)->get($url);
|
||||
$results[$engine] = $r->successful() ? 'success' : 'error';
|
||||
} catch (\Throwable) {
|
||||
$results[$engine] = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
Setting::set('seo_sitemap_pinged_at', now()->toDateTimeString(), 'seo');
|
||||
return back()->with('ping_results', $results)->with('success', 'Arama motorlarına bildirim gönderildi.');
|
||||
}
|
||||
|
||||
public function auditJson()
|
||||
{
|
||||
return response()->json($this->runAudit());
|
||||
}
|
||||
|
||||
// ── Keyword Tracker ───────────────────────────────────────────────────────
|
||||
|
||||
public function storeKeyword(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'keyword' => 'required|string|max:255',
|
||||
'target_url' => 'nullable|string|max:500',
|
||||
'search_volume' => 'nullable|integer|min:0',
|
||||
'difficulty' => 'nullable|integer|min:0|max:100',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
SeoKeyword::create($data);
|
||||
return back()->with('success', 'Anahtar kelime eklendi.');
|
||||
}
|
||||
|
||||
public function destroyKeyword(SeoKeyword $keyword)
|
||||
{
|
||||
$keyword->delete();
|
||||
return back()->with('success', 'Anahtar kelime silindi.');
|
||||
}
|
||||
|
||||
// ── Redirect Manager ─────────────────────────────────────────────────────
|
||||
|
||||
public function storeRedirect(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'from_path' => 'required|string|max:500',
|
||||
'to_path' => 'required|string|max:500',
|
||||
'type' => 'required|in:301,302',
|
||||
]);
|
||||
|
||||
$data['from_path'] = '/' . ltrim($data['from_path'], '/');
|
||||
|
||||
SeoRedirect::updateOrCreate(['from_path' => $data['from_path']], $data);
|
||||
cache()->forget('seo_redirect_' . md5($data['from_path']));
|
||||
return back()->with('success', 'Yönlendirme eklendi/güncellendi.');
|
||||
}
|
||||
|
||||
public function destroyRedirect(SeoRedirect $redirect)
|
||||
{
|
||||
cache()->forget('seo_redirect_' . md5($redirect->from_path));
|
||||
$redirect->delete();
|
||||
return back()->with('success', 'Yönlendirme silindi.');
|
||||
}
|
||||
|
||||
public function toggleRedirect(SeoRedirect $redirect)
|
||||
{
|
||||
$redirect->update(['is_active' => !$redirect->is_active]);
|
||||
cache()->forget('seo_redirect_' . md5($redirect->from_path));
|
||||
return response()->json(['is_active' => $redirect->is_active]);
|
||||
}
|
||||
|
||||
// ── Bulk Anime SEO ────────────────────────────────────────────────────────
|
||||
|
||||
public function bulkSaveAnime(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'animes' => 'required|array',
|
||||
'animes.*.id' => 'required|integer|exists:animes,id',
|
||||
'animes.*.seo_title' => 'nullable|string|max:100',
|
||||
'animes.*.seo_meta_desc' => 'nullable|string|max:320',
|
||||
'animes.*.seo_keywords' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
foreach ($data['animes'] as $row) {
|
||||
Anime::where('id', $row['id'])->update([
|
||||
'seo_title' => $row['seo_title'] ?? null,
|
||||
'seo_meta_desc' => $row['seo_meta_desc'] ?? null,
|
||||
'seo_keywords' => $row['seo_keywords'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', count($data['animes']) . ' anime için SEO verileri kaydedildi.');
|
||||
}
|
||||
|
||||
public function generateAnimeSeo(Anime $anime)
|
||||
{
|
||||
$title = trim($anime->title);
|
||||
$seoTitle = $title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime Dizi') . ' | Animexe';
|
||||
$seoTitle = mb_substr($seoTitle, 0, 70);
|
||||
|
||||
$desc = $anime->description
|
||||
? mb_substr(strip_tags($anime->description), 0, 130)
|
||||
: '';
|
||||
$seoDesc = $desc
|
||||
? $desc . ' Animexe\'de Türkçe altyazılı izle.'
|
||||
: $title . '\'yi Türkçe altyazılı veya dublajlı, ücretsiz ve HD kalitede Animexe\'de izleyin.';
|
||||
$seoDesc = mb_substr($seoDesc, 0, 160);
|
||||
|
||||
$keywords = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe, ' . strtolower($title) . ' türkçe altyazılı';
|
||||
|
||||
$anime->update([
|
||||
'seo_title' => $seoTitle,
|
||||
'seo_meta_desc' => $seoDesc,
|
||||
'seo_keywords' => $keywords,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'seo_title' => $seoTitle,
|
||||
'seo_meta_desc' => $seoDesc,
|
||||
'seo_keywords' => $keywords,
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkGenerateAllSeo(Request $request)
|
||||
{
|
||||
$animes = Anime::where('is_published', true)
|
||||
->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))
|
||||
->get(['id', 'title', 'type', 'description']);
|
||||
|
||||
$count = 0;
|
||||
foreach ($animes as $anime) {
|
||||
$title = trim($anime->title);
|
||||
$seoTitle = mb_substr($title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime Dizi') . ' | Animexe', 0, 70);
|
||||
$desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 130) : '';
|
||||
$seoDesc = mb_substr($desc ? $desc . ' Animexe\'de Türkçe izle.' : $title . '\'yi Animexe\'de ücretsiz izleyin.', 0, 160);
|
||||
$keywords = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe altyazılı';
|
||||
|
||||
$anime->update([
|
||||
'seo_title' => $seoTitle,
|
||||
'seo_meta_desc' => $seoDesc,
|
||||
'seo_keywords' => $keywords,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return response()->json(['generated' => $count]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek ile toplu AI SEO üretimi — SEO başlığı olmayan animeleri işler.
|
||||
* Her batch 10 anime, aralarında 1s bekleme (rate limit önlemi).
|
||||
* İstek başına max 10 anime işler; frontend'den tekrar tekrar çağrılarak tamamlanır.
|
||||
*/
|
||||
public function aiBulkGenerateSeo(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$batchSize = min((int)$request->input('batch', 10), 20);
|
||||
|
||||
$animes = Anime::where('is_published', true)
|
||||
->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))
|
||||
->with('genres:id,name')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
$remaining = Anime::where('is_published', true)
|
||||
->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))
|
||||
->count();
|
||||
|
||||
$done = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($animes as $anime) {
|
||||
$result = $ai->generateAnimeSeoMeta($anime);
|
||||
if ($result) {
|
||||
$anime->update([
|
||||
'seo_title' => $result['seo_title'] ?? null,
|
||||
'seo_meta_desc' => $result['seo_meta_desc'] ?? null,
|
||||
'seo_keywords' => $result['seo_keywords'] ?? null,
|
||||
]);
|
||||
$done++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
sleep(1); // DeepSeek rate limit
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'done' => $done,
|
||||
'errors' => $errors,
|
||||
'remaining' => max(0, $remaining - $done),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── PageSpeed ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function pagespeedCheck(Request $request)
|
||||
{
|
||||
$request->validate(['url' => 'required|url', 'strategy' => 'in:mobile,desktop']);
|
||||
|
||||
$apiKey = Setting::get('seo_pagespeed_api_key', '');
|
||||
$url = $request->url;
|
||||
$strategy = $request->input('strategy', 'mobile');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return response()->json(['error' => 'PageSpeed API anahtarı girilmemiş. SEO ayarlarından ekleyin.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$endpoint = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=" . urlencode($url) . "&strategy={$strategy}&key={$apiKey}";
|
||||
$resp = Http::timeout(20)->get($endpoint);
|
||||
|
||||
if (!$resp->successful()) {
|
||||
return response()->json(['error' => 'PageSpeed API hatası: ' . $resp->status()], 422);
|
||||
}
|
||||
|
||||
$data = $resp->json();
|
||||
$categories = $data['lighthouseResult']['categories'] ?? [];
|
||||
$audits = $data['lighthouseResult']['audits'] ?? [];
|
||||
|
||||
$scores = [
|
||||
'performance' => round(($categories['performance']['score'] ?? 0) * 100),
|
||||
'accessibility' => round(($categories['accessibility']['score'] ?? 0) * 100),
|
||||
'seo' => round(($categories['seo']['score'] ?? 0) * 100),
|
||||
'best_practices'=> round(($categories['best-practices']['score'] ?? 0) * 100),
|
||||
];
|
||||
|
||||
$opportunities = [];
|
||||
foreach ($audits as $id => $audit) {
|
||||
if (($audit['score'] ?? 1) < 0.9 && isset($audit['details']['type']) && $audit['details']['type'] === 'opportunity') {
|
||||
$opportunities[] = [
|
||||
'title' => $audit['title'],
|
||||
'description' => $audit['description'] ?? '',
|
||||
'savings' => $audit['details']['overallSavingsMs'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$fcp = $audits['first-contentful-paint']['displayValue'] ?? null;
|
||||
$lcp = $audits['largest-contentful-paint']['displayValue'] ?? null;
|
||||
$cls = $audits['cumulative-layout-shift']['displayValue'] ?? null;
|
||||
$tbt = $audits['total-blocking-time']['displayValue'] ?? null;
|
||||
|
||||
return response()->json([
|
||||
'scores' => $scores,
|
||||
'vitals' => compact('fcp', 'lcp', 'cls', 'tbt'),
|
||||
'opportunities' => array_slice($opportunities, 0, 8),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal Links Audit ──────────────────────────────────────────────────
|
||||
|
||||
public function internalLinksAudit()
|
||||
{
|
||||
// Find animes with no other anime referencing them in descriptions (orphaned)
|
||||
$allAnimes = Anime::where('is_published', true)->get(['id', 'title', 'slug']);
|
||||
$result = [];
|
||||
|
||||
foreach ($allAnimes as $anime) {
|
||||
$mentionedIn = Anime::where('is_published', true)
|
||||
->where('id', '!=', $anime->id)
|
||||
->where('description', 'like', '%' . $anime->title . '%')
|
||||
->count();
|
||||
$result[] = [
|
||||
'id' => $anime->id,
|
||||
'title' => $anime->title,
|
||||
'slug' => $anime->slug,
|
||||
'mentioned_in'=> $mentionedIn,
|
||||
];
|
||||
}
|
||||
|
||||
usort($result, fn($a, $b) => $a['mentioned_in'] <=> $b['mentioned_in']);
|
||||
|
||||
return response()->json(array_slice($result, 0, 50));
|
||||
}
|
||||
|
||||
// ── Duplicate Content ─────────────────────────────────────────────────────
|
||||
|
||||
public function duplicateContent()
|
||||
{
|
||||
$dups = DB::table('animes')
|
||||
->select('description', DB::raw('COUNT(*) as cnt'), DB::raw('GROUP_CONCAT(title ORDER BY title SEPARATOR ", ") as titles'))
|
||||
->where('is_published', true)
|
||||
->whereNotNull('description')
|
||||
->where('description', '!=', '')
|
||||
->groupBy('description')
|
||||
->having('cnt', '>', 1)
|
||||
->get();
|
||||
|
||||
return response()->json($dups);
|
||||
}
|
||||
|
||||
// ── AI SEO Methods ────────────────────────────────────────────────────────
|
||||
|
||||
public function aiChat(Request $request)
|
||||
{
|
||||
$request->validate(['messages' => 'required|array', 'messages.*.role' => 'required|in:user,assistant', 'messages.*.content' => 'required|string|max:4000']);
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar sayfasından ekleyin.'], 422);
|
||||
}
|
||||
|
||||
$total = Anime::where('is_published', true)->count();
|
||||
$covered = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count();
|
||||
$audit = $this->runAudit();
|
||||
$sitemap = $total + Genre::where('is_active', true)->count() + 2;
|
||||
|
||||
$context = [
|
||||
'anime_count' => $total,
|
||||
'seo_covered' => $covered,
|
||||
'seo_score' => $audit['score'],
|
||||
'sitemap_urls' => $sitemap,
|
||||
];
|
||||
|
||||
$reply = $ai->seoChat($request->messages, $context);
|
||||
|
||||
if (!$reply) {
|
||||
return response()->json(['error' => 'DeepSeek yanıt vermedi. API anahtarını kontrol edin.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['reply' => $reply]);
|
||||
}
|
||||
|
||||
public function aiGenerateAnimeSeo(Anime $anime)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$anime->loadMissing('genres');
|
||||
$result = $ai->generateAnimeSeoMeta($anime);
|
||||
|
||||
if (!$result) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
|
||||
}
|
||||
|
||||
$anime->update([
|
||||
'seo_title' => $result['seo_title'] ?? null,
|
||||
'seo_meta_desc' => $result['seo_meta_desc'] ?? null,
|
||||
'seo_keywords' => $result['seo_keywords'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function aiKeywordSuggest(Request $request)
|
||||
{
|
||||
$request->validate(['topic' => 'required|string|max:200']);
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$result = $ai->suggestKeywords($request->topic);
|
||||
if (!$result) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function aiPageAnalysis(Request $request)
|
||||
{
|
||||
$request->validate(['url' => 'required|url', 'title' => 'nullable|string', 'description' => 'nullable|string', 'content' => 'nullable|string']);
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$result = $ai->analyzePageSeo(
|
||||
$request->url,
|
||||
$request->input('title', ''),
|
||||
$request->input('description', ''),
|
||||
$request->input('content', '')
|
||||
);
|
||||
|
||||
if (!$result) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function aiFaqSchema(Request $request)
|
||||
{
|
||||
$request->validate(['anime_id' => 'required|integer|exists:animes,id']);
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$anime = Anime::with('genres')->findOrFail($request->anime_id);
|
||||
$result = $ai->generateFaqSchema($anime);
|
||||
|
||||
if (!$result) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function aiContentStrategy(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$total = Anime::where('is_published', true)->count();
|
||||
$covered = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count();
|
||||
$audit = $this->runAudit();
|
||||
$kwds = SeoKeyword::orderBy('search_volume', 'desc')->take(10)->pluck('keyword')->toArray();
|
||||
|
||||
$strategy = $ai->generateContentStrategy([
|
||||
'seo_score' => $audit['score'],
|
||||
'anime_count' => $total,
|
||||
'seo_covered' => $covered,
|
||||
], $kwds);
|
||||
|
||||
if (!$strategy) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['strategy' => $strategy]);
|
||||
}
|
||||
|
||||
public function aiRobotsTxt(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$domain = Setting::get('seo_canonical_domain', 'animexe.com');
|
||||
$result = $ai->generateRobotsTxt($domain);
|
||||
|
||||
if (!$result) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['robots_txt' => $result]);
|
||||
}
|
||||
|
||||
// ── Toplu Doldurma (Batch) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Yayınlanan animelerin coverage istatistiklerini döndür.
|
||||
* GET /admin/seo/bulk-fill-stats
|
||||
*/
|
||||
public function bulkFillStats()
|
||||
{
|
||||
$total = Anime::where('is_published', true)->count();
|
||||
$hasDesc = Anime::where('is_published', true)->whereNotNull('description')->where('description', '!=', '')->count();
|
||||
$hasSeoTitle = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count();
|
||||
$hasSeoDesc = Anime::where('is_published', true)->whereNotNull('seo_meta_desc')->where('seo_meta_desc', '!=', '')->count();
|
||||
$hasYear = Anime::where('is_published', true)->whereNotNull('release_year')->count();
|
||||
$hasGenres = Anime::where('is_published', true)->has('genres')->count();
|
||||
|
||||
// Kaç adet işlenecek (her mode için)
|
||||
$needsSeo = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))->count();
|
||||
$needsMeta = Anime::where('is_published', true)->where(fn($q) =>
|
||||
$q->whereNull('description')->orWhere('description', '')
|
||||
->orWhereNull('release_year')
|
||||
)->count();
|
||||
$needsAll = Anime::where('is_published', true)->where(fn($q) =>
|
||||
$q->whereNull('seo_title')->orWhere('seo_title', '')
|
||||
->orWhereNull('description')->orWhere('description', '')
|
||||
)->count();
|
||||
|
||||
return response()->json([
|
||||
'total' => $total,
|
||||
'has_desc' => $hasDesc,
|
||||
'has_seo_title'=> $hasSeoTitle,
|
||||
'has_seo_desc' => $hasSeoDesc,
|
||||
'has_year' => $hasYear,
|
||||
'has_genres' => $hasGenres,
|
||||
'needs_seo' => $needsSeo,
|
||||
'needs_meta' => $needsMeta,
|
||||
'needs_all' => $needsAll,
|
||||
'pct_seo' => $total > 0 ? round($hasSeoTitle / $total * 100) : 0,
|
||||
'pct_desc' => $total > 0 ? round($hasDesc / $total * 100) : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toplu doldurma — batch tabanlı, timeout olmaz.
|
||||
*
|
||||
* POST /admin/seo/bulk-fill-batch
|
||||
* body: {
|
||||
* mode: 'template_seo' | 'ai_seo' | 'ai_meta' | 'ai_all',
|
||||
* last_id: 0, // son işlenen anime id'si (pagination için)
|
||||
* batch_size: 5, // kaç anime işlensin
|
||||
* force: false, // dolu alanları da üzerine yaz
|
||||
* }
|
||||
* returns: { done, errors, last_id, remaining, total }
|
||||
*/
|
||||
public function bulkFillBatch(Request $request)
|
||||
{
|
||||
$mode = $request->input('mode', 'template_seo');
|
||||
$lastId = (int) $request->input('last_id', 0);
|
||||
$batchSize = min((int) $request->input('batch_size', 10), 50);
|
||||
$force = $request->boolean('force', false);
|
||||
|
||||
$isAi = str_starts_with($mode, 'ai_');
|
||||
|
||||
if ($isAi) {
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar > DeepSeek ekleyin.'], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Hangi animelere ihtiyaç var?
|
||||
$query = Anime::where('is_published', true)->where('id', '>', $lastId);
|
||||
|
||||
if (!$force) {
|
||||
if ($mode === 'template_seo' || $mode === 'ai_seo') {
|
||||
$query->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''));
|
||||
} elseif ($mode === 'ai_meta') {
|
||||
$query->where(fn($q) =>
|
||||
$q->whereNull('description')->orWhere('description', '')
|
||||
->orWhereNull('release_year')
|
||||
);
|
||||
} elseif ($mode === 'ai_all') {
|
||||
$query->where(fn($q) =>
|
||||
$q->whereNull('seo_title')->orWhere('seo_title', '')
|
||||
->orWhereNull('description')->orWhere('description', '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$total = $query->clone()->count();
|
||||
$animes = $query->with('genres:id,name')->orderBy('id')->limit($batchSize)->get();
|
||||
|
||||
$done = 0;
|
||||
$errors = 0;
|
||||
$newLastId = $lastId;
|
||||
|
||||
foreach ($animes as $anime) {
|
||||
$newLastId = $anime->id;
|
||||
|
||||
try {
|
||||
if ($mode === 'template_seo') {
|
||||
// Hızlı template — AI çağrısı yok
|
||||
$title = trim($anime->title);
|
||||
$seoTitle = mb_substr(
|
||||
$title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime') . ' İzle | Animexe',
|
||||
0, 70
|
||||
);
|
||||
$desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 130) : '';
|
||||
$seoDesc = mb_substr(
|
||||
$desc
|
||||
? $desc . ' Animexe\'de Türkçe altyazılı izle.'
|
||||
: $title . '\'yi Türkçe altyazılı veya dublajlı ücretsiz HD olarak Animexe\'de izleyin.',
|
||||
0, 160
|
||||
);
|
||||
$kwds = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe altyazılı, ' . strtolower($title) . ' türkçe dublaj';
|
||||
|
||||
$updates = ['seo_title' => $seoTitle, 'seo_meta_desc' => $seoDesc, 'seo_keywords' => $kwds];
|
||||
if ($force) {
|
||||
$anime->update($updates);
|
||||
} else {
|
||||
$anime->update(array_filter($updates, fn($v) => !empty($v)));
|
||||
}
|
||||
$done++;
|
||||
|
||||
} elseif ($mode === 'ai_seo') {
|
||||
$result = $ai->generateAnimeSeoMeta($anime);
|
||||
if ($result) {
|
||||
$updates = array_filter([
|
||||
'seo_title' => $result['seo_title'] ?? null,
|
||||
'seo_meta_desc' => $result['seo_meta_desc'] ?? null,
|
||||
'seo_keywords' => $result['seo_keywords'] ?? null,
|
||||
]);
|
||||
if ($force || empty($anime->seo_title)) {
|
||||
$anime->update($updates);
|
||||
}
|
||||
$done++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
|
||||
} elseif ($mode === 'ai_meta') {
|
||||
$meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? '');
|
||||
if ($meta) {
|
||||
$this->applyAnimeMeta($anime, $meta, $force);
|
||||
$done++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
|
||||
} elseif ($mode === 'ai_all') {
|
||||
// Meta + SEO birlikte — 2 AI çağrısı
|
||||
$meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? '');
|
||||
if ($meta) {
|
||||
$this->applyAnimeMeta($anime->fresh(), $meta, $force);
|
||||
}
|
||||
|
||||
$anime->loadMissing('genres');
|
||||
$seoResult = $ai->generateAnimeSeoMeta($anime->fresh(['genres']));
|
||||
if ($seoResult) {
|
||||
$anime->update(array_filter([
|
||||
'seo_title' => $seoResult['seo_title'] ?? null,
|
||||
'seo_meta_desc' => $seoResult['seo_meta_desc'] ?? null,
|
||||
'seo_keywords' => $seoResult['seo_keywords'] ?? null,
|
||||
]));
|
||||
$done++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$errors++;
|
||||
\Illuminate\Support\Facades\Log::warning("[bulkFillBatch] Hata [{$anime->id}] {$anime->title}: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// AI çağrıları arası kısa bekleme (rate limit önlemi)
|
||||
if ($isAi && $done + $errors < count($animes)) {
|
||||
usleep(800_000); // 0.8s
|
||||
}
|
||||
}
|
||||
|
||||
// Kalan animeler (bu batch'ten sonra)
|
||||
$remaining = max(0, $total - $done - $errors);
|
||||
|
||||
return response()->json([
|
||||
'done' => $done,
|
||||
'errors' => $errors,
|
||||
'last_id' => $newLastId,
|
||||
'remaining' => $remaining,
|
||||
'total' => $total,
|
||||
'finished' => $animes->count() < $batchSize || $remaining === 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function applyAnimeMeta(Anime $anime, array $meta, bool $force): void
|
||||
{
|
||||
$updates = [];
|
||||
$fill = function (string $field, $value) use ($anime, $force, &$updates) {
|
||||
if ($value === null || $value === '') return;
|
||||
if ($force || empty($anime->$field)) $updates[$field] = $value;
|
||||
};
|
||||
|
||||
$fill('description', $meta['description'] ?? null);
|
||||
$fill('release_year', $meta['release_year'] ?? null);
|
||||
$fill('studio', $meta['studio'] ?? null);
|
||||
$fill('type', $meta['type'] ?? null);
|
||||
$fill('status', $meta['status'] ?? null);
|
||||
$fill('title_en', $meta['title_en'] ?? null);
|
||||
$fill('title_jp', $meta['title_jp'] ?? null);
|
||||
if (!empty($meta['rating']) && ($force || !$anime->rating)) {
|
||||
$updates['rating'] = min(10, max(0, (float) $meta['rating']));
|
||||
}
|
||||
|
||||
if (!empty($updates)) $anime->update($updates);
|
||||
|
||||
if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) {
|
||||
$ids = [];
|
||||
foreach ($meta['genres'] as $name) {
|
||||
$g = \App\Models\Genre::firstOrCreate(
|
||||
['name' => $name],
|
||||
['slug' => \Illuminate\Support\Str::slug($name)]
|
||||
);
|
||||
$ids[] = $g->id;
|
||||
}
|
||||
if ($ids) {
|
||||
$force ? $anime->genres()->sync($ids) : $anime->genres()->syncWithoutDetaching($ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function runAudit(): array
|
||||
{
|
||||
$total = Anime::where('is_published', true)->count();
|
||||
$noDesc = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('description')->orWhere('description', ''))->count();
|
||||
$noCover = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('cover_image')->orWhere('cover_image', ''))->count();
|
||||
$shortDesc = Anime::where('is_published', true)->whereNotNull('description')->whereRaw('CHAR_LENGTH(description) < 100')->count();
|
||||
$noSlug = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('slug')->orWhere('slug', ''))->count();
|
||||
$noSeoTitle = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))->count();
|
||||
|
||||
$seo = Setting::where('key', 'like', 'seo_%')->pluck('value', 'key');
|
||||
$robots = File::exists(public_path('robots.txt')) ? File::get(public_path('robots.txt')) : '';
|
||||
$hasSitemap = file_exists(public_path('sitemap.xml'));
|
||||
|
||||
$dupDesc = DB::table('animes')->select('description')->where('is_published', true)
|
||||
->whereNotNull('description')->where('description', '!=', '')
|
||||
->groupBy('description')->havingRaw('COUNT(*) > 1')->count();
|
||||
|
||||
$checks = [];
|
||||
|
||||
// Site config
|
||||
$checks[] = $this->check('site_name', !empty($seo['seo_site_name']), 'Site Adı Ayarlandı', 'Site adı eksik', 10);
|
||||
$checks[] = $this->check('home_title', !empty($seo['seo_home_title']), 'Anasayfa Başlığı Mevcut', 'Anasayfa başlığı eksik', 10);
|
||||
$checks[] = $this->check('home_desc', !empty($seo['seo_home_description']), 'Anasayfa Meta Açıklaması Mevcut', 'Anasayfa meta açıklaması eksik', 10);
|
||||
$checks[] = $this->check('title_length', strlen($seo['seo_home_title'] ?? '') <= 70 && strlen($seo['seo_home_title'] ?? '') >= 30, 'Başlık Uzunluğu İdeal (30–70)', 'Başlık çok kısa veya çok uzun', 5);
|
||||
$checks[] = $this->check('desc_length', strlen($seo['seo_home_description'] ?? '') <= 160 && strlen($seo['seo_home_description'] ?? '') >= 100, 'Meta Açıklama Uzunluğu İdeal', 'Meta açıklama 100–160 karakter arası olmalı', 5);
|
||||
$checks[] = $this->check('og_image', !empty($seo['seo_og_image']), 'OG Görseli Tanımlandı', 'Varsayılan OG görseli eksik', 8);
|
||||
$checks[] = $this->check('canonical', !empty($seo['seo_canonical_domain']), 'Canonical Domain Ayarlı', 'Canonical domain ayarlanmamış', 8);
|
||||
$checks[] = $this->check('analytics', !empty($seo['seo_google_analytics']), 'Google Analytics Entegre', 'GA4 ID girilmemiş', 7);
|
||||
$checks[] = $this->check('gsc', !empty($seo['seo_gsc_verification']), 'Search Console Doğrulandı', 'GSC doğrulama kodu eksik', 7);
|
||||
$checks[] = $this->check('schema', ($seo['seo_enable_schema'] ?? '1') === '1', 'Schema.org İşaretleme Aktif', 'Schema.org işaretleme kapalı', 7);
|
||||
$checks[] = $this->check('faq_schema', ($seo['seo_enable_faq_schema'] ?? '1') === '1', 'FAQ Schema Aktif', 'FAQ şema kapalı (rich snippets kayıp)', 5);
|
||||
$checks[] = $this->check('video_schema', ($seo['seo_enable_video_schema'] ?? '1') === '1', 'Video Schema Aktif', 'Video şema kapalı', 5);
|
||||
|
||||
// Technical SEO
|
||||
$checks[] = $this->check('sitemap', $hasSitemap, 'Sitemap Mevcut', 'sitemap.xml bulunamadı', 8);
|
||||
$checks[] = $this->check('robots_exists', !empty($robots), 'robots.txt Mevcut', 'robots.txt yok veya boş', 6);
|
||||
$checks[] = $this->check('robots_admin', str_contains($robots, 'Disallow: /admin'), 'robots.txt Admin Kapalı', 'robots.txt /admin dizini kapalı değil', 6);
|
||||
$checks[] = $this->check('noindex_search', ($seo['seo_noindex_search'] ?? '1') === '1', 'Arama Sayfası Noindex', 'Arama sayfası indexleniyor', 5);
|
||||
$checks[] = $this->check('twitter', !empty($seo['seo_twitter_site']), 'Twitter Card Yapılandırıldı', 'Twitter hesabı girilmemiş', 4);
|
||||
$checks[] = $this->check('bing', !empty($seo['seo_bing_verification']), 'Bing Webmaster Doğrulandı', 'Bing doğrulama kodu eksik', 3);
|
||||
|
||||
// Content quality
|
||||
$checks[] = $this->check('anime_desc', $noDesc === 0, 'Tüm Animelerin Açıklaması Var', "{$noDesc} animenin açıklaması eksik", 8);
|
||||
$checks[] = $this->check('anime_cover', $noCover === 0, 'Tüm Animelerin Kapağı Var', "{$noCover} animenin görseli eksik", 7);
|
||||
$checks[] = $this->check('desc_quality', $shortDesc < max(1, $total * 0.1), 'Açıklama Kalitesi İyi', "{$shortDesc} animenin açıklaması çok kısa", 4);
|
||||
$checks[] = $this->check('slug_coverage', $noSlug === 0, 'Tüm Animeler URL Slug\'a Sahip', "{$noSlug} animenin slug\'u eksik", 6);
|
||||
$checks[] = $this->check('seo_titles', $noSeoTitle < $total * 0.2, 'Anime SEO Başlıkları Yeterli', "{$noSeoTitle} animenin SEO başlığı eksik", 6);
|
||||
$checks[] = $this->check('dup_desc', $dupDesc === 0, 'Tekrarlayan İçerik Yok', "{$dupDesc} grup tekrarlayan açıklama var", 5);
|
||||
|
||||
$score = $weight = 0;
|
||||
foreach ($checks as $c) {
|
||||
$weight += $c['weight'];
|
||||
if ($c['pass']) $score += $c['weight'];
|
||||
}
|
||||
|
||||
$scorePercent = $weight > 0 ? round(($score / $weight) * 100) : 0;
|
||||
|
||||
return [
|
||||
'score' => $scorePercent,
|
||||
'checks' => $checks,
|
||||
'totals' => ['total' => $total, 'noDesc' => $noDesc, 'noCover' => $noCover, 'shortDesc' => $shortDesc, 'noSeoTitle' => $noSeoTitle, 'dupDesc' => $dupDesc],
|
||||
'pass_count' => collect($checks)->where('pass', true)->count(),
|
||||
'fail_count' => collect($checks)->where('pass', false)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
private function check(string $id, bool $pass, string $passMsg, string $failMsg, int $weight): array
|
||||
{
|
||||
return compact('id', 'pass', 'passMsg', 'failMsg', 'weight');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\TestMail;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SettingController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$settings = Setting::all()->keyBy('key');
|
||||
return view('admin.settings.index', compact('settings'));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$data = $request->except(['_token', '_method', 'intro_video_file']);
|
||||
|
||||
// Checkbox keys: explicitly set to '0' when not present in request
|
||||
$booleanKeys = [
|
||||
'comments_enabled', 'comments_require_approval',
|
||||
'intro_enabled', 'nav_show_messages',
|
||||
'ai_auto_description', 'ai_auto_seo',
|
||||
'premium_free_mode',
|
||||
'ads_enabled',
|
||||
];
|
||||
foreach ($booleanKeys as $k) {
|
||||
if (!array_key_exists($k, $data)) {
|
||||
$data[$k] = '0';
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
Setting::set($key, $value);
|
||||
}
|
||||
|
||||
cache()->forget('premium_free_mode');
|
||||
|
||||
return back()->with('success', 'Ayarlar kaydedildi.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Favicon yükle — public/favicon.{ext} olarak kaydet, setting'e yaz.
|
||||
*/
|
||||
public function uploadFavicon(Request $request)
|
||||
{
|
||||
$request->validate(['favicon_file' => 'required|file|mimes:png,ico,svg,jpg,jpeg|max:2048']);
|
||||
|
||||
$file = $request->file('favicon_file');
|
||||
$ext = strtolower($file->getClientOriginalExtension()) ?: 'png';
|
||||
$dest = public_path('favicon.' . $ext);
|
||||
|
||||
// Eski favicon dosyalarını temizle
|
||||
foreach (['png', 'ico', 'svg', 'jpg', 'jpeg'] as $e) {
|
||||
$old = public_path('favicon.' . $e);
|
||||
if (file_exists($old) && $old !== $dest) @unlink($old);
|
||||
}
|
||||
|
||||
$file->move(public_path(), 'favicon.' . $ext);
|
||||
|
||||
$url = '/favicon.' . $ext;
|
||||
Setting::set('site_favicon', $url);
|
||||
|
||||
return back()->with('favicon_success', 'Favicon güncellendi.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Intro videoyu BunnyCDN Storage'a yükle, URL'yi ayarlara kaydet.
|
||||
*/
|
||||
public function uploadIntro(Request $request)
|
||||
{
|
||||
$request->validate(['intro_video_file' => 'required|file|mimes:mp4,webm|max:204800']); // max 200MB
|
||||
|
||||
$zone = Setting::get('bunnycdn_zone');
|
||||
$apiKey = Setting::get('bunnycdn_api_key');
|
||||
$pullUrl = rtrim(Setting::get('bunnycdn_pull_url', ''), '/');
|
||||
|
||||
if (!$zone || !$apiKey || !$pullUrl) {
|
||||
return back()->with('intro_error', 'Önce BunnyCDN ayarlarını kaydedin (Zone, API Key, Pull URL).');
|
||||
}
|
||||
|
||||
$file = $request->file('intro_video_file');
|
||||
$ext = $file->getClientOriginalExtension() ?: 'mp4';
|
||||
$fileName = 'intro/site-intro.' . $ext;
|
||||
$apiUrl = "https://storage.bunnycdn.com/{$zone}/{$fileName}";
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'AccessKey' => $apiKey,
|
||||
'Content-Type' => $file->getMimeType(),
|
||||
])->withBody(file_get_contents($file->getRealPath()), $file->getMimeType())
|
||||
->put($apiUrl);
|
||||
|
||||
if (!$response->successful()) {
|
||||
return back()->with('intro_error', 'BunnyCDN yükleme başarısız: ' . $response->status() . ' — ' . $response->body());
|
||||
}
|
||||
|
||||
$cdnUrl = $pullUrl . '/' . $fileName;
|
||||
Setting::set('intro_video_url', $cdnUrl, 'intro');
|
||||
|
||||
return back()->with('intro_success', 'Intro video yüklendi ve URL kaydedildi.');
|
||||
}
|
||||
|
||||
public function testMail(Request $request)
|
||||
{
|
||||
$request->validate(['test_mail_to' => 'required|email'], [
|
||||
'test_mail_to.required' => 'Alıcı e-posta adresi zorunludur.',
|
||||
'test_mail_to.email' => 'Geçerli bir e-posta adresi girin.',
|
||||
]);
|
||||
|
||||
// DB'deki ayarları runtime'da uygula
|
||||
$keys = ['mail_host','mail_port','mail_username','mail_password',
|
||||
'mail_from_address','mail_from_name','mail_encryption'];
|
||||
$rows = Setting::whereIn('key', $keys)->pluck('value', 'key');
|
||||
|
||||
if (!$rows->get('mail_host')) {
|
||||
return back()->with('mail_error', 'Önce SMTP ayarlarını kaydedin.');
|
||||
}
|
||||
|
||||
$encryption = strtolower($rows->get('mail_encryption', 'tls'));
|
||||
$port = (int) $rows->get('mail_port', 587);
|
||||
|
||||
Config::set('mail.mailers.smtp.host', $rows->get('mail_host'));
|
||||
Config::set('mail.mailers.smtp.port', $port);
|
||||
Config::set('mail.mailers.smtp.username', $rows->get('mail_username'));
|
||||
Config::set('mail.mailers.smtp.password', $rows->get('mail_password'));
|
||||
Config::set('mail.mailers.smtp.encryption', $encryption);
|
||||
Config::set('mail.mailers.smtp.timeout', 15);
|
||||
Config::set('mail.mailers.smtp.stream', [
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true,
|
||||
],
|
||||
]);
|
||||
Config::set('mail.from.address', $rows->get('mail_from_address'));
|
||||
Config::set('mail.from.name', $rows->get('mail_from_name', config('app.name')));
|
||||
Config::set('mail.default', 'smtp');
|
||||
Mail::purge('smtp');
|
||||
|
||||
// Socket timeout — PHP default 60s, düşür
|
||||
$prevTimeout = ini_get('default_socket_timeout');
|
||||
ini_set('default_socket_timeout', '15');
|
||||
set_time_limit(30);
|
||||
|
||||
try {
|
||||
Mail::to($request->test_mail_to)->send(new TestMail());
|
||||
ini_set('default_socket_timeout', $prevTimeout);
|
||||
return back()->with('mail_success', 'Test e-postası başarıyla gönderildi → ' . $request->test_mail_to);
|
||||
} catch (\Throwable $e) {
|
||||
ini_set('default_socket_timeout', $prevTimeout);
|
||||
return back()->with('mail_error', 'Gönderi başarısız: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MembershipPlan;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SubscriptionController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Subscription::with(['user', 'plan'])->latest();
|
||||
|
||||
if ($request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
if ($request->search) {
|
||||
$query->whereHas('user', fn($q) =>
|
||||
$q->where('name', 'like', '%' . $request->search . '%')
|
||||
->orWhere('email', 'like', '%' . $request->search . '%')
|
||||
);
|
||||
}
|
||||
|
||||
$subscriptions = $query->paginate(30)->withQueryString();
|
||||
return view('admin.subscriptions.index', compact('subscriptions'));
|
||||
}
|
||||
|
||||
public function show(Subscription $subscription)
|
||||
{
|
||||
$subscription->load(['user', 'plan']);
|
||||
return view('admin.subscriptions.show', compact('subscription'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// Manuel abonelik ekleme (UserController.givePremium ile aynı mantık)
|
||||
$request->validate([
|
||||
'user_id' => 'required|exists:users,id',
|
||||
'plan_id' => 'required|exists:membership_plans,id',
|
||||
]);
|
||||
|
||||
$plan = MembershipPlan::findOrFail($request->plan_id);
|
||||
$user = User::findOrFail($request->user_id);
|
||||
|
||||
$hasEverSubscribed = Subscription::where('user_id', $user->id)->exists();
|
||||
$bonusDays = (!$hasEverSubscribed && ($plan->trial_days ?? 0) > 0) ? $plan->trial_days : 0;
|
||||
$expiresAt = now()->addDays($plan->duration_days + $bonusDays);
|
||||
|
||||
$user->update(['membership' => 'premium', 'premium_expires_at' => $expiresAt]);
|
||||
|
||||
Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'status' => 'active',
|
||||
'starts_at' => now(),
|
||||
'expires_at' => $expiresAt,
|
||||
'payment_method' => 'manual',
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Abonelik eklendi.');
|
||||
}
|
||||
|
||||
public function destroy(Subscription $subscription)
|
||||
{
|
||||
$subscription->update(['status' => 'cancelled']);
|
||||
return back()->with('success', 'Abonelik iptal edildi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TrendingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Trend yönetim sayfası.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$manual = Anime::where('is_trending', true)
|
||||
->where('is_published', true)
|
||||
->orderBy('trending_order')
|
||||
->get();
|
||||
|
||||
$autoTrending = $this->getAutoTrending(20);
|
||||
|
||||
// Son skor hesaplama zamanı
|
||||
$lastComputed = cache()->get('trending_score_computed_at');
|
||||
|
||||
return view('admin.trending.index', compact('manual', 'autoTrending', 'lastComputed'));
|
||||
}
|
||||
|
||||
// ── Manuel trending yönetimi ──────────────────────────────────────────────
|
||||
|
||||
public function toggle(Request $request, Anime $anime)
|
||||
{
|
||||
$newState = !$anime->is_trending;
|
||||
|
||||
if ($newState) {
|
||||
$maxOrder = Anime::where('is_trending', true)->max('trending_order') ?? 0;
|
||||
$anime->update([
|
||||
'is_trending' => true,
|
||||
'trending_order' => $maxOrder + 1,
|
||||
'trending_score' => $anime->trending_score + 200, // Manuel boost
|
||||
]);
|
||||
} else {
|
||||
$anime->update(['is_trending' => false, 'trending_order' => 0]);
|
||||
$this->reorderAll();
|
||||
}
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['ok' => true, 'is_trending' => $newState]);
|
||||
}
|
||||
return back()->with('success', $newState
|
||||
? '"'.$anime->title.'" trend listesine eklendi.'
|
||||
: '"'.$anime->title.'" trend listesinden çıkarıldı.');
|
||||
}
|
||||
|
||||
public function reorder(Request $request)
|
||||
{
|
||||
$request->validate(['ids' => 'required|array', 'ids.*' => 'integer']);
|
||||
foreach ($request->ids as $i => $id) {
|
||||
Anime::where('id', $id)->update(['trending_order' => $i + 1]);
|
||||
}
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function move(Request $request, Anime $anime)
|
||||
{
|
||||
$direction = $request->input('direction');
|
||||
$current = $anime->trending_order;
|
||||
|
||||
if ($direction === 'up' && $current > 1) {
|
||||
$swap = Anime::where('is_trending', true)->where('trending_order', $current - 1)->first();
|
||||
if ($swap) { $swap->update(['trending_order' => $current]); $anime->update(['trending_order' => $current - 1]); }
|
||||
} elseif ($direction === 'down') {
|
||||
$swap = Anime::where('is_trending', true)->where('trending_order', $current + 1)->first();
|
||||
if ($swap) { $swap->update(['trending_order' => $current]); $anime->update(['trending_order' => $current + 1]); }
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function search(Request $request)
|
||||
{
|
||||
$results = Anime::where('is_published', true)
|
||||
->where('title', 'like', "%{$request->query('q', '')}%")
|
||||
->select('id', 'title', 'cover_image', 'is_trending', 'release_year', 'trending_score')
|
||||
->take(8)->get()
|
||||
->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'cover' => $a->coverUrl,
|
||||
'is_trending' => (bool) $a->is_trending,
|
||||
'year' => $a->release_year,
|
||||
'trending_score'=> round($a->trending_score, 1),
|
||||
]);
|
||||
return response()->json(['results' => $results]);
|
||||
}
|
||||
|
||||
// ── Trend Skoru Hesaplama (YouTube algoritması) ───────────────────────────
|
||||
|
||||
/**
|
||||
* Admin butonu: tüm animelerin trend skorunu hesapla ve kaydet.
|
||||
* POST /admin/trending/compute-scores
|
||||
*/
|
||||
public function computeScores()
|
||||
{
|
||||
$count = self::runScoreComputation();
|
||||
cache()->put('trending_score_computed_at', now()->toDateTimeString(), 3600);
|
||||
cache()->flush(); // Anasayfa cache'ini temizle
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'updated' => $count,
|
||||
'message' => "{$count} anime için trend skoru güncellendi.",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube-benzeri Trend Skoru Algoritması
|
||||
* ─────────────────────────────────────────
|
||||
* score = view_24h × 12 ← Son 24 saatin izlenme sayısı (en yüksek ağırlık)
|
||||
* + view_7d × 4 ← Son 7 günün izlenme sayısı
|
||||
* + view_30d × 1 ← Son 30 günün izlenme sayısı
|
||||
* + watch_minutes_7d × 0.8 ← Gerçek izleme dakikası (kalite sinyali)
|
||||
* + new_episode_bonus ← Yeni bölüm varsa büyük bonus
|
||||
* + rating × 4 ← Kalite sinyali
|
||||
* + manual_boost ← Manuel trending = +250
|
||||
*
|
||||
* Decay: Eski içeriklerin skoru doğal olarak düşer (view_count azalır).
|
||||
* Herhangi bir yeni bölüm veya izlenme olmadan skor sıfıra yaklaşır.
|
||||
*/
|
||||
public static function runScoreComputation(): int
|
||||
{
|
||||
$now = now();
|
||||
$day1 = $now->copy()->subDay();
|
||||
$day7 = $now->copy()->subDays(7);
|
||||
$day30 = $now->copy()->subDays(30);
|
||||
|
||||
$animes = DB::table('animes')
|
||||
->where('is_published', true)
|
||||
->select('id', 'rating', 'is_trending', 'status')
|
||||
->get();
|
||||
|
||||
$updated = 0;
|
||||
|
||||
foreach ($animes as $anime) {
|
||||
// ── Bölüm izlenme sayıları (view_count zaman dilimine göre) ──────
|
||||
// Episode.updated_at → son izleme zamanının proxy'si
|
||||
$views = DB::table('episodes')
|
||||
->where('anime_id', $anime->id)
|
||||
->where('is_published', true)
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN updated_at >= ? THEN view_count ELSE 0 END) as v24h,
|
||||
SUM(CASE WHEN updated_at >= ? THEN view_count ELSE 0 END) as v7d,
|
||||
SUM(CASE WHEN updated_at >= ? THEN view_count ELSE 0 END) as v30d
|
||||
", [$day1, $day7, $day30])
|
||||
->first();
|
||||
|
||||
// ── Gerçek izleme dakikası (analytics_watch_events) ─────────────
|
||||
$watchMinutes = 0;
|
||||
try {
|
||||
$watchMinutes = DB::table('analytics_watch_events')
|
||||
->where('anime_id', $anime->id)
|
||||
->where('created_at', '>=', $day7)
|
||||
->sum('seconds_watched') / 60;
|
||||
} catch (\Throwable) {}
|
||||
|
||||
// ── Yeni bölüm bonusu ────────────────────────────────────────────
|
||||
$newEpBonus = 0;
|
||||
$latestEpDate = DB::table('episodes')
|
||||
->where('anime_id', $anime->id)
|
||||
->where('is_published', true)
|
||||
->max('created_at');
|
||||
|
||||
if ($latestEpDate) {
|
||||
$epAge = now()->diffInHours($latestEpDate);
|
||||
if ($epAge <= 24) $newEpBonus = 80; // Bugün yeni bölüm → çok büyük boost
|
||||
elseif ($epAge <= 72) $newEpBonus = 40; // Son 3 gün
|
||||
elseif ($epAge <= 168) $newEpBonus = 15; // Son 7 gün
|
||||
elseif ($epAge <= 720) $newEpBonus = 5; // Son 30 gün
|
||||
}
|
||||
|
||||
// ── Ongoing bonus ─────────────────────────────────────────────────
|
||||
$ongoingBonus = ($anime->status === 'ongoing') ? 10 : 0;
|
||||
|
||||
// ── Manuel trending boost ─────────────────────────────────────────
|
||||
$manualBoost = $anime->is_trending ? 250 : 0;
|
||||
|
||||
// ── Skor hesapla ─────────────────────────────────────────────────
|
||||
$score =
|
||||
($views->v24h ?? 0) * 12 +
|
||||
($views->v7d ?? 0) * 4 +
|
||||
($views->v30d ?? 0) * 1 +
|
||||
$watchMinutes * 0.8 +
|
||||
$newEpBonus +
|
||||
$ongoingBonus +
|
||||
((float)($anime->rating ?? 5)) * 4 +
|
||||
$manualBoost;
|
||||
|
||||
DB::table('animes')
|
||||
->where('id', $anime->id)
|
||||
->update(['trending_score' => round($score, 2)]);
|
||||
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-trending: trending_score'a göre sırala.
|
||||
* Fallback: score kolonu yoksa eski yönteme dön.
|
||||
*/
|
||||
public static function getAutoTrending(int $limit = 12): \Illuminate\Support\Collection
|
||||
{
|
||||
try {
|
||||
return Anime::where('is_published', true)
|
||||
->orderByDesc('trending_score')
|
||||
->take($limit)
|
||||
->get();
|
||||
} catch (\Throwable) {
|
||||
// trending_score kolonu henüz oluşturulmamış → eski yöntem
|
||||
return Anime::where('is_published', true)
|
||||
->withSum(['episodes as recent_views' => fn($q) =>
|
||||
$q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30))
|
||||
], 'view_count')
|
||||
->orderByDesc('recent_views')
|
||||
->take($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
private function reorderAll(): void
|
||||
{
|
||||
$animes = Anime::where('is_trending', true)->orderBy('trending_order')->get();
|
||||
foreach ($animes as $i => $a) {
|
||||
$a->update(['trending_order' => $i + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\UserActivityLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UserAnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$tab = $request->input('tab', 'overview'); // overview | bots | activity | country
|
||||
$country = $request->input('country');
|
||||
$period = (int) $request->input('period', 30); // days
|
||||
$from = now()->subDays($period);
|
||||
|
||||
// ── Overview stats ────────────────────────────────────────────────────
|
||||
$totalReal = User::where('role', '!=', 'admin')->count();
|
||||
$newReal = User::where('role', '!=', 'admin')->where('created_at', '>=', $from)->count();
|
||||
$active30 = DB::table('analytics_pageviews')
|
||||
->where('is_bot', 0)->where('created_at', '>=', $from)
|
||||
->distinct('user_id')->whereNotNull('user_id')->count('user_id');
|
||||
$botViews = DB::table('analytics_pageviews')
|
||||
->where('is_bot', 1)->where('created_at', '>=', $from)->count();
|
||||
$realViews = DB::table('analytics_pageviews')
|
||||
->where('is_bot', 0)->where('created_at', '>=', $from)->count();
|
||||
|
||||
// ── Daily new users (chart) ───────────────────────────────────────────
|
||||
$dailyNew = DB::table('users')
|
||||
->selectRaw('DATE(created_at) as day, COUNT(*) as cnt')
|
||||
->where('role', '!=', 'admin')
|
||||
->where('created_at', '>=', $from)
|
||||
->groupBy('day')->orderBy('day')
|
||||
->pluck('cnt', 'day');
|
||||
|
||||
// ── Country breakdown ─────────────────────────────────────────────────
|
||||
$countriesQuery = DB::table('analytics_pageviews')
|
||||
->selectRaw('country, COUNT(*) as views, COUNT(DISTINCT user_id) as users')
|
||||
->where('is_bot', 0)
|
||||
->where('created_at', '>=', $from)
|
||||
->whereNotNull('country')
|
||||
->groupBy('country')
|
||||
->orderByDesc('views');
|
||||
if ($country) $countriesQuery->where('country', $country);
|
||||
$countries = $countriesQuery->limit(50)->get();
|
||||
|
||||
// ── Bot analysis ──────────────────────────────────────────────────────
|
||||
$botStats = DB::table('analytics_bot_logs')
|
||||
->selectRaw('bot_name, action, COUNT(*) as cnt')
|
||||
->where('created_at', '>=', $from)
|
||||
->groupBy('bot_name', 'action')
|
||||
->orderByDesc('cnt')
|
||||
->limit(30)->get();
|
||||
|
||||
$topBotIps = DB::table('analytics_bot_logs')
|
||||
->selectRaw('ip, COUNT(*) as cnt')
|
||||
->where('created_at', '>=', $from)
|
||||
->groupBy('ip')
|
||||
->orderByDesc('cnt')
|
||||
->limit(20)->get();
|
||||
|
||||
$blockedIps = DB::table('blocked_ips')
|
||||
->orderByDesc('blocked_at')
|
||||
->limit(30)->get();
|
||||
|
||||
// ── User activity log ─────────────────────────────────────────────────
|
||||
$actQuery = UserActivityLog::with('user:id,name,username,avatar')
|
||||
->where('created_at', '>=', $from);
|
||||
if ($country) $actQuery->where('country', $country);
|
||||
if ($request->input('user_id')) $actQuery->where('user_id', $request->input('user_id'));
|
||||
if ($request->input('action')) $actQuery->where('action', $request->input('action'));
|
||||
$actQuery->orderByDesc('created_at');
|
||||
$actLogs = $actQuery->paginate(50)->withQueryString();
|
||||
|
||||
// ── Top active users ──────────────────────────────────────────────────
|
||||
$topUsers = DB::table('user_activity_logs')
|
||||
->selectRaw('user_id, COUNT(*) as actions')
|
||||
->where('is_bot', 0)->whereNotNull('user_id')
|
||||
->where('created_at', '>=', $from)
|
||||
->groupBy('user_id')->orderByDesc('actions')
|
||||
->limit(10)->get();
|
||||
$topUserIds = $topUsers->pluck('user_id');
|
||||
$topUserMap = User::whereIn('id', $topUserIds)->get()->keyBy('id');
|
||||
|
||||
// ── Action breakdown ──────────────────────────────────────────────────
|
||||
$actionBreakdown = DB::table('user_activity_logs')
|
||||
->selectRaw('action, COUNT(*) as cnt')
|
||||
->where('is_bot', 0)
|
||||
->where('created_at', '>=', $from)
|
||||
->groupBy('action')->orderByDesc('cnt')
|
||||
->get();
|
||||
|
||||
// ── Device breakdown ──────────────────────────────────────────────────
|
||||
$deviceBreakdown = DB::table('analytics_pageviews')
|
||||
->selectRaw('device, COUNT(*) as cnt')
|
||||
->where('is_bot', 0)->where('created_at', '>=', $from)
|
||||
->groupBy('device')->orderByDesc('cnt')->get();
|
||||
|
||||
return view('admin.analytics.users', compact(
|
||||
'tab', 'period', 'country',
|
||||
'totalReal', 'newReal', 'active30', 'botViews', 'realViews',
|
||||
'dailyNew', 'countries', 'botStats', 'topBotIps', 'blockedIps',
|
||||
'actLogs', 'topUsers', 'topUserMap', 'actionBreakdown', 'deviceBreakdown'
|
||||
));
|
||||
}
|
||||
|
||||
public function userDetail(Request $request, User $user)
|
||||
{
|
||||
$period = (int) $request->input('period', 30);
|
||||
$from = now()->subDays($period);
|
||||
|
||||
$logs = UserActivityLog::where('user_id', $user->id)
|
||||
->where('created_at', '>=', $from)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(50)->withQueryString();
|
||||
|
||||
$actBreakdown = DB::table('user_activity_logs')
|
||||
->selectRaw('action, COUNT(*) as cnt')
|
||||
->where('user_id', $user->id)->where('created_at', '>=', $from)
|
||||
->groupBy('action')->orderByDesc('cnt')->get();
|
||||
|
||||
$pageviews = DB::table('analytics_pageviews')
|
||||
->where('user_id', $user->id)->where('created_at', '>=', $from)
|
||||
->orderByDesc('created_at')->limit(100)->get();
|
||||
|
||||
$watchEvents = DB::table('analytics_watch_events as we')
|
||||
->join('episodes as e', 'e.id', '=', 'we.episode_id')
|
||||
->join('animes as a', 'a.id', '=', 'we.anime_id')
|
||||
->selectRaw('we.created_at, a.title as anime_title, e.episode_number, we.percent_complete, we.seconds_watched')
|
||||
->where('we.user_id', $user->id)->where('we.created_at', '>=', $from)
|
||||
->orderByDesc('we.created_at')->limit(50)->get();
|
||||
|
||||
return view('admin.analytics.user-detail', compact(
|
||||
'user', 'logs', 'actBreakdown', 'pageviews', 'watchEvents', 'period'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MembershipPlan;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = User::latest();
|
||||
|
||||
if ($request->search) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('name', 'like', '%' . $request->search . '%')
|
||||
->orWhere('email', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
if ($request->membership) {
|
||||
$query->where('membership', $request->membership);
|
||||
}
|
||||
if ($request->role) {
|
||||
$query->where('role', $request->role);
|
||||
}
|
||||
if ($request->banned) {
|
||||
$query->where('is_banned', true);
|
||||
}
|
||||
|
||||
$users = $query->paginate(30)->withQueryString();
|
||||
return view('admin.users.index', compact('users'));
|
||||
}
|
||||
|
||||
public function show(User $user)
|
||||
{
|
||||
$user->load(['subscriptions.plan', 'comments']);
|
||||
$plans = MembershipPlan::where('is_active', true)->get();
|
||||
return view('admin.users.show', compact('user', 'plans'));
|
||||
}
|
||||
|
||||
public function edit(User $user)
|
||||
{
|
||||
return view('admin.users.edit', compact('user'));
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user)
|
||||
{
|
||||
if ($user->isAdmin() && !auth()->user()->isAdmin()) {
|
||||
return back()->with('error', 'Admin kullanıcı düzenlenemez.');
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email,' . $user->id,
|
||||
'role' => 'required|in:user,moderator,admin',
|
||||
'password' => 'nullable|string|min:8',
|
||||
'admin_badge' => 'nullable|string|max:32',
|
||||
]);
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
} else {
|
||||
unset($data['password']);
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
return redirect()->route('admin.users.show', $user)->with('success', 'Kullanıcı güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(User $user)
|
||||
{
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', 'Kendinizi silemezsiniz.');
|
||||
}
|
||||
if ($user->isAdmin()) {
|
||||
return back()->with('error', 'Admin kullanıcı silinemez.');
|
||||
}
|
||||
$user->delete();
|
||||
return redirect()->route('admin.users.index')->with('success', 'Kullanıcı silindi.');
|
||||
}
|
||||
|
||||
public function ban(Request $request, User $user)
|
||||
{
|
||||
$request->validate(['ban_reason' => 'nullable|string|max:500']);
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
return back()->with('error', 'Admin kullanıcı banlanamaz.');
|
||||
}
|
||||
|
||||
$user->update([
|
||||
'is_banned' => true,
|
||||
'ban_reason' => $request->ban_reason,
|
||||
'banned_at' => now(),
|
||||
]);
|
||||
|
||||
return back()->with('success', $user->name . ' banlandı.');
|
||||
}
|
||||
|
||||
public function unban(User $user)
|
||||
{
|
||||
$user->update([
|
||||
'is_banned' => false,
|
||||
'ban_reason' => null,
|
||||
'banned_at' => null,
|
||||
]);
|
||||
return back()->with('success', $user->name . ' bandan çıkarıldı.');
|
||||
}
|
||||
|
||||
public function givePremium(Request $request, User $user)
|
||||
{
|
||||
$request->validate([
|
||||
'plan_id' => 'required|exists:membership_plans,id',
|
||||
]);
|
||||
|
||||
$plan = MembershipPlan::findOrFail($request->plan_id);
|
||||
$expiresAt = now()->addDays($plan->duration_days);
|
||||
|
||||
$user->update([
|
||||
'membership' => 'premium',
|
||||
'premium_expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'status' => 'active',
|
||||
'starts_at' => now(),
|
||||
'expires_at' => $expiresAt,
|
||||
'payment_method' => 'manual',
|
||||
'notes' => 'Admin tarafından verildi: ' . auth()->user()->name,
|
||||
]);
|
||||
|
||||
return back()->with('success', $user->name . "'e {$plan->duration_days} günlük premium verildi.");
|
||||
}
|
||||
|
||||
public function removePremium(User $user)
|
||||
{
|
||||
$user->update([
|
||||
'membership' => 'free',
|
||||
'premium_expires_at' => null,
|
||||
]);
|
||||
|
||||
Subscription::where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->update(['status' => 'cancelled']);
|
||||
|
||||
return back()->with('success', $user->name . "'in premiumu kaldırıldı.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user