Initial commit: Animexe Laravel platform

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 00:01:48 +03:00
co-authored by Claude Opus 4.8
commit a63515cfc6
366 changed files with 74773 additions and 0 deletions
@@ -0,0 +1,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"',
]);
}
}
+187
View File
@@ -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;
}
}
+267
View File
@@ -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 (3070)', '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 100160 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ı.");
}
}