Initial commit: Animexe Laravel platform
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\Tribunal;
|
||||
use App\Models\TribunalVote;
|
||||
|
||||
class CloseTribunals extends Command
|
||||
{
|
||||
protected $signature = 'tribunals:close';
|
||||
protected $description = 'Süresi dolan mahkemeleri kapat ve kazananı belirle';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$expired = Tribunal::where('status', 'open')
|
||||
->where('closes_at', '<=', now())
|
||||
->get();
|
||||
|
||||
if ($expired->isEmpty()) {
|
||||
$this->info('Kapatılacak mahkeme yok.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($expired as $tribunal) {
|
||||
$sides = $tribunal->allSides();
|
||||
$counts = [];
|
||||
foreach (array_keys($sides) as $side) {
|
||||
$counts[$side] = TribunalVote::where('tribunal_id', $tribunal->id)
|
||||
->where('side', $side)
|
||||
->count();
|
||||
}
|
||||
|
||||
arsort($counts);
|
||||
$topSide = array_key_first($counts);
|
||||
$topCount = $counts[$topSide];
|
||||
$allEqual = count(array_unique(array_values($counts))) === 1 && array_sum($counts) > 0;
|
||||
|
||||
$verdict = null;
|
||||
if (!$allEqual && $topCount > 0) {
|
||||
$verdict = $sides[$topSide] ?? $topSide;
|
||||
}
|
||||
|
||||
$tribunal->update([
|
||||
'status' => 'closed',
|
||||
'verdict' => $verdict,
|
||||
]);
|
||||
|
||||
$this->line("Kapatıldı: #{$tribunal->id} — Karar: " . ($verdict ?? 'Beraberlik'));
|
||||
}
|
||||
|
||||
$this->info("{$expired->count()} mahkeme kapatıldı.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ImportJob;
|
||||
use App\Models\Anime;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* CreateCrossImportJobs
|
||||
*
|
||||
* Yayınlanan animeler için çapraz kaynak job'ları oluşturur:
|
||||
* • Anizium import'u olan ama AnimeCix import'u olmayan anime → AnimeCix job (watch_id bilgisi Python'dan)
|
||||
* • AnimeCix import'u olan ama Anizium import'u olmayan anime → Anizium job (watch_id bilgisi job'lardan)
|
||||
* • Her iki kaynakta da done job'u olan anime → bölüm bazlı video_sources yenileme için
|
||||
* yeni job (done job'u "pending" yaparak botu yeniden çalıştırır)
|
||||
*
|
||||
* Kullanım:
|
||||
* php artisan animexe:cross-import # İki kaynakta da eksik job'ları oluştur
|
||||
* php artisan animexe:cross-import --force # Var olan done job'larını da yeniden kuyruğa al
|
||||
* php artisan animexe:cross-import --only=anizium # Sadece Anizium job'larını oluştur
|
||||
* php artisan animexe:cross-import --only=animecix # Sadece AnimeCix job'larını oluştur
|
||||
*/
|
||||
class CreateCrossImportJobs extends Command
|
||||
{
|
||||
protected $signature = 'animexe:cross-import
|
||||
{--force : Mevcut done job\'larını yeniden pending yap}
|
||||
{--only= : Sadece belirtilen kaynağı oluştur (anizium|animecix)}
|
||||
{--dry-run : Job oluşturmadan listele}';
|
||||
|
||||
protected $description = 'Mevcut animeler için eksik kaynak job\'larını oluşturur (Anizium ↔ AnimeCix çapraz import)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$force = $this->option('force');
|
||||
$only = $this->option('only');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info("Animexe Çapraz Import Job Oluşturucu");
|
||||
$this->info("====================================");
|
||||
|
||||
// Tüm yayınlanan animeleri job durumlarıyla al
|
||||
$animes = Anime::where('is_published', true)
|
||||
->with('importJobs')
|
||||
->get();
|
||||
|
||||
$this->info("Toplam yayınlanan anime: {$animes->count()}");
|
||||
|
||||
$aniziumCreated = 0;
|
||||
$animecixCreated = 0;
|
||||
$skipped = 0;
|
||||
$requeued = 0;
|
||||
|
||||
foreach ($animes as $anime) {
|
||||
$jobs = $anime->importJobs;
|
||||
$aniziumJobs = $jobs->where('source', 'anizium');
|
||||
$animecixJobs = $jobs->where('source', 'animecix');
|
||||
|
||||
$hasAniziumDone = $aniziumJobs->where('status', 'done')->isNotEmpty();
|
||||
$hasAnimecixDone = $animecixJobs->where('status', 'done')->isNotEmpty();
|
||||
$hasAniziumAny = $aniziumJobs->whereIn('status', ['pending','fetching','downloading','uploading','done'])->isNotEmpty();
|
||||
$hasAnimecixAny = $animecixJobs->whereIn('status', ['pending','fetching','downloading','uploading','done'])->isNotEmpty();
|
||||
|
||||
// ── Re-queue: ikisi de done olan animeleri yeniden işlet ──────────
|
||||
if ($force && $hasAniziumDone && $hasAnimecixDone) {
|
||||
if (!$dryRun) {
|
||||
// En son done Anizium job'unu yeniden pending yap
|
||||
$aj = $aniziumJobs->where('status', 'done')->sortByDesc('id')->first();
|
||||
if ($aj) {
|
||||
$aj->update(['status' => 'pending', 'done_episodes' => 0, 'error_log' => null, 'current_step' => 'Çapraz re-import']);
|
||||
}
|
||||
// En son done AnimeCix job'unu yeniden pending yap
|
||||
$cj = $animecixJobs->where('status', 'done')->sortByDesc('id')->first();
|
||||
if ($cj) {
|
||||
$cj->update(['status' => 'pending', 'done_episodes' => 0, 'error_log' => null, 'current_step' => 'Çapraz re-import']);
|
||||
}
|
||||
$requeued++;
|
||||
} else {
|
||||
$this->line("[DRY] Re-queue: {$anime->title} (her iki kaynak mevcut)");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Anizium job oluştur ───────────────────────────────────────────
|
||||
if ((!$only || $only === 'anizium') && !$hasAniziumAny) {
|
||||
// Anizium watch_id'yi bilmiyoruz — Python reimport_all.py bu görevi üstlenir.
|
||||
// Buradan sadece anime_id'yi atayarak "ihtiyaç listesi" oluşturabiliriz;
|
||||
// watch_id olmadan bot job'ı işleyemez. Bu yüzden sadece logluyoruz.
|
||||
$this->warn("[SKIP-ANİZİUM] {$anime->title} (watch_id bilinmiyor — reimport_all.py kullan)");
|
||||
$skipped++;
|
||||
}
|
||||
|
||||
// ── AnimeCix job oluştur ──────────────────────────────────────────
|
||||
if ((!$only || $only === 'animecix') && !$hasAnimecixAny) {
|
||||
// animecix_title_id'yi bilmiyoruz — daemon.py bunu otomatik keşfeder.
|
||||
$this->warn("[SKIP-ANİMECİX] {$anime->title} (title_id bilinmiyor — daemon.py kullan)");
|
||||
$skipped++;
|
||||
}
|
||||
|
||||
// ── Anizium'u olan ama AnimeCix'i olmayan: Anizium job'larından watch_id var ──
|
||||
// ─ (bu zaten mevcut) ─
|
||||
|
||||
// ── AnimeCix'i olan ama Anizium'u olmayan: watch_id bilinmiyorsa skip ──
|
||||
if ((!$only || $only === 'anizium') && $hasAnimecixDone && !$hasAniziumAny) {
|
||||
// AnimeCix'te var ama Anizium'da yok — reimport_all.py Anizium katalogunda arayacak
|
||||
$this->line("[ANİZİUM-GEREKLİ] {$anime->title} (MAL: {$anime->mal_id}) — reimport_all.py işleyecek");
|
||||
}
|
||||
|
||||
// ── Anizium'u olan ama AnimeCix'i olmayan: daemon.py katalog taramasında otomatik bulur ──
|
||||
if ((!$only || $only === 'animecix') && $hasAniziumDone && !$hasAnimecixAny) {
|
||||
$this->line("[ANİMECİX-GEREKLİ] {$anime->title} (MAL: {$anime->mal_id}) — daemon.py bulacak");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Var olan Anizium done job'larını yeniden pending yap (--force) ───
|
||||
if ($force && !$dryRun) {
|
||||
$this->info("Re-queue tamamlandı: {$requeued} anime");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Özet:");
|
||||
$this->info(" Anizium job oluşturuldu : {$aniziumCreated}");
|
||||
$this->info(" AnimeCix job oluşturuldu: {$animecixCreated}");
|
||||
$this->info(" Yeniden kuyruğa alındı : {$requeued}");
|
||||
$this->info(" Atlandı (watch_id yok) : {$skipped}");
|
||||
$this->newLine();
|
||||
$this->info("Tüm anime + kaynak eşleştirmesi için: python reimport_all.py");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Anime;
|
||||
use App\Services\AniListService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class FetchAniListImages extends Command
|
||||
{
|
||||
protected $signature = 'anime:fetch-images
|
||||
{--missing : Sadece kapak veya banneri olmayan animeleri işle (varsayılan)}
|
||||
{--all : Tüm animeleri işle (dolu alanların üzerine yazmaz)}
|
||||
{--id= : Belirli bir anime ID}
|
||||
{--limit=50 : Tek seferinde işlenecek maksimum sayı}';
|
||||
|
||||
protected $description = 'AniList\'ten kapak ve banner resimlerini çek';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$service = new AniListService();
|
||||
|
||||
$query = Anime::query();
|
||||
|
||||
if ($id = $this->option('id')) {
|
||||
$query->where('id', $id);
|
||||
} elseif (!$this->option('all')) {
|
||||
// Varsayılan: eksik resmi olanlar
|
||||
$query->where(function ($q) {
|
||||
$q->whereNull('cover_image')->orWhere('cover_image', '')
|
||||
->orWhereNull('banner_image')->orWhere('banner_image', '');
|
||||
});
|
||||
}
|
||||
|
||||
$limit = (int) $this->option('limit');
|
||||
$animes = $query->limit($limit)->get();
|
||||
|
||||
if ($animes->isEmpty()) {
|
||||
$this->info('Eksik resim bulunamadı.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info("İşlenecek: {$animes->count()} anime");
|
||||
$bar = $this->output->createProgressBar($animes->count());
|
||||
$bar->start();
|
||||
|
||||
$ok = $skip = $fail = 0;
|
||||
|
||||
foreach ($animes as $anime) {
|
||||
try {
|
||||
$updated = $service->fillImages($anime);
|
||||
$updated ? $ok++ : $skip++;
|
||||
} catch (\Throwable $e) {
|
||||
$fail++;
|
||||
$this->newLine();
|
||||
$this->warn("#{$anime->id} {$anime->title}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
usleep(500_000); // AniList rate limit: 90 req/dakika
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
$this->info("Bitti — güncellendi: {$ok}, değişmedi: {$skip}, hata: {$fail}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Anime;
|
||||
use App\Services\JikanService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class FetchMalIds extends Command
|
||||
{
|
||||
protected $signature = 'animexe:fetch-mal-ids {--force : Overwrite existing MAL IDs}';
|
||||
protected $description = 'Auto-fetch MAL IDs for all animes and fill season mal_id chain via Jikan API';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$jikan = new JikanService();
|
||||
$query = Anime::query();
|
||||
|
||||
if (!$this->option('force')) {
|
||||
$query->whereNull('mal_id');
|
||||
}
|
||||
|
||||
$animes = $query->get();
|
||||
$this->info("Processing {$animes->count()} anime(s)…");
|
||||
$bar = $this->output->createProgressBar($animes->count());
|
||||
$bar->start();
|
||||
|
||||
$found = 0;
|
||||
foreach ($animes as $anime) {
|
||||
try {
|
||||
if (!$anime->mal_id || $this->option('force')) {
|
||||
$malId = $jikan->searchMalId($anime->title, $anime->title_en, $anime->title_jp, $anime->type);
|
||||
if ($malId) {
|
||||
$anime->update(['mal_id' => $malId]);
|
||||
$found++;
|
||||
}
|
||||
usleep(400_000); // rate limit
|
||||
}
|
||||
|
||||
// Fill season chain
|
||||
if ($anime->mal_id) {
|
||||
$chain = $jikan->fetchSeasonMalIds($anime->mal_id);
|
||||
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 $e) {
|
||||
$this->newLine();
|
||||
$this->warn(" ⚠ [{$anime->title}]: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info("Done. {$found} new MAL ID(s) fetched.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Anime;
|
||||
use App\Models\Genre;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FillAnimeMeta extends Command
|
||||
{
|
||||
protected $signature = 'animexe:fill-anime-meta
|
||||
{--limit=0 : Max anime sayısı (0 = sınırsız, hepsi)}
|
||||
{--anime= : Sadece belirli bir anime ID işle}
|
||||
{--force : Dolu alanlar olsa bile yeniden doldur}
|
||||
{--dry-run : Gerçekten kaydetme, sadece ne yapılacağını göster}';
|
||||
|
||||
protected $description = 'Eksik meta verisi olan animeleri DeepSeek AI ile toplu doldurur (description, genres, studio, year, vb.)';
|
||||
|
||||
public function handle(DeepSeekService $ai): int
|
||||
{
|
||||
// PHP zaman aşımını kaldır — tüm animeler işlenene kadar çalışsın
|
||||
set_time_limit(0);
|
||||
|
||||
if (!$ai->isConfigured()) {
|
||||
$this->error('DeepSeek API anahtarı ayarlanmamış. Admin > Ayarlar > deepseek_api_key');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$limit = (int) $this->option('limit');
|
||||
$animeId = $this->option('anime');
|
||||
$force = $this->option('force');
|
||||
$dry = $this->option('dry-run');
|
||||
|
||||
// İşlenecek animeleri belirle
|
||||
if ($animeId) {
|
||||
$animes = Anime::where('id', $animeId)->with('genres')->get();
|
||||
} else {
|
||||
$query = Anime::with('genres');
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
$query->orderBy('id');
|
||||
if ($limit > 0) $query->limit($limit);
|
||||
$animes = $query->get();
|
||||
}
|
||||
|
||||
if ($animes->isEmpty()) {
|
||||
$this->info('İşlenecek anime bulunamadı (tüm alanlar dolu).');
|
||||
Log::channel('daily')->info('[FillAnimeMeta] İşlenecek anime yok.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Toplam {$animes->count()} anime işlenecek" . ($dry ? ' (dry-run)' : '') . '...');
|
||||
Log::channel('daily')->info("[FillAnimeMeta] Başladı. {$animes->count()} anime, limit={$limit}, force=" . ($force ? 'evet' : 'hayır'));
|
||||
|
||||
$done = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($animes as $anime) {
|
||||
$missing = $this->missingFields($anime);
|
||||
|
||||
if (!$force && empty($missing)) {
|
||||
$this->line(" <fg=gray>ATLA</> {$anime->title} — tüm alanlar dolu");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = $force ? 'tüm alanlar' : implode(', ', $missing);
|
||||
$this->line(" <fg=cyan>İŞLE</> [{$anime->id}] {$anime->title} — eksik: {$label}");
|
||||
|
||||
if ($dry) {
|
||||
$done++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? '');
|
||||
|
||||
if (!$meta) {
|
||||
$this->warn(" <fg=red>HATA</> {$anime->title} — AI boş yanıt döndürdü");
|
||||
Log::channel('daily')->warning("[FillAnimeMeta] HATA [{$anime->id}] {$anime->title}: AI boş yanıt");
|
||||
$failed++;
|
||||
sleep(3);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sadece boş alanları doldur (force modunda hepsini güncelle)
|
||||
$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);
|
||||
|
||||
// Rating: sadece boşsa veya 0 ise doldur
|
||||
if (!empty($meta['rating']) && ($force || !$anime->rating)) {
|
||||
$updates['rating'] = min(10, max(0, (float) $meta['rating']));
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
$anime->update($updates);
|
||||
}
|
||||
|
||||
// Genres: boşsa ekle
|
||||
if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) {
|
||||
$genreIds = [];
|
||||
foreach ($meta['genres'] as $genreName) {
|
||||
$genre = Genre::firstOrCreate(
|
||||
['name' => $genreName],
|
||||
['slug' => \Illuminate\Support\Str::slug($genreName)]
|
||||
);
|
||||
$genreIds[] = $genre->id;
|
||||
}
|
||||
if ($genreIds) {
|
||||
$force ? $anime->genres()->sync($genreIds) : $anime->genres()->syncWithoutDetaching($genreIds);
|
||||
}
|
||||
}
|
||||
|
||||
$updatedFields = array_keys($updates);
|
||||
if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) {
|
||||
$updatedFields[] = 'genres(' . implode(',', $meta['genres'] ?? []) . ')';
|
||||
}
|
||||
|
||||
$summary = empty($updatedFields) ? 'Yeni alan yok' : implode(', ', $updatedFields);
|
||||
$this->info(" <fg=green>✓ OK</> [{$anime->id}] {$anime->title} → {$summary}");
|
||||
Log::channel('daily')->info("[FillAnimeMeta] OK [{$anime->id}] {$anime->title} → {$summary}");
|
||||
|
||||
$done++;
|
||||
|
||||
// API rate limit — DeepSeek'i boğma
|
||||
sleep(2);
|
||||
}
|
||||
|
||||
$summary = "Tamamlandı: {$done} işlendi, {$skipped} atlandı, {$failed} hata.";
|
||||
$this->info($summary);
|
||||
Log::channel('daily')->info("[FillAnimeMeta] {$summary}");
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function missingFields(Anime $anime): array
|
||||
{
|
||||
$missing = [];
|
||||
if (empty($anime->description)) $missing[] = 'description';
|
||||
if (empty($anime->release_year)) $missing[] = 'release_year';
|
||||
if (empty($anime->studio)) $missing[] = 'studio';
|
||||
if (empty($anime->type)) $missing[] = 'type';
|
||||
if (empty($anime->status)) $missing[] = 'status';
|
||||
if (!$anime->rating) $missing[] = 'rating';
|
||||
if (empty($anime->title_en)) $missing[] = 'title_en';
|
||||
if ($anime->genres->isEmpty()) $missing[] = 'genres';
|
||||
return $missing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Episode;
|
||||
use App\Models\Season;
|
||||
use App\Models\Subtitle;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* FixSubtitleMismatch
|
||||
*
|
||||
* Anizium altyazı bug'ı: episode 1'in altyazısı diğer bölümlere yanlışlıkla kaydedilmiş olabilir.
|
||||
* Altyazı URL'sindeki name=s1_b1_XX parametresini kontrol eder; yanlış bölüme işaret edenleri siler.
|
||||
*
|
||||
* Kullanım:
|
||||
* php artisan animexe:fix-subtitles # Yanlış altyazıları sil
|
||||
* php artisan animexe:fix-subtitles --dry-run # Sadece listele
|
||||
* php artisan animexe:fix-subtitles --anime-id=42 # Sadece bu anime
|
||||
*/
|
||||
class FixSubtitleMismatch extends Command
|
||||
{
|
||||
protected $signature = 'animexe:fix-subtitles
|
||||
{--dry-run : Silmeden önce listele}
|
||||
{--anime-id= : Sadece bu anime_id\'yi kontrol et}';
|
||||
|
||||
protected $description = 'Anizium altyazı bölüm uyuşmazlığı bug\'ını düzelt (name parametresi yanlış episode\'a işaret eden altyazıları sil)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$animeId = $this->option('anime-id');
|
||||
|
||||
$this->info("Anizium Altyazı Uyuşmazlık Düzeltici");
|
||||
$this->info("=====================================");
|
||||
if ($dryRun) $this->warn("DRY-RUN modu — hiçbir şey silinmeyecek");
|
||||
|
||||
$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.episode_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();
|
||||
$this->info("Kontrol edilecek Anizium altyazısı: {$subtitles->count()}");
|
||||
|
||||
$mismatchIds = [];
|
||||
|
||||
foreach ($subtitles as $sub) {
|
||||
$url = $sub->url;
|
||||
$season = (int) $sub->season_number;
|
||||
$episode = (int) $sub->episode_number;
|
||||
$lang = $sub->language;
|
||||
|
||||
// URL'den name parametresini çıkar
|
||||
$parsed = parse_url($url);
|
||||
if (!isset($parsed['query'])) continue;
|
||||
|
||||
parse_str($parsed['query'], $params);
|
||||
$name = $params['name'] ?? '';
|
||||
|
||||
if (!$name) continue;
|
||||
|
||||
// Beklenen: s{season}_b{episode}_{lang}
|
||||
$expectedPrefix = "s{$season}_b{$episode}_";
|
||||
if (!str_starts_with($name, $expectedPrefix)) {
|
||||
$mismatchIds[] = $sub->subtitle_id;
|
||||
$this->line(
|
||||
"[MISMATCH] sub_id={$sub->subtitle_id} "
|
||||
. "anime_id={$sub->anime_id} "
|
||||
. "S{$season}E{$episode} {$lang} "
|
||||
. "| name={$name} "
|
||||
. "(beklenen prefix: {$expectedPrefix})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Uyuşmazlık bulunan: " . count($mismatchIds));
|
||||
|
||||
if (empty($mismatchIds)) {
|
||||
$this->info("Düzeltilecek altyazı bulunamadı.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn("--dry-run: {" . count($mismatchIds) . "} altyazı silinecekti.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$deleted = Subtitle::whereIn('id', $mismatchIds)->delete();
|
||||
$this->info("Silindi: {$deleted} yanlış altyazı.");
|
||||
$this->info("Botları yeniden çalıştırarak doğru altyazıları yeniden indirebilirsiniz.");
|
||||
$this->info(" python anizium_scraper/bot2_upload.py --daemon");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Anime;
|
||||
use App\Models\BlogPost;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenerateBlogPosts extends Command
|
||||
{
|
||||
protected $signature = 'animexe:generate-blogs
|
||||
{--count=2 : Kaç blog yazısı üretileceği}
|
||||
{--anime= : Belirli bir anime ID için üret}
|
||||
{--force : Daha önce blog yazılmış animeler için de üret}';
|
||||
|
||||
protected $description = 'DeepSeek AI ile anime blog yazıları üretir';
|
||||
|
||||
public function handle(DeepSeekService $deepseek): int
|
||||
{
|
||||
if (!$deepseek->isConfigured()) {
|
||||
$this->error('DeepSeek API anahtarı ayarlanmamış. Admin > Ayarlar > deepseek_api_key');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$count = (int) $this->option('count');
|
||||
$animeId = $this->option('anime');
|
||||
$force = $this->option('force');
|
||||
|
||||
if ($animeId) {
|
||||
$animes = Anime::where('id', $animeId)->where('is_published', true)->with('genres')->get();
|
||||
} else {
|
||||
$alreadyBlogged = $force ? [] : BlogPost::whereNotNull('anime_id')->pluck('anime_id')->toArray();
|
||||
$animesQuery = Anime::where('is_published', true)
|
||||
->whereNotIn('id', $alreadyBlogged)
|
||||
->with('genres')
|
||||
->orderByDesc('rating')
|
||||
->limit($count * 3)
|
||||
->get();
|
||||
|
||||
$randomResult = $animesQuery->random(min($count, $animesQuery->count()));
|
||||
$animes = collect($randomResult);
|
||||
}
|
||||
|
||||
if ($animes->isEmpty()) {
|
||||
$this->info('Blog yazısı üretilecek anime bulunamadı.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$generated = 0;
|
||||
|
||||
foreach ($animes->take($count) as $anime) {
|
||||
$this->info("Blog üretiliyor: {$anime->title}...");
|
||||
|
||||
// Aynı türden ilgili animeler bul
|
||||
$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'])) {
|
||||
$this->warn(" [{$anime->title}] için içerik üretilemedi: " . $deepseek->lastError);
|
||||
continue;
|
||||
}
|
||||
|
||||
// [LINK:slug]Title[/LINK] placeholder'larını gerçek URL'lerle değiştir
|
||||
$content = preg_replace_callback(
|
||||
'/\[LINK:([^\]]+)\]([^\[]*)\[\/LINK\]/',
|
||||
function ($m) {
|
||||
$slug = trim($m[1]);
|
||||
$label = trim($m[2]);
|
||||
try {
|
||||
$url = route('anime.show', $slug);
|
||||
return "<a href=\"{$url}\">{$label}</a>";
|
||||
} catch (\Exception $e) {
|
||||
return $label;
|
||||
}
|
||||
},
|
||||
$data['content'] ?? ''
|
||||
);
|
||||
|
||||
$title = $data['title'] ?? ($anime->title . ' İzle — Animexe Rehberi');
|
||||
$slug = BlogPost::generateSlug($title);
|
||||
|
||||
// Linked anime IDs
|
||||
$linkedIds = [];
|
||||
if (!empty($data['linked_slugs'])) {
|
||||
$linkedIds = Anime::whereIn('slug', $data['linked_slugs'])->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
$readingTime = max(3, (int) (str_word_count(strip_tags($content)) / 200));
|
||||
|
||||
BlogPost::create([
|
||||
'title' => $title,
|
||||
'slug' => $slug,
|
||||
'excerpt' => $data['excerpt'] ?? '',
|
||||
'content' => $content,
|
||||
'cover_image' => $anime->cover_image,
|
||||
'focus_keyword' => $data['focus_keyword'] ?? $anime->title,
|
||||
'meta_title' => $data['title'] ?? null,
|
||||
'meta_description' => $data['meta_description'] ?? $data['excerpt'] ?? '',
|
||||
'meta_keywords' => implode(', ', array_filter([
|
||||
$anime->title,
|
||||
$anime->title . ' izle',
|
||||
'türkçe anime',
|
||||
$data['focus_keyword'] ?? '',
|
||||
])),
|
||||
'status' => 'published',
|
||||
'ai_generated' => true,
|
||||
'anime_id' => $anime->id,
|
||||
'linked_anime_ids' => $linkedIds,
|
||||
'faq' => $data['faq'] ?? [],
|
||||
'reading_time' => $readingTime,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
$this->info(" ✓ Blog yazısı oluşturuldu: {$title}");
|
||||
$generated++;
|
||||
|
||||
// API rate limit
|
||||
sleep(2);
|
||||
}
|
||||
|
||||
$this->info("Tamamlandı. {$generated} blog yazısı üretildi.");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Anime;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GenerateDiscoveryHooks extends Command
|
||||
{
|
||||
protected $signature = 'anime:generate-hooks {--limit=50 : Kaç anime işlensin} {--force : Zaten hook olanları da yeniden üret}';
|
||||
protected $description = 'Keşfet sayfası için anime hook metinlerini AI ile üret';
|
||||
|
||||
public function handle(DeepSeekService $ai): int
|
||||
{
|
||||
if (!$ai->isConfigured()) {
|
||||
$this->error('DeepSeek API anahtarı ayarlanmamış.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$limit = (int) $this->option('limit');
|
||||
$force = $this->option('force');
|
||||
|
||||
$query = Anime::where('is_published', true)->with('genres:id,name');
|
||||
if (!$force) {
|
||||
$query->whereNull('discovery_hook');
|
||||
}
|
||||
|
||||
$animes = $query->limit($limit)->get();
|
||||
|
||||
if ($animes->isEmpty()) {
|
||||
$this->info('Hook üretilecek anime bulunamadı.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Toplam {$animes->count()} anime için hook üretiliyor...");
|
||||
$bar = $this->output->createProgressBar($animes->count());
|
||||
$bar->start();
|
||||
|
||||
$done = 0; $failed = 0;
|
||||
foreach ($animes as $anime) {
|
||||
$hook = $ai->generateDiscoveryHook($anime);
|
||||
if ($hook) {
|
||||
$anime->updateQuietly(['discovery_hook' => $hook]);
|
||||
$done++;
|
||||
} else {
|
||||
$failed++;
|
||||
}
|
||||
$bar->advance();
|
||||
usleep(300_000); // Rate limit — 0.3sn ara
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info("Tamamlandı: {$done} başarılı, {$failed} başarısız.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivationCode;
|
||||
use App\Models\MembershipPlan;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ActivationCodeController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = ActivationCode::with(['plan', 'usedBy', 'createdBy'])->latest();
|
||||
|
||||
if ($request->filled('plan_id')) {
|
||||
$query->where('plan_id', $request->plan_id);
|
||||
}
|
||||
|
||||
if ($request->filled('batch')) {
|
||||
$query->where('batch', $request->batch);
|
||||
}
|
||||
|
||||
match ($request->status) {
|
||||
'used' => $query->whereNotNull('used_at'),
|
||||
'unused' => $query->whereNull('used_at'),
|
||||
default => null,
|
||||
};
|
||||
|
||||
$codes = $query->paginate(50)->withQueryString();
|
||||
$plans = MembershipPlan::where('is_active', true)->orderBy('sort_order')->get();
|
||||
$batches = ActivationCode::select('batch')->whereNotNull('batch')
|
||||
->distinct()->orderBy('batch', 'desc')->pluck('batch');
|
||||
|
||||
$stats = [
|
||||
'total' => ActivationCode::count(),
|
||||
'used' => ActivationCode::whereNotNull('used_at')->count(),
|
||||
'unused' => ActivationCode::whereNull('used_at')->count(),
|
||||
];
|
||||
|
||||
return view('admin.activation-codes.index', compact('codes', 'plans', 'stats', 'batches'));
|
||||
}
|
||||
|
||||
public function generate(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'plan_id' => 'required|exists:membership_plans,id',
|
||||
'quantity' => 'required|integer|min:1|max:500',
|
||||
'expires_at' => 'nullable|date|after:today',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
'batch' => 'nullable|string|max:64',
|
||||
]);
|
||||
|
||||
$batch = $request->batch ?: 'toplu-' . now()->format('Ymd-His');
|
||||
$generated = [];
|
||||
|
||||
DB::transaction(function () use ($request, $batch, &$generated) {
|
||||
for ($i = 0; $i < $request->quantity; $i++) {
|
||||
$code = ActivationCode::create([
|
||||
'code' => ActivationCode::generateCode(),
|
||||
'plan_id' => $request->plan_id,
|
||||
'expires_at' => $request->expires_at ?: null,
|
||||
'batch' => $batch,
|
||||
'notes' => $request->notes,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
$generated[] = $code->code;
|
||||
}
|
||||
});
|
||||
|
||||
return back()
|
||||
->with('generated_codes', $generated)
|
||||
->with('success', count($generated) . ' adet aktivasyon kodu oluşturuldu. (Batch: ' . $batch . ')');
|
||||
}
|
||||
|
||||
public function destroy(ActivationCode $activationCode)
|
||||
{
|
||||
if ($activationCode->isUsed()) {
|
||||
return back()->withErrors(['error' => 'Kullanılmış kodlar silinemez.']);
|
||||
}
|
||||
|
||||
$activationCode->delete();
|
||||
|
||||
return back()->with('success', 'Aktivasyon kodu silindi.');
|
||||
}
|
||||
|
||||
public function destroyBatch(Request $request)
|
||||
{
|
||||
$request->validate(['batch' => 'required|string|max:64']);
|
||||
|
||||
$count = ActivationCode::where('batch', $request->batch)
|
||||
->whereNull('used_at')
|
||||
->delete();
|
||||
|
||||
return back()->with('success', $count . ' adet kullanılmamış kod silindi.');
|
||||
}
|
||||
|
||||
public function destroySelected(Request $request)
|
||||
{
|
||||
$request->validate(['ids' => 'required|array|min:1', 'ids.*' => 'integer|exists:activation_codes,id']);
|
||||
|
||||
$count = ActivationCode::whereIn('id', $request->ids)
|
||||
->whereNull('used_at')
|
||||
->delete();
|
||||
|
||||
return back()->with('success', $count . ' adet aktivasyon kodu silindi.');
|
||||
}
|
||||
|
||||
public function export(Request $request)
|
||||
{
|
||||
$query = ActivationCode::with('plan')->whereNull('used_at');
|
||||
|
||||
if ($request->filled('plan_id')) {
|
||||
$query->where('plan_id', $request->plan_id);
|
||||
}
|
||||
|
||||
if ($request->filled('batch')) {
|
||||
$query->where('batch', $request->batch);
|
||||
}
|
||||
|
||||
$codes = $query->orderBy('batch')->orderBy('created_at')->get();
|
||||
|
||||
$csv = "\xEF\xBB\xBF"; // UTF-8 BOM (Excel için)
|
||||
$csv .= "Kod,Plan,Batch,Son Kullanma,Oluşturulma\n";
|
||||
|
||||
foreach ($codes as $code) {
|
||||
$csv .= implode(',', [
|
||||
$code->code,
|
||||
'"' . str_replace('"', '""', $code->plan->name) . '"',
|
||||
$code->batch ?? '-',
|
||||
$code->expires_at?->format('d.m.Y') ?? '-',
|
||||
$code->created_at->format('d.m.Y H:i'),
|
||||
]) . "\n";
|
||||
}
|
||||
|
||||
return response($csv, 200, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="aktivasyon-kodlari-' . now()->format('Ymd') . '.csv"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Ad;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AdController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$ads = Ad::orderByDesc('created_at')->get();
|
||||
|
||||
$settings = [
|
||||
'vad_enabled' => Setting::get('vad_enabled', '0'),
|
||||
'vad_freq_episodes' => Setting::get('vad_freq_episodes', 2),
|
||||
'vad_freq_minutes' => Setting::get('vad_freq_minutes', 5),
|
||||
'vad_upsell_percent' => Setting::get('vad_upsell_percent', 20),
|
||||
'banner_ads_enabled' => Setting::get('banner_ads_enabled', '0'),
|
||||
];
|
||||
|
||||
$stats = [
|
||||
'total_impressions' => $ads->sum('impressions'),
|
||||
'total_clicks' => $ads->sum('clicks'),
|
||||
'avg_ctr' => $ads->sum('impressions') > 0
|
||||
? round($ads->sum('clicks') / $ads->sum('impressions') * 100, 2) : 0,
|
||||
'active_count' => $ads->where('is_active', true)->count(),
|
||||
];
|
||||
|
||||
return view('admin.ads.index', compact('ads', 'settings', 'stats'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $this->validateAd($request);
|
||||
|
||||
if ($request->hasFile('media_file')) {
|
||||
$data['file_path'] = $this->storeMedia($request->file('media_file'));
|
||||
}
|
||||
|
||||
unset($data['media_file']);
|
||||
Ad::create($data);
|
||||
|
||||
return back()->with('success', 'Reklam eklendi.');
|
||||
}
|
||||
|
||||
public function edit(Ad $ad)
|
||||
{
|
||||
return view('admin.ads.edit', compact('ad'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Ad $ad)
|
||||
{
|
||||
$data = $this->validateAd($request, $ad);
|
||||
|
||||
if ($request->hasFile('media_file')) {
|
||||
$newPath = $this->storeMedia($request->file('media_file'));
|
||||
if ($ad->file_path) Storage::disk('public')->delete($ad->file_path);
|
||||
$data['file_path'] = $newPath;
|
||||
}
|
||||
|
||||
unset($data['media_file']);
|
||||
$ad->update($data);
|
||||
|
||||
return redirect()->route('admin.ads.index')->with('success', 'Reklam güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(Ad $ad)
|
||||
{
|
||||
if ($ad->file_path) Storage::disk('public')->delete($ad->file_path);
|
||||
$ad->delete();
|
||||
|
||||
return back()->with('success', 'Reklam silindi.');
|
||||
}
|
||||
|
||||
public function toggle(Ad $ad)
|
||||
{
|
||||
$ad->update(['is_active' => !$ad->is_active]);
|
||||
return back()->with('success', $ad->is_active ? 'Reklam aktifleştirildi.' : 'Reklam durduruldu.');
|
||||
}
|
||||
|
||||
public function saveSettings(Request $request)
|
||||
{
|
||||
Setting::set('vad_enabled', $request->boolean('vad_enabled') ? '1' : '0', 'ads');
|
||||
Setting::set('vad_freq_episodes', max(1, (int) $request->input('vad_freq_episodes', 2)), 'ads');
|
||||
Setting::set('vad_freq_minutes', max(1, (int) $request->input('vad_freq_minutes', 5)), 'ads');
|
||||
Setting::set('vad_upsell_percent', min(100, max(0, (int) $request->input('vad_upsell_percent', 20))), 'ads');
|
||||
Setting::set('banner_ads_enabled', $request->boolean('banner_ads_enabled') ? '1' : '0', 'ads');
|
||||
|
||||
return back()->with('success', 'Reklam ayarları kaydedildi.');
|
||||
}
|
||||
|
||||
private function validateAd(Request $request, ?Ad $existing = null): array
|
||||
{
|
||||
$type = $request->input('type', 'video');
|
||||
|
||||
// Sunucu upload limitini aşan dosya: PHP boş/bozuk upload gönderir.
|
||||
// Sessizce medyasız reklam kaydetmek yerine net hata ver.
|
||||
$this->guardUploadError($request);
|
||||
|
||||
// Yüklenmiş dosya da dış URL de yoksa reklam gösterilemez (media_url null olur).
|
||||
// Düzenlemede mevcut dosya varsa yeniden yükleme zorunlu değil.
|
||||
$hasExisting = $existing?->file_path || $existing?->external_url;
|
||||
$needsMedia = !$request->hasFile('media_file') && !$hasExisting;
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:120',
|
||||
'type' => 'required|in:video,banner',
|
||||
'placement' => 'required|in:preroll,home_mid,home_bottom',
|
||||
'media_file' => [
|
||||
'nullable', 'file',
|
||||
$type === 'video' ? 'mimes:mp4,m4v' : 'mimes:jpg,jpeg,png,webp,gif',
|
||||
$type === 'video' ? 'max:102400' : 'max:20480', // video 100MB, görsel/gif 20MB
|
||||
],
|
||||
'external_url' => [$needsMedia ? 'required' : 'nullable', 'nullable', 'url', 'max:2000'],
|
||||
'click_url' => 'nullable|url|max:2000',
|
||||
'skip_after' => 'required|integer|min:0|max:60',
|
||||
'weight' => 'required|integer|min:1|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'starts_at' => 'nullable|date',
|
||||
'ends_at' => 'nullable|date|after:starts_at',
|
||||
], [
|
||||
'external_url.required' => 'Bir medya dosyası yükleyin veya dış URL girin. '
|
||||
. 'Dosya seçtiyseniz sunucu yükleme limitini aşmış olabilir (maks. '
|
||||
. ini_get('upload_max_filesize') . ').',
|
||||
'media_file.mimes' => $type === 'video'
|
||||
? 'Video dosyası MP4 formatında olmalı.'
|
||||
: 'Görsel JPG, PNG, WebP veya GIF formatında olmalı.',
|
||||
'media_file.max' => 'Dosya çok büyük.',
|
||||
]);
|
||||
|
||||
// Checkbox işaretli değilse request'te hiç gelmez — açıkça boolean'a çevir
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** PHP upload hatalarını (limit aşımı, kısmi yükleme) net mesajla yüzeye çıkar. */
|
||||
private function guardUploadError(Request $request): void
|
||||
{
|
||||
$file = $request->file('media_file');
|
||||
if (!$file || $file->isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$msg = match ($file->getError()) {
|
||||
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE =>
|
||||
'Dosya sunucunun yükleme limitini aşıyor (maks. ' . ini_get('upload_max_filesize')
|
||||
. '). Daha küçük bir dosya seçin veya hosting limitini yükseltin.',
|
||||
UPLOAD_ERR_PARTIAL => 'Dosya yalnızca kısmen yüklendi, lütfen tekrar deneyin.',
|
||||
UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE =>
|
||||
'Sunucu dosyayı geçici klasöre yazamadı. Hosting sağlayıcınıza bildirin.',
|
||||
default => 'Dosya yüklenemedi (hata kodu: ' . $file->getError() . ').',
|
||||
};
|
||||
|
||||
throw \Illuminate\Validation\ValidationException::withMessages(['media_file' => $msg]);
|
||||
}
|
||||
|
||||
/** Dosyayı public diske yaz ve tam yazıldığını doğrula. */
|
||||
private function storeMedia(\Illuminate\Http\UploadedFile $file): string
|
||||
{
|
||||
// NOT: klasör adı bilerek nötr ('ads' değil) — adblocker /media/ads/ yolunu
|
||||
// ERR_BLOCKED_BY_CLIENT ile engelliyor. 'content' engellenmez.
|
||||
$expected = $file->getSize();
|
||||
$path = $file->store('content', 'public');
|
||||
|
||||
if (!$path || !Storage::disk('public')->exists($path)) {
|
||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
||||
'media_file' => 'Dosya sunucuya kaydedilemedi. storage/app/public klasörünün yazma izni olduğundan emin olun.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Kısmi yazma (disk dolu / kesilen upload) sessizce bozuk reklam bırakmasın
|
||||
$written = Storage::disk('public')->size($path);
|
||||
if ($expected > 0 && $written !== $expected) {
|
||||
Storage::disk('public')->delete($path);
|
||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
||||
'media_file' => "Dosya eksik yüklendi ({$written}/{$expected} byte). Tekrar deneyin.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Setting;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AiController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /admin/ai/anime-meta — toplu anime meta doldurma sayfası.
|
||||
*/
|
||||
public function animeMetaPage()
|
||||
{
|
||||
$total = Anime::count();
|
||||
|
||||
$missing = Anime::where(function ($q) {
|
||||
$q->whereNull('description')->orWhere('description', '')
|
||||
->orWhereNull('release_year')
|
||||
->orWhereNull('studio')->orWhere('studio', '')
|
||||
->orWhereNull('type')->orWhere('type', '')
|
||||
->orWhereNull('status')->orWhere('status', '');
|
||||
})->count();
|
||||
|
||||
$noGenres = Anime::doesntHave('genres')->count();
|
||||
|
||||
return view('admin.ai.anime-meta', compact('total', 'missing', 'noGenres'));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/ai/anime-meta-ids — eksik animelerin ID listesini döndür.
|
||||
*/
|
||||
public function animeMetaIds(Request $request)
|
||||
{
|
||||
$force = $request->boolean('force');
|
||||
|
||||
$query = Anime::with('genres:id')->select('id', 'title', 'description', 'release_year', 'studio', 'type', 'status', 'rating', 'title_en', 'title_jp');
|
||||
|
||||
if (!$force) {
|
||||
$query->where(function ($q) {
|
||||
$q->whereNull('description')->orWhere('description', '')
|
||||
->orWhereNull('release_year')
|
||||
->orWhereNull('studio')->orWhere('studio', '')
|
||||
->orWhereNull('type')->orWhere('type', '')
|
||||
->orWhereNull('status')->orWhere('status', '');
|
||||
})->orDoesntHave('genres');
|
||||
}
|
||||
|
||||
$animes = $query->orderBy('id')->get()->map(function ($a) {
|
||||
$missing = [];
|
||||
if (empty($a->description)) $missing[] = 'açıklama';
|
||||
if (empty($a->release_year)) $missing[] = 'yıl';
|
||||
if (empty($a->studio)) $missing[] = 'stüdyo';
|
||||
if (empty($a->type)) $missing[] = 'tür';
|
||||
if (empty($a->status)) $missing[] = 'durum';
|
||||
if (!$a->rating) $missing[] = 'puan';
|
||||
if ($a->genres->isEmpty()) $missing[] = 'kategoriler';
|
||||
return ['id' => $a->id, 'title' => $a->title, 'missing' => $missing];
|
||||
});
|
||||
|
||||
return response()->json(['animes' => $animes]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/ai/fill-anime-meta — tek anime için meta doldur ve kaydet.
|
||||
*/
|
||||
public function fillAnimeMeta(Request $request)
|
||||
{
|
||||
$anime = Anime::with('genres:id,name')->findOrFail($request->anime_id);
|
||||
$force = $request->boolean('force');
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? '');
|
||||
|
||||
if (!$meta) {
|
||||
Log::channel('daily')->warning("[FillAnimeMeta-UI] HATA [{$anime->id}] {$anime->title}");
|
||||
return response()->json(['error' => 'DeepSeek boş yanıt döndürdü.'], 500);
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
$fillIfEmpty = function (string $field, $value) use ($anime, $force, &$updates) {
|
||||
if ($value === null || $value === '') return;
|
||||
if ($force || empty($anime->$field)) $updates[$field] = $value;
|
||||
};
|
||||
|
||||
$fillIfEmpty('description', $meta['description'] ?? null);
|
||||
$fillIfEmpty('release_year', $meta['release_year'] ?? null);
|
||||
$fillIfEmpty('studio', $meta['studio'] ?? null);
|
||||
$fillIfEmpty('type', $meta['type'] ?? null);
|
||||
$fillIfEmpty('status', $meta['status'] ?? null);
|
||||
$fillIfEmpty('title_en', $meta['title_en'] ?? null);
|
||||
$fillIfEmpty('title_jp', $meta['title_jp'] ?? null);
|
||||
if (!empty($meta['rating']) && ($force || !$anime->rating)) {
|
||||
$updates['rating'] = min(10, max(0, (float) $meta['rating']));
|
||||
}
|
||||
|
||||
if (!empty($updates)) $anime->update($updates);
|
||||
|
||||
$syncedGenres = [];
|
||||
if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) {
|
||||
$ids = [];
|
||||
foreach ($meta['genres'] as $name) {
|
||||
$g = Genre::firstOrCreate(['name' => $name], ['slug' => Str::slug($name)]);
|
||||
$ids[] = $g->id;
|
||||
}
|
||||
if ($ids) {
|
||||
$force ? $anime->genres()->sync($ids) : $anime->genres()->syncWithoutDetaching($ids);
|
||||
$syncedGenres = $meta['genres'];
|
||||
}
|
||||
}
|
||||
|
||||
$filled = array_keys($updates);
|
||||
if ($syncedGenres) $filled[] = 'kategoriler';
|
||||
|
||||
Log::channel('daily')->info("[FillAnimeMeta-UI] OK [{$anime->id}] {$anime->title} → " . implode(', ', $filled));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'filled' => $filled,
|
||||
'meta' => array_merge($updates, ['genres' => $syncedGenres]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toplu açıklama yazma sayfası.
|
||||
*/
|
||||
public function descriptionsPage()
|
||||
{
|
||||
$animes = Anime::orderBy('title')
|
||||
->withCount(['episodes as total_eps' => fn($q) => $q->whereNull('description')->orWhere('description', '')])
|
||||
->get()
|
||||
->filter(fn($a) => $a->total_eps > 0);
|
||||
|
||||
$totalMissing = Episode::where(fn($q) => $q->whereNull('description')->orWhere('description', ''))->count();
|
||||
|
||||
return view('admin.ai.descriptions', compact('animes', 'totalMissing'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Açıklaması olmayan bölüm ID'lerini döndür (JS için).
|
||||
* POST { anime_id: 0=tümü }
|
||||
*/
|
||||
public function episodeIds(Request $request)
|
||||
{
|
||||
$query = Episode::where(fn($q) => $q->whereNull('description')->orWhere('description', ''));
|
||||
|
||||
if ($request->anime_id && $request->anime_id != '0') {
|
||||
$query->where('anime_id', $request->anime_id);
|
||||
}
|
||||
|
||||
$ids = $query->with('anime:id,title')->get()->map(fn($ep) => [
|
||||
'id' => $ep->id,
|
||||
'label' => ($ep->anime->title ?? '?') . ' — ' . $ep->episode_number . '. Bölüm' . ($ep->title ? ' — '.$ep->title : ''),
|
||||
]);
|
||||
|
||||
return response()->json(['episodes' => $ids]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tek bir bölüme açıklama yaz ve kaydet.
|
||||
* POST { episode_id }
|
||||
*/
|
||||
public function fillOne(Request $request)
|
||||
{
|
||||
$episode = Episode::with('anime:id,title')->findOrFail($request->episode_id);
|
||||
|
||||
if (!empty($episode->description)) {
|
||||
return response()->json(['ok' => true, 'skipped' => true, 'description' => $episode->description]);
|
||||
}
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$desc = $ai->generateEpisodeDescription(
|
||||
$episode->anime->title ?? 'Bilinmeyen',
|
||||
$episode->episode_number,
|
||||
$episode->title ?? ''
|
||||
);
|
||||
|
||||
if (!$desc) {
|
||||
return response()->json(['error' => 'DeepSeek boş yanıt döndürdü veya hata oluştu.'], 500);
|
||||
}
|
||||
|
||||
$episode->update(['description' => $desc]);
|
||||
return response()->json(['ok' => true, 'description' => $desc]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anime için tüm meta verileri AI ile doldur.
|
||||
* POST { anime_id, title?, title_jp? }
|
||||
* Döner: { description, release_year, studio, type, status, rating, title_en, title_jp, genres[] }
|
||||
*/
|
||||
public function animeMeta(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$title = trim($request->input('title', ''));
|
||||
$titleJp = trim($request->input('title_jp', ''));
|
||||
|
||||
if (!$title) {
|
||||
return response()->json(['error' => 'Başlık boş olamaz.'], 422);
|
||||
}
|
||||
|
||||
$meta = $ai->generateAnimeMeta($title, $titleJp);
|
||||
|
||||
if (!$meta) {
|
||||
return response()->json(['error' => 'DeepSeek boş yanıt döndürdü veya hata oluştu.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'meta' => $meta]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek ile Türkçe açıklama üret.
|
||||
* POST body: { type: 'anime'|'episode', title, title_jp?, anime_title?, episode_number?, genres? }
|
||||
*/
|
||||
public function generate(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar > DeepSeek bölümüne ekleyin.'], 422);
|
||||
}
|
||||
|
||||
$type = $request->input('type', 'anime');
|
||||
$title = trim($request->input('title', ''));
|
||||
if (!$title) {
|
||||
return response()->json(['error' => 'Başlık boş olamaz.'], 422);
|
||||
}
|
||||
|
||||
if ($type === 'episode') {
|
||||
$desc = $ai->generateEpisodeDescription(
|
||||
trim($request->input('anime_title', $title)),
|
||||
(int) $request->input('episode_number', 1),
|
||||
trim($request->input('episode_title', ''))
|
||||
);
|
||||
} else {
|
||||
$desc = $ai->generateAnimeDescription(
|
||||
$title,
|
||||
trim($request->input('title_jp', '')),
|
||||
trim($request->input('genres', ''))
|
||||
);
|
||||
}
|
||||
|
||||
if (!$desc) {
|
||||
return response()->json(['error' => 'DeepSeek boş yanıt döndürdü veya bağlantı hatası.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['description' => $desc]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\PageView;
|
||||
use App\Models\Analytics\WatchEvent;
|
||||
use App\Models\Analytics\AiQuery;
|
||||
use App\Models\Analytics\VisitorSession;
|
||||
use App\Models\Anime;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$period = $request->input('period', '7d');
|
||||
$from = match ($period) {
|
||||
'today' => now()->startOfDay(),
|
||||
'30d' => now()->subDays(30),
|
||||
'90d' => now()->subDays(90),
|
||||
default => now()->subDays(7),
|
||||
};
|
||||
|
||||
$cacheKey = 'admin_analytics_' . $period;
|
||||
$cached = Cache::remember($cacheKey, 300, function () use ($from, $period) {
|
||||
return $this->buildAnalytics($from, $period);
|
||||
});
|
||||
extract($cached);
|
||||
|
||||
// Gerçek zamanlı veriler (cache'lenmiyor)
|
||||
$recentViews = PageView::with('user:id,name')
|
||||
->where('created_at', '>=', $from)
|
||||
->orderByDesc('id')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
$blockedIps = collect();
|
||||
$recentBots = collect();
|
||||
try {
|
||||
$blockedIps = DB::table('blocked_ips')->orderByDesc('blocked_at')->limit(20)->get();
|
||||
$recentBots = DB::table('analytics_bot_logs')->orderByDesc('id')->limit(30)->get();
|
||||
} catch (\Exception) {}
|
||||
|
||||
return view('admin.analytics.index', compact(
|
||||
'period', 'from',
|
||||
'totalViews', 'uniqueVisitors', 'watchSeconds', 'aiTotal', 'newUsers',
|
||||
'viewsDelta', 'todayViews', 'yesterdayViews',
|
||||
'trendLabels', 'trendData', 'watchTrendData',
|
||||
'hourlyData',
|
||||
'topAnimes',
|
||||
'topEpisodes',
|
||||
'deviceStats', 'browserStats', 'pageTypeStats',
|
||||
'geoStats',
|
||||
'activeUsers',
|
||||
'aiByType', 'aiTopQuestions', 'aiTopUsers',
|
||||
'recentViews',
|
||||
'referrerStats', 'directTraffic',
|
||||
'botViews', 'humanViews', 'botRatio', 'botTopIps', 'botByName',
|
||||
'blockedIps', 'recentBots',
|
||||
'sessions', 'avgSessionTime', 'avgPages',
|
||||
));
|
||||
}
|
||||
|
||||
private function buildAnalytics($from, string $period): array
|
||||
{
|
||||
// ── Özet kartlar ──────────────────────────────────────────────────────
|
||||
$totalViews = PageView::where('created_at', '>=', $from)->count();
|
||||
$uniqueVisitors = PageView::where('created_at', '>=', $from)->distinct('session_id')->count('session_id');
|
||||
$watchSeconds = WatchEvent::where('created_at', '>=', $from)->sum('seconds_watched');
|
||||
$aiTotal = AiQuery::where('created_at', '>=', $from)->count();
|
||||
$newUsers = User::where('created_at', '>=', $from)->count();
|
||||
|
||||
$yesterday = now()->subDay();
|
||||
$todayViews = PageView::where('created_at', '>=', now()->startOfDay())->count();
|
||||
$yesterdayViews = PageView::whereBetween('created_at', [$yesterday->startOfDay(), $yesterday->endOfDay()])->count();
|
||||
$viewsDelta = $yesterdayViews > 0 ? round(($todayViews - $yesterdayViews) / $yesterdayViews * 100) : 0;
|
||||
|
||||
// ── Görüntüleme trendi (gün bazlı) ────────────────────────────────────
|
||||
$viewsByDay = PageView::where('created_at', '>=', $from)
|
||||
->selectRaw('DATE(created_at) as date, COUNT(*) as cnt')
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->pluck('cnt', 'date');
|
||||
|
||||
$trendLabels = [];
|
||||
$trendData = [];
|
||||
$cur = clone $from;
|
||||
while ($cur->lte(now())) {
|
||||
$key = $cur->format('Y-m-d');
|
||||
$trendLabels[] = $cur->format($period === 'today' ? 'H:i' : 'd M');
|
||||
$trendData[] = $viewsByDay[$key] ?? 0;
|
||||
$cur->addDay();
|
||||
}
|
||||
|
||||
// ── Saatlik dağılım (bugün) ───────────────────────────────────────────
|
||||
$hourlyRaw = PageView::where('created_at', '>=', now()->startOfDay())
|
||||
->selectRaw('HOUR(created_at) as hour, COUNT(*) as cnt')
|
||||
->groupBy('hour')
|
||||
->pluck('cnt', 'hour');
|
||||
$hourlyData = array_map(fn($h) => $hourlyRaw[$h] ?? 0, range(0, 23));
|
||||
|
||||
// ── İzleme süresi trendi ─────────────────────────────────────────────
|
||||
$watchByDay = WatchEvent::where('created_at', '>=', $from)
|
||||
->selectRaw('DATE(created_at) as date, ROUND(SUM(seconds_watched)/3600, 1) as hours')
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->pluck('hours', 'date');
|
||||
$watchTrendData = array_map(fn($k) => (float)($watchByDay[$k] ?? 0), array_keys(array_flip($trendLabels)));
|
||||
|
||||
// ── Top 10 anime ─────────────────────────────────────────────────────
|
||||
$topAnimeIds = PageView::where('created_at', '>=', $from)
|
||||
->whereNotNull('anime_id')
|
||||
->selectRaw('anime_id, COUNT(*) as cnt')
|
||||
->groupBy('anime_id')
|
||||
->orderByDesc('cnt')
|
||||
->limit(10)
|
||||
->pluck('cnt', 'anime_id');
|
||||
|
||||
$topAnimes = Anime::whereIn('id', $topAnimeIds->keys())
|
||||
->get(['id', 'title', 'cover_image'])
|
||||
->map(fn($a) => [
|
||||
'title' => $a->title,
|
||||
'views' => $topAnimeIds[$a->id] ?? 0,
|
||||
'cover' => $a->cover_url,
|
||||
'slug' => $a->slug,
|
||||
])
|
||||
->sortByDesc('views')
|
||||
->values();
|
||||
|
||||
// ── Top bölümler ─────────────────────────────────────────────────────
|
||||
$topEpisodes = WatchEvent::where('analytics_watch_events.created_at', '>=', $from)
|
||||
->selectRaw('anime_id, season_number, episode_number, episode_id,
|
||||
SUM(seconds_watched) as total_sec,
|
||||
COUNT(*) as plays,
|
||||
ROUND(AVG(percent_complete), 0) as avg_pct')
|
||||
->groupBy('anime_id', 'season_number', 'episode_number', 'episode_id')
|
||||
->orderByDesc('total_sec')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$epAnimes = Anime::whereIn('id', $topEpisodes->pluck('anime_id')->unique())->pluck('title', 'id');
|
||||
$topEpisodes = $topEpisodes->map(fn($e) => [
|
||||
'anime' => $epAnimes[$e->anime_id] ?? 'Bilinmiyor',
|
||||
'label' => "S{$e->season_number}E{$e->episode_number}",
|
||||
'plays' => $e->plays,
|
||||
'hours' => round($e->total_sec / 3600, 1),
|
||||
'avg_pct' => $e->avg_pct,
|
||||
]);
|
||||
|
||||
// ── Cihaz / tarayıcı / sayfa türü ────────────────────────────────────
|
||||
$deviceStats = PageView::where('created_at', '>=', $from)
|
||||
->selectRaw('device, COUNT(*) as cnt')
|
||||
->groupBy('device')
|
||||
->pluck('cnt', 'device');
|
||||
|
||||
$browserStats = PageView::where('created_at', '>=', $from)
|
||||
->selectRaw('browser, COUNT(*) as cnt')
|
||||
->groupBy('browser')
|
||||
->orderByDesc('cnt')
|
||||
->pluck('cnt', 'browser');
|
||||
|
||||
$pageTypeStats = PageView::where('created_at', '>=', $from)
|
||||
->selectRaw('page_type, COUNT(*) as cnt')
|
||||
->groupBy('page_type')
|
||||
->orderByDesc('cnt')
|
||||
->pluck('cnt', 'page_type');
|
||||
|
||||
// ── Coğrafi dağılım ───────────────────────────────────────────────────
|
||||
$geoStats = PageView::where('created_at', '>=', $from)
|
||||
->whereNotNull('city')
|
||||
->selectRaw('city, country, COUNT(*) as cnt')
|
||||
->groupBy('city', 'country')
|
||||
->orderByDesc('cnt')
|
||||
->limit(15)
|
||||
->get(['city', 'country', DB::raw('COUNT(*) as cnt')]);
|
||||
|
||||
// ── En aktif kullanıcılar ─────────────────────────────────────────────
|
||||
$activeUserIds = PageView::where('created_at', '>=', $from)
|
||||
->whereNotNull('user_id')
|
||||
->selectRaw('user_id, COUNT(*) as views, COUNT(DISTINCT DATE(created_at)) as days')
|
||||
->groupBy('user_id')
|
||||
->orderByDesc('views')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$activeUserList = User::whereIn('id', $activeUserIds->pluck('user_id'))
|
||||
->get(['id', 'name', 'email', 'created_at'])
|
||||
->keyBy('id');
|
||||
|
||||
$activeUsers = $activeUserIds->map(fn($r) => [
|
||||
'user' => $activeUserList[$r->user_id] ?? null,
|
||||
'views' => $r->views,
|
||||
'days' => $r->days,
|
||||
])->filter(fn($r) => $r['user']);
|
||||
|
||||
// ── AI istatistikleri ─────────────────────────────────────────────────
|
||||
$aiByType = AiQuery::where('created_at', '>=', $from)
|
||||
->selectRaw('query_type, COUNT(*) as cnt')
|
||||
->groupBy('query_type')
|
||||
->orderByDesc('cnt')
|
||||
->pluck('cnt', 'query_type');
|
||||
|
||||
$aiTopQuestions = AiQuery::where('created_at', '>=', $from)
|
||||
->where('query_type', 'chat')
|
||||
->whereNotNull('query_text')
|
||||
->selectRaw('query_text, COUNT(*) as cnt')
|
||||
->groupBy('query_text')
|
||||
->orderByDesc('cnt')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$aiByUser = AiQuery::where('created_at', '>=', $from)
|
||||
->whereNotNull('user_id')
|
||||
->selectRaw('user_id, COUNT(*) as cnt')
|
||||
->groupBy('user_id')
|
||||
->orderByDesc('cnt')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
$aiUserList = User::whereIn('id', $aiByUser->pluck('user_id'))->pluck('name', 'id');
|
||||
$aiTopUsers = $aiByUser->map(fn($r) => [
|
||||
'name' => $aiUserList[$r->user_id] ?? 'Bilinmiyor',
|
||||
'cnt' => $r->cnt,
|
||||
]);
|
||||
|
||||
// ── Referrer ─────────────────────────────────────────────────────────
|
||||
$referrerRaw = PageView::where('created_at', '>=', $from)
|
||||
->whereNotNull('referrer')
|
||||
->where('referrer', '!=', '')
|
||||
->selectRaw('referrer, COUNT(*) as cnt')
|
||||
->groupBy('referrer')
|
||||
->orderByDesc('cnt')
|
||||
->limit(30)
|
||||
->pluck('cnt', 'referrer');
|
||||
|
||||
$referrerStats = collect();
|
||||
foreach ($referrerRaw as $url => $cnt) {
|
||||
try {
|
||||
$parsed = parse_url($url);
|
||||
$domain = $parsed['host'] ?? $url;
|
||||
$domain = preg_replace('/^www\./', '', $domain);
|
||||
} catch (\Throwable) {
|
||||
$domain = $url;
|
||||
}
|
||||
if ($referrerStats->has($domain)) {
|
||||
$referrerStats[$domain] += $cnt;
|
||||
} else {
|
||||
$referrerStats[$domain] = $cnt;
|
||||
}
|
||||
}
|
||||
$referrerStats = $referrerStats->sortDesc()->take(15);
|
||||
|
||||
$directTraffic = PageView::where('created_at', '>=', $from)
|
||||
->where(fn($q) => $q->whereNull('referrer')->orWhere('referrer', ''))
|
||||
->count();
|
||||
|
||||
// ── Bot istatistikleri ────────────────────────────────────────────────
|
||||
$botViews = 0;
|
||||
$humanViews = 0;
|
||||
$botRatio = 0;
|
||||
$botTopIps = collect();
|
||||
$botByName = collect();
|
||||
|
||||
try {
|
||||
$botViews = PageView::where('created_at', '>=', $from)->where('is_bot', 1)->count();
|
||||
$humanViews = PageView::where('created_at', '>=', $from)->where('is_bot', 0)->count();
|
||||
$botRatio = ($botViews + $humanViews) > 0 ? round($botViews / ($botViews + $humanViews) * 100) : 0;
|
||||
|
||||
$botTopIps = DB::table('analytics_bot_logs')
|
||||
->where('created_at', '>=', $from)
|
||||
->selectRaw('ip, COUNT(*) as cnt, MAX(user_agent) as ua, MAX(action) as action')
|
||||
->groupBy('ip')
|
||||
->orderByDesc('cnt')
|
||||
->limit(15)
|
||||
->get();
|
||||
|
||||
$botByName = DB::table('analytics_bot_logs')
|
||||
->where('created_at', '>=', $from)
|
||||
->selectRaw('bot_name, COUNT(*) as cnt, action')
|
||||
->groupBy('bot_name', 'action')
|
||||
->orderByDesc('cnt')
|
||||
->limit(20)
|
||||
->get();
|
||||
} catch (\Exception) {}
|
||||
|
||||
// ── Oturum istatistikleri ─────────────────────────────────────────────
|
||||
$sessions = collect();
|
||||
$avgSessionTime = 0;
|
||||
$avgPages = 0;
|
||||
|
||||
try {
|
||||
$avgSessionTime = (int) DB::table('analytics_sessions')
|
||||
->where('started_at', '>=', $from)
|
||||
->where('is_bot', 0)
|
||||
->avg('total_seconds');
|
||||
|
||||
$avgPages = round((float) DB::table('analytics_sessions')
|
||||
->where('started_at', '>=', $from)
|
||||
->where('is_bot', 0)
|
||||
->avg('pages_visited'), 1);
|
||||
|
||||
$sessions = DB::table('analytics_sessions')
|
||||
->where('started_at', '>=', $from)
|
||||
->orderByDesc('started_at')
|
||||
->limit(30)
|
||||
->get();
|
||||
} catch (\Exception) {}
|
||||
|
||||
return compact(
|
||||
'totalViews', 'uniqueVisitors', 'watchSeconds', 'aiTotal', 'newUsers',
|
||||
'viewsDelta', 'todayViews', 'yesterdayViews',
|
||||
'trendLabels', 'trendData', 'watchTrendData',
|
||||
'hourlyData',
|
||||
'topAnimes', 'topEpisodes',
|
||||
'deviceStats', 'browserStats', 'pageTypeStats',
|
||||
'geoStats',
|
||||
'activeUsers',
|
||||
'aiByType', 'aiTopQuestions', 'aiTopUsers',
|
||||
'referrerStats', 'directTraffic',
|
||||
'botViews', 'humanViews', 'botRatio', 'botTopIps', 'botByName',
|
||||
'sessions', 'avgSessionTime', 'avgPages'
|
||||
);
|
||||
}
|
||||
|
||||
public function blockIp(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'ip' => 'required|ip',
|
||||
'reason' => 'nullable|string|max:255',
|
||||
'expires_at' => 'nullable|date|after:now',
|
||||
]);
|
||||
|
||||
DB::table('blocked_ips')->updateOrInsert(
|
||||
['ip' => $data['ip']],
|
||||
[
|
||||
'reason' => $data['reason'] ?? 'Manuel engel',
|
||||
'auto_blocked' => 0,
|
||||
'blocked_at' => now(),
|
||||
'expires_at' => $data['expires_at'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
\Illuminate\Support\Facades\Cache::forget('blocked_ip_' . $data['ip']);
|
||||
return back()->with('success', $data['ip'] . ' engellendi.');
|
||||
}
|
||||
|
||||
public function unblockIp(Request $request)
|
||||
{
|
||||
$ip = $request->input('ip');
|
||||
DB::table('blocked_ips')->where('ip', $ip)->delete();
|
||||
\Illuminate\Support\Facades\Cache::forget('blocked_ip_' . $ip);
|
||||
return back()->with('success', $ip . ' engeli kaldırıldı.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Season;
|
||||
use App\Models\ContentPermission;
|
||||
use App\Models\Genre;
|
||||
use App\Models\PermissionSetting;
|
||||
use App\Services\JikanService;
|
||||
use App\Support\ImageOptimizer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AnimeController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Anime::with('genres')->latest();
|
||||
|
||||
if ($request->search) {
|
||||
$query->where('title', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
if ($request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
if ($request->type) {
|
||||
$query->where('type', $request->type);
|
||||
}
|
||||
if ($request->no_episodes) {
|
||||
$query->whereDoesntHave('episodes');
|
||||
}
|
||||
|
||||
$animes = $query->paginate(20)->withQueryString();
|
||||
$zeroEpisodeCount = Anime::whereDoesntHave('episodes')->count();
|
||||
return view('admin.animes.index', compact('animes', 'zeroEpisodeCount'));
|
||||
}
|
||||
|
||||
public function destroyZeroEpisodes()
|
||||
{
|
||||
$animes = Anime::whereDoesntHave('episodes')->get();
|
||||
$count = $animes->count();
|
||||
foreach ($animes as $anime) {
|
||||
$anime->delete();
|
||||
}
|
||||
return response()->json(['success' => true, 'count' => $count]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$genres = Genre::where('is_active', true)->get();
|
||||
$permissions = PermissionSetting::all();
|
||||
return view('admin.animes.create', compact('genres', 'permissions'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'title_en' => 'nullable|string|max:255',
|
||||
'title_jp' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|in:series,movie,ova,ona,special',
|
||||
'status' => 'required|in:ongoing,completed,upcoming',
|
||||
'release_year' => 'nullable|integer|min:1900|max:2099',
|
||||
'studio' => 'nullable|string|max:255',
|
||||
'rating' => 'nullable|numeric|min:0|max:10',
|
||||
'mal_id' => 'nullable|string|max:50',
|
||||
'trailer_url' => 'nullable|url',
|
||||
'is_featured' => 'boolean',
|
||||
'is_published' => 'boolean',
|
||||
'is_dubbed' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['slug'] = Str::slug($data['title']);
|
||||
$data['is_featured'] = $request->boolean('is_featured');
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
$data['is_dubbed'] = $request->boolean('is_dubbed');
|
||||
|
||||
// Auto-fetch MAL ID if not provided
|
||||
if (empty($data['mal_id'])) {
|
||||
try {
|
||||
$data['mal_id'] = (new JikanService())->searchMalId(
|
||||
$data['title'],
|
||||
$data['title_en'] ?? null,
|
||||
$data['title_jp'] ?? null,
|
||||
$data['type'] ?? null,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
if ($request->hasFile('cover_image')) {
|
||||
$data['cover_image'] = ImageOptimizer::store($request->file('cover_image'), 'covers', 'cover');
|
||||
}
|
||||
if ($request->hasFile('banner_image')) {
|
||||
$data['banner_image'] = ImageOptimizer::store($request->file('banner_image'), 'banners', 'banner');
|
||||
}
|
||||
|
||||
$anime = Anime::create($data);
|
||||
|
||||
if ($request->genres) {
|
||||
$anime->genres()->sync($request->genres);
|
||||
}
|
||||
|
||||
// Auto-fill season MAL IDs if mal_id was found
|
||||
if ($anime->mal_id) {
|
||||
dispatch(function () use ($anime) {
|
||||
try {
|
||||
$chain = (new JikanService())->fetchSeasonMalIds($anime->mal_id);
|
||||
foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) {
|
||||
if (isset($chain[$i])) $season->update(['mal_id' => $chain[$i]]);
|
||||
}
|
||||
} catch (\Throwable) {}
|
||||
})->afterResponse();
|
||||
}
|
||||
|
||||
return redirect()->route('admin.animes.show', $anime)->with('success', 'Anime eklendi.');
|
||||
}
|
||||
|
||||
public function show(Anime $anime)
|
||||
{
|
||||
$anime->load(['genres', 'seasons.episodes']);
|
||||
$permissions = PermissionSetting::all();
|
||||
$contentPerms = ContentPermission::where('content_type', 'anime')
|
||||
->where('content_id', $anime->id)
|
||||
->pluck('required_membership', 'permission_key');
|
||||
|
||||
return view('admin.animes.show', compact('anime', 'permissions', 'contentPerms'));
|
||||
}
|
||||
|
||||
public function edit(Anime $anime)
|
||||
{
|
||||
$genres = Genre::where('is_active', true)->get();
|
||||
$permissions = PermissionSetting::all();
|
||||
$contentPerms = ContentPermission::where('content_type', 'anime')
|
||||
->where('content_id', $anime->id)
|
||||
->pluck('required_membership', 'permission_key');
|
||||
|
||||
return view('admin.animes.edit', compact('anime', 'genres', 'permissions', 'contentPerms'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Anime $anime)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'title_en' => 'nullable|string|max:255',
|
||||
'title_jp' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|in:series,movie,ova,ona,special',
|
||||
'status' => 'required|in:ongoing,completed,upcoming',
|
||||
'release_year' => 'nullable|integer|min:1900|max:2099',
|
||||
'studio' => 'nullable|string|max:255',
|
||||
'rating' => 'nullable|numeric|min:0|max:10',
|
||||
'mal_id' => 'nullable|string|max:50',
|
||||
'trailer_url' => 'nullable|url',
|
||||
'is_featured' => 'boolean',
|
||||
'is_published' => 'boolean',
|
||||
'is_dubbed' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['is_featured'] = $request->boolean('is_featured');
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
$data['is_dubbed'] = $request->boolean('is_dubbed');
|
||||
|
||||
// Auto-fetch MAL ID if not provided and anime doesn't already have one
|
||||
if (empty($data['mal_id']) && empty($anime->mal_id)) {
|
||||
try {
|
||||
$data['mal_id'] = (new JikanService())->searchMalId(
|
||||
$data['title'],
|
||||
$data['title_en'] ?? null,
|
||||
$data['title_jp'] ?? null,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
if ($request->hasFile('cover_image')) {
|
||||
ImageOptimizer::delete($anime->cover_image);
|
||||
$data['cover_image'] = ImageOptimizer::store($request->file('cover_image'), 'covers', 'cover');
|
||||
}
|
||||
if ($request->hasFile('banner_image')) {
|
||||
ImageOptimizer::delete($anime->banner_image);
|
||||
$data['banner_image'] = ImageOptimizer::store($request->file('banner_image'), 'banners', 'banner');
|
||||
}
|
||||
|
||||
$anime->update($data);
|
||||
|
||||
if ($request->has('genres')) {
|
||||
$anime->genres()->sync($request->genres ?? []);
|
||||
}
|
||||
|
||||
// MAL ID değiştiyse: AniSkip cache'lerini temizle + sezon MAL ID'lerini doldur
|
||||
if ($anime->mal_id) {
|
||||
dispatch(function () use ($anime) {
|
||||
try {
|
||||
// AniSkip null cache'lerini temizle (tüm bölümler için)
|
||||
foreach ($anime->seasons as $s) {
|
||||
if ($s->mal_id) {
|
||||
foreach ($anime->episodes()->where('season_id', $s->id)->pluck('episode_number') as $epNum) {
|
||||
\Illuminate\Support\Facades\Cache::forget("aniskip_{$s->mal_id}_{$epNum}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// S1 için doğrudan anime.mal_id kullan
|
||||
$s1 = $anime->seasons()->where('season_number', 1)->first();
|
||||
if ($s1 && !$s1->mal_id) {
|
||||
$s1->update(['mal_id' => $anime->mal_id]);
|
||||
foreach ($anime->episodes()->where('season_id', $s1->id)->pluck('episode_number') as $epNum) {
|
||||
\Illuminate\Support\Facades\Cache::forget("aniskip_{$anime->mal_id}_{$epNum}");
|
||||
}
|
||||
}
|
||||
// S2+ için Jikan chain
|
||||
$chain = (new JikanService())->fetchSeasonMalIds($anime->mal_id);
|
||||
\Illuminate\Support\Facades\Cache::put("jikan_chain_{$anime->mal_id}", $chain, 60 * 60 * 24 * 7);
|
||||
foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) {
|
||||
if (!$season->mal_id && isset($chain[$i])) {
|
||||
$season->update(['mal_id' => $chain[$i]]);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {}
|
||||
})->afterResponse();
|
||||
}
|
||||
|
||||
return redirect()->route('admin.animes.show', $anime)->with('success', 'Anime güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(Anime $anime)
|
||||
{
|
||||
// CDN klasörü için örnek bir video_url al (anime_XXXXX/ path'ini çıkarmak için)
|
||||
$sampleVideoUrl = $anime->episodes()->whereNotNull('video_url')->value('video_url');
|
||||
|
||||
$anime->delete();
|
||||
|
||||
// CDN'den tüm anime klasörünü arka planda sil (anime_XXXXX/season_X/...)
|
||||
dispatch(function () use ($sampleVideoUrl) {
|
||||
\App\Services\BunnyCdnStorage::deleteAnimeFolder($sampleVideoUrl);
|
||||
})->afterResponse();
|
||||
|
||||
return redirect()->route('admin.animes.index')->with('success', 'Anime silindi.');
|
||||
}
|
||||
|
||||
public function updatePermissions(Request $request, Anime $anime)
|
||||
{
|
||||
$permissions = $request->permissions ?? [];
|
||||
|
||||
// Mevcut override'ları sil
|
||||
ContentPermission::where('content_type', 'anime')
|
||||
->where('content_id', $anime->id)
|
||||
->delete();
|
||||
|
||||
// Yeni override'ları kaydet
|
||||
foreach ($permissions as $key => $value) {
|
||||
if (in_array($value, ['free', 'premium'])) {
|
||||
ContentPermission::create([
|
||||
'content_type' => 'anime',
|
||||
'content_id' => $anime->id,
|
||||
'permission_key' => $key,
|
||||
'required_membership' => $value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'İzinler güncellendi.');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST admin/animes/{anime}/fetch-mal-seasons
|
||||
* Walks the Jikan sequel chain and fills seasons.mal_id automatically.
|
||||
*/
|
||||
public function fetchMalSeasons(Request $request, Anime $anime)
|
||||
{
|
||||
// Formdan gelen mal_id varsa önce güncelle
|
||||
if ($request->filled('mal_id')) {
|
||||
$anime->update(['mal_id' => $request->input('mal_id')]);
|
||||
}
|
||||
|
||||
if (!$anime->mal_id) {
|
||||
return response()->json(['error' => 'MAL ID girilmemiş. MyAnimeList.net\'ten anime sayfasını açıp URL\'deki numarayı gir.'], 422);
|
||||
}
|
||||
|
||||
$jikan = new JikanService();
|
||||
$chain = $jikan->fetchSeasonMalIds($anime->mal_id);
|
||||
|
||||
if (empty($chain)) {
|
||||
return response()->json(['error' => 'Jikan API\'den veri alınamadı.'], 502);
|
||||
}
|
||||
|
||||
$seasons = Season::where('anime_id', $anime->id)
|
||||
->orderBy('season_number')
|
||||
->get();
|
||||
|
||||
$updated = [];
|
||||
foreach ($seasons as $index => $season) {
|
||||
$malId = $chain[$index] ?? null;
|
||||
if ($malId) {
|
||||
$season->update(['mal_id' => $malId]);
|
||||
$updated[] = [
|
||||
'season' => $season->season_number,
|
||||
'mal_id' => $malId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// If anime has more seasons than chain entries, remaining seasons stay null
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'chain' => $chain,
|
||||
'updated' => $updated,
|
||||
'message' => count($updated) . ' sezon güncellendi.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST admin/animes/{anime}/fetch-mal
|
||||
* Tek bir anime için MAL ID arar ve kaydeder.
|
||||
*/
|
||||
public function fetchMalSingle(Anime $anime)
|
||||
{
|
||||
try {
|
||||
$malId = (new JikanService())->searchMalId(
|
||||
$anime->title, $anime->title_en, $anime->title_jp, $anime->type
|
||||
);
|
||||
if ($malId) {
|
||||
$anime->update(['mal_id' => $malId]);
|
||||
$s1 = $anime->seasons()->where('season_number', 1)->first();
|
||||
if ($s1 && !$s1->mal_id) $s1->update(['mal_id' => $malId]);
|
||||
return response()->json(['found' => true, 'mal_id' => $malId]);
|
||||
}
|
||||
return response()->json(['found' => false]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['found' => false, 'error' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function bulkDestroy(Request $request)
|
||||
{
|
||||
if ($request->boolean('all')) {
|
||||
$query = Anime::query();
|
||||
$f = $request->input('filters', []);
|
||||
if (!empty($f['search'])) $query->where('title', 'like', '%'.$f['search'].'%');
|
||||
if (!empty($f['status'])) $query->where('status', $f['status']);
|
||||
if (!empty($f['type'])) $query->where('type', $f['type']);
|
||||
$animes = $query->get();
|
||||
} else {
|
||||
$request->validate(['ids' => 'required|array', 'ids.*' => 'integer']);
|
||||
$animes = Anime::whereIn('id', $request->ids)->get();
|
||||
}
|
||||
|
||||
$sampleUrls = [];
|
||||
foreach ($animes as $anime) {
|
||||
$url = $anime->episodes()->whereNotNull('video_url')->value('video_url');
|
||||
if ($url) $sampleUrls[] = $url;
|
||||
$anime->delete();
|
||||
}
|
||||
|
||||
dispatch(function () use ($sampleUrls) {
|
||||
foreach ($sampleUrls as $url) {
|
||||
\App\Services\BunnyCdnStorage::deleteAnimeFolder($url);
|
||||
}
|
||||
})->afterResponse();
|
||||
|
||||
return response()->json(['success' => true, 'deleted' => count($animes)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST admin/animes/bulk-find-mal
|
||||
* MAL ID'si olmayan animeleri Jikan title search ile toplu doldurur.
|
||||
* Her seferinde 1 anime işler (AJAX loop), Jikan rate limit aşılmaz.
|
||||
*/
|
||||
public function bulkFindMal(Request $request)
|
||||
{
|
||||
$skipIds = $request->input('skip_ids', []);
|
||||
|
||||
$anime = Anime::where(fn($q) => $q->whereNull('mal_id')->orWhere('mal_id', ''))
|
||||
->when($skipIds, fn($q) => $q->whereNotIn('id', $skipIds))
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (!$anime) {
|
||||
return response()->json(['done' => true, 'message' => 'Tüm animelerin MAL ID\'si dolu!']);
|
||||
}
|
||||
|
||||
$jikan = new JikanService();
|
||||
$malId = $jikan->searchMalId($anime->title, $anime->title_en, $anime->title_jp, $anime->type);
|
||||
|
||||
if ($malId) {
|
||||
$anime->update(['mal_id' => $malId]);
|
||||
|
||||
// S1 için season.mal_id de doldur
|
||||
$s1 = $anime->seasons()->where('season_number', 1)->first();
|
||||
if ($s1 && !$s1->mal_id) $s1->update(['mal_id' => $malId]);
|
||||
|
||||
return response()->json([
|
||||
'done' => false,
|
||||
'found' => true,
|
||||
'anime' => $anime->title,
|
||||
'mal_id' => $malId,
|
||||
'remaining' => Anime::whereNull('mal_id')->orWhere('mal_id', '')->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Bulunamadı — bir sonrakine geç (geçici olarak dummy değer koy, sonra temizle)
|
||||
return response()->json([
|
||||
'done' => false,
|
||||
'found' => false,
|
||||
'anime' => $anime->title,
|
||||
'mal_id' => null,
|
||||
'remaining' => Anime::whereNull('mal_id')->orWhere('mal_id', '')->count() - 1,
|
||||
'skipped_id' => $anime->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AnimeRequest;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AnimeRequestController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$status = $request->input('status', 'pending');
|
||||
|
||||
$requests = AnimeRequest::with('user:id,name,email')
|
||||
->when($status !== 'all', fn($q) => $q->where('status', $status))
|
||||
->orderByDesc('vote_count')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(30);
|
||||
|
||||
$counts = AnimeRequest::selectRaw('status, COUNT(*) as cnt')
|
||||
->groupBy('status')
|
||||
->pluck('cnt', 'status');
|
||||
|
||||
return view('admin.anime-requests.index', compact('requests', 'counts', 'status'));
|
||||
}
|
||||
|
||||
public function update(Request $request, AnimeRequest $animeRequest)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'status' => 'required|in:pending,approved,rejected,added',
|
||||
'admin_note' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$animeRequest->update($data);
|
||||
|
||||
return back()->with('success', 'İstek güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(AnimeRequest $animeRequest)
|
||||
{
|
||||
$animeRequest->delete();
|
||||
return back()->with('success', 'İstek silindi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin()
|
||||
{
|
||||
if (Auth::check() && Auth::user()->isAdmin()) {
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
return view('admin.auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
if (Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) {
|
||||
if (!Auth::user()->isAdmin() && !Auth::user()->isModerator()) {
|
||||
Auth::logout();
|
||||
return back()->withErrors(['email' => 'Bu hesabın yönetici yetkisi yok.']);
|
||||
}
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
|
||||
return back()->withErrors(['email' => 'E-posta veya şifre hatalı.']);
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Banner;
|
||||
use App\Support\ImageOptimizer;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BannerController extends Controller
|
||||
{
|
||||
public function create() { return redirect()->route('admin.banners.index'); }
|
||||
public function show(Banner $banner) { return redirect()->route('admin.banners.index'); }
|
||||
public function edit(Banner $banner) { return redirect()->route('admin.banners.index'); }
|
||||
|
||||
public function index()
|
||||
{
|
||||
$banners = Banner::orderBy('sort_order')->get();
|
||||
return view('admin.banners.index', compact('banners'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'link' => 'nullable|url',
|
||||
'sort_order' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
$data['image'] = ImageOptimizer::store($request->file('image'), 'banners', 'site_banner');
|
||||
} else {
|
||||
return back()->withErrors(['image' => 'Görsel zorunludur.']);
|
||||
}
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
Banner::create($data);
|
||||
return back()->with('success', 'Banner eklendi.');
|
||||
}
|
||||
|
||||
public function update(Request $request, Banner $banner)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'link' => 'nullable|url',
|
||||
'sort_order' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
$data['image'] = ImageOptimizer::store($request->file('image'), 'banners', 'site_banner');
|
||||
}
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$banner->update($data);
|
||||
return back()->with('success', 'Banner güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(Banner $banner)
|
||||
{
|
||||
$banner->delete();
|
||||
return back()->with('success', 'Banner silindi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\BlogPost;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BlogController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$q = $request->get('q');
|
||||
$posts = BlogPost::with('anime')
|
||||
->when($q, fn($query) => $query->where('title', 'like', "%{$q}%"))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
$stats = [
|
||||
'total' => BlogPost::count(),
|
||||
'published' => BlogPost::where('status', 'published')->count(),
|
||||
'draft' => BlogPost::where('status', 'draft')->count(),
|
||||
'ai' => BlogPost::where('ai_generated', true)->count(),
|
||||
];
|
||||
|
||||
return view('admin.blog.index', compact('posts', 'stats', 'q'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$animes = Anime::where('is_published', true)->orderBy('title')->get(['id', 'title']);
|
||||
$post = new BlogPost();
|
||||
return view('admin.blog.edit', compact('post', 'animes'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $this->validated($request);
|
||||
$data['slug'] = BlogPost::generateSlug($data['title']);
|
||||
$data['published_at'] = $data['status'] === 'published' ? now() : null;
|
||||
BlogPost::create($data);
|
||||
return redirect()->route('admin.blog.index')->with('success', 'Blog yazısı oluşturuldu.');
|
||||
}
|
||||
|
||||
public function edit(BlogPost $blog)
|
||||
{
|
||||
$animes = Anime::where('is_published', true)->orderBy('title')->get(['id', 'title']);
|
||||
return view('admin.blog.edit', compact('blog', 'animes'));
|
||||
}
|
||||
|
||||
public function update(Request $request, BlogPost $blog)
|
||||
{
|
||||
$data = $this->validated($request);
|
||||
if ($data['status'] === 'published' && !$blog->published_at) {
|
||||
$data['published_at'] = now();
|
||||
}
|
||||
$blog->update($data);
|
||||
return redirect()->route('admin.blog.index')->with('success', 'Blog yazısı güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(BlogPost $blog)
|
||||
{
|
||||
$blog->delete();
|
||||
return redirect()->route('admin.blog.index')->with('success', 'Blog yazısı silindi.');
|
||||
}
|
||||
|
||||
public function generateAi(Request $request, DeepSeekService $deepseek)
|
||||
{
|
||||
$request->validate(['anime_id' => 'required|exists:animes,id']);
|
||||
|
||||
if (!$deepseek->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API anahtarı ayarlanmamış. Admin > Ayarlar > deepseek_api_key'], 422);
|
||||
}
|
||||
|
||||
set_time_limit(120);
|
||||
|
||||
$anime = Anime::with('genres')->findOrFail($request->anime_id);
|
||||
$genreIds = $anime->genres->pluck('id');
|
||||
$related = Anime::where('is_published', true)
|
||||
->where('id', '!=', $anime->id)
|
||||
->whereHas('genres', fn($q) => $q->whereIn('genres.id', $genreIds))
|
||||
->orderByDesc('rating')
|
||||
->limit(5)
|
||||
->get(['id', 'title', 'slug'])
|
||||
->map(fn($a) => ['slug' => $a->slug, 'title' => $a->title])
|
||||
->toArray();
|
||||
|
||||
$data = $deepseek->generateBlogPost($anime, $related);
|
||||
|
||||
if (!$data || empty($data['content'])) {
|
||||
return response()->json(['error' => 'AI içerik üretemedi: ' . $deepseek->lastError], 422);
|
||||
}
|
||||
|
||||
$content = preg_replace_callback(
|
||||
'/\[LINK:([^\]]+)\]([^\[]*)\[\/LINK\]/',
|
||||
function ($m) {
|
||||
$slug = trim($m[1]);
|
||||
$label = trim($m[2]);
|
||||
try {
|
||||
return '<a href="' . route('anime.show', $slug) . '">' . $label . '</a>';
|
||||
} catch (\Exception $e) {
|
||||
return $label;
|
||||
}
|
||||
},
|
||||
$data['content']
|
||||
);
|
||||
|
||||
$linkedIds = [];
|
||||
if (!empty($data['linked_slugs'])) {
|
||||
$linkedIds = Anime::whereIn('slug', $data['linked_slugs'])->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'title' => $data['title'] ?? '',
|
||||
'excerpt' => $data['excerpt'] ?? '',
|
||||
'content' => $content,
|
||||
'focus_keyword' => $data['focus_keyword'] ?? $anime->title,
|
||||
'meta_description' => $data['meta_description'] ?? '',
|
||||
'faq' => $data['faq'] ?? [],
|
||||
'linked_anime_ids' => $linkedIds,
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkGenerate(Request $request)
|
||||
{
|
||||
$count = min(5, (int) $request->get('count', 3));
|
||||
set_time_limit(300);
|
||||
try {
|
||||
\Artisan::call('animexe:generate-blogs', ['--count' => $count, '--force' => false]);
|
||||
$output = \Artisan::output();
|
||||
return redirect()->route('admin.blog.index')->with('success', 'AI blog üretimi tamamlandı: ' . trim($output));
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('admin.blog.index')->with('error', 'Hata: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function validated(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'excerpt' => 'nullable|string',
|
||||
'content' => 'nullable|string',
|
||||
'cover_image' => 'nullable|string|max:500',
|
||||
'focus_keyword' => 'nullable|string|max:255',
|
||||
'meta_title' => 'nullable|string|max:255',
|
||||
'meta_description' => 'nullable|string',
|
||||
'meta_keywords' => 'nullable|string',
|
||||
'status' => 'required|in:draft,published',
|
||||
'anime_id' => 'nullable|exists:animes,id',
|
||||
'reading_time' => 'nullable|integer|min:1|max:60',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Comment;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Comment::with([
|
||||
'user',
|
||||
'commentable' => fn(MorphTo $m) => $m->constrain([
|
||||
\App\Models\Episode::class => fn($q) => $q->with('season.anime'),
|
||||
\App\Models\Anime::class => fn($q) => $q,
|
||||
]),
|
||||
])->latest();
|
||||
|
||||
if ($request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
if ($request->search) {
|
||||
$query->where('content', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
if ($request->user_id) {
|
||||
$query->where('user_id', $request->user_id);
|
||||
}
|
||||
|
||||
$comments = $query->paginate(30)->withQueryString();
|
||||
return view('admin.comments.index', compact('comments'));
|
||||
}
|
||||
|
||||
public function show(Comment $comment)
|
||||
{
|
||||
$comment->load(['user', 'replies.user', 'parent.user']);
|
||||
return view('admin.comments.show', compact('comment'));
|
||||
}
|
||||
|
||||
public function approve(Comment $comment)
|
||||
{
|
||||
$comment->update(['status' => 'approved']);
|
||||
return back()->with('success', 'Yorum onaylandı.');
|
||||
}
|
||||
|
||||
public function reject(Comment $comment)
|
||||
{
|
||||
$comment->update(['status' => 'rejected']);
|
||||
return back()->with('success', 'Yorum reddedildi.');
|
||||
}
|
||||
|
||||
public function pin(Comment $comment)
|
||||
{
|
||||
$comment->update(['is_pinned' => !$comment->is_pinned]);
|
||||
$msg = $comment->is_pinned ? 'Yorum sabitlendi.' : 'Yorum sabit kaldırıldı.';
|
||||
return back()->with('success', $msg);
|
||||
}
|
||||
|
||||
public function destroy(Comment $comment)
|
||||
{
|
||||
$comment->delete();
|
||||
return back()->with('success', 'Yorum silindi.');
|
||||
}
|
||||
|
||||
public function reply(Request $request, Comment $comment)
|
||||
{
|
||||
$data = $request->validate(['content' => 'required|string|max:2000']);
|
||||
|
||||
Comment::create([
|
||||
'user_id' => auth()->id(),
|
||||
'commentable_type' => $comment->commentable_type,
|
||||
'commentable_id' => $comment->commentable_id,
|
||||
'parent_id' => $comment->id,
|
||||
'content' => $data['content'],
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Yanıt gönderildi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Season;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ContentStatsController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// ── Özet sayılar ──────────────────────────────────────────────────────
|
||||
$totalAnimes = Anime::count();
|
||||
$publishedAnimes= Anime::where('is_published', true)->count();
|
||||
$totalEpisodes = Episode::count();
|
||||
$publishedEps = Episode::where('is_published', true)->count();
|
||||
$totalSeasons = Season::count();
|
||||
|
||||
// ── Son 365 gün — günlük bölüm yükleme (ısı haritası için) ───────────
|
||||
$epsByDay = Episode::selectRaw('DATE(created_at) as day, COUNT(*) as cnt')
|
||||
->where('created_at', '>=', now()->subYear())
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->pluck('cnt', 'day');
|
||||
|
||||
// ── Son 365 gün — günlük anime yükleme ───────────────────────────────
|
||||
$animesByDay = Anime::selectRaw('DATE(created_at) as day, COUNT(*) as cnt')
|
||||
->where('created_at', '>=', now()->subYear())
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->pluck('cnt', 'day');
|
||||
|
||||
// ── Son 90 gün trend (chart için) ─────────────────────────────────────
|
||||
$from90 = now()->subDays(89)->startOfDay();
|
||||
$trendLabels = [];
|
||||
$epTrendData = [];
|
||||
$animeTrendData = [];
|
||||
$cur = clone $from90;
|
||||
while ($cur->lte(now())) {
|
||||
$key = $cur->format('Y-m-d');
|
||||
$trendLabels[] = $cur->format('d M');
|
||||
$epTrendData[] = (int)($epsByDay[$key] ?? 0);
|
||||
$animeTrendData[] = (int)($animesByDay[$key] ?? 0);
|
||||
$cur->addDay();
|
||||
}
|
||||
|
||||
// ── Saatlik yükleme dağılımı (tüm zamanlar) ──────────────────────────
|
||||
$hourlyEps = Episode::selectRaw('HOUR(created_at) as hour, COUNT(*) as cnt')
|
||||
->groupBy('hour')
|
||||
->pluck('cnt', 'hour');
|
||||
$hourlyEpsData = array_map(fn($h) => (int)($hourlyEps[$h] ?? 0), range(0, 23));
|
||||
|
||||
// ── Haftanın günlerine göre dağılım ───────────────────────────────────
|
||||
$weekdayEps = Episode::selectRaw('DAYOFWEEK(created_at) as dow, COUNT(*) as cnt')
|
||||
->groupBy('dow')
|
||||
->pluck('cnt', 'dow');
|
||||
// MySQL DAYOFWEEK: 1=Pazar, 2=Pazartesi, ..., 7=Cumartesi
|
||||
$weekdayLabels = ['Paz', 'Pzt', 'Sal', 'Çar', 'Per', 'Cum', 'Cmt'];
|
||||
$weekdayData = array_map(fn($d) => (int)($weekdayEps[$d] ?? 0), range(1, 7));
|
||||
|
||||
// ── Aylık dağılım (son 24 ay) ─────────────────────────────────────────
|
||||
$monthlyEps = Episode::selectRaw('DATE_FORMAT(created_at, "%Y-%m") as mon, COUNT(*) as cnt')
|
||||
->where('created_at', '>=', now()->subMonths(24))
|
||||
->groupBy('mon')
|
||||
->orderBy('mon')
|
||||
->pluck('cnt', 'mon');
|
||||
|
||||
$monthLabels = [];
|
||||
$monthData = [];
|
||||
$mCur = now()->subMonths(23)->startOfMonth();
|
||||
while ($mCur->lte(now())) {
|
||||
$key = $mCur->format('Y-m');
|
||||
$monthLabels[] = $mCur->format('M y');
|
||||
$monthData[] = (int)($monthlyEps[$key] ?? 0);
|
||||
$mCur->addMonth();
|
||||
}
|
||||
|
||||
// ── Top 10 en fazla bölüm olan anime ──────────────────────────────────
|
||||
$topByEpisodes = Anime::withCount('episodes')
|
||||
->orderByDesc('episodes_count')
|
||||
->limit(10)
|
||||
->get(['id', 'title', 'slug', 'cover_image', 'status', 'type']);
|
||||
|
||||
// ── Son eklenen 20 bölüm ───────────────────────────────────────────────
|
||||
$recentEpisodes = Episode::with(['anime:id,title,slug', 'season:id,season_number'])
|
||||
->orderByDesc('created_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// ── Son eklenen 10 anime ───────────────────────────────────────────────
|
||||
$recentAnimes = Anime::orderByDesc('created_at')
|
||||
->limit(10)
|
||||
->get(['id', 'title', 'slug', 'cover_image', 'type', 'status', 'is_published', 'created_at']);
|
||||
|
||||
// ── Isı haritası verisi (52 hafta × 7 gün) ────────────────────────────
|
||||
$heatStart = now()->subWeeks(51)->startOfWeek(\Carbon\Carbon::MONDAY);
|
||||
$heatData = [];
|
||||
for ($w = 0; $w < 52; $w++) {
|
||||
$week = [];
|
||||
for ($d = 0; $d < 7; $d++) {
|
||||
$day = $heatStart->copy()->addDays($w * 7 + $d);
|
||||
$key = $day->format('Y-m-d');
|
||||
$week[] = [
|
||||
'date' => $key,
|
||||
'cnt' => (int)($epsByDay[$key] ?? 0),
|
||||
];
|
||||
}
|
||||
$heatData[] = $week;
|
||||
}
|
||||
|
||||
// ── Tür bazlı bölüm sayısı ────────────────────────────────────────────
|
||||
$genreEpStats = DB::table('anime_genre')
|
||||
->join('genres', 'genres.id', '=', 'anime_genre.genre_id')
|
||||
->join('episodes', 'episodes.anime_id', '=', 'anime_genre.anime_id')
|
||||
->select('genres.name', DB::raw('COUNT(episodes.id) as ep_count'))
|
||||
->groupBy('genres.id', 'genres.name')
|
||||
->orderByDesc('ep_count')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
return view('admin.stats.index', compact(
|
||||
'totalAnimes', 'publishedAnimes', 'totalEpisodes', 'publishedEps', 'totalSeasons',
|
||||
'trendLabels', 'epTrendData', 'animeTrendData',
|
||||
'hourlyEpsData',
|
||||
'weekdayLabels', 'weekdayData',
|
||||
'monthLabels', 'monthData',
|
||||
'topByEpisodes',
|
||||
'recentEpisodes', 'recentAnimes',
|
||||
'heatData',
|
||||
'genreEpStats',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$stats = [
|
||||
'total_users' => User::count(),
|
||||
'premium_users' => User::where('membership', 'premium')->count(),
|
||||
'total_animes' => Anime::count(),
|
||||
'total_episodes' => Episode::count(),
|
||||
'total_comments' => Comment::count(),
|
||||
'pending_comments' => Comment::where('status', 'pending')->count(),
|
||||
'active_subs' => Subscription::where('status', 'active')->count(),
|
||||
];
|
||||
|
||||
$recent_users = User::latest()->take(5)->get();
|
||||
$recent_comments = Comment::with('user')->latest()->take(5)->get();
|
||||
$recent_animes = Anime::latest()->take(5)->get();
|
||||
|
||||
return view('admin.dashboard', compact('stats', 'recent_users', 'recent_comments', 'recent_animes'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\ContentPermission;
|
||||
use App\Models\Episode;
|
||||
use App\Models\PermissionSetting;
|
||||
use App\Models\Season;
|
||||
use App\Models\VideoSource;
|
||||
use App\Support\ImageOptimizer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class EpisodeController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Episode::with(['anime', 'season'])->latest();
|
||||
|
||||
if ($request->anime_id) {
|
||||
$query->where('anime_id', $request->anime_id);
|
||||
}
|
||||
if ($request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
if ($request->search) {
|
||||
$query->where('title', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
|
||||
$episodes = $query->paginate(30)->withQueryString();
|
||||
$animes = Anime::orderBy('title')->get();
|
||||
|
||||
return view('admin.episodes.index', compact('episodes', 'animes'));
|
||||
}
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$animes = Anime::orderBy('title')->get();
|
||||
$seasons = [];
|
||||
$selectedAnime = null;
|
||||
|
||||
if ($request->anime_id) {
|
||||
$selectedAnime = Anime::find($request->anime_id);
|
||||
$seasons = Season::where('anime_id', $request->anime_id)->get();
|
||||
}
|
||||
|
||||
$permissions = PermissionSetting::all();
|
||||
return view('admin.episodes.create', compact('animes', 'seasons', 'selectedAnime', 'permissions'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|exists:animes,id',
|
||||
'season_id' => 'required|exists:seasons,id',
|
||||
'episode_number' => 'required|integer|min:1',
|
||||
'title' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'duration' => 'nullable|integer',
|
||||
'source_url' => 'nullable|string',
|
||||
'video_url' => 'nullable|string',
|
||||
'm3u8_url' => 'nullable|string',
|
||||
'source' => 'required|in:bunnycdn,external,direct',
|
||||
'is_published' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['status'] = $data['is_published'] ? 'published' : 'pending';
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
$data['thumbnail'] = ImageOptimizer::store($request->file('thumbnail'), 'thumbnails', 'thumbnail');
|
||||
}
|
||||
|
||||
$episode = Episode::create($data);
|
||||
|
||||
// İzin override'ları
|
||||
$this->savePermissions($episode, $request->permissions ?? []);
|
||||
|
||||
// Takipçilere bildirim gönder
|
||||
if ($episode->is_published) {
|
||||
$this->notifyFollowers($episode);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.episodes.index', ['anime_id' => $episode->anime_id])
|
||||
->with('success', 'Bölüm eklendi.');
|
||||
}
|
||||
|
||||
public function show(Episode $episode)
|
||||
{
|
||||
return redirect()->route('admin.episodes.edit', $episode);
|
||||
}
|
||||
|
||||
public function edit(Episode $episode)
|
||||
{
|
||||
$animes = Anime::orderBy('title')->get();
|
||||
$seasons = Season::where('anime_id', $episode->anime_id)->get();
|
||||
$permissions = PermissionSetting::all();
|
||||
$contentPerms = ContentPermission::where('content_type', 'episode')
|
||||
->where('content_id', $episode->id)
|
||||
->pluck('required_membership', 'permission_key');
|
||||
|
||||
return view('admin.episodes.edit', compact('episode', 'animes', 'seasons', 'permissions', 'contentPerms'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Episode $episode)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|exists:animes,id',
|
||||
'season_id' => 'required|exists:seasons,id',
|
||||
'episode_number' => 'required|integer|min:1',
|
||||
'title' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'duration' => 'nullable|integer',
|
||||
'intro_start' => 'nullable|integer|min:0',
|
||||
'intro_end' => 'nullable|integer|min:0',
|
||||
'source_url' => 'nullable|string',
|
||||
'video_url' => 'nullable|string',
|
||||
'm3u8_url' => 'nullable|string',
|
||||
'source' => 'required|in:bunnycdn,external,direct',
|
||||
'is_published' => 'boolean',
|
||||
]);
|
||||
|
||||
$wasPublished = $episode->is_published;
|
||||
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
$data['status'] = $data['is_published'] ? 'published' : 'pending';
|
||||
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
$data['thumbnail'] = ImageOptimizer::store($request->file('thumbnail'), 'thumbnails', 'thumbnail');
|
||||
}
|
||||
|
||||
$episode->update($data);
|
||||
$this->savePermissions($episode, $request->permissions ?? []);
|
||||
|
||||
// Sadece yeni yayınlandıysa bildirim gönder (zaten yayındaysa tekrar gönderme)
|
||||
if (!$wasPublished && $episode->is_published) {
|
||||
$this->notifyFollowers($episode);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.episodes.edit', $episode)->with('success', 'Bölüm güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(Episode $episode)
|
||||
{
|
||||
$animeId = $episode->anime_id;
|
||||
$videoUrl = $episode->video_url;
|
||||
$subUrls = $episode->subtitles()->pluck('url')->all();
|
||||
|
||||
$episode->delete();
|
||||
|
||||
// CDN'den dosyaları arka planda sil
|
||||
dispatch(function () use ($videoUrl, $subUrls) {
|
||||
\App\Services\BunnyCdnStorage::deleteFile($videoUrl);
|
||||
foreach ($subUrls as $url) {
|
||||
\App\Services\BunnyCdnStorage::deleteFile($url);
|
||||
}
|
||||
})->afterResponse();
|
||||
|
||||
return redirect()->route('admin.episodes.index', ['anime_id' => $animeId])
|
||||
->with('success', 'Bölüm silindi.');
|
||||
}
|
||||
|
||||
private function notifyFollowers(Episode $episode): void
|
||||
{
|
||||
$anime = Anime::find($episode->anime_id);
|
||||
$season = Season::find($episode->season_id);
|
||||
|
||||
if (!$anime) return;
|
||||
|
||||
$followers = \App\Models\AnimeFollow::where('anime_id', $episode->anime_id)
|
||||
->join('users', 'users.id', '=', 'anime_follows.user_id')
|
||||
->select('users.id as user_id', 'users.fcm_token')
|
||||
->get();
|
||||
|
||||
if ($followers->isEmpty()) return;
|
||||
|
||||
$seasonNum = $season?->season_number ?? 1;
|
||||
$notifData = json_encode([
|
||||
'anime_id' => $anime->id,
|
||||
'anime_title' => $anime->title,
|
||||
'anime_slug' => $anime->slug,
|
||||
'episode_number' => $episode->episode_number,
|
||||
'season_number' => $seasonNum,
|
||||
'episode_title' => $episode->title,
|
||||
]);
|
||||
|
||||
$rows = [];
|
||||
$now = now();
|
||||
foreach ($followers as $follower) {
|
||||
$rows[] = [
|
||||
'user_id' => $follower->user_id,
|
||||
'type' => 'episode',
|
||||
'data' => $notifData,
|
||||
'created_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
\App\Models\UserNotification::insert($rows);
|
||||
|
||||
// FCM Push
|
||||
$fcmTokens = $followers->pluck('fcm_token')->filter()->values()->toArray();
|
||||
if (!empty($fcmTokens)) {
|
||||
$title = $anime->title . ' — Yeni Bölüm!';
|
||||
$body = "Sezon {$seasonNum}, {$episode->episode_number}. Bölüm"
|
||||
. ($episode->title ? ' — ' . $episode->title : '') . ' eklendi.';
|
||||
$fcm = new \App\Services\FcmService();
|
||||
$fcm->sendToTokens($fcmTokens, $title, $body, [
|
||||
'type' => 'episode',
|
||||
'anime_slug' => $anime->slug,
|
||||
'season_number' => (string)$seasonNum,
|
||||
'episode_number' => (string)$episode->episode_number,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function savePermissions(Episode $episode, array $permissions): void
|
||||
{
|
||||
ContentPermission::where('content_type', 'episode')
|
||||
->where('content_id', $episode->id)
|
||||
->delete();
|
||||
|
||||
foreach ($permissions as $key => $value) {
|
||||
if (in_array($value, ['free', 'premium'])) {
|
||||
ContentPermission::create([
|
||||
'content_type' => 'episode',
|
||||
'content_id' => $episode->id,
|
||||
'permission_key' => $key,
|
||||
'required_membership' => $value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function bulkDestroy(Request $request)
|
||||
{
|
||||
if ($request->boolean('all')) {
|
||||
$query = Episode::query();
|
||||
$f = $request->input('filters', []);
|
||||
if (!empty($f['anime_id'])) $query->where('anime_id', $f['anime_id']);
|
||||
if (!empty($f['status'])) $query->where('status', $f['status']);
|
||||
if (!empty($f['search'])) $query->where('title', 'like', '%'.$f['search'].'%');
|
||||
$episodes = $query->get();
|
||||
} else {
|
||||
$request->validate(['ids' => 'required|array', 'ids.*' => 'integer']);
|
||||
$episodes = Episode::whereIn('id', $request->ids)->get();
|
||||
}
|
||||
|
||||
$videoUrls = [];
|
||||
$subUrls = [];
|
||||
foreach ($episodes as $ep) {
|
||||
if ($ep->video_url) $videoUrls[] = $ep->video_url;
|
||||
foreach ($ep->subtitles()->pluck('url') as $u) $subUrls[] = $u;
|
||||
$ep->delete();
|
||||
}
|
||||
|
||||
dispatch(function () use ($videoUrls, $subUrls) {
|
||||
foreach ($videoUrls as $url) \App\Services\BunnyCdnStorage::deleteFile($url);
|
||||
foreach ($subUrls as $url) \App\Services\BunnyCdnStorage::deleteFile($url);
|
||||
})->afterResponse();
|
||||
|
||||
return response()->json(['success' => true, 'deleted' => count($episodes)]);
|
||||
}
|
||||
|
||||
public function bulkIntro(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'anime_id' => 'required|exists:animes,id',
|
||||
'season' => 'required|integer|min:0',
|
||||
'intro_start' => 'required|integer|min:0',
|
||||
'intro_end' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
$query = Episode::where('anime_id', $request->anime_id);
|
||||
|
||||
if ((int)$request->season > 0) {
|
||||
$season = \App\Models\Season::where('anime_id', $request->anime_id)
|
||||
->where('season_number', $request->season)->first();
|
||||
if ($season) $query->where('season_id', $season->id);
|
||||
}
|
||||
|
||||
$updated = $query->update([
|
||||
'intro_start' => $request->intro_start,
|
||||
'intro_end' => $request->intro_end,
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'updated' => $updated]);
|
||||
}
|
||||
|
||||
// POST /admin/episodes/{episode}/scan-hevc
|
||||
// Admin panelinden bölümün HLS kaynaklarını sunucu tarafında tarar, HEVC olanları işaretler
|
||||
public function scanHevc(Episode $episode)
|
||||
{
|
||||
$sources = VideoSource::where('episode_id', $episode->id)
|
||||
->where('type', 'hls')
|
||||
->get();
|
||||
|
||||
$results = [];
|
||||
foreach ($sources as $src) {
|
||||
$isHevc = $this->probeM3u8ForHevc($src->url);
|
||||
$src->update(['is_hevc' => $isHevc, 'hevc_checked_at' => now()]);
|
||||
$results[] = [
|
||||
'id' => $src->id,
|
||||
'label' => $src->label,
|
||||
'quality' => $src->quality,
|
||||
'is_hevc' => $isHevc,
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'results' => $results]);
|
||||
}
|
||||
|
||||
private function probeM3u8ForHevc(string $url): bool
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(8)->withHeaders(['User-Agent' => 'Mozilla/5.0'])->get($url);
|
||||
if (!$response->ok()) return false;
|
||||
$text = $response->body();
|
||||
|
||||
preg_match_all('/#EXT-X-STREAM-INF:([^\n]+)/i', $text, $matches);
|
||||
if (empty($matches[1])) return false;
|
||||
|
||||
$isHevcCodec = fn($attrs) => (bool) preg_match('/CODECS="[^"]*(?:hev1|hvc1|dvh1)[^"]*"/i', $attrs);
|
||||
|
||||
foreach ($matches[1] as $attrs) {
|
||||
// CODECS tag yoksa bilinmiyor — H.264 uyumlu say, HEVC değil
|
||||
if (!str_contains(strtoupper($attrs), 'CODECS=')) return false;
|
||||
if (!$isHevcCodec($attrs)) return false;
|
||||
}
|
||||
|
||||
return true; // tüm stream'ler HEVC
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Genre;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenreController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$genres = Genre::withCount('animes')->get();
|
||||
return view('admin.genres.index', compact('genres'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'color' => 'nullable|string|max:7',
|
||||
]);
|
||||
$data['slug'] = Str::slug($data['name']);
|
||||
Genre::create($data);
|
||||
return back()->with('success', 'Tür eklendi.');
|
||||
}
|
||||
|
||||
public function update(Request $request, Genre $genre)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'color' => 'nullable|string|max:7',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$genre->update($data);
|
||||
return back()->with('success', 'Tür güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(Genre $genre)
|
||||
{
|
||||
$genre->delete();
|
||||
return back()->with('success', 'Tür silindi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class HealthController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// 1. Duplike anime — aynı MAL ID'ye sahip birden fazla anime
|
||||
$malDuplicates = DB::table('animes')
|
||||
->whereNotNull('mal_id')
|
||||
->where('mal_id', '>', 0)
|
||||
->select('mal_id', DB::raw('COUNT(*) as cnt'))
|
||||
->groupBy('mal_id')
|
||||
->having('cnt', '>', 1)
|
||||
->get()
|
||||
->map(function ($row) {
|
||||
$animes = Anime::where('mal_id', $row->mal_id)
|
||||
->withCount('episodes')
|
||||
->get(['id', 'title', 'slug', 'mal_id', 'created_at']);
|
||||
return ['mal_id' => $row->mal_id, 'animes' => $animes];
|
||||
});
|
||||
|
||||
// 2. Karışık kaynak — aynı anime içinde hem animecix hem anizium bölüm var
|
||||
$mixedSources = DB::table('episodes')
|
||||
->whereIn('source', ['anizium', 'animecix'])
|
||||
->whereNotNull('anime_id')
|
||||
->select('anime_id', 'source', DB::raw('COUNT(*) as cnt'))
|
||||
->groupBy('anime_id', 'source')
|
||||
->get()
|
||||
->groupBy('anime_id')
|
||||
->filter(fn($group) => $group->pluck('source')->unique()->count() > 1)
|
||||
->map(function ($group) {
|
||||
$anime = Anime::find($group->first()->anime_id, ['id', 'title', 'slug']);
|
||||
if (!$anime) return null;
|
||||
$sources = $group->mapWithKeys(fn($r) => [$r->source => $r->cnt]);
|
||||
return ['anime' => $anime, 'sources' => $sources];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
// 3. Eksik bölümler — episode_count > gerçek bölüm sayısı
|
||||
$missingEpisodes = Anime::whereNotNull('episode_count')
|
||||
->where('episode_count', '>', 0)
|
||||
->withCount('episodes')
|
||||
->get(['id', 'title', 'slug', 'episode_count'])
|
||||
->filter(fn($a) => $a->episodes_count < $a->episode_count)
|
||||
->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'slug' => $a->slug,
|
||||
'expected' => $a->episode_count,
|
||||
'actual' => $a->episodes_count,
|
||||
'missing' => $a->episode_count - $a->episodes_count,
|
||||
])
|
||||
->sortByDesc('missing')
|
||||
->values();
|
||||
|
||||
// 4. Harici CDN bölümler — BunnyCDN'e taşınmamış, Anizium CDN'de kalan
|
||||
$externalCount = Episode::whereNull('video_url')
|
||||
->where(function ($q) {
|
||||
$q->where('m3u8_url', 'like', '%aniziumserver%')
|
||||
->orWhere('m3u8_url', 'like', '%anizium%');
|
||||
})
|
||||
->count();
|
||||
|
||||
$externalSample = Episode::whereNull('video_url')
|
||||
->where(function ($q) {
|
||||
$q->where('m3u8_url', 'like', '%aniziumserver%')
|
||||
->orWhere('m3u8_url', 'like', '%anizium%');
|
||||
})
|
||||
->with('anime:id,title,slug')
|
||||
->select('id', 'anime_id', 'season_id', 'episode_number', 'm3u8_url', 'source')
|
||||
->orderByDesc('id')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
// 5. Sıfır bölümlü animeler
|
||||
$zeroEpisodeAnimes = Anime::whereDoesntHave('episodes')
|
||||
->get(['id', 'title', 'slug', 'created_at']);
|
||||
|
||||
return view('admin.health.index', compact(
|
||||
'malDuplicates',
|
||||
'mixedSources',
|
||||
'missingEpisodes',
|
||||
'externalCount',
|
||||
'externalSample',
|
||||
'zeroEpisodeAnimes'
|
||||
));
|
||||
}
|
||||
|
||||
// ── Sistem Temizliği ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Depolama istatistiklerini döndür — inode tüketimini gösterir.
|
||||
*/
|
||||
public function storageStats()
|
||||
{
|
||||
$dirs = [
|
||||
'seg_cache' => storage_path('app/seg_cache'),
|
||||
'cache_data' => storage_path('framework/cache/data'),
|
||||
'sessions' => storage_path('framework/sessions'),
|
||||
'views' => storage_path('framework/views'),
|
||||
'logs' => storage_path('logs'),
|
||||
'app_public' => storage_path('app/public'),
|
||||
];
|
||||
|
||||
$stats = [];
|
||||
foreach ($dirs as $key => $path) {
|
||||
if (!is_dir($path)) {
|
||||
$stats[$key] = ['count' => 0, 'size' => 0, 'path' => $path];
|
||||
continue;
|
||||
}
|
||||
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS));
|
||||
$count = 0;
|
||||
$size = 0;
|
||||
foreach ($files as $f) {
|
||||
$count++;
|
||||
$size += $f->getSize();
|
||||
}
|
||||
$stats[$key] = ['count' => $count, 'size' => $size, 'path' => $path];
|
||||
}
|
||||
|
||||
return response()->json(['stats' => $stats, 'total_files' => array_sum(array_column($stats, 'count'))]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Belirtilen depolama dizinini temizle.
|
||||
*/
|
||||
public function cleanupStorage(Request $request)
|
||||
{
|
||||
$target = $request->input('target');
|
||||
$allowed = [
|
||||
'seg_cache' => storage_path('app/seg_cache'),
|
||||
'cache_data' => storage_path('framework/cache/data'),
|
||||
'sessions' => storage_path('framework/sessions'),
|
||||
'views' => storage_path('framework/views'),
|
||||
'old_logs' => storage_path('logs'),
|
||||
];
|
||||
|
||||
if (!array_key_exists($target, $allowed)) {
|
||||
return response()->json(['error' => 'Geçersiz hedef.'], 422);
|
||||
}
|
||||
|
||||
$path = $allowed[$target];
|
||||
$deleted = 0;
|
||||
|
||||
if (!is_dir($path)) {
|
||||
return response()->json(['ok' => true, 'deleted' => 0, 'message' => 'Dizin yok.']);
|
||||
}
|
||||
|
||||
if ($target === 'old_logs') {
|
||||
// Logları tamamen silme — sadece 7 günden eskilerini sil
|
||||
foreach (glob($path . '/*.log') ?: [] as $f) {
|
||||
if (filemtime($f) < time() - 604800) { // 7 gün
|
||||
@unlink($f);
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
// Laravel her gün yeni log açar, bugünküne dokunma
|
||||
} else {
|
||||
// Diğer dizinler: tümünü temizle
|
||||
$files = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($files as $f) {
|
||||
if ($f->isFile()) {
|
||||
@unlink($f->getRealPath());
|
||||
$deleted++;
|
||||
} elseif ($f->isDir()) {
|
||||
@rmdir($f->getRealPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Laravel cache'i PHP seviyesinde de temizle
|
||||
if ($target === 'cache_data') {
|
||||
try { \Illuminate\Support\Facades\Cache::flush(); } catch (\Throwable) {}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'deleted' => $deleted,
|
||||
'message' => "{$deleted} dosya silindi.",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Session driver bilgisi + önerisi.
|
||||
*/
|
||||
public function sessionInfo()
|
||||
{
|
||||
$driver = config('session.driver', 'file');
|
||||
$sessionPath = storage_path('framework/sessions');
|
||||
$sessionCount = is_dir($sessionPath) ? count(glob($sessionPath . '/*') ?: []) : 0;
|
||||
|
||||
return response()->json([
|
||||
'driver' => $driver,
|
||||
'session_files' => $sessionCount,
|
||||
'recommendation'=> $driver === 'file'
|
||||
? 'SESSION_DRIVER=database veya cookie kullanmanız önerilir (inode tasarrufu).'
|
||||
: 'Session sürücüsü inode-dostu.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteAnime(Request $request, Anime $anime)
|
||||
{
|
||||
$title = $anime->title;
|
||||
$anime->delete();
|
||||
return back()->with('success', "\"$title\" silindi.");
|
||||
}
|
||||
|
||||
public function deleteSourceEpisodes(Request $request, Anime $anime)
|
||||
{
|
||||
$source = $request->validate(['source' => 'required|in:anizium,animecix'])['source'];
|
||||
$count = Episode::where('anime_id', $anime->id)->where('source', $source)->count();
|
||||
Episode::where('anime_id', $anime->id)->where('source', $source)->delete();
|
||||
return back()->with('success', "$anime->title — $source kaynağından $count bölüm silindi.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ImportJob;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Season;
|
||||
use App\Models\Subtitle;
|
||||
use App\Models\VideoSource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ImportController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$jobs = ImportJob::latest()->paginate(20);
|
||||
|
||||
// Araçlar paneli için istatistikler
|
||||
$stats = [
|
||||
'total_animes' => \App\Models\Anime::where('is_published', true)->count(),
|
||||
'anizium_done' => ImportJob::where('source', 'anizium')->where('status', 'done')->count(),
|
||||
'animecix_done' => ImportJob::where('source', 'animecix')->where('status', 'done')->count(),
|
||||
'video_sources_total' => VideoSource::count(),
|
||||
'anizium_sources' => VideoSource::where('source', 'anizium')->count(),
|
||||
'animecix_sources' => VideoSource::where('source', 'animecix')->count(),
|
||||
'subtitle_mismatch' => $this->countSubtitleMismatch(),
|
||||
];
|
||||
|
||||
return view('admin.import.index', compact('jobs', 'stats'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'source_url' => 'required|url|max:500',
|
||||
'cdn_id' => 'nullable|string|max:50',
|
||||
'anime_title' => 'nullable|string|max:255',
|
||||
'season_ranges' => 'nullable|array',
|
||||
'season_ranges.*.season' => 'required_with:season_ranges|integer|min:1',
|
||||
'season_ranges.*.from' => 'required_with:season_ranges|integer|min:1',
|
||||
'season_ranges.*.to' => 'required_with:season_ranges|integer|min:1',
|
||||
]);
|
||||
|
||||
$watchId = null;
|
||||
if ($request->source_url) {
|
||||
preg_match('/\/(?:anime|watch)\/(\d+)/', $request->source_url, $m);
|
||||
$watchId = $m[1] ?? null;
|
||||
}
|
||||
|
||||
$ranges = null;
|
||||
if ($request->filled('season_ranges')) {
|
||||
$ranges = [];
|
||||
foreach ($request->season_ranges as $r) {
|
||||
if (empty($r['season']) || empty($r['from']) || empty($r['to'])) continue;
|
||||
$from = (int) $r['from'];
|
||||
$to = (int) $r['to'];
|
||||
if ($from > $to) [$from, $to] = [$to, $from];
|
||||
$ranges[] = ['season' => (int)$r['season'], 'from' => $from, 'to' => $to];
|
||||
}
|
||||
if (empty($ranges)) $ranges = null;
|
||||
}
|
||||
|
||||
$job = ImportJob::create([
|
||||
'source_url' => $request->source_url,
|
||||
'watch_id' => $watchId,
|
||||
'cdn_id' => $request->cdn_id ? trim($request->cdn_id) : null,
|
||||
'anime_title' => $request->anime_title,
|
||||
'season_ranges' => $ranges,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.import.show', $job)
|
||||
->with('success', "Import job #{$job->id} oluşturuldu. Python script'i başlatın.");
|
||||
}
|
||||
|
||||
public function show(ImportJob $import)
|
||||
{
|
||||
return view('admin.import.show', compact('import'));
|
||||
}
|
||||
|
||||
public function destroy(ImportJob $import)
|
||||
{
|
||||
$import->delete();
|
||||
return redirect()->route('admin.import.index')->with('success', 'Job silindi.');
|
||||
}
|
||||
|
||||
public function destroyFailed()
|
||||
{
|
||||
$count = ImportJob::where('status', 'failed')->count();
|
||||
ImportJob::where('status', 'failed')->delete();
|
||||
return redirect()->route('admin.import.index')->with('success', "{$count} hatalı job silindi.");
|
||||
}
|
||||
|
||||
public function destroyPending()
|
||||
{
|
||||
$count = ImportJob::where('status', 'pending')->count();
|
||||
ImportJob::where('status', 'pending')->delete();
|
||||
return redirect()->route('admin.import.index')->with('success', "{$count} bekleyen job silindi.");
|
||||
}
|
||||
|
||||
public function destroyStuck()
|
||||
{
|
||||
// fetching/downloading/uploading ama 2 saatten fazladır güncellenmemiş = takılı kalmış
|
||||
$cutoff = now()->subHours(2);
|
||||
$count = ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading'])
|
||||
->where('updated_at', '<', $cutoff)
|
||||
->count();
|
||||
ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading'])
|
||||
->where('updated_at', '<', $cutoff)
|
||||
->delete();
|
||||
return redirect()->route('admin.import.index')->with('success', "{$count} takılı kalmış job silindi.");
|
||||
}
|
||||
|
||||
public function bulkCounts()
|
||||
{
|
||||
$cutoff = now()->subHours(2);
|
||||
return response()->json([
|
||||
'failed' => ImportJob::where('status', 'failed')->count(),
|
||||
'pending' => ImportJob::where('status', 'pending')->count(),
|
||||
'stuck' => ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading'])
|
||||
->where('updated_at', '<', $cutoff)
|
||||
->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroyByStatus(Request $request)
|
||||
{
|
||||
$statuses = $request->input('statuses', []);
|
||||
$hours = (int) $request->input('stuck_hours', 2);
|
||||
|
||||
$allowed = ['pending', 'failed', 'fetching', 'downloading', 'uploading'];
|
||||
$statuses = array_intersect($statuses, $allowed);
|
||||
|
||||
if (empty($statuses)) {
|
||||
return response()->json(['ok' => false, 'message' => 'Geçerli status seçilmedi.'], 422);
|
||||
}
|
||||
|
||||
$query = ImportJob::whereIn('status', $statuses);
|
||||
|
||||
// Aktif statüler için sadece belirtilen saatten eskilerini sil
|
||||
$activeStatuses = array_intersect($statuses, ['fetching', 'downloading', 'uploading']);
|
||||
if (!empty($activeStatuses) && count($activeStatuses) === count($statuses)) {
|
||||
$query->where('updated_at', '<', now()->subHours($hours));
|
||||
}
|
||||
|
||||
$count = $query->count();
|
||||
$query->delete();
|
||||
|
||||
return response()->json(['ok' => true, 'deleted' => $count]);
|
||||
}
|
||||
|
||||
// ── ARAÇLAR: Terminal gerektirmez, admin panelden çalışır ─────────────────
|
||||
|
||||
/**
|
||||
* Altyazı uyuşmazlığı düzelt (Anizium episode-1 cache bug).
|
||||
* Subtitle URL'sindeki name=s1_b1_XX yanlış bölümü işaret edenleri siler.
|
||||
*/
|
||||
public function fixSubtitles(Request $request)
|
||||
{
|
||||
$dryRun = $request->boolean('dry_run', false);
|
||||
$animeId = $request->input('anime_id');
|
||||
|
||||
$query = Subtitle::query()
|
||||
->join('episodes', 'subtitles.episode_id', '=', 'episodes.id')
|
||||
->join('seasons', 'seasons.id', '=', 'episodes.season_id')
|
||||
->whereNotNull('subtitles.url')
|
||||
->where('subtitles.url', 'like', '%anizium%')
|
||||
->select(
|
||||
'subtitles.id as subtitle_id',
|
||||
'subtitles.language',
|
||||
'subtitles.url',
|
||||
'seasons.season_number',
|
||||
'episodes.episode_number',
|
||||
'episodes.anime_id',
|
||||
);
|
||||
|
||||
if ($animeId) {
|
||||
$query->where('episodes.anime_id', (int) $animeId);
|
||||
}
|
||||
|
||||
$subtitles = $query->get();
|
||||
$mismatchIds = [];
|
||||
$details = [];
|
||||
|
||||
foreach ($subtitles as $sub) {
|
||||
$parsed = parse_url($sub->url);
|
||||
if (!isset($parsed['query'])) continue;
|
||||
parse_str($parsed['query'], $params);
|
||||
$name = $params['name'] ?? '';
|
||||
if (!$name) continue;
|
||||
|
||||
$expectedPrefix = "s{$sub->season_number}_b{$sub->episode_number}_";
|
||||
if (!str_starts_with($name, $expectedPrefix)) {
|
||||
$mismatchIds[] = $sub->subtitle_id;
|
||||
$details[] = [
|
||||
'anime_id' => $sub->anime_id,
|
||||
'season' => $sub->season_number,
|
||||
'episode' => $sub->episode_number,
|
||||
'lang' => $sub->language,
|
||||
'name' => $name,
|
||||
'expected' => $expectedPrefix . $sub->language,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$deleted = 0;
|
||||
if (!$dryRun && !empty($mismatchIds)) {
|
||||
$deleted = Subtitle::whereIn('id', $mismatchIds)->delete();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'dry_run' => $dryRun,
|
||||
'checked' => $subtitles->count(),
|
||||
'mismatch' => count($mismatchIds),
|
||||
'deleted' => $deleted,
|
||||
'details' => array_slice($details, 0, 30),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anizium done job'larını yeniden pending yap (yeni video_sources eklemek için).
|
||||
* Her job'ın done_episodes sıfırlanır; Anizium bot yeniden çalışınca
|
||||
* doneEpisodes() artık source='anizium' kontrolü yaptığından
|
||||
* sadece video_sources'ta anizium kaydı OLMAYAN bölümleri yeniden işler.
|
||||
*/
|
||||
public function requeueAnizium(Request $request)
|
||||
{
|
||||
$limit = (int) $request->input('limit', 50);
|
||||
$animeId = $request->input('anime_id');
|
||||
|
||||
$query = ImportJob::where('source', 'anizium')
|
||||
->where('status', 'done')
|
||||
->whereNotNull('watch_id')
|
||||
->latest();
|
||||
|
||||
if ($animeId) {
|
||||
$query->where('anime_id', (int) $animeId);
|
||||
}
|
||||
|
||||
$jobs = $query->limit($limit)->get();
|
||||
$requeued = 0;
|
||||
|
||||
foreach ($jobs as $job) {
|
||||
// Zaten pending/işleniyor olan var mı?
|
||||
$active = ImportJob::where('watch_id', $job->watch_id)
|
||||
->where('source', 'anizium')
|
||||
->whereIn('status', ['pending', 'fetching', 'downloading', 'uploading'])
|
||||
->exists();
|
||||
|
||||
if (!$active) {
|
||||
$job->update([
|
||||
'status' => 'pending',
|
||||
'done_episodes'=> 0,
|
||||
'error_log' => null,
|
||||
'current_step' => 'Çapraz re-import — video_sources yenileme',
|
||||
]);
|
||||
$requeued++;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'checked' => $jobs->count(),
|
||||
'requeued' => $requeued,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AnimeCix done job'larını yeniden pending yap.
|
||||
*/
|
||||
public function requeueAnimecix(Request $request)
|
||||
{
|
||||
$limit = (int) $request->input('limit', 50);
|
||||
$animeId = $request->input('anime_id');
|
||||
|
||||
$query = ImportJob::where('source', 'animecix')
|
||||
->where('status', 'done')
|
||||
->whereNotNull('animecix_title_id')
|
||||
->latest();
|
||||
|
||||
if ($animeId) {
|
||||
$query->where('anime_id', (int) $animeId);
|
||||
}
|
||||
|
||||
$jobs = $query->limit($limit)->get();
|
||||
$requeued = 0;
|
||||
|
||||
foreach ($jobs as $job) {
|
||||
$active = ImportJob::where('animecix_title_id', $job->animecix_title_id)
|
||||
->where('source', 'animecix')
|
||||
->whereIn('status', ['pending', 'fetching'])
|
||||
->exists();
|
||||
|
||||
if (!$active) {
|
||||
$job->update([
|
||||
'status' => 'pending',
|
||||
'done_episodes'=> 0,
|
||||
'error_log' => null,
|
||||
'current_step' => 'Çapraz re-import — video_sources yenileme',
|
||||
]);
|
||||
$requeued++;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'checked' => $jobs->count(),
|
||||
'requeued' => $requeued,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* video_sources istatistikleri (AJAX için).
|
||||
*/
|
||||
public function sourceStats()
|
||||
{
|
||||
$animeCount = \App\Models\Anime::where('is_published', true)->count();
|
||||
|
||||
$episodesWithBoth = DB::table('episodes')
|
||||
->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium'))
|
||||
->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix'))
|
||||
->where('is_published', true)
|
||||
->count();
|
||||
|
||||
$episodesOnlyAnizium = DB::table('episodes')
|
||||
->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium'))
|
||||
->whereNotExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix'))
|
||||
->where('is_published', true)
|
||||
->count();
|
||||
|
||||
$episodesOnlyAnimecix = DB::table('episodes')
|
||||
->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix'))
|
||||
->whereNotExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium'))
|
||||
->where('is_published', true)
|
||||
->count();
|
||||
|
||||
return response()->json([
|
||||
'anime_count' => $animeCount,
|
||||
'episodes_with_both' => $episodesWithBoth,
|
||||
'episodes_only_anizium' => $episodesOnlyAnizium,
|
||||
'episodes_only_animecix' => $episodesOnlyAnimecix,
|
||||
'subtitle_mismatch' => $this->countSubtitleMismatch(),
|
||||
'anizium_pending_jobs' => ImportJob::where('source', 'anizium')->where('status', 'pending')->count(),
|
||||
'animecix_pending_jobs' => ImportJob::where('source', 'animecix')->where('status', 'pending')->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Yardımcı ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function countSubtitleMismatch(): int
|
||||
{
|
||||
$rows = Subtitle::query()
|
||||
->join('episodes', 'subtitles.episode_id', '=', 'episodes.id')
|
||||
->join('seasons', 'seasons.id', '=', 'episodes.season_id')
|
||||
->whereNotNull('subtitles.url')
|
||||
->where('subtitles.url', 'like', '%anizium%')
|
||||
->select('subtitles.url', 'seasons.season_number', 'episodes.episode_number')
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
foreach ($rows as $r) {
|
||||
$parsed = parse_url($r->url);
|
||||
if (!isset($parsed['query'])) continue;
|
||||
parse_str($parsed['query'], $params);
|
||||
$name = $params['name'] ?? '';
|
||||
if ($name && !str_starts_with($name, "s{$r->season_number}_b{$r->episode_number}_")) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotification;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MobileAppController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// App settings
|
||||
$settings = [
|
||||
'mobile_min_version' => Setting::get('mobile_min_version', '1.0.0'),
|
||||
'mobile_current_version' => Setting::get('mobile_current_version', '1.0.0'),
|
||||
'mobile_apk_url' => Setting::get('mobile_apk_url', ''),
|
||||
'mobile_maintenance_mode' => Setting::get('mobile_maintenance_mode', '0'),
|
||||
'mobile_maintenance_message'=> Setting::get('mobile_maintenance_message', 'Uygulama şu anda bakımda. Lütfen daha sonra tekrar deneyin.'),
|
||||
'mobile_force_update_msg' => Setting::get('mobile_force_update_msg', 'Uygulamayı kullanmaya devam etmek için lütfen güncelleyin.'),
|
||||
];
|
||||
|
||||
// Stats
|
||||
$stats = [
|
||||
'total_users' => User::count(),
|
||||
'fcm_tokens' => User::whereNotNull('fcm_token')->where('fcm_token', '!=', '')->count(),
|
||||
'notifications_sent'=> UserNotification::count(),
|
||||
'notifs_today' => UserNotification::whereDate('created_at', today())->count(),
|
||||
'notifs_unread' => UserNotification::whereNull('read_at')->count(),
|
||||
];
|
||||
|
||||
// Active users (logged in last 30 days, via tokens)
|
||||
try {
|
||||
$stats['active_30d'] = DB::table('personal_access_tokens')
|
||||
->where('tokenable_type', User::class)
|
||||
->where('last_used_at', '>=', now()->subDays(30))
|
||||
->distinct('tokenable_id')
|
||||
->count('tokenable_id');
|
||||
} catch (\Throwable $e) {
|
||||
$stats['active_30d'] = '–';
|
||||
}
|
||||
|
||||
return view('admin.mobile.index', compact('settings', 'stats'));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'mobile_min_version' => 'required|string|max:20',
|
||||
'mobile_current_version' => 'required|string|max:20',
|
||||
'mobile_apk_url' => 'nullable|url|max:500',
|
||||
'mobile_maintenance_mode' => 'boolean',
|
||||
'mobile_maintenance_message' => 'required|string|max:300',
|
||||
'mobile_force_update_msg' => 'required|string|max:300',
|
||||
]);
|
||||
|
||||
// Checkbox absent = unchecked → force '0'
|
||||
$data['mobile_maintenance_mode'] = $request->boolean('mobile_maintenance_mode') ? '1' : '0';
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
Setting::set($key, $value ?? '', 'mobile');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Mobil uygulama ayarları güncellendi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ModeratorPermission;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ModeratorController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$moderators = User::where('role', 'moderator')
|
||||
->withCount('moderatorPermissions')
|
||||
->with('moderatorPermissions:user_id,permission')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.moderators.index', [
|
||||
'moderators' => $moderators,
|
||||
'groups' => ModeratorPermission::$groups,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(User $user)
|
||||
{
|
||||
abort_if($user->isAdmin(), 403);
|
||||
|
||||
$permissions = ModeratorPermission::where('user_id', $user->id)
|
||||
->pluck('permission')
|
||||
->flip() // key = permission, value = true for fast lookup
|
||||
->all();
|
||||
|
||||
return view('admin.moderators.edit', [
|
||||
'moderator' => $user,
|
||||
'groups' => ModeratorPermission::$groups,
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Admin assigns a user the moderator role */
|
||||
public function promote(Request $request)
|
||||
{
|
||||
$request->validate(['user_id' => 'required|exists:users,id']);
|
||||
|
||||
$user = User::findOrFail($request->user_id);
|
||||
abort_if($user->isAdmin(), 403, 'Admin kullanıcı düzenlenemez.');
|
||||
|
||||
$user->update(['role' => 'moderator']);
|
||||
|
||||
return back()->with('success', "{$user->name} moderatör yapıldı.");
|
||||
}
|
||||
|
||||
/** Remove moderator role */
|
||||
public function demote(User $user)
|
||||
{
|
||||
abort_if($user->isAdmin(), 403);
|
||||
$user->update(['role' => 'user']);
|
||||
ModeratorPermission::where('user_id', $user->id)->delete();
|
||||
$user->flushPermCache();
|
||||
|
||||
return back()->with('success', "{$user->name} moderatörlükten çıkarıldı.");
|
||||
}
|
||||
|
||||
/** Save permission checkboxes */
|
||||
public function savePermissions(Request $request, User $user)
|
||||
{
|
||||
abort_if($user->isAdmin(), 403);
|
||||
abort_if($user->role !== 'moderator', 422, 'Kullanıcı moderatör değil.');
|
||||
|
||||
$allKeys = ModeratorPermission::allKeys();
|
||||
$submitted = array_intersect($request->input('permissions', []), $allKeys);
|
||||
|
||||
// Delete old, insert new
|
||||
ModeratorPermission::where('user_id', $user->id)->delete();
|
||||
foreach ($submitted as $perm) {
|
||||
ModeratorPermission::create([
|
||||
'user_id' => $user->id,
|
||||
'permission' => $perm,
|
||||
'granted_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
$user->flushPermCache();
|
||||
|
||||
return back()->with('success', 'İzinler kaydedildi. (' . count($submitted) . ' izin aktif)');
|
||||
}
|
||||
|
||||
/** Quick permission toggle via AJAX */
|
||||
public function togglePermission(Request $request, User $user)
|
||||
{
|
||||
abort_if($user->isAdmin(), 403);
|
||||
$perm = $request->input('permission');
|
||||
abort_unless(in_array($perm, ModeratorPermission::allKeys()), 422);
|
||||
|
||||
$existing = ModeratorPermission::where('user_id', $user->id)
|
||||
->where('permission', $perm)->first();
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$active = false;
|
||||
} else {
|
||||
ModeratorPermission::create(['user_id' => $user->id, 'permission' => $perm, 'granted_by' => auth()->id()]);
|
||||
$active = true;
|
||||
}
|
||||
|
||||
$user->flushPermCache();
|
||||
|
||||
return response()->json(['active' => $active]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotification;
|
||||
use App\Services\FcmService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$recent = UserNotification::with('user')
|
||||
->orderByDesc('created_at')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
$stats = [
|
||||
'total' => UserNotification::count(),
|
||||
'unread' => UserNotification::whereNull('read_at')->count(),
|
||||
'users' => User::count(),
|
||||
'today' => UserNotification::whereDate('created_at', today())->count(),
|
||||
];
|
||||
|
||||
return view('admin.notifications.index', compact('recent', 'stats'));
|
||||
}
|
||||
|
||||
public function send(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:100',
|
||||
'body' => 'required|string|max:500',
|
||||
'url' => 'nullable|url|max:300',
|
||||
'target' => 'required|in:all,premium,free',
|
||||
'icon' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$query = User::query();
|
||||
|
||||
if ($data['target'] === 'premium') {
|
||||
$query->where('membership', 'premium')
|
||||
->where(fn($q) => $q->whereNull('premium_expires_at')->orWhere('premium_expires_at', '>', now()));
|
||||
} elseif ($data['target'] === 'free') {
|
||||
$query->where(fn($q) => $q->where('membership', '!=', 'premium')->orWhere('premium_expires_at', '<=', now()));
|
||||
}
|
||||
|
||||
$users = $query->select('id', 'fcm_token')->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
return back()->with('error', 'Hedef kullanıcı bulunamadı.');
|
||||
}
|
||||
|
||||
$notifData = json_encode([
|
||||
'title' => $data['title'],
|
||||
'body' => $data['body'],
|
||||
'url' => $data['url'] ?? null,
|
||||
'icon' => $data['icon'] ?? 'bi-megaphone-fill',
|
||||
'admin' => true,
|
||||
]);
|
||||
|
||||
$now = now();
|
||||
$rows = $users->map(fn($u) => [
|
||||
'user_id' => $u->id,
|
||||
'type' => 'admin',
|
||||
'data' => $notifData,
|
||||
'created_at' => $now,
|
||||
])->toArray();
|
||||
|
||||
// In-app notifications
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
UserNotification::insert($chunk);
|
||||
}
|
||||
|
||||
// FCM Push notifications
|
||||
$fcmTokens = $users->pluck('fcm_token')->filter()->values()->toArray();
|
||||
if (!empty($fcmTokens)) {
|
||||
$fcm = new FcmService();
|
||||
$fcm->sendToTokens($fcmTokens, $data['title'], $data['body'], [
|
||||
'type' => 'admin',
|
||||
'url' => $data['url'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', count($rows) . ' kullanıcıya bildirim gönderildi' . (!empty($fcmTokens) ? ' (' . count($fcmTokens) . ' push)' : '') . '.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PermissionSetting;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PermissionController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$permissions = PermissionSetting::all();
|
||||
return view('admin.permissions.index', compact('permissions'));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$permissions = $request->permissions ?? [];
|
||||
|
||||
foreach ($permissions as $key => $value) {
|
||||
if (in_array($value, ['free', 'premium'])) {
|
||||
PermissionSetting::where('key', $key)->update(['required_membership' => $value]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'Global izinler güncellendi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MembershipPlan;
|
||||
use App\Services\PremiumFeatures;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PlanController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$plans = MembershipPlan::orderBy('sort_order')->get();
|
||||
return view('admin.plans.index', compact('plans'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$allPerks = PremiumFeatures::grouped();
|
||||
return view('admin.plans.create', compact('allPerks'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'purchase_link' => 'nullable|url|max:1000',
|
||||
'duration_days' => 'required|integer|min:1',
|
||||
'trial_days' => 'nullable|integer|min:0',
|
||||
'badge_label' => 'nullable|string|max:32',
|
||||
'accent_color' => 'nullable|string|max:16',
|
||||
'features' => 'nullable|array',
|
||||
'features.*' => 'string',
|
||||
'perks' => 'nullable|array',
|
||||
'is_active' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'visible_until' => 'nullable|date',
|
||||
'sort_order' => 'integer',
|
||||
]);
|
||||
|
||||
$data['slug'] = Str::slug($data['name']);
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$data['is_public'] = $request->boolean('is_public', true);
|
||||
$data['trial_days'] = (int) ($request->input('trial_days', 0));
|
||||
$data['purchase_link'] = $request->filled('purchase_link') ? $request->input('purchase_link') : null;
|
||||
$data['badge_label'] = $request->filled('badge_label') ? $request->input('badge_label') : null;
|
||||
$data['accent_color'] = $request->filled('accent_color') ? $request->input('accent_color') : null;
|
||||
$data['visible_until'] = $request->filled('visible_until') ? $request->input('visible_until') : null;
|
||||
$data['features'] = array_values(array_filter($request->features ?? []));
|
||||
|
||||
$perks = [];
|
||||
foreach (array_keys(PremiumFeatures::ALL) as $key) {
|
||||
$perks[$key] = in_array($key, $request->input('perks', []));
|
||||
}
|
||||
$data['perks'] = $perks;
|
||||
|
||||
MembershipPlan::create($data);
|
||||
return redirect()->route('admin.plans.index')->with('success', 'Plan eklendi.');
|
||||
}
|
||||
|
||||
public function edit(MembershipPlan $plan)
|
||||
{
|
||||
$allPerks = PremiumFeatures::grouped();
|
||||
return view('admin.plans.edit', compact('plan', 'allPerks'));
|
||||
}
|
||||
|
||||
public function update(Request $request, MembershipPlan $plan)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'purchase_link' => 'nullable|url|max:1000',
|
||||
'duration_days' => 'required|integer|min:1',
|
||||
'trial_days' => 'nullable|integer|min:0',
|
||||
'badge_label' => 'nullable|string|max:32',
|
||||
'accent_color' => 'nullable|string|max:16',
|
||||
'features' => 'nullable|array',
|
||||
'features.*' => 'string',
|
||||
'perks' => 'nullable|array',
|
||||
'is_active' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'visible_until' => 'nullable|date',
|
||||
'sort_order' => 'integer',
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$data['is_public'] = $request->boolean('is_public', true);
|
||||
$data['trial_days'] = (int) ($request->input('trial_days', 0));
|
||||
$data['purchase_link'] = $request->filled('purchase_link') ? $request->input('purchase_link') : null;
|
||||
$data['badge_label'] = $request->filled('badge_label') ? $request->input('badge_label') : null;
|
||||
$data['accent_color'] = $request->filled('accent_color') ? $request->input('accent_color') : null;
|
||||
$data['visible_until'] = $request->filled('visible_until') ? $request->input('visible_until') : null;
|
||||
$data['features'] = array_values(array_filter($request->features ?? []));
|
||||
|
||||
$perks = [];
|
||||
foreach (array_keys(PremiumFeatures::ALL) as $key) {
|
||||
$perks[$key] = in_array($key, $request->input('perks', []));
|
||||
}
|
||||
$data['perks'] = $perks;
|
||||
|
||||
$plan->update($data);
|
||||
return redirect()->route('admin.plans.index')->with('success', 'Plan güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(MembershipPlan $plan)
|
||||
{
|
||||
$plan->delete();
|
||||
return redirect()->route('admin.plans.index')->with('success', 'Plan silindi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Season;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SeasonController extends Controller
|
||||
{
|
||||
public function index(Anime $anime) { return redirect()->route('admin.animes.show', $anime); }
|
||||
public function create(Anime $anime) { return redirect()->route('admin.animes.show', $anime); }
|
||||
public function show(Season $season) { return redirect()->route('admin.animes.show', $season->anime_id); }
|
||||
|
||||
public function store(Request $request, Anime $anime)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'season_number' => 'required|integer|min:1',
|
||||
'title' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'release_year' => 'nullable|integer|min:1900|max:2099',
|
||||
'is_published' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['anime_id'] = $anime->id;
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
|
||||
Season::create($data);
|
||||
return redirect()->route('admin.animes.show', $anime)->with('success', 'Sezon eklendi.');
|
||||
}
|
||||
|
||||
public function edit(Season $season)
|
||||
{
|
||||
return view('admin.seasons.edit', compact('season'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Season $season)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'season_number' => 'required|integer|min:1',
|
||||
'title' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'release_year' => 'nullable|integer|min:1900|max:2099',
|
||||
'is_published' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
$season->update($data);
|
||||
return redirect()->route('admin.animes.show', $season->anime_id)->with('success', 'Sezon güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(Season $season)
|
||||
{
|
||||
$animeId = $season->anime_id;
|
||||
$season->delete();
|
||||
return redirect()->route('admin.animes.show', $animeId)->with('success', 'Sezon silindi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Setting;
|
||||
use App\Models\SeoKeyword;
|
||||
use App\Models\SeoRedirect;
|
||||
use App\Models\Episode;
|
||||
use App\Models\User;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Watchlist;
|
||||
use App\Models\AnimeRating;
|
||||
use App\Models\BlogPost;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SeoController extends Controller
|
||||
{
|
||||
private array $defaults = [
|
||||
'seo_site_name' => 'Animexe',
|
||||
'seo_title_template' => '%s — Animexe | Türkçe Anime İzle',
|
||||
'seo_home_title' => 'Animexe — Türkçe Anime İzle | Ücretsiz HD',
|
||||
'seo_home_description' => 'Animexe\'de binlerce anime dizisi ve filmini Türkçe altyazılı veya dublajlı, ücretsiz ve yüksek kalitede izleyin.',
|
||||
'seo_home_keywords' => 'anime izle, türkçe anime, anime dizi, anime film, ücretsiz anime izle, hd anime, türkçe altyazılı anime, türkçe dublajlı anime',
|
||||
'seo_og_image' => '/logo.jpg',
|
||||
'seo_twitter_site' => '',
|
||||
'seo_facebook_app_id' => '',
|
||||
'seo_canonical_domain' => '',
|
||||
'seo_google_analytics' => '',
|
||||
'seo_gtm_id' => '',
|
||||
'seo_gsc_verification' => '',
|
||||
'seo_bing_verification' => '',
|
||||
'seo_yandex_verification' => '',
|
||||
'seo_enable_schema' => '1',
|
||||
'seo_enable_breadcrumb' => '1',
|
||||
'seo_noindex_search' => '1',
|
||||
'seo_noindex_profile' => '1',
|
||||
'seo_noindex_watch' => '0',
|
||||
'seo_org_logo' => '/logo.jpg',
|
||||
'seo_org_twitter' => '',
|
||||
'seo_org_facebook' => '',
|
||||
'seo_org_instagram' => '',
|
||||
'seo_robots_custom' => '',
|
||||
'seo_pagespeed_api_key' => '',
|
||||
'seo_looker_embed_url' => '',
|
||||
'seo_enable_faq_schema' => '1',
|
||||
'seo_enable_video_schema' => '1',
|
||||
];
|
||||
|
||||
public function index()
|
||||
{
|
||||
$settings = Setting::where('key', 'like', 'seo_%')->pluck('value', 'key')->toArray();
|
||||
foreach ($this->defaults as $key => $val) {
|
||||
if (!array_key_exists($key, $settings)) {
|
||||
$settings[$key] = $val;
|
||||
}
|
||||
}
|
||||
|
||||
$robotsPath = public_path('robots.txt');
|
||||
$robotsTxt = File::exists($robotsPath) ? File::get($robotsPath) : '';
|
||||
$audit = $this->runAudit();
|
||||
|
||||
$sitemapStats = [
|
||||
'anime_count' => Anime::where('is_published', true)->count(),
|
||||
'genre_count' => Genre::where('is_active', true)->count(),
|
||||
'static_count' => 2,
|
||||
'last_updated' => Setting::get('seo_sitemap_generated_at', null),
|
||||
];
|
||||
|
||||
// Keyword tracker
|
||||
$keywords = SeoKeyword::orderBy('keyword')->get();
|
||||
|
||||
// Redirect manager
|
||||
$redirects = SeoRedirect::orderByDesc('hits')->paginate(25, ['*'], 'rpage');
|
||||
|
||||
// Bulk SEO — animelerin SEO verileri (seo_title veya seo_meta_desc eksik olanlar önce)
|
||||
$animes = Anime::where('is_published', true)
|
||||
->orderByRaw('(seo_title IS NULL OR seo_title = "") DESC')
|
||||
->orderBy('title')
|
||||
->select('id', 'title', 'slug', 'description', 'seo_title', 'seo_meta_desc', 'seo_keywords')
|
||||
->paginate(30, ['*'], 'apage');
|
||||
|
||||
$animeSeoCoverage = [
|
||||
'total' => Anime::where('is_published', true)->count(),
|
||||
'has_seo_title'=> Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count(),
|
||||
'has_seo_desc' => Anime::where('is_published', true)->whereNotNull('seo_meta_desc')->where('seo_meta_desc', '!=', '')->count(),
|
||||
];
|
||||
|
||||
// Image alt audit — animes with cover
|
||||
$missingAlt = Anime::where('is_published', true)
|
||||
->whereNotNull('cover_image')->where('cover_image', '!=', '')
|
||||
->whereNull('title')->count(); // titles serve as alt text, so just check no-title
|
||||
|
||||
// Duplicate descriptions
|
||||
$dupDesc = DB::table('animes')
|
||||
->select('description', DB::raw('COUNT(*) as cnt'))
|
||||
->where('is_published', true)
|
||||
->whereNotNull('description')
|
||||
->where('description', '!=', '')
|
||||
->groupBy('description')
|
||||
->having('cnt', '>', 1)
|
||||
->count();
|
||||
|
||||
// ── Analytics stats for Google tab ───────────────────────────────────
|
||||
$analyticsStats = [
|
||||
'total_anime' => Anime::where('is_published', true)->count(),
|
||||
'total_episodes' => class_exists(Episode::class) ? Episode::count() : 0,
|
||||
'total_users' => User::count(),
|
||||
'total_genres' => Genre::where('is_active', true)->count(),
|
||||
'total_comments' => class_exists(Comment::class) ? Comment::count() : 0,
|
||||
'total_watchlists' => class_exists(Watchlist::class) ? Watchlist::count() : 0,
|
||||
'total_ratings' => class_exists(AnimeRating::class) ? AnimeRating::count() : 0,
|
||||
'total_blog_posts' => class_exists(BlogPost::class) ? BlogPost::count() : 0,
|
||||
'total_redirects' => SeoRedirect::where('is_active', true)->count(),
|
||||
'total_redirect_hits'=> SeoRedirect::sum('hits'),
|
||||
'seo_title_pct' => $sitemapStats['anime_count'] > 0
|
||||
? round($animeSeoCoverage['has_seo_title'] / $sitemapStats['anime_count'] * 100)
|
||||
: 0,
|
||||
'seo_desc_pct' => $sitemapStats['anime_count'] > 0
|
||||
? round($animeSeoCoverage['has_seo_desc'] / $sitemapStats['anime_count'] * 100)
|
||||
: 0,
|
||||
'new_anime_this_month' => Anime::where('is_published', true)
|
||||
->where('created_at', '>=', now()->startOfMonth())->count(),
|
||||
'new_users_this_month' => User::where('created_at', '>=', now()->startOfMonth())->count(),
|
||||
];
|
||||
|
||||
// Integration status
|
||||
$integrations = [
|
||||
'ga4' => !empty($settings['seo_google_analytics'] ?? ''),
|
||||
'gtm' => !empty($settings['seo_gtm_id'] ?? ''),
|
||||
'gsc' => !empty($settings['seo_gsc_verification'] ?? ''),
|
||||
'bing' => !empty($settings['seo_bing_verification'] ?? ''),
|
||||
'yandex' => !empty($settings['seo_yandex_verification'] ?? ''),
|
||||
];
|
||||
|
||||
return view('admin.seo.index', compact(
|
||||
'settings', 'robotsTxt', 'audit', 'sitemapStats',
|
||||
'keywords', 'redirects', 'animes', 'animeSeoCoverage', 'dupDesc',
|
||||
'analyticsStats', 'integrations'
|
||||
));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
// Tüm alanlar opsiyonel — her tab kendi alanlarını gönderir (partial update)
|
||||
$rules = [
|
||||
'seo_site_name' => 'nullable|string|max:100',
|
||||
'seo_title_template' => 'nullable|string|max:200',
|
||||
'seo_home_title' => 'nullable|string|max:200',
|
||||
'seo_home_description' => 'nullable|string|max:500',
|
||||
'seo_home_keywords' => 'nullable|string|max:500',
|
||||
'seo_og_image' => 'nullable|string|max:500',
|
||||
'seo_twitter_site' => 'nullable|string|max:100',
|
||||
'seo_facebook_app_id' => 'nullable|string|max:100',
|
||||
'seo_canonical_domain' => 'nullable|url|max:200',
|
||||
'seo_google_analytics' => 'nullable|string|max:50',
|
||||
'seo_gtm_id' => 'nullable|string|max:50',
|
||||
'seo_gsc_verification' => 'nullable|string|max:200',
|
||||
'seo_bing_verification' => 'nullable|string|max:200',
|
||||
'seo_yandex_verification' => 'nullable|string|max:200',
|
||||
'seo_org_logo' => 'nullable|string|max:500',
|
||||
'seo_org_twitter' => 'nullable|string|max:200',
|
||||
'seo_org_facebook' => 'nullable|string|max:200',
|
||||
'seo_org_instagram' => 'nullable|string|max:200',
|
||||
'seo_pagespeed_api_key' => 'nullable|string|max:100',
|
||||
'seo_looker_embed_url' => 'nullable|string|max:500',
|
||||
];
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
|
||||
// Checkbox alanları: sadece request'te varsa güncelle
|
||||
$checkboxes = [
|
||||
'seo_enable_schema', 'seo_enable_breadcrumb', 'seo_noindex_search',
|
||||
'seo_noindex_profile', 'seo_noindex_watch', 'seo_enable_faq_schema', 'seo_enable_video_schema',
|
||||
];
|
||||
foreach ($checkboxes as $key) {
|
||||
if ($request->has($key) || $request->has('_seo_section')) {
|
||||
$value = $request->input($key);
|
||||
$validated[$key] = ($value === '1' || $value === 'on') ? '1' : '0';
|
||||
}
|
||||
}
|
||||
|
||||
// Sadece gönderilen (non-null) alanları kaydet
|
||||
foreach ($validated as $key => $value) {
|
||||
if ($value !== null) {
|
||||
Setting::set($key, $value, 'seo');
|
||||
}
|
||||
}
|
||||
|
||||
cache()->forget('seo_settings');
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['ok' => true, 'message' => 'SEO ayarları kaydedildi.']);
|
||||
}
|
||||
return back()->with('success', 'SEO ayarları başarıyla kaydedildi.');
|
||||
}
|
||||
|
||||
public function updateRobots(Request $request)
|
||||
{
|
||||
$request->validate(['robots_txt' => 'required|string|max:10000']);
|
||||
File::put(public_path('robots.txt'), $request->input('robots_txt'));
|
||||
return back()->with('success', 'robots.txt güncellendi.');
|
||||
}
|
||||
|
||||
public function pingSearchEngines(Request $request)
|
||||
{
|
||||
$domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/');
|
||||
$sitemapUrl = urlencode($domain . '/sitemap.xml');
|
||||
$results = [];
|
||||
|
||||
foreach (['google' => "https://www.google.com/ping?sitemap={$sitemapUrl}", 'bing' => "https://www.bing.com/ping?sitemap={$sitemapUrl}"] as $engine => $url) {
|
||||
try {
|
||||
$r = Http::timeout(5)->get($url);
|
||||
$results[$engine] = $r->successful() ? 'success' : 'error';
|
||||
} catch (\Throwable) {
|
||||
$results[$engine] = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
Setting::set('seo_sitemap_pinged_at', now()->toDateTimeString(), 'seo');
|
||||
return back()->with('ping_results', $results)->with('success', 'Arama motorlarına bildirim gönderildi.');
|
||||
}
|
||||
|
||||
public function auditJson()
|
||||
{
|
||||
return response()->json($this->runAudit());
|
||||
}
|
||||
|
||||
// ── Keyword Tracker ───────────────────────────────────────────────────────
|
||||
|
||||
public function storeKeyword(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'keyword' => 'required|string|max:255',
|
||||
'target_url' => 'nullable|string|max:500',
|
||||
'search_volume' => 'nullable|integer|min:0',
|
||||
'difficulty' => 'nullable|integer|min:0|max:100',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
SeoKeyword::create($data);
|
||||
return back()->with('success', 'Anahtar kelime eklendi.');
|
||||
}
|
||||
|
||||
public function destroyKeyword(SeoKeyword $keyword)
|
||||
{
|
||||
$keyword->delete();
|
||||
return back()->with('success', 'Anahtar kelime silindi.');
|
||||
}
|
||||
|
||||
// ── Redirect Manager ─────────────────────────────────────────────────────
|
||||
|
||||
public function storeRedirect(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'from_path' => 'required|string|max:500',
|
||||
'to_path' => 'required|string|max:500',
|
||||
'type' => 'required|in:301,302',
|
||||
]);
|
||||
|
||||
$data['from_path'] = '/' . ltrim($data['from_path'], '/');
|
||||
|
||||
SeoRedirect::updateOrCreate(['from_path' => $data['from_path']], $data);
|
||||
cache()->forget('seo_redirect_' . md5($data['from_path']));
|
||||
return back()->with('success', 'Yönlendirme eklendi/güncellendi.');
|
||||
}
|
||||
|
||||
public function destroyRedirect(SeoRedirect $redirect)
|
||||
{
|
||||
cache()->forget('seo_redirect_' . md5($redirect->from_path));
|
||||
$redirect->delete();
|
||||
return back()->with('success', 'Yönlendirme silindi.');
|
||||
}
|
||||
|
||||
public function toggleRedirect(SeoRedirect $redirect)
|
||||
{
|
||||
$redirect->update(['is_active' => !$redirect->is_active]);
|
||||
cache()->forget('seo_redirect_' . md5($redirect->from_path));
|
||||
return response()->json(['is_active' => $redirect->is_active]);
|
||||
}
|
||||
|
||||
// ── Bulk Anime SEO ────────────────────────────────────────────────────────
|
||||
|
||||
public function bulkSaveAnime(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'animes' => 'required|array',
|
||||
'animes.*.id' => 'required|integer|exists:animes,id',
|
||||
'animes.*.seo_title' => 'nullable|string|max:100',
|
||||
'animes.*.seo_meta_desc' => 'nullable|string|max:320',
|
||||
'animes.*.seo_keywords' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
foreach ($data['animes'] as $row) {
|
||||
Anime::where('id', $row['id'])->update([
|
||||
'seo_title' => $row['seo_title'] ?? null,
|
||||
'seo_meta_desc' => $row['seo_meta_desc'] ?? null,
|
||||
'seo_keywords' => $row['seo_keywords'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', count($data['animes']) . ' anime için SEO verileri kaydedildi.');
|
||||
}
|
||||
|
||||
public function generateAnimeSeo(Anime $anime)
|
||||
{
|
||||
$title = trim($anime->title);
|
||||
$seoTitle = $title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime Dizi') . ' | Animexe';
|
||||
$seoTitle = mb_substr($seoTitle, 0, 70);
|
||||
|
||||
$desc = $anime->description
|
||||
? mb_substr(strip_tags($anime->description), 0, 130)
|
||||
: '';
|
||||
$seoDesc = $desc
|
||||
? $desc . ' Animexe\'de Türkçe altyazılı izle.'
|
||||
: $title . '\'yi Türkçe altyazılı veya dublajlı, ücretsiz ve HD kalitede Animexe\'de izleyin.';
|
||||
$seoDesc = mb_substr($seoDesc, 0, 160);
|
||||
|
||||
$keywords = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe, ' . strtolower($title) . ' türkçe altyazılı';
|
||||
|
||||
$anime->update([
|
||||
'seo_title' => $seoTitle,
|
||||
'seo_meta_desc' => $seoDesc,
|
||||
'seo_keywords' => $keywords,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'seo_title' => $seoTitle,
|
||||
'seo_meta_desc' => $seoDesc,
|
||||
'seo_keywords' => $keywords,
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkGenerateAllSeo(Request $request)
|
||||
{
|
||||
$animes = Anime::where('is_published', true)
|
||||
->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))
|
||||
->get(['id', 'title', 'type', 'description']);
|
||||
|
||||
$count = 0;
|
||||
foreach ($animes as $anime) {
|
||||
$title = trim($anime->title);
|
||||
$seoTitle = mb_substr($title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime Dizi') . ' | Animexe', 0, 70);
|
||||
$desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 130) : '';
|
||||
$seoDesc = mb_substr($desc ? $desc . ' Animexe\'de Türkçe izle.' : $title . '\'yi Animexe\'de ücretsiz izleyin.', 0, 160);
|
||||
$keywords = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe altyazılı';
|
||||
|
||||
$anime->update([
|
||||
'seo_title' => $seoTitle,
|
||||
'seo_meta_desc' => $seoDesc,
|
||||
'seo_keywords' => $keywords,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return response()->json(['generated' => $count]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek ile toplu AI SEO üretimi — SEO başlığı olmayan animeleri işler.
|
||||
* Her batch 10 anime, aralarında 1s bekleme (rate limit önlemi).
|
||||
* İstek başına max 10 anime işler; frontend'den tekrar tekrar çağrılarak tamamlanır.
|
||||
*/
|
||||
public function aiBulkGenerateSeo(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$batchSize = min((int)$request->input('batch', 10), 20);
|
||||
|
||||
$animes = Anime::where('is_published', true)
|
||||
->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))
|
||||
->with('genres:id,name')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
$remaining = Anime::where('is_published', true)
|
||||
->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))
|
||||
->count();
|
||||
|
||||
$done = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($animes as $anime) {
|
||||
$result = $ai->generateAnimeSeoMeta($anime);
|
||||
if ($result) {
|
||||
$anime->update([
|
||||
'seo_title' => $result['seo_title'] ?? null,
|
||||
'seo_meta_desc' => $result['seo_meta_desc'] ?? null,
|
||||
'seo_keywords' => $result['seo_keywords'] ?? null,
|
||||
]);
|
||||
$done++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
sleep(1); // DeepSeek rate limit
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'done' => $done,
|
||||
'errors' => $errors,
|
||||
'remaining' => max(0, $remaining - $done),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── PageSpeed ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function pagespeedCheck(Request $request)
|
||||
{
|
||||
$request->validate(['url' => 'required|url', 'strategy' => 'in:mobile,desktop']);
|
||||
|
||||
$apiKey = Setting::get('seo_pagespeed_api_key', '');
|
||||
$url = $request->url;
|
||||
$strategy = $request->input('strategy', 'mobile');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return response()->json(['error' => 'PageSpeed API anahtarı girilmemiş. SEO ayarlarından ekleyin.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$endpoint = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=" . urlencode($url) . "&strategy={$strategy}&key={$apiKey}";
|
||||
$resp = Http::timeout(20)->get($endpoint);
|
||||
|
||||
if (!$resp->successful()) {
|
||||
return response()->json(['error' => 'PageSpeed API hatası: ' . $resp->status()], 422);
|
||||
}
|
||||
|
||||
$data = $resp->json();
|
||||
$categories = $data['lighthouseResult']['categories'] ?? [];
|
||||
$audits = $data['lighthouseResult']['audits'] ?? [];
|
||||
|
||||
$scores = [
|
||||
'performance' => round(($categories['performance']['score'] ?? 0) * 100),
|
||||
'accessibility' => round(($categories['accessibility']['score'] ?? 0) * 100),
|
||||
'seo' => round(($categories['seo']['score'] ?? 0) * 100),
|
||||
'best_practices'=> round(($categories['best-practices']['score'] ?? 0) * 100),
|
||||
];
|
||||
|
||||
$opportunities = [];
|
||||
foreach ($audits as $id => $audit) {
|
||||
if (($audit['score'] ?? 1) < 0.9 && isset($audit['details']['type']) && $audit['details']['type'] === 'opportunity') {
|
||||
$opportunities[] = [
|
||||
'title' => $audit['title'],
|
||||
'description' => $audit['description'] ?? '',
|
||||
'savings' => $audit['details']['overallSavingsMs'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$fcp = $audits['first-contentful-paint']['displayValue'] ?? null;
|
||||
$lcp = $audits['largest-contentful-paint']['displayValue'] ?? null;
|
||||
$cls = $audits['cumulative-layout-shift']['displayValue'] ?? null;
|
||||
$tbt = $audits['total-blocking-time']['displayValue'] ?? null;
|
||||
|
||||
return response()->json([
|
||||
'scores' => $scores,
|
||||
'vitals' => compact('fcp', 'lcp', 'cls', 'tbt'),
|
||||
'opportunities' => array_slice($opportunities, 0, 8),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal Links Audit ──────────────────────────────────────────────────
|
||||
|
||||
public function internalLinksAudit()
|
||||
{
|
||||
// Find animes with no other anime referencing them in descriptions (orphaned)
|
||||
$allAnimes = Anime::where('is_published', true)->get(['id', 'title', 'slug']);
|
||||
$result = [];
|
||||
|
||||
foreach ($allAnimes as $anime) {
|
||||
$mentionedIn = Anime::where('is_published', true)
|
||||
->where('id', '!=', $anime->id)
|
||||
->where('description', 'like', '%' . $anime->title . '%')
|
||||
->count();
|
||||
$result[] = [
|
||||
'id' => $anime->id,
|
||||
'title' => $anime->title,
|
||||
'slug' => $anime->slug,
|
||||
'mentioned_in'=> $mentionedIn,
|
||||
];
|
||||
}
|
||||
|
||||
usort($result, fn($a, $b) => $a['mentioned_in'] <=> $b['mentioned_in']);
|
||||
|
||||
return response()->json(array_slice($result, 0, 50));
|
||||
}
|
||||
|
||||
// ── Duplicate Content ─────────────────────────────────────────────────────
|
||||
|
||||
public function duplicateContent()
|
||||
{
|
||||
$dups = DB::table('animes')
|
||||
->select('description', DB::raw('COUNT(*) as cnt'), DB::raw('GROUP_CONCAT(title ORDER BY title SEPARATOR ", ") as titles'))
|
||||
->where('is_published', true)
|
||||
->whereNotNull('description')
|
||||
->where('description', '!=', '')
|
||||
->groupBy('description')
|
||||
->having('cnt', '>', 1)
|
||||
->get();
|
||||
|
||||
return response()->json($dups);
|
||||
}
|
||||
|
||||
// ── AI SEO Methods ────────────────────────────────────────────────────────
|
||||
|
||||
public function aiChat(Request $request)
|
||||
{
|
||||
$request->validate(['messages' => 'required|array', 'messages.*.role' => 'required|in:user,assistant', 'messages.*.content' => 'required|string|max:4000']);
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar sayfasından ekleyin.'], 422);
|
||||
}
|
||||
|
||||
$total = Anime::where('is_published', true)->count();
|
||||
$covered = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count();
|
||||
$audit = $this->runAudit();
|
||||
$sitemap = $total + Genre::where('is_active', true)->count() + 2;
|
||||
|
||||
$context = [
|
||||
'anime_count' => $total,
|
||||
'seo_covered' => $covered,
|
||||
'seo_score' => $audit['score'],
|
||||
'sitemap_urls' => $sitemap,
|
||||
];
|
||||
|
||||
$reply = $ai->seoChat($request->messages, $context);
|
||||
|
||||
if (!$reply) {
|
||||
return response()->json(['error' => 'DeepSeek yanıt vermedi. API anahtarını kontrol edin.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['reply' => $reply]);
|
||||
}
|
||||
|
||||
public function aiGenerateAnimeSeo(Anime $anime)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$anime->loadMissing('genres');
|
||||
$result = $ai->generateAnimeSeoMeta($anime);
|
||||
|
||||
if (!$result) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
|
||||
}
|
||||
|
||||
$anime->update([
|
||||
'seo_title' => $result['seo_title'] ?? null,
|
||||
'seo_meta_desc' => $result['seo_meta_desc'] ?? null,
|
||||
'seo_keywords' => $result['seo_keywords'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function aiKeywordSuggest(Request $request)
|
||||
{
|
||||
$request->validate(['topic' => 'required|string|max:200']);
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$result = $ai->suggestKeywords($request->topic);
|
||||
if (!$result) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function aiPageAnalysis(Request $request)
|
||||
{
|
||||
$request->validate(['url' => 'required|url', 'title' => 'nullable|string', 'description' => 'nullable|string', 'content' => 'nullable|string']);
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$result = $ai->analyzePageSeo(
|
||||
$request->url,
|
||||
$request->input('title', ''),
|
||||
$request->input('description', ''),
|
||||
$request->input('content', '')
|
||||
);
|
||||
|
||||
if (!$result) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function aiFaqSchema(Request $request)
|
||||
{
|
||||
$request->validate(['anime_id' => 'required|integer|exists:animes,id']);
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$anime = Anime::with('genres')->findOrFail($request->anime_id);
|
||||
$result = $ai->generateFaqSchema($anime);
|
||||
|
||||
if (!$result) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function aiContentStrategy(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$total = Anime::where('is_published', true)->count();
|
||||
$covered = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count();
|
||||
$audit = $this->runAudit();
|
||||
$kwds = SeoKeyword::orderBy('search_volume', 'desc')->take(10)->pluck('keyword')->toArray();
|
||||
|
||||
$strategy = $ai->generateContentStrategy([
|
||||
'seo_score' => $audit['score'],
|
||||
'anime_count' => $total,
|
||||
'seo_covered' => $covered,
|
||||
], $kwds);
|
||||
|
||||
if (!$strategy) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['strategy' => $strategy]);
|
||||
}
|
||||
|
||||
public function aiRobotsTxt(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422);
|
||||
}
|
||||
|
||||
$domain = Setting::get('seo_canonical_domain', 'animexe.com');
|
||||
$result = $ai->generateRobotsTxt($domain);
|
||||
|
||||
if (!$result) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['robots_txt' => $result]);
|
||||
}
|
||||
|
||||
// ── Toplu Doldurma (Batch) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Yayınlanan animelerin coverage istatistiklerini döndür.
|
||||
* GET /admin/seo/bulk-fill-stats
|
||||
*/
|
||||
public function bulkFillStats()
|
||||
{
|
||||
$total = Anime::where('is_published', true)->count();
|
||||
$hasDesc = Anime::where('is_published', true)->whereNotNull('description')->where('description', '!=', '')->count();
|
||||
$hasSeoTitle = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count();
|
||||
$hasSeoDesc = Anime::where('is_published', true)->whereNotNull('seo_meta_desc')->where('seo_meta_desc', '!=', '')->count();
|
||||
$hasYear = Anime::where('is_published', true)->whereNotNull('release_year')->count();
|
||||
$hasGenres = Anime::where('is_published', true)->has('genres')->count();
|
||||
|
||||
// Kaç adet işlenecek (her mode için)
|
||||
$needsSeo = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))->count();
|
||||
$needsMeta = Anime::where('is_published', true)->where(fn($q) =>
|
||||
$q->whereNull('description')->orWhere('description', '')
|
||||
->orWhereNull('release_year')
|
||||
)->count();
|
||||
$needsAll = Anime::where('is_published', true)->where(fn($q) =>
|
||||
$q->whereNull('seo_title')->orWhere('seo_title', '')
|
||||
->orWhereNull('description')->orWhere('description', '')
|
||||
)->count();
|
||||
|
||||
return response()->json([
|
||||
'total' => $total,
|
||||
'has_desc' => $hasDesc,
|
||||
'has_seo_title'=> $hasSeoTitle,
|
||||
'has_seo_desc' => $hasSeoDesc,
|
||||
'has_year' => $hasYear,
|
||||
'has_genres' => $hasGenres,
|
||||
'needs_seo' => $needsSeo,
|
||||
'needs_meta' => $needsMeta,
|
||||
'needs_all' => $needsAll,
|
||||
'pct_seo' => $total > 0 ? round($hasSeoTitle / $total * 100) : 0,
|
||||
'pct_desc' => $total > 0 ? round($hasDesc / $total * 100) : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toplu doldurma — batch tabanlı, timeout olmaz.
|
||||
*
|
||||
* POST /admin/seo/bulk-fill-batch
|
||||
* body: {
|
||||
* mode: 'template_seo' | 'ai_seo' | 'ai_meta' | 'ai_all',
|
||||
* last_id: 0, // son işlenen anime id'si (pagination için)
|
||||
* batch_size: 5, // kaç anime işlensin
|
||||
* force: false, // dolu alanları da üzerine yaz
|
||||
* }
|
||||
* returns: { done, errors, last_id, remaining, total }
|
||||
*/
|
||||
public function bulkFillBatch(Request $request)
|
||||
{
|
||||
$mode = $request->input('mode', 'template_seo');
|
||||
$lastId = (int) $request->input('last_id', 0);
|
||||
$batchSize = min((int) $request->input('batch_size', 10), 50);
|
||||
$force = $request->boolean('force', false);
|
||||
|
||||
$isAi = str_starts_with($mode, 'ai_');
|
||||
|
||||
if ($isAi) {
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar > DeepSeek ekleyin.'], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Hangi animelere ihtiyaç var?
|
||||
$query = Anime::where('is_published', true)->where('id', '>', $lastId);
|
||||
|
||||
if (!$force) {
|
||||
if ($mode === 'template_seo' || $mode === 'ai_seo') {
|
||||
$query->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''));
|
||||
} elseif ($mode === 'ai_meta') {
|
||||
$query->where(fn($q) =>
|
||||
$q->whereNull('description')->orWhere('description', '')
|
||||
->orWhereNull('release_year')
|
||||
);
|
||||
} elseif ($mode === 'ai_all') {
|
||||
$query->where(fn($q) =>
|
||||
$q->whereNull('seo_title')->orWhere('seo_title', '')
|
||||
->orWhereNull('description')->orWhere('description', '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$total = $query->clone()->count();
|
||||
$animes = $query->with('genres:id,name')->orderBy('id')->limit($batchSize)->get();
|
||||
|
||||
$done = 0;
|
||||
$errors = 0;
|
||||
$newLastId = $lastId;
|
||||
|
||||
foreach ($animes as $anime) {
|
||||
$newLastId = $anime->id;
|
||||
|
||||
try {
|
||||
if ($mode === 'template_seo') {
|
||||
// Hızlı template — AI çağrısı yok
|
||||
$title = trim($anime->title);
|
||||
$seoTitle = mb_substr(
|
||||
$title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime') . ' İzle | Animexe',
|
||||
0, 70
|
||||
);
|
||||
$desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 130) : '';
|
||||
$seoDesc = mb_substr(
|
||||
$desc
|
||||
? $desc . ' Animexe\'de Türkçe altyazılı izle.'
|
||||
: $title . '\'yi Türkçe altyazılı veya dublajlı ücretsiz HD olarak Animexe\'de izleyin.',
|
||||
0, 160
|
||||
);
|
||||
$kwds = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe altyazılı, ' . strtolower($title) . ' türkçe dublaj';
|
||||
|
||||
$updates = ['seo_title' => $seoTitle, 'seo_meta_desc' => $seoDesc, 'seo_keywords' => $kwds];
|
||||
if ($force) {
|
||||
$anime->update($updates);
|
||||
} else {
|
||||
$anime->update(array_filter($updates, fn($v) => !empty($v)));
|
||||
}
|
||||
$done++;
|
||||
|
||||
} elseif ($mode === 'ai_seo') {
|
||||
$result = $ai->generateAnimeSeoMeta($anime);
|
||||
if ($result) {
|
||||
$updates = array_filter([
|
||||
'seo_title' => $result['seo_title'] ?? null,
|
||||
'seo_meta_desc' => $result['seo_meta_desc'] ?? null,
|
||||
'seo_keywords' => $result['seo_keywords'] ?? null,
|
||||
]);
|
||||
if ($force || empty($anime->seo_title)) {
|
||||
$anime->update($updates);
|
||||
}
|
||||
$done++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
|
||||
} elseif ($mode === 'ai_meta') {
|
||||
$meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? '');
|
||||
if ($meta) {
|
||||
$this->applyAnimeMeta($anime, $meta, $force);
|
||||
$done++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
|
||||
} elseif ($mode === 'ai_all') {
|
||||
// Meta + SEO birlikte — 2 AI çağrısı
|
||||
$meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? '');
|
||||
if ($meta) {
|
||||
$this->applyAnimeMeta($anime->fresh(), $meta, $force);
|
||||
}
|
||||
|
||||
$anime->loadMissing('genres');
|
||||
$seoResult = $ai->generateAnimeSeoMeta($anime->fresh(['genres']));
|
||||
if ($seoResult) {
|
||||
$anime->update(array_filter([
|
||||
'seo_title' => $seoResult['seo_title'] ?? null,
|
||||
'seo_meta_desc' => $seoResult['seo_meta_desc'] ?? null,
|
||||
'seo_keywords' => $seoResult['seo_keywords'] ?? null,
|
||||
]));
|
||||
$done++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$errors++;
|
||||
\Illuminate\Support\Facades\Log::warning("[bulkFillBatch] Hata [{$anime->id}] {$anime->title}: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// AI çağrıları arası kısa bekleme (rate limit önlemi)
|
||||
if ($isAi && $done + $errors < count($animes)) {
|
||||
usleep(800_000); // 0.8s
|
||||
}
|
||||
}
|
||||
|
||||
// Kalan animeler (bu batch'ten sonra)
|
||||
$remaining = max(0, $total - $done - $errors);
|
||||
|
||||
return response()->json([
|
||||
'done' => $done,
|
||||
'errors' => $errors,
|
||||
'last_id' => $newLastId,
|
||||
'remaining' => $remaining,
|
||||
'total' => $total,
|
||||
'finished' => $animes->count() < $batchSize || $remaining === 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function applyAnimeMeta(Anime $anime, array $meta, bool $force): void
|
||||
{
|
||||
$updates = [];
|
||||
$fill = function (string $field, $value) use ($anime, $force, &$updates) {
|
||||
if ($value === null || $value === '') return;
|
||||
if ($force || empty($anime->$field)) $updates[$field] = $value;
|
||||
};
|
||||
|
||||
$fill('description', $meta['description'] ?? null);
|
||||
$fill('release_year', $meta['release_year'] ?? null);
|
||||
$fill('studio', $meta['studio'] ?? null);
|
||||
$fill('type', $meta['type'] ?? null);
|
||||
$fill('status', $meta['status'] ?? null);
|
||||
$fill('title_en', $meta['title_en'] ?? null);
|
||||
$fill('title_jp', $meta['title_jp'] ?? null);
|
||||
if (!empty($meta['rating']) && ($force || !$anime->rating)) {
|
||||
$updates['rating'] = min(10, max(0, (float) $meta['rating']));
|
||||
}
|
||||
|
||||
if (!empty($updates)) $anime->update($updates);
|
||||
|
||||
if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) {
|
||||
$ids = [];
|
||||
foreach ($meta['genres'] as $name) {
|
||||
$g = \App\Models\Genre::firstOrCreate(
|
||||
['name' => $name],
|
||||
['slug' => \Illuminate\Support\Str::slug($name)]
|
||||
);
|
||||
$ids[] = $g->id;
|
||||
}
|
||||
if ($ids) {
|
||||
$force ? $anime->genres()->sync($ids) : $anime->genres()->syncWithoutDetaching($ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function runAudit(): array
|
||||
{
|
||||
$total = Anime::where('is_published', true)->count();
|
||||
$noDesc = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('description')->orWhere('description', ''))->count();
|
||||
$noCover = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('cover_image')->orWhere('cover_image', ''))->count();
|
||||
$shortDesc = Anime::where('is_published', true)->whereNotNull('description')->whereRaw('CHAR_LENGTH(description) < 100')->count();
|
||||
$noSlug = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('slug')->orWhere('slug', ''))->count();
|
||||
$noSeoTitle = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))->count();
|
||||
|
||||
$seo = Setting::where('key', 'like', 'seo_%')->pluck('value', 'key');
|
||||
$robots = File::exists(public_path('robots.txt')) ? File::get(public_path('robots.txt')) : '';
|
||||
$hasSitemap = file_exists(public_path('sitemap.xml'));
|
||||
|
||||
$dupDesc = DB::table('animes')->select('description')->where('is_published', true)
|
||||
->whereNotNull('description')->where('description', '!=', '')
|
||||
->groupBy('description')->havingRaw('COUNT(*) > 1')->count();
|
||||
|
||||
$checks = [];
|
||||
|
||||
// Site config
|
||||
$checks[] = $this->check('site_name', !empty($seo['seo_site_name']), 'Site Adı Ayarlandı', 'Site adı eksik', 10);
|
||||
$checks[] = $this->check('home_title', !empty($seo['seo_home_title']), 'Anasayfa Başlığı Mevcut', 'Anasayfa başlığı eksik', 10);
|
||||
$checks[] = $this->check('home_desc', !empty($seo['seo_home_description']), 'Anasayfa Meta Açıklaması Mevcut', 'Anasayfa meta açıklaması eksik', 10);
|
||||
$checks[] = $this->check('title_length', strlen($seo['seo_home_title'] ?? '') <= 70 && strlen($seo['seo_home_title'] ?? '') >= 30, 'Başlık Uzunluğu İdeal (30–70)', 'Başlık çok kısa veya çok uzun', 5);
|
||||
$checks[] = $this->check('desc_length', strlen($seo['seo_home_description'] ?? '') <= 160 && strlen($seo['seo_home_description'] ?? '') >= 100, 'Meta Açıklama Uzunluğu İdeal', 'Meta açıklama 100–160 karakter arası olmalı', 5);
|
||||
$checks[] = $this->check('og_image', !empty($seo['seo_og_image']), 'OG Görseli Tanımlandı', 'Varsayılan OG görseli eksik', 8);
|
||||
$checks[] = $this->check('canonical', !empty($seo['seo_canonical_domain']), 'Canonical Domain Ayarlı', 'Canonical domain ayarlanmamış', 8);
|
||||
$checks[] = $this->check('analytics', !empty($seo['seo_google_analytics']), 'Google Analytics Entegre', 'GA4 ID girilmemiş', 7);
|
||||
$checks[] = $this->check('gsc', !empty($seo['seo_gsc_verification']), 'Search Console Doğrulandı', 'GSC doğrulama kodu eksik', 7);
|
||||
$checks[] = $this->check('schema', ($seo['seo_enable_schema'] ?? '1') === '1', 'Schema.org İşaretleme Aktif', 'Schema.org işaretleme kapalı', 7);
|
||||
$checks[] = $this->check('faq_schema', ($seo['seo_enable_faq_schema'] ?? '1') === '1', 'FAQ Schema Aktif', 'FAQ şema kapalı (rich snippets kayıp)', 5);
|
||||
$checks[] = $this->check('video_schema', ($seo['seo_enable_video_schema'] ?? '1') === '1', 'Video Schema Aktif', 'Video şema kapalı', 5);
|
||||
|
||||
// Technical SEO
|
||||
$checks[] = $this->check('sitemap', $hasSitemap, 'Sitemap Mevcut', 'sitemap.xml bulunamadı', 8);
|
||||
$checks[] = $this->check('robots_exists', !empty($robots), 'robots.txt Mevcut', 'robots.txt yok veya boş', 6);
|
||||
$checks[] = $this->check('robots_admin', str_contains($robots, 'Disallow: /admin'), 'robots.txt Admin Kapalı', 'robots.txt /admin dizini kapalı değil', 6);
|
||||
$checks[] = $this->check('noindex_search', ($seo['seo_noindex_search'] ?? '1') === '1', 'Arama Sayfası Noindex', 'Arama sayfası indexleniyor', 5);
|
||||
$checks[] = $this->check('twitter', !empty($seo['seo_twitter_site']), 'Twitter Card Yapılandırıldı', 'Twitter hesabı girilmemiş', 4);
|
||||
$checks[] = $this->check('bing', !empty($seo['seo_bing_verification']), 'Bing Webmaster Doğrulandı', 'Bing doğrulama kodu eksik', 3);
|
||||
|
||||
// Content quality
|
||||
$checks[] = $this->check('anime_desc', $noDesc === 0, 'Tüm Animelerin Açıklaması Var', "{$noDesc} animenin açıklaması eksik", 8);
|
||||
$checks[] = $this->check('anime_cover', $noCover === 0, 'Tüm Animelerin Kapağı Var', "{$noCover} animenin görseli eksik", 7);
|
||||
$checks[] = $this->check('desc_quality', $shortDesc < max(1, $total * 0.1), 'Açıklama Kalitesi İyi', "{$shortDesc} animenin açıklaması çok kısa", 4);
|
||||
$checks[] = $this->check('slug_coverage', $noSlug === 0, 'Tüm Animeler URL Slug\'a Sahip', "{$noSlug} animenin slug\'u eksik", 6);
|
||||
$checks[] = $this->check('seo_titles', $noSeoTitle < $total * 0.2, 'Anime SEO Başlıkları Yeterli', "{$noSeoTitle} animenin SEO başlığı eksik", 6);
|
||||
$checks[] = $this->check('dup_desc', $dupDesc === 0, 'Tekrarlayan İçerik Yok', "{$dupDesc} grup tekrarlayan açıklama var", 5);
|
||||
|
||||
$score = $weight = 0;
|
||||
foreach ($checks as $c) {
|
||||
$weight += $c['weight'];
|
||||
if ($c['pass']) $score += $c['weight'];
|
||||
}
|
||||
|
||||
$scorePercent = $weight > 0 ? round(($score / $weight) * 100) : 0;
|
||||
|
||||
return [
|
||||
'score' => $scorePercent,
|
||||
'checks' => $checks,
|
||||
'totals' => ['total' => $total, 'noDesc' => $noDesc, 'noCover' => $noCover, 'shortDesc' => $shortDesc, 'noSeoTitle' => $noSeoTitle, 'dupDesc' => $dupDesc],
|
||||
'pass_count' => collect($checks)->where('pass', true)->count(),
|
||||
'fail_count' => collect($checks)->where('pass', false)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
private function check(string $id, bool $pass, string $passMsg, string $failMsg, int $weight): array
|
||||
{
|
||||
return compact('id', 'pass', 'passMsg', 'failMsg', 'weight');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\TestMail;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SettingController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$settings = Setting::all()->keyBy('key');
|
||||
return view('admin.settings.index', compact('settings'));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$data = $request->except(['_token', '_method', 'intro_video_file']);
|
||||
|
||||
// Checkbox keys: explicitly set to '0' when not present in request
|
||||
$booleanKeys = [
|
||||
'comments_enabled', 'comments_require_approval',
|
||||
'intro_enabled', 'nav_show_messages',
|
||||
'ai_auto_description', 'ai_auto_seo',
|
||||
'premium_free_mode',
|
||||
'ads_enabled',
|
||||
];
|
||||
foreach ($booleanKeys as $k) {
|
||||
if (!array_key_exists($k, $data)) {
|
||||
$data[$k] = '0';
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
Setting::set($key, $value);
|
||||
}
|
||||
|
||||
cache()->forget('premium_free_mode');
|
||||
|
||||
return back()->with('success', 'Ayarlar kaydedildi.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Favicon yükle — public/favicon.{ext} olarak kaydet, setting'e yaz.
|
||||
*/
|
||||
public function uploadFavicon(Request $request)
|
||||
{
|
||||
$request->validate(['favicon_file' => 'required|file|mimes:png,ico,svg,jpg,jpeg|max:2048']);
|
||||
|
||||
$file = $request->file('favicon_file');
|
||||
$ext = strtolower($file->getClientOriginalExtension()) ?: 'png';
|
||||
$dest = public_path('favicon.' . $ext);
|
||||
|
||||
// Eski favicon dosyalarını temizle
|
||||
foreach (['png', 'ico', 'svg', 'jpg', 'jpeg'] as $e) {
|
||||
$old = public_path('favicon.' . $e);
|
||||
if (file_exists($old) && $old !== $dest) @unlink($old);
|
||||
}
|
||||
|
||||
$file->move(public_path(), 'favicon.' . $ext);
|
||||
|
||||
$url = '/favicon.' . $ext;
|
||||
Setting::set('site_favicon', $url);
|
||||
|
||||
return back()->with('favicon_success', 'Favicon güncellendi.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Intro videoyu BunnyCDN Storage'a yükle, URL'yi ayarlara kaydet.
|
||||
*/
|
||||
public function uploadIntro(Request $request)
|
||||
{
|
||||
$request->validate(['intro_video_file' => 'required|file|mimes:mp4,webm|max:204800']); // max 200MB
|
||||
|
||||
$zone = Setting::get('bunnycdn_zone');
|
||||
$apiKey = Setting::get('bunnycdn_api_key');
|
||||
$pullUrl = rtrim(Setting::get('bunnycdn_pull_url', ''), '/');
|
||||
|
||||
if (!$zone || !$apiKey || !$pullUrl) {
|
||||
return back()->with('intro_error', 'Önce BunnyCDN ayarlarını kaydedin (Zone, API Key, Pull URL).');
|
||||
}
|
||||
|
||||
$file = $request->file('intro_video_file');
|
||||
$ext = $file->getClientOriginalExtension() ?: 'mp4';
|
||||
$fileName = 'intro/site-intro.' . $ext;
|
||||
$apiUrl = "https://storage.bunnycdn.com/{$zone}/{$fileName}";
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'AccessKey' => $apiKey,
|
||||
'Content-Type' => $file->getMimeType(),
|
||||
])->withBody(file_get_contents($file->getRealPath()), $file->getMimeType())
|
||||
->put($apiUrl);
|
||||
|
||||
if (!$response->successful()) {
|
||||
return back()->with('intro_error', 'BunnyCDN yükleme başarısız: ' . $response->status() . ' — ' . $response->body());
|
||||
}
|
||||
|
||||
$cdnUrl = $pullUrl . '/' . $fileName;
|
||||
Setting::set('intro_video_url', $cdnUrl, 'intro');
|
||||
|
||||
return back()->with('intro_success', 'Intro video yüklendi ve URL kaydedildi.');
|
||||
}
|
||||
|
||||
public function testMail(Request $request)
|
||||
{
|
||||
$request->validate(['test_mail_to' => 'required|email'], [
|
||||
'test_mail_to.required' => 'Alıcı e-posta adresi zorunludur.',
|
||||
'test_mail_to.email' => 'Geçerli bir e-posta adresi girin.',
|
||||
]);
|
||||
|
||||
// DB'deki ayarları runtime'da uygula
|
||||
$keys = ['mail_host','mail_port','mail_username','mail_password',
|
||||
'mail_from_address','mail_from_name','mail_encryption'];
|
||||
$rows = Setting::whereIn('key', $keys)->pluck('value', 'key');
|
||||
|
||||
if (!$rows->get('mail_host')) {
|
||||
return back()->with('mail_error', 'Önce SMTP ayarlarını kaydedin.');
|
||||
}
|
||||
|
||||
$encryption = strtolower($rows->get('mail_encryption', 'tls'));
|
||||
$port = (int) $rows->get('mail_port', 587);
|
||||
|
||||
Config::set('mail.mailers.smtp.host', $rows->get('mail_host'));
|
||||
Config::set('mail.mailers.smtp.port', $port);
|
||||
Config::set('mail.mailers.smtp.username', $rows->get('mail_username'));
|
||||
Config::set('mail.mailers.smtp.password', $rows->get('mail_password'));
|
||||
Config::set('mail.mailers.smtp.encryption', $encryption);
|
||||
Config::set('mail.mailers.smtp.timeout', 15);
|
||||
Config::set('mail.mailers.smtp.stream', [
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true,
|
||||
],
|
||||
]);
|
||||
Config::set('mail.from.address', $rows->get('mail_from_address'));
|
||||
Config::set('mail.from.name', $rows->get('mail_from_name', config('app.name')));
|
||||
Config::set('mail.default', 'smtp');
|
||||
Mail::purge('smtp');
|
||||
|
||||
// Socket timeout — PHP default 60s, düşür
|
||||
$prevTimeout = ini_get('default_socket_timeout');
|
||||
ini_set('default_socket_timeout', '15');
|
||||
set_time_limit(30);
|
||||
|
||||
try {
|
||||
Mail::to($request->test_mail_to)->send(new TestMail());
|
||||
ini_set('default_socket_timeout', $prevTimeout);
|
||||
return back()->with('mail_success', 'Test e-postası başarıyla gönderildi → ' . $request->test_mail_to);
|
||||
} catch (\Throwable $e) {
|
||||
ini_set('default_socket_timeout', $prevTimeout);
|
||||
return back()->with('mail_error', 'Gönderi başarısız: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MembershipPlan;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SubscriptionController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Subscription::with(['user', 'plan'])->latest();
|
||||
|
||||
if ($request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
if ($request->search) {
|
||||
$query->whereHas('user', fn($q) =>
|
||||
$q->where('name', 'like', '%' . $request->search . '%')
|
||||
->orWhere('email', 'like', '%' . $request->search . '%')
|
||||
);
|
||||
}
|
||||
|
||||
$subscriptions = $query->paginate(30)->withQueryString();
|
||||
return view('admin.subscriptions.index', compact('subscriptions'));
|
||||
}
|
||||
|
||||
public function show(Subscription $subscription)
|
||||
{
|
||||
$subscription->load(['user', 'plan']);
|
||||
return view('admin.subscriptions.show', compact('subscription'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// Manuel abonelik ekleme (UserController.givePremium ile aynı mantık)
|
||||
$request->validate([
|
||||
'user_id' => 'required|exists:users,id',
|
||||
'plan_id' => 'required|exists:membership_plans,id',
|
||||
]);
|
||||
|
||||
$plan = MembershipPlan::findOrFail($request->plan_id);
|
||||
$user = User::findOrFail($request->user_id);
|
||||
|
||||
$hasEverSubscribed = Subscription::where('user_id', $user->id)->exists();
|
||||
$bonusDays = (!$hasEverSubscribed && ($plan->trial_days ?? 0) > 0) ? $plan->trial_days : 0;
|
||||
$expiresAt = now()->addDays($plan->duration_days + $bonusDays);
|
||||
|
||||
$user->update(['membership' => 'premium', 'premium_expires_at' => $expiresAt]);
|
||||
|
||||
Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'status' => 'active',
|
||||
'starts_at' => now(),
|
||||
'expires_at' => $expiresAt,
|
||||
'payment_method' => 'manual',
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Abonelik eklendi.');
|
||||
}
|
||||
|
||||
public function destroy(Subscription $subscription)
|
||||
{
|
||||
$subscription->update(['status' => 'cancelled']);
|
||||
return back()->with('success', 'Abonelik iptal edildi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TrendingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Trend yönetim sayfası.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$manual = Anime::where('is_trending', true)
|
||||
->where('is_published', true)
|
||||
->orderBy('trending_order')
|
||||
->get();
|
||||
|
||||
$autoTrending = $this->getAutoTrending(20);
|
||||
|
||||
// Son skor hesaplama zamanı
|
||||
$lastComputed = cache()->get('trending_score_computed_at');
|
||||
|
||||
return view('admin.trending.index', compact('manual', 'autoTrending', 'lastComputed'));
|
||||
}
|
||||
|
||||
// ── Manuel trending yönetimi ──────────────────────────────────────────────
|
||||
|
||||
public function toggle(Request $request, Anime $anime)
|
||||
{
|
||||
$newState = !$anime->is_trending;
|
||||
|
||||
if ($newState) {
|
||||
$maxOrder = Anime::where('is_trending', true)->max('trending_order') ?? 0;
|
||||
$anime->update([
|
||||
'is_trending' => true,
|
||||
'trending_order' => $maxOrder + 1,
|
||||
'trending_score' => $anime->trending_score + 200, // Manuel boost
|
||||
]);
|
||||
} else {
|
||||
$anime->update(['is_trending' => false, 'trending_order' => 0]);
|
||||
$this->reorderAll();
|
||||
}
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['ok' => true, 'is_trending' => $newState]);
|
||||
}
|
||||
return back()->with('success', $newState
|
||||
? '"'.$anime->title.'" trend listesine eklendi.'
|
||||
: '"'.$anime->title.'" trend listesinden çıkarıldı.');
|
||||
}
|
||||
|
||||
public function reorder(Request $request)
|
||||
{
|
||||
$request->validate(['ids' => 'required|array', 'ids.*' => 'integer']);
|
||||
foreach ($request->ids as $i => $id) {
|
||||
Anime::where('id', $id)->update(['trending_order' => $i + 1]);
|
||||
}
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function move(Request $request, Anime $anime)
|
||||
{
|
||||
$direction = $request->input('direction');
|
||||
$current = $anime->trending_order;
|
||||
|
||||
if ($direction === 'up' && $current > 1) {
|
||||
$swap = Anime::where('is_trending', true)->where('trending_order', $current - 1)->first();
|
||||
if ($swap) { $swap->update(['trending_order' => $current]); $anime->update(['trending_order' => $current - 1]); }
|
||||
} elseif ($direction === 'down') {
|
||||
$swap = Anime::where('is_trending', true)->where('trending_order', $current + 1)->first();
|
||||
if ($swap) { $swap->update(['trending_order' => $current]); $anime->update(['trending_order' => $current + 1]); }
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function search(Request $request)
|
||||
{
|
||||
$results = Anime::where('is_published', true)
|
||||
->where('title', 'like', "%{$request->query('q', '')}%")
|
||||
->select('id', 'title', 'cover_image', 'is_trending', 'release_year', 'trending_score')
|
||||
->take(8)->get()
|
||||
->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'cover' => $a->coverUrl,
|
||||
'is_trending' => (bool) $a->is_trending,
|
||||
'year' => $a->release_year,
|
||||
'trending_score'=> round($a->trending_score, 1),
|
||||
]);
|
||||
return response()->json(['results' => $results]);
|
||||
}
|
||||
|
||||
// ── Trend Skoru Hesaplama (YouTube algoritması) ───────────────────────────
|
||||
|
||||
/**
|
||||
* Admin butonu: tüm animelerin trend skorunu hesapla ve kaydet.
|
||||
* POST /admin/trending/compute-scores
|
||||
*/
|
||||
public function computeScores()
|
||||
{
|
||||
$count = self::runScoreComputation();
|
||||
cache()->put('trending_score_computed_at', now()->toDateTimeString(), 3600);
|
||||
cache()->flush(); // Anasayfa cache'ini temizle
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'updated' => $count,
|
||||
'message' => "{$count} anime için trend skoru güncellendi.",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube-benzeri Trend Skoru Algoritması
|
||||
* ─────────────────────────────────────────
|
||||
* score = view_24h × 12 ← Son 24 saatin izlenme sayısı (en yüksek ağırlık)
|
||||
* + view_7d × 4 ← Son 7 günün izlenme sayısı
|
||||
* + view_30d × 1 ← Son 30 günün izlenme sayısı
|
||||
* + watch_minutes_7d × 0.8 ← Gerçek izleme dakikası (kalite sinyali)
|
||||
* + new_episode_bonus ← Yeni bölüm varsa büyük bonus
|
||||
* + rating × 4 ← Kalite sinyali
|
||||
* + manual_boost ← Manuel trending = +250
|
||||
*
|
||||
* Decay: Eski içeriklerin skoru doğal olarak düşer (view_count azalır).
|
||||
* Herhangi bir yeni bölüm veya izlenme olmadan skor sıfıra yaklaşır.
|
||||
*/
|
||||
public static function runScoreComputation(): int
|
||||
{
|
||||
$now = now();
|
||||
$day1 = $now->copy()->subDay();
|
||||
$day7 = $now->copy()->subDays(7);
|
||||
$day30 = $now->copy()->subDays(30);
|
||||
|
||||
$animes = DB::table('animes')
|
||||
->where('is_published', true)
|
||||
->select('id', 'rating', 'is_trending', 'status')
|
||||
->get();
|
||||
|
||||
$updated = 0;
|
||||
|
||||
foreach ($animes as $anime) {
|
||||
// ── Bölüm izlenme sayıları (view_count zaman dilimine göre) ──────
|
||||
// Episode.updated_at → son izleme zamanının proxy'si
|
||||
$views = DB::table('episodes')
|
||||
->where('anime_id', $anime->id)
|
||||
->where('is_published', true)
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN updated_at >= ? THEN view_count ELSE 0 END) as v24h,
|
||||
SUM(CASE WHEN updated_at >= ? THEN view_count ELSE 0 END) as v7d,
|
||||
SUM(CASE WHEN updated_at >= ? THEN view_count ELSE 0 END) as v30d
|
||||
", [$day1, $day7, $day30])
|
||||
->first();
|
||||
|
||||
// ── Gerçek izleme dakikası (analytics_watch_events) ─────────────
|
||||
$watchMinutes = 0;
|
||||
try {
|
||||
$watchMinutes = DB::table('analytics_watch_events')
|
||||
->where('anime_id', $anime->id)
|
||||
->where('created_at', '>=', $day7)
|
||||
->sum('seconds_watched') / 60;
|
||||
} catch (\Throwable) {}
|
||||
|
||||
// ── Yeni bölüm bonusu ────────────────────────────────────────────
|
||||
$newEpBonus = 0;
|
||||
$latestEpDate = DB::table('episodes')
|
||||
->where('anime_id', $anime->id)
|
||||
->where('is_published', true)
|
||||
->max('created_at');
|
||||
|
||||
if ($latestEpDate) {
|
||||
$epAge = now()->diffInHours($latestEpDate);
|
||||
if ($epAge <= 24) $newEpBonus = 80; // Bugün yeni bölüm → çok büyük boost
|
||||
elseif ($epAge <= 72) $newEpBonus = 40; // Son 3 gün
|
||||
elseif ($epAge <= 168) $newEpBonus = 15; // Son 7 gün
|
||||
elseif ($epAge <= 720) $newEpBonus = 5; // Son 30 gün
|
||||
}
|
||||
|
||||
// ── Ongoing bonus ─────────────────────────────────────────────────
|
||||
$ongoingBonus = ($anime->status === 'ongoing') ? 10 : 0;
|
||||
|
||||
// ── Manuel trending boost ─────────────────────────────────────────
|
||||
$manualBoost = $anime->is_trending ? 250 : 0;
|
||||
|
||||
// ── Skor hesapla ─────────────────────────────────────────────────
|
||||
$score =
|
||||
($views->v24h ?? 0) * 12 +
|
||||
($views->v7d ?? 0) * 4 +
|
||||
($views->v30d ?? 0) * 1 +
|
||||
$watchMinutes * 0.8 +
|
||||
$newEpBonus +
|
||||
$ongoingBonus +
|
||||
((float)($anime->rating ?? 5)) * 4 +
|
||||
$manualBoost;
|
||||
|
||||
DB::table('animes')
|
||||
->where('id', $anime->id)
|
||||
->update(['trending_score' => round($score, 2)]);
|
||||
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-trending: trending_score'a göre sırala.
|
||||
* Fallback: score kolonu yoksa eski yönteme dön.
|
||||
*/
|
||||
public static function getAutoTrending(int $limit = 12): \Illuminate\Support\Collection
|
||||
{
|
||||
try {
|
||||
return Anime::where('is_published', true)
|
||||
->orderByDesc('trending_score')
|
||||
->take($limit)
|
||||
->get();
|
||||
} catch (\Throwable) {
|
||||
// trending_score kolonu henüz oluşturulmamış → eski yöntem
|
||||
return Anime::where('is_published', true)
|
||||
->withSum(['episodes as recent_views' => fn($q) =>
|
||||
$q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30))
|
||||
], 'view_count')
|
||||
->orderByDesc('recent_views')
|
||||
->take($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
private function reorderAll(): void
|
||||
{
|
||||
$animes = Anime::where('is_trending', true)->orderBy('trending_order')->get();
|
||||
foreach ($animes as $i => $a) {
|
||||
$a->update(['trending_order' => $i + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\UserActivityLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UserAnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$tab = $request->input('tab', 'overview'); // overview | bots | activity | country
|
||||
$country = $request->input('country');
|
||||
$period = (int) $request->input('period', 30); // days
|
||||
$from = now()->subDays($period);
|
||||
|
||||
// ── Overview stats ────────────────────────────────────────────────────
|
||||
$totalReal = User::where('role', '!=', 'admin')->count();
|
||||
$newReal = User::where('role', '!=', 'admin')->where('created_at', '>=', $from)->count();
|
||||
$active30 = DB::table('analytics_pageviews')
|
||||
->where('is_bot', 0)->where('created_at', '>=', $from)
|
||||
->distinct('user_id')->whereNotNull('user_id')->count('user_id');
|
||||
$botViews = DB::table('analytics_pageviews')
|
||||
->where('is_bot', 1)->where('created_at', '>=', $from)->count();
|
||||
$realViews = DB::table('analytics_pageviews')
|
||||
->where('is_bot', 0)->where('created_at', '>=', $from)->count();
|
||||
|
||||
// ── Daily new users (chart) ───────────────────────────────────────────
|
||||
$dailyNew = DB::table('users')
|
||||
->selectRaw('DATE(created_at) as day, COUNT(*) as cnt')
|
||||
->where('role', '!=', 'admin')
|
||||
->where('created_at', '>=', $from)
|
||||
->groupBy('day')->orderBy('day')
|
||||
->pluck('cnt', 'day');
|
||||
|
||||
// ── Country breakdown ─────────────────────────────────────────────────
|
||||
$countriesQuery = DB::table('analytics_pageviews')
|
||||
->selectRaw('country, COUNT(*) as views, COUNT(DISTINCT user_id) as users')
|
||||
->where('is_bot', 0)
|
||||
->where('created_at', '>=', $from)
|
||||
->whereNotNull('country')
|
||||
->groupBy('country')
|
||||
->orderByDesc('views');
|
||||
if ($country) $countriesQuery->where('country', $country);
|
||||
$countries = $countriesQuery->limit(50)->get();
|
||||
|
||||
// ── Bot analysis ──────────────────────────────────────────────────────
|
||||
$botStats = DB::table('analytics_bot_logs')
|
||||
->selectRaw('bot_name, action, COUNT(*) as cnt')
|
||||
->where('created_at', '>=', $from)
|
||||
->groupBy('bot_name', 'action')
|
||||
->orderByDesc('cnt')
|
||||
->limit(30)->get();
|
||||
|
||||
$topBotIps = DB::table('analytics_bot_logs')
|
||||
->selectRaw('ip, COUNT(*) as cnt')
|
||||
->where('created_at', '>=', $from)
|
||||
->groupBy('ip')
|
||||
->orderByDesc('cnt')
|
||||
->limit(20)->get();
|
||||
|
||||
$blockedIps = DB::table('blocked_ips')
|
||||
->orderByDesc('blocked_at')
|
||||
->limit(30)->get();
|
||||
|
||||
// ── User activity log ─────────────────────────────────────────────────
|
||||
$actQuery = UserActivityLog::with('user:id,name,username,avatar')
|
||||
->where('created_at', '>=', $from);
|
||||
if ($country) $actQuery->where('country', $country);
|
||||
if ($request->input('user_id')) $actQuery->where('user_id', $request->input('user_id'));
|
||||
if ($request->input('action')) $actQuery->where('action', $request->input('action'));
|
||||
$actQuery->orderByDesc('created_at');
|
||||
$actLogs = $actQuery->paginate(50)->withQueryString();
|
||||
|
||||
// ── Top active users ──────────────────────────────────────────────────
|
||||
$topUsers = DB::table('user_activity_logs')
|
||||
->selectRaw('user_id, COUNT(*) as actions')
|
||||
->where('is_bot', 0)->whereNotNull('user_id')
|
||||
->where('created_at', '>=', $from)
|
||||
->groupBy('user_id')->orderByDesc('actions')
|
||||
->limit(10)->get();
|
||||
$topUserIds = $topUsers->pluck('user_id');
|
||||
$topUserMap = User::whereIn('id', $topUserIds)->get()->keyBy('id');
|
||||
|
||||
// ── Action breakdown ──────────────────────────────────────────────────
|
||||
$actionBreakdown = DB::table('user_activity_logs')
|
||||
->selectRaw('action, COUNT(*) as cnt')
|
||||
->where('is_bot', 0)
|
||||
->where('created_at', '>=', $from)
|
||||
->groupBy('action')->orderByDesc('cnt')
|
||||
->get();
|
||||
|
||||
// ── Device breakdown ──────────────────────────────────────────────────
|
||||
$deviceBreakdown = DB::table('analytics_pageviews')
|
||||
->selectRaw('device, COUNT(*) as cnt')
|
||||
->where('is_bot', 0)->where('created_at', '>=', $from)
|
||||
->groupBy('device')->orderByDesc('cnt')->get();
|
||||
|
||||
return view('admin.analytics.users', compact(
|
||||
'tab', 'period', 'country',
|
||||
'totalReal', 'newReal', 'active30', 'botViews', 'realViews',
|
||||
'dailyNew', 'countries', 'botStats', 'topBotIps', 'blockedIps',
|
||||
'actLogs', 'topUsers', 'topUserMap', 'actionBreakdown', 'deviceBreakdown'
|
||||
));
|
||||
}
|
||||
|
||||
public function userDetail(Request $request, User $user)
|
||||
{
|
||||
$period = (int) $request->input('period', 30);
|
||||
$from = now()->subDays($period);
|
||||
|
||||
$logs = UserActivityLog::where('user_id', $user->id)
|
||||
->where('created_at', '>=', $from)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(50)->withQueryString();
|
||||
|
||||
$actBreakdown = DB::table('user_activity_logs')
|
||||
->selectRaw('action, COUNT(*) as cnt')
|
||||
->where('user_id', $user->id)->where('created_at', '>=', $from)
|
||||
->groupBy('action')->orderByDesc('cnt')->get();
|
||||
|
||||
$pageviews = DB::table('analytics_pageviews')
|
||||
->where('user_id', $user->id)->where('created_at', '>=', $from)
|
||||
->orderByDesc('created_at')->limit(100)->get();
|
||||
|
||||
$watchEvents = DB::table('analytics_watch_events as we')
|
||||
->join('episodes as e', 'e.id', '=', 'we.episode_id')
|
||||
->join('animes as a', 'a.id', '=', 'we.anime_id')
|
||||
->selectRaw('we.created_at, a.title as anime_title, e.episode_number, we.percent_complete, we.seconds_watched')
|
||||
->where('we.user_id', $user->id)->where('we.created_at', '>=', $from)
|
||||
->orderByDesc('we.created_at')->limit(50)->get();
|
||||
|
||||
return view('admin.analytics.user-detail', compact(
|
||||
'user', 'logs', 'actBreakdown', 'pageviews', 'watchEvents', 'period'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MembershipPlan;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = User::latest();
|
||||
|
||||
if ($request->search) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('name', 'like', '%' . $request->search . '%')
|
||||
->orWhere('email', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
if ($request->membership) {
|
||||
$query->where('membership', $request->membership);
|
||||
}
|
||||
if ($request->role) {
|
||||
$query->where('role', $request->role);
|
||||
}
|
||||
if ($request->banned) {
|
||||
$query->where('is_banned', true);
|
||||
}
|
||||
|
||||
$users = $query->paginate(30)->withQueryString();
|
||||
return view('admin.users.index', compact('users'));
|
||||
}
|
||||
|
||||
public function show(User $user)
|
||||
{
|
||||
$user->load(['subscriptions.plan', 'comments']);
|
||||
$plans = MembershipPlan::where('is_active', true)->get();
|
||||
return view('admin.users.show', compact('user', 'plans'));
|
||||
}
|
||||
|
||||
public function edit(User $user)
|
||||
{
|
||||
return view('admin.users.edit', compact('user'));
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user)
|
||||
{
|
||||
if ($user->isAdmin() && !auth()->user()->isAdmin()) {
|
||||
return back()->with('error', 'Admin kullanıcı düzenlenemez.');
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email,' . $user->id,
|
||||
'role' => 'required|in:user,moderator,admin',
|
||||
'password' => 'nullable|string|min:8',
|
||||
'admin_badge' => 'nullable|string|max:32',
|
||||
]);
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
} else {
|
||||
unset($data['password']);
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
return redirect()->route('admin.users.show', $user)->with('success', 'Kullanıcı güncellendi.');
|
||||
}
|
||||
|
||||
public function destroy(User $user)
|
||||
{
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', 'Kendinizi silemezsiniz.');
|
||||
}
|
||||
if ($user->isAdmin()) {
|
||||
return back()->with('error', 'Admin kullanıcı silinemez.');
|
||||
}
|
||||
$user->delete();
|
||||
return redirect()->route('admin.users.index')->with('success', 'Kullanıcı silindi.');
|
||||
}
|
||||
|
||||
public function ban(Request $request, User $user)
|
||||
{
|
||||
$request->validate(['ban_reason' => 'nullable|string|max:500']);
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
return back()->with('error', 'Admin kullanıcı banlanamaz.');
|
||||
}
|
||||
|
||||
$user->update([
|
||||
'is_banned' => true,
|
||||
'ban_reason' => $request->ban_reason,
|
||||
'banned_at' => now(),
|
||||
]);
|
||||
|
||||
return back()->with('success', $user->name . ' banlandı.');
|
||||
}
|
||||
|
||||
public function unban(User $user)
|
||||
{
|
||||
$user->update([
|
||||
'is_banned' => false,
|
||||
'ban_reason' => null,
|
||||
'banned_at' => null,
|
||||
]);
|
||||
return back()->with('success', $user->name . ' bandan çıkarıldı.');
|
||||
}
|
||||
|
||||
public function givePremium(Request $request, User $user)
|
||||
{
|
||||
$request->validate([
|
||||
'plan_id' => 'required|exists:membership_plans,id',
|
||||
]);
|
||||
|
||||
$plan = MembershipPlan::findOrFail($request->plan_id);
|
||||
$expiresAt = now()->addDays($plan->duration_days);
|
||||
|
||||
$user->update([
|
||||
'membership' => 'premium',
|
||||
'premium_expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'status' => 'active',
|
||||
'starts_at' => now(),
|
||||
'expires_at' => $expiresAt,
|
||||
'payment_method' => 'manual',
|
||||
'notes' => 'Admin tarafından verildi: ' . auth()->user()->name,
|
||||
]);
|
||||
|
||||
return back()->with('success', $user->name . "'e {$plan->duration_days} günlük premium verildi.");
|
||||
}
|
||||
|
||||
public function removePremium(User $user)
|
||||
{
|
||||
$user->update([
|
||||
'membership' => 'free',
|
||||
'premium_expires_at' => null,
|
||||
]);
|
||||
|
||||
Subscription::where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->update(['status' => 'cancelled']);
|
||||
|
||||
return back()->with('success', $user->name . "'in premiumu kaldırıldı.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Ad;
|
||||
|
||||
class AdApiController extends Controller
|
||||
{
|
||||
// POST /api/ads/{ad}/impression
|
||||
public function impression(Ad $ad)
|
||||
{
|
||||
$ad->increment('impressions');
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// POST /api/ads/{ad}/click
|
||||
public function click(Ad $ad)
|
||||
{
|
||||
$ad->increment('clicks');
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontend\AiController;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AiApiController extends Controller
|
||||
{
|
||||
private AiController $ai;
|
||||
|
||||
public function __construct(AiController $ai)
|
||||
{
|
||||
$this->ai = $ai;
|
||||
}
|
||||
|
||||
public function chat(Request $request)
|
||||
{
|
||||
return $this->ai->chat($request);
|
||||
}
|
||||
|
||||
public function recommend(Request $request)
|
||||
{
|
||||
return $this->ai->recommend($request);
|
||||
}
|
||||
|
||||
public function similar(Request $request)
|
||||
{
|
||||
return $this->ai->similar($request);
|
||||
}
|
||||
|
||||
public function search(Request $request)
|
||||
{
|
||||
return $this->ai->search($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Banner;
|
||||
use App\Models\ContinueWatching;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AnimeApiController extends Controller
|
||||
{
|
||||
// GET /api/home
|
||||
public function home(Request $request)
|
||||
{
|
||||
$featured = Anime::where('is_featured', true)->where('is_published', true)
|
||||
->with('genres', 'seasons', 'episodes')
|
||||
->latest()->take(5)->get();
|
||||
|
||||
if ($featured->isEmpty()) {
|
||||
$featured = Anime::where('is_published', true)->with('genres', 'seasons', 'episodes')
|
||||
->where('rating', '>=', 1)->orderByDesc('rating')->take(5)->get();
|
||||
}
|
||||
|
||||
$latest = Anime::where('is_published', true)->latest()->take(20)->get();
|
||||
$topRated = Anime::where('is_published', true)->where('rating', '>=', 7)
|
||||
->orderByDesc('rating')->take(12)->get();
|
||||
|
||||
try {
|
||||
$manualTrending = Anime::where('is_trending', true)->where('is_published', true)
|
||||
->orderBy('trending_order')->take(12)->get();
|
||||
|
||||
if ($manualTrending->count() >= 6) {
|
||||
$trending = $manualTrending->take(12);
|
||||
} else {
|
||||
$autoIds = $manualTrending->pluck('id')->toArray();
|
||||
$autoFill = Anime::where('is_published', true)
|
||||
->whereNotIn('id', $autoIds)
|
||||
->withSum(['episodes as recent_views' => fn($q) =>
|
||||
$q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30))
|
||||
], 'view_count')
|
||||
->orderByDesc('recent_views')
|
||||
->take(12 - $manualTrending->count())->get();
|
||||
$trending = $manualTrending->concat($autoFill);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$trending = collect();
|
||||
}
|
||||
|
||||
if ($trending->isEmpty()) $trending = $latest->take(12);
|
||||
|
||||
$newEpisodes = Episode::with(['anime', 'season'])
|
||||
->where('is_published', true)->latest()->take(12)->get()
|
||||
->filter(fn($e) => $e->anime && $e->season)->values();
|
||||
|
||||
$genres = Genre::where('is_active', true)->take(16)->get();
|
||||
|
||||
$continueWatching = collect();
|
||||
$recommended = collect();
|
||||
|
||||
$authUser = auth('sanctum')->user();
|
||||
if ($authUser) {
|
||||
try {
|
||||
$continueWatching = ContinueWatching::where('user_id', $authUser->id)
|
||||
->with('anime:id,title,slug,cover_image')
|
||||
->where('percent_complete', '>=', 5)
|
||||
->where('percent_complete', '<', 95)
|
||||
->orderByDesc('updated_at')->limit(10)->get();
|
||||
|
||||
$watchedIds = ContinueWatching::where('user_id', $authUser->id)->pluck('anime_id');
|
||||
if ($watchedIds->isNotEmpty()) {
|
||||
$topGenreIds = DB::table('anime_genre')
|
||||
->whereIn('anime_id', $watchedIds)
|
||||
->select('genre_id', DB::raw('count(*) as cnt'))
|
||||
->groupBy('genre_id')->orderByDesc('cnt')->limit(3)->pluck('genre_id');
|
||||
|
||||
if ($topGenreIds->isNotEmpty()) {
|
||||
$recommended = Anime::where('is_published', true)
|
||||
->whereNotIn('id', $watchedIds)
|
||||
->whereHas('genres', fn($q) => $q->whereIn('genres.id', $topGenreIds))
|
||||
->where('rating', '>=', 6)->inRandomOrder()->take(12)->get();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'featured' => $featured->map(fn($a) => $this->animeResource($a, true)),
|
||||
'trending' => $trending->values()->map(fn($a) => $this->animeResource($a)),
|
||||
'latest' => $latest->map(fn($a) => $this->animeResource($a)),
|
||||
'top_rated' => $topRated->map(fn($a) => $this->animeResource($a)),
|
||||
'new_episodes' => $newEpisodes->map(fn($e) => $this->episodeCardResource($e)),
|
||||
'genres' => $genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug]),
|
||||
'continue_watching'=> $continueWatching->map(fn($cw) => $this->continueWatchingResource($cw)),
|
||||
'recommended' => $recommended->map(fn($a) => $this->animeResource($a)),
|
||||
]);
|
||||
}
|
||||
|
||||
// GET /api/animes
|
||||
public function index(Request $request)
|
||||
{
|
||||
$q = $request->input('q', '');
|
||||
$genre = $request->input('genre');
|
||||
$type = $request->input('type');
|
||||
$status = $request->input('status');
|
||||
$year = $request->input('year');
|
||||
$sort = $request->input('sort', 'latest'); // latest|rating|views
|
||||
|
||||
$query = Anime::where('is_published', true)->with('genres');
|
||||
|
||||
if ($q) {
|
||||
$query->where(function ($qb) use ($q) {
|
||||
$qb->where('title', 'like', "%$q%")
|
||||
->orWhere('title_en', 'like', "%$q%")
|
||||
->orWhere('title_jp', 'like', "%$q%");
|
||||
});
|
||||
}
|
||||
if ($genre) $query->whereHas('genres', fn($qb) => $qb->where('slug', $genre));
|
||||
if ($type) $query->where('type', $type);
|
||||
if ($status) $query->where('status', $status);
|
||||
if ($year) $query->where('release_year', $year);
|
||||
|
||||
match ($sort) {
|
||||
'rating' => $query->orderByDesc('rating'),
|
||||
default => $query->latest(),
|
||||
};
|
||||
|
||||
$results = $query->paginate(24)->withQueryString();
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($results->items())->map(fn($a) => $this->animeResource($a)),
|
||||
'total' => $results->total(),
|
||||
'per_page' => $results->perPage(),
|
||||
'current_page'=> $results->currentPage(),
|
||||
'last_page' => $results->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// GET /api/animes/{slug}
|
||||
public function show(Request $request, string $slug)
|
||||
{
|
||||
$anime = Anime::where('slug', $slug)->where('is_published', true)
|
||||
->with(['genres', 'seasons', 'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number')])
|
||||
->firstOrFail();
|
||||
|
||||
$userId = $request->user()?->id;
|
||||
|
||||
$inWatchlist = false;
|
||||
$userRating = null;
|
||||
$isFollowing = false;
|
||||
|
||||
if ($userId) {
|
||||
$wl = \App\Models\Watchlist::where('user_id', $userId)->where('anime_id', $anime->id)->first();
|
||||
$inWatchlist = $wl !== null;
|
||||
$watchlistStatus = $wl?->status;
|
||||
$userRating = \App\Models\AnimeRating::where('user_id', $userId)->where('anime_id', $anime->id)->value('rating');
|
||||
$isFollowing = \App\Models\AnimeFollow::where('user_id', $userId)->where('anime_id', $anime->id)->exists();
|
||||
}
|
||||
|
||||
$seasons = $anime->seasons->map(function ($season) {
|
||||
return [
|
||||
'id' => $season->id,
|
||||
'season_number' => $season->season_number,
|
||||
'title' => $season->title,
|
||||
'episodes' => $season->episodes->map(fn($ep) => $this->episodeResource($ep)),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'anime' => $this->animeResource($anime, true),
|
||||
'seasons' => $seasons,
|
||||
'in_watchlist' => $inWatchlist,
|
||||
'watchlist_status'=> $watchlistStatus ?? null,
|
||||
'user_rating' => $userRating,
|
||||
'is_following' => $isFollowing,
|
||||
]);
|
||||
}
|
||||
|
||||
// GET /api/genres/{slug}
|
||||
public function genre(Request $request, string $slug)
|
||||
{
|
||||
$genre = Genre::where('slug', $slug)->where('is_active', true)->firstOrFail();
|
||||
$animes = $genre->animes()->where('is_published', true)->latest()->paginate(24);
|
||||
|
||||
return response()->json([
|
||||
'genre' => ['id'=>$genre->id,'name'=>$genre->name,'slug'=>$genre->slug],
|
||||
'data' => collect($animes->items())->map(fn($a) => $this->animeResource($a)),
|
||||
'total' => $animes->total(),
|
||||
'last_page' => $animes->lastPage(),
|
||||
'current_page' => $animes->currentPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// GET /api/genres
|
||||
public function genres()
|
||||
{
|
||||
$genres = Genre::where('is_active', true)->orderBy('name')->get();
|
||||
return response()->json($genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug]));
|
||||
}
|
||||
|
||||
// GET /api/watch/{slug}/{season}/{episode}
|
||||
public function watch(Request $request, string $slug, int $season, int $episode)
|
||||
{
|
||||
$anime = Anime::where('slug', $slug)->where('is_published', true)->firstOrFail();
|
||||
$seasonModel = $anime->seasons()->where('season_number', $season)->firstOrFail();
|
||||
$ep = $seasonModel->episodes()->where('episode_number', $episode)->where('is_published', true)->firstOrFail();
|
||||
|
||||
$ep->increment('view_count');
|
||||
|
||||
$prev = $seasonModel->episodes()->where('episode_number', '<', $episode)->where('is_published', true)->orderByDesc('episode_number')->first();
|
||||
$next = $seasonModel->episodes()->where('episode_number', '>', $episode)->where('is_published', true)->orderBy('episode_number')->first();
|
||||
|
||||
// Cross-season next
|
||||
if (!$next) {
|
||||
$nextSeason = $anime->seasons()->where('season_number', $season + 1)->first();
|
||||
if ($nextSeason) {
|
||||
$next = $nextSeason->episodes()->where('episode_number', 1)->where('is_published', true)->first();
|
||||
}
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
$subtitles = [];
|
||||
if (method_exists($ep, 'subtitles')) {
|
||||
$subtitles = $ep->subtitles()->get()->map(fn($s) => [
|
||||
'label' => $s->label,
|
||||
'lang' => $s->language,
|
||||
'url' => $s->url,
|
||||
'is_default' => (bool)($s->is_default ?? false),
|
||||
])->values()->toArray();
|
||||
}
|
||||
|
||||
// Dub sources from m3u8 URL
|
||||
$dubSources = [];
|
||||
if ($ep->m3u8_url) {
|
||||
$rawDubs = $ep->available_dubs ?? null;
|
||||
$availableDubs = is_string($rawDubs) ? json_decode($rawDubs, true) : (is_array($rawDubs) ? $rawDubs : null);
|
||||
[$dubSources] = \App\Http\Controllers\Frontend\PlayerController::resolveDubSourcesPublic(
|
||||
$ep->m3u8_url, $availableDubs
|
||||
);
|
||||
}
|
||||
|
||||
// Quality sources (legacy — episode.video_url / m3u8_url)
|
||||
$sources = collect();
|
||||
if ($ep->m3u8_url) $sources->push(['quality'=>'Auto (HLS)','url'=>$ep->m3u8_url,'type'=>'hls']);
|
||||
if ($ep->video_url) {
|
||||
$isHls = str_ends_with($ep->video_url, '.m3u8') || str_contains($ep->video_url, 'master.m3u8');
|
||||
$type = $isHls ? 'hls' : 'mp4';
|
||||
$sources->push(['quality'=>'Auto','url'=>$ep->video_url,'type'=>$type]);
|
||||
}
|
||||
if ($ep->video_url_1080 ?? null) $sources->push(['quality'=>'1080p','url'=>$ep->video_url_1080,'type'=>'mp4']);
|
||||
if ($ep->video_url_720 ?? null) $sources->push(['quality'=>'720p','url'=>$ep->video_url_720,'type'=>'mp4']);
|
||||
if ($ep->video_url_480 ?? null) $sources->push(['quality'=>'480p','url'=>$ep->video_url_480,'type'=>'mp4']);
|
||||
|
||||
// Çok kaynak desteği (video_sources tablosu)
|
||||
// Her translator/kaynak bir grup → [{key, label, url, type, quality}]
|
||||
$multiSources = \App\Models\VideoSource::where('episode_id', $ep->id)
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->groupBy(fn($vs) => $vs->translator_id ?: $vs->label)
|
||||
->map(function ($group) {
|
||||
$default = $group->firstWhere('is_default', true) ?? $group->first();
|
||||
// Tüm kaliteler (1080p, 720p, vb.)
|
||||
$qualities = $group->map(fn($vs) => [
|
||||
'quality' => $vs->quality ?: 'Auto',
|
||||
'url' => $vs->url,
|
||||
'type' => $vs->type ?? 'mp4',
|
||||
])->values()->toArray();
|
||||
|
||||
return [
|
||||
'key' => $default->translator_id
|
||||
?: \Illuminate\Support\Str::slug($default->label ?? 'kaynak'),
|
||||
'label' => $default->label ?: 'Kaynak',
|
||||
'url' => $default->url,
|
||||
'type' => $default->type ?? 'mp4',
|
||||
'quality' => $default->quality ?: 'Auto',
|
||||
'source' => $default->source ?? 'animecix',
|
||||
'qualities' => $qualities,
|
||||
'is_default'=> (bool) $default->is_default,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
// Player settings
|
||||
$skipSeconds = (int) \App\Models\Setting::get('main_video_skip_seconds', 10);
|
||||
$wmCoverSeconds = (int) \App\Models\Setting::get('watermark_cover_seconds', 11);
|
||||
$introEnabled = \App\Models\Setting::get('intro_enabled') == '1';
|
||||
$introUrl = $introEnabled ? (\App\Models\Setting::get('intro_video_url') ?: null) : null;
|
||||
$introSkipAfter = (int) \App\Models\Setting::get('intro_skip_after', 5);
|
||||
|
||||
// AniSkip is fetched via separate /api/aniskip endpoint to avoid blocking video load
|
||||
$aniSkipData = null;
|
||||
|
||||
// All episodes list for in-player episode switcher
|
||||
$allEpisodes = $seasonModel->episodes()->where('is_published', true)->orderBy('episode_number')
|
||||
->get()->map(fn($e) => [
|
||||
'id' => $e->id,
|
||||
'episode_number' => $e->episode_number,
|
||||
'season_number' => $season,
|
||||
'title' => $e->title,
|
||||
'thumbnail_url' => $e->thumbnail_url ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'anime' => ['id'=>$anime->id,'title'=>$anime->title,'slug'=>$anime->slug,'cover_url'=>$anime->coverUrl],
|
||||
'season' => ['id'=>$seasonModel->id,'season_number'=>$seasonModel->season_number,'title'=>$seasonModel->title],
|
||||
'episode' => $this->episodeResource($ep),
|
||||
'sources' => $sources->values(),
|
||||
'multi_sources'=> $multiSources, // Çok kaynak (Anizium "4K" + AnimeCix çevirmenler)
|
||||
'dub_sources' => $dubSources,
|
||||
'subtitles' => $subtitles,
|
||||
'episodes' => $allEpisodes,
|
||||
'prev_episode' => $prev ? ['season'=>$prev->season?->season_number ?? $season,'episode'=>$prev->episode_number] : null,
|
||||
'next_episode' => $next ? ['season'=>$next->season?->season_number ?? $season,'episode'=>$next->episode_number] : null,
|
||||
'settings' => [
|
||||
'skip_seconds' => $skipSeconds,
|
||||
'wm_cover_seconds' => $wmCoverSeconds,
|
||||
'intro_url' => $introUrl,
|
||||
'intro_skip_after' => $introSkipAfter,
|
||||
// AniSkip timestamps (null if not available)
|
||||
'aniskip' => $aniSkipData, // {'op':{'start':X,'end':Y}, 'ed':{'start':X,'end':Y}}
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Resources ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function animeResource(Anime $a, bool $full = false): array
|
||||
{
|
||||
$base = [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'title_en' => $a->title_en,
|
||||
'title_jp' => $a->title_jp,
|
||||
'slug' => $a->slug,
|
||||
'cover_url' => $a->coverUrl,
|
||||
'banner_url' => $a->bannerUrl,
|
||||
'type' => $a->type,
|
||||
'status' => $a->status,
|
||||
'rating' => $a->rating ? (float)$a->rating : null,
|
||||
'release_year' => $a->release_year,
|
||||
'episode_count' => $a->episode_count,
|
||||
'genres' => $a->relationLoaded('genres')
|
||||
? $a->genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug])->values()
|
||||
: [],
|
||||
];
|
||||
|
||||
if ($full) {
|
||||
$base['description'] = $a->description;
|
||||
$base['studio'] = $a->studio ?? null;
|
||||
$base['duration'] = $a->duration ?? null;
|
||||
$base['is_featured'] = $a->is_featured;
|
||||
|
||||
// First episode for "watch now" button
|
||||
if ($a->relationLoaded('seasons') && $a->seasons->isNotEmpty()) {
|
||||
$firstSeason = $a->seasons->first();
|
||||
$eps = $a->relationLoaded('episodes') ? $a->episodes : $firstSeason->episodes;
|
||||
$firstEp = $eps->where('season_id', $firstSeason->id)->where('is_published', true)->sortBy('episode_number')->first();
|
||||
$base['first_watch'] = ($firstSeason && $firstEp) ? [
|
||||
'season' => $firstSeason->season_number,
|
||||
'episode' => $firstEp->episode_number,
|
||||
] : null;
|
||||
}
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function episodeResource(Episode $ep): array
|
||||
{
|
||||
return [
|
||||
'id' => $ep->id,
|
||||
'episode_number' => $ep->episode_number,
|
||||
'title' => $ep->title,
|
||||
'thumbnail_url' => $ep->thumbnailUrl ?? null,
|
||||
'duration' => $ep->duration,
|
||||
'view_count' => $ep->view_count,
|
||||
'created_at' => $ep->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
|
||||
private function episodeCardResource(Episode $ep): array
|
||||
{
|
||||
return [
|
||||
'id' => $ep->id,
|
||||
'episode_number' => $ep->episode_number,
|
||||
'season_number' => $ep->season?->season_number,
|
||||
'title' => $ep->title,
|
||||
'thumbnail_url' => $ep->thumbnailUrl ?? null,
|
||||
'created_at' => $ep->created_at?->diffForHumans(),
|
||||
'anime' => [
|
||||
'id' => $ep->anime->id,
|
||||
'title' => $ep->anime->title,
|
||||
'slug' => $ep->anime->slug,
|
||||
'cover_url' => $ep->anime->coverUrl,
|
||||
'rating' => $ep->anime->rating ? (float)$ep->anime->rating : null,
|
||||
'description' => $ep->anime->description,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ── AniSkip endpoint ─────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/aniskip/{slug}/{season}/{episode}
|
||||
// Fully automatic: finds MAL ID by title if missing, caches everything
|
||||
public function aniSkip(string $slug, int $season, int $episode)
|
||||
{
|
||||
$anime = Anime::where('slug', $slug)->first();
|
||||
if (!$anime) return response()->json(['aniskip' => null]);
|
||||
|
||||
$seasonModel = $anime->seasons()->where('season_number', $season)->first();
|
||||
if (!$seasonModel) return response()->json(['aniskip' => null]);
|
||||
|
||||
$seasonMalId = $seasonModel->mal_id;
|
||||
|
||||
try {
|
||||
// anime.mal_id yoksa title search (bir kez, cache'lenir)
|
||||
if (!$anime->mal_id) {
|
||||
$found = (new \App\Services\JikanService())->searchMalId($anime->title, $anime->title_en, $anime->title_jp);
|
||||
if ($found) $anime->update(['mal_id' => $found]);
|
||||
}
|
||||
|
||||
if (!$seasonMalId && $anime->mal_id) {
|
||||
// S1 için anime.mal_id direkt kullan — Jikan'a gitme
|
||||
if ($season === 1) {
|
||||
$seasonMalId = $anime->mal_id;
|
||||
$seasonModel->update(['mal_id' => $seasonMalId]);
|
||||
} else {
|
||||
// Diğer sezonlar: sadece cache'ten bak, yoksa null dön (page load bloke olmasın)
|
||||
$chain = \Illuminate\Support\Facades\Cache::get("jikan_chain_{$anime->mal_id}");
|
||||
if ($chain) {
|
||||
$seasonMalId = $chain[$season - 1] ?? $chain[0] ?? null;
|
||||
if ($seasonMalId) $seasonModel->update(['mal_id' => $seasonMalId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($seasonMalId) {
|
||||
$data = (new \App\Services\AniSkipService())->getSkipTimes((string)$seasonMalId, $episode);
|
||||
return response()->json(['aniskip' => $data]);
|
||||
}
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json(['aniskip' => null]);
|
||||
}
|
||||
|
||||
// ── Skip segments ─────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/episodes/{episode}/skip-event (anonim OK, rate-limited)
|
||||
public function recordSkipEvent(Request $request, Episode $episode)
|
||||
{
|
||||
$from = (int) $request->input('from_sec', 0);
|
||||
$to = (int) $request->input('to_sec', 0);
|
||||
|
||||
// Basic sanity: must skip forward at least 5s, not more than 10 min
|
||||
if ($to <= $from + 4 || ($to - $from) > 600) {
|
||||
return response()->json(['ok' => false]);
|
||||
}
|
||||
|
||||
DB::table('episode_skip_events')->insert([
|
||||
'episode_id' => $episode->id,
|
||||
'from_sec' => $from,
|
||||
'to_sec' => $to,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// GET /api/episodes/{episode}/skip-segments
|
||||
// Returns segments where >= 10 users skipped from within a 30-second window
|
||||
public function skipSegments(Episode $episode)
|
||||
{
|
||||
// İntro tespiti: ilk 3 dakika içinde 45-150sn ileri atlama = intro skip
|
||||
// Kümeleme: 20sn bucket, to_sec standart sapması <= 15sn, en az 2 farklı kullanıcı
|
||||
$rows = DB::table('episode_skip_events')
|
||||
->where('episode_id', $episode->id)
|
||||
->where('from_sec', '<', 180)
|
||||
->whereRaw('(to_sec - from_sec) BETWEEN 45 AND 150')
|
||||
->selectRaw('
|
||||
FLOOR(from_sec / 20) * 20 AS bucket_start,
|
||||
AVG(to_sec) AS avg_to,
|
||||
STDDEV_POP(to_sec) AS stddev_to,
|
||||
COUNT(*) AS cnt
|
||||
')
|
||||
->groupByRaw('FLOOR(from_sec / 20) * 20')
|
||||
->havingRaw('cnt >= 2 AND (STDDEV_POP(to_sec) <= 15 OR cnt = 1)')
|
||||
->orderBy('cnt', 'desc')
|
||||
->limit(1)
|
||||
->get();
|
||||
|
||||
$segments = $rows->map(fn($r) => [
|
||||
'from' => (int) $r->bucket_start,
|
||||
'to' => (int) round($r->avg_to),
|
||||
'count'=> (int) $r->cnt,
|
||||
])->values();
|
||||
|
||||
return response()->json(['segments' => $segments]);
|
||||
}
|
||||
|
||||
private function continueWatchingResource($cw): array
|
||||
{
|
||||
return [
|
||||
'id' => $cw->id,
|
||||
'season_number' => $cw->season_number,
|
||||
'episode_number' => $cw->episode_number,
|
||||
'percent_complete' => $cw->percent_complete,
|
||||
'anime' => $cw->anime ? [
|
||||
'id' => $cw->anime->id,
|
||||
'title' => $cw->anime->title,
|
||||
'slug' => $cw->anime->slug,
|
||||
'cover_url' => \App\Support\MediaUrl::fromStoragePath($cw->anime->cover_image),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// POST /api/sources/flag-hevc
|
||||
// Player tarafından çağrılır: HEVC hatası alınan kaynak URL'sini DB'ye işler
|
||||
public function flagHevc(Request $request)
|
||||
{
|
||||
$url = $request->input('url');
|
||||
if (!$url) return response()->json(['ok' => false]);
|
||||
|
||||
\App\Models\VideoSource::where('url', $url)->update([
|
||||
'is_hevc' => true,
|
||||
'hevc_checked_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthApiController extends Controller
|
||||
{
|
||||
public function register(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'username' => 'required|string|max:50|unique:users|alpha_dash',
|
||||
'email' => 'required|email|unique:users',
|
||||
'password' => 'required|string|min:6|confirmed',
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'username' => $data['username'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'role' => 'user',
|
||||
'membership' => 'free',
|
||||
]);
|
||||
|
||||
$token = $user->createToken('animexe-app')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'token' => $token,
|
||||
'user' => $this->userResource($user),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
$user = User::where('email', $data['email'])->first();
|
||||
|
||||
if (!$user || !Hash::check($data['password'], $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => ['E-posta veya şifre hatalı.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($user->is_banned) {
|
||||
return response()->json([
|
||||
'message' => 'Hesabınız yasaklandı. Sebep: ' . ($user->ban_reason ?? 'Belirtilmedi'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
$token = $user->createToken('animexe-app')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'token' => $token,
|
||||
'user' => $this->userResource($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
return response()->json(['message' => 'Çıkış yapıldı.']);
|
||||
}
|
||||
|
||||
public function me(Request $request)
|
||||
{
|
||||
return response()->json(['user' => $this->userResource($request->user())]);
|
||||
}
|
||||
|
||||
public function saveFcmToken(Request $request)
|
||||
{
|
||||
$data = $request->validate(['token' => 'required|string|max:500']);
|
||||
$request->user()->update(['fcm_token' => $data['token']]);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function updateProfile(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$request->validate([
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'username' => 'sometimes|string|max:50|unique:users,username,' . $user->id . '|alpha_dash',
|
||||
'password' => 'sometimes|string|min:6|confirmed',
|
||||
'bio' => 'sometimes|nullable|string|max:300',
|
||||
'website' => 'sometimes|nullable|string|max:100',
|
||||
'twitter' => 'sometimes|nullable|string|max:50',
|
||||
'instagram' => 'sometimes|nullable|string|max:50',
|
||||
'discord' => 'sometimes|nullable|string|max:50',
|
||||
'avatar' => 'sometimes|nullable|image|max:3072',
|
||||
'banner' => 'sometimes|nullable|image|max:6144',
|
||||
]);
|
||||
|
||||
$data = $request->only(['name', 'username', 'bio', 'website', 'twitter', 'instagram', 'discord']);
|
||||
$data = array_filter($data, fn($v) => $v !== null);
|
||||
|
||||
if ($request->filled('password')) {
|
||||
$data['password'] = bcrypt($request->input('password'));
|
||||
}
|
||||
|
||||
if ($request->hasFile('avatar')) {
|
||||
$data['avatar'] = $request->file('avatar')->store('avatars', 'public');
|
||||
}
|
||||
|
||||
if ($request->hasFile('banner')) {
|
||||
$data['banner_image'] = $request->file('banner')->store('banners', 'public');
|
||||
}
|
||||
|
||||
if (!empty($data)) {
|
||||
$user->update($data);
|
||||
}
|
||||
|
||||
return response()->json(['user' => $this->userResource($user->fresh())]);
|
||||
}
|
||||
|
||||
private function userResource(User $user): array
|
||||
{
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
'email' => $user->email,
|
||||
'bio' => $user->bio,
|
||||
'website' => $user->website,
|
||||
'twitter' => $user->twitter,
|
||||
'instagram' => $user->instagram,
|
||||
'discord' => $user->discord,
|
||||
'avatar' => $user->avatar
|
||||
? (\App\Support\MediaUrl::fromStoragePath($user->avatar))
|
||||
: null,
|
||||
'banner_image' => $user->banner_image
|
||||
? (\App\Support\MediaUrl::fromStoragePath($user->banner_image))
|
||||
: null,
|
||||
'role' => $user->role,
|
||||
'membership' => $user->membership,
|
||||
'is_premium' => $user->isPremium(),
|
||||
'premium_expires_at' => $user->premium_expires_at?->toISOString(),
|
||||
'created_at' => $user->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Comment;
|
||||
use App\Models\CommentLike;
|
||||
use App\Models\Anime;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CommentApiController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$animeId = $request->input('anime_id');
|
||||
$episodeId = $request->input('episode_id');
|
||||
|
||||
$query = Comment::with('user:id,name,username,avatar')
|
||||
->where('status', 'approved')
|
||||
->orderByDesc('is_pinned')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($episodeId) {
|
||||
$query->where('commentable_type', \App\Models\Episode::class)
|
||||
->where('commentable_id', $episodeId);
|
||||
} elseif ($animeId) {
|
||||
$query->where('commentable_type', Anime::class)
|
||||
->where('commentable_id', $animeId);
|
||||
}
|
||||
|
||||
$items = $query->paginate(20);
|
||||
$userId = $request->user()?->id;
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($items->items())->map(fn($c) => $this->fmt($c, $userId)),
|
||||
'total' => $items->total(),
|
||||
'last_page' => $items->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'nullable|exists:animes,id',
|
||||
'episode_id' => 'nullable|exists:episodes,id',
|
||||
'body' => 'required|string|max:1000',
|
||||
'gif_url' => 'nullable|url|max:500',
|
||||
]);
|
||||
|
||||
if (empty($data['anime_id']) && empty($data['episode_id'])) {
|
||||
return response()->json(['error' => 'anime_id veya episode_id gerekli.'], 422);
|
||||
}
|
||||
|
||||
$isEpisode = !empty($data['episode_id']);
|
||||
$comment = Comment::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'commentable_type' => $isEpisode ? \App\Models\Episode::class : Anime::class,
|
||||
'commentable_id' => $isEpisode ? $data['episode_id'] : $data['anime_id'],
|
||||
'content' => $data['body'],
|
||||
'gif_url' => $data['gif_url'] ?? null,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$comment->load('user:id,name,username,avatar');
|
||||
|
||||
return response()->json($this->fmt($comment, $request->user()->id), 201);
|
||||
}
|
||||
|
||||
public function like(Request $request, Comment $comment)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$existing = CommentLike::where('user_id', $userId)
|
||||
->where('comment_id', $comment->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$comment->decrement('like_count');
|
||||
return response()->json(['liked' => false, 'likes' => $comment->fresh()->like_count]);
|
||||
}
|
||||
|
||||
CommentLike::create(['user_id' => $userId, 'comment_id' => $comment->id]);
|
||||
$comment->increment('like_count');
|
||||
return response()->json(['liked' => true, 'likes' => $comment->fresh()->like_count]);
|
||||
}
|
||||
|
||||
private function fmt(Comment $c, ?int $userId): array
|
||||
{
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'body' => $c->content,
|
||||
'gif_url' => $c->gif_url,
|
||||
'likes_count' => $c->like_count ?? 0,
|
||||
'is_pinned' => $c->is_pinned ?? false,
|
||||
'created_at' => $c->created_at?->diffForHumans(),
|
||||
'user_liked' => $userId
|
||||
? CommentLike::where('user_id', $userId)->where('comment_id', $c->id)->exists()
|
||||
: false,
|
||||
'user' => $c->user ? [
|
||||
'id' => $c->user->id,
|
||||
'name' => $c->user->name,
|
||||
'username' => $c->user->username,
|
||||
'avatar' => $c->user->avatar
|
||||
? \App\Support\MediaUrl::fromStoragePath($c->user->avatar)
|
||||
: null,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MessageApiController extends Controller
|
||||
{
|
||||
// GET /api/messages — conversations list
|
||||
public function conversations()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$convs = $user->conversations()
|
||||
->with(['participants', 'lastMessage.user'])
|
||||
->orderByDesc('conversations.updated_at')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(function ($conv) use ($user) {
|
||||
$other = $conv->participants->firstWhere('id', '!=', $user->id);
|
||||
$last = $conv->lastMessage;
|
||||
$unread = $conv->unreadCountFor($user->id);
|
||||
|
||||
$preview = null;
|
||||
if ($last) {
|
||||
if (str_starts_with($last->body, 'IMAGE::')) $preview = '📷 Fotoğraf';
|
||||
elseif (str_starts_with($last->body, 'GIF::')) $preview = '🎞 GIF';
|
||||
elseif (str_starts_with($last->body, 'ANIMESHARE::')) {
|
||||
try { $sd = json_decode(substr($last->body, 12), true); $preview = '🎬 ' . ($sd['title'] ?? 'Anime'); } catch (\Throwable) {}
|
||||
} else {
|
||||
$isMine = $last->user_id === $user->id;
|
||||
$preview = ($isMine ? 'Sen: ' : '') . \Illuminate\Support\Str::limit($last->body, 60);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $conv->id,
|
||||
'other_user' => $other ? [
|
||||
'id' => $other->id,
|
||||
'name' => $other->name,
|
||||
'username' => $other->username,
|
||||
'avatar' => $other->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null,
|
||||
] : null,
|
||||
'last_message' => $last ? [
|
||||
'body' => $preview ?? '',
|
||||
'user_id' => $last->user_id,
|
||||
'created_at' => $last->created_at?->toISOString(),
|
||||
] : null,
|
||||
'unread_count' => $unread,
|
||||
'updated_at' => $conv->updated_at?->toISOString(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['conversations' => $convs]);
|
||||
}
|
||||
|
||||
// GET /api/messages/{conversation} — messages in a conversation
|
||||
public function show(Conversation $conversation)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403);
|
||||
|
||||
$other = $conversation->participants()->where('user_id', '!=', $user->id)->first();
|
||||
|
||||
$messages = $conversation->messages()
|
||||
->with('user')
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(fn($m) => [
|
||||
'id' => $m->id,
|
||||
'user_id' => $m->user_id,
|
||||
'body' => $m->body,
|
||||
'created_at' => $m->created_at?->toISOString(),
|
||||
'author' => [
|
||||
'id' => $m->user?->id,
|
||||
'name' => $m->user?->name,
|
||||
'avatar' => $m->user?->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null,
|
||||
],
|
||||
]);
|
||||
|
||||
// Mark as read
|
||||
$conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]);
|
||||
|
||||
return response()->json([
|
||||
'messages' => $messages,
|
||||
'other_user' => $other ? [
|
||||
'id' => $other->id,
|
||||
'name' => $other->name,
|
||||
'username' => $other->username,
|
||||
'avatar' => $other->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
// POST /api/messages/{conversation} — send a message
|
||||
public function send(Request $request, Conversation $conversation)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403);
|
||||
|
||||
$request->validate(['body' => 'required|string|max:5000']);
|
||||
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conversation->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => $request->body,
|
||||
]);
|
||||
|
||||
$conversation->touch();
|
||||
$conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $message->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => $message->body,
|
||||
'created_at' => $message->created_at->toISOString(),
|
||||
'author' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'avatar' => $user->avatar ? \App\Support\MediaUrl::fromStoragePath($user->avatar) : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// POST /api/messages/start/{user} — start or open conversation
|
||||
public function startConversation(User $user)
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
if ($me->id === $user->id) abort(422, 'Kendinize mesaj gönderemezsiniz.');
|
||||
|
||||
$conv = Conversation::whereHas('participants', fn($q) => $q->where('user_id', $me->id))
|
||||
->whereHas('participants', fn($q) => $q->where('user_id', $user->id))
|
||||
->first();
|
||||
|
||||
if (!$conv) {
|
||||
$conv = DB::transaction(function () use ($me, $user) {
|
||||
$c = Conversation::create();
|
||||
$c->participants()->attach([$me->id, $user->id]);
|
||||
return $c;
|
||||
});
|
||||
}
|
||||
|
||||
return response()->json(['conversation_id' => $conv->id]);
|
||||
}
|
||||
|
||||
// GET /api/messages/{conv}/poll?after={id} — poll for new messages (mobile)
|
||||
public function poll(Request $request, Conversation $conversation)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403);
|
||||
|
||||
$after = (int) $request->query('after', 0);
|
||||
|
||||
$messages = $conversation->messages()
|
||||
->with('user')
|
||||
->where('id', '>', $after)
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(fn($m) => [
|
||||
'id' => $m->id,
|
||||
'user_id' => $m->user_id,
|
||||
'body' => $m->body,
|
||||
'created_at' => $m->created_at?->toISOString(),
|
||||
'author' => [
|
||||
'id' => $m->user?->id,
|
||||
'name' => $m->user?->name,
|
||||
'avatar' => $m->user?->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null,
|
||||
],
|
||||
]);
|
||||
|
||||
$conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]);
|
||||
|
||||
return response()->json(['messages' => $messages]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MembershipPlan;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PlanApiController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$plans = MembershipPlan::where('is_active', true)
|
||||
->where('is_public', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('price')
|
||||
->get()
|
||||
->map(fn($p) => $this->fmtPlan($p));
|
||||
|
||||
$subscription = null;
|
||||
$user = $request->user();
|
||||
if ($user) {
|
||||
$sub = Subscription::where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->where('expires_at', '>', now())
|
||||
->with('plan')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($sub) {
|
||||
$subscription = [
|
||||
'plan_id' => $sub->plan_id,
|
||||
'plan_name' => $sub->plan?->name,
|
||||
'plan_slug' => $sub->plan?->slug,
|
||||
'status' => $sub->status,
|
||||
'expires_at' => $sub->expires_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'plans' => $plans,
|
||||
'subscription' => $subscription,
|
||||
'is_premium' => $user?->isPremium() ?? false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function fmtPlan(MembershipPlan $p): array
|
||||
{
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'slug' => $p->slug,
|
||||
'description' => $p->description,
|
||||
'price' => $p->price,
|
||||
'purchase_link' => $p->purchase_link,
|
||||
'duration_days' => $p->duration_days,
|
||||
'trial_days' => $p->trial_days,
|
||||
'features' => $p->features ?? [],
|
||||
'perks' => $p->perks ?? [],
|
||||
'badge_label' => $p->badge_label,
|
||||
'accent_color' => $p->accent_color,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use App\Models\EpisodePrediction;
|
||||
use App\Models\EpisodeTimestampComment;
|
||||
use App\Models\PredictionVote;
|
||||
use App\Models\SpoilerBox;
|
||||
use App\Models\SpoilerBoxLike;
|
||||
use App\Models\TimeCapsule;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFollow;
|
||||
use App\Models\WatchParty;
|
||||
use App\Models\WatchPartyMember;
|
||||
use App\Services\DeepSeekService;
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class SocialApiController extends Controller
|
||||
{
|
||||
// ── NicoNico Timestamp Yorumları ─────────────────────────────────────────
|
||||
|
||||
public function timestampComments(Episode $episode)
|
||||
{
|
||||
$comments = EpisodeTimestampComment::with('user:id,name,username')
|
||||
->where('episode_id', $episode->id)
|
||||
->where('is_hidden', false)
|
||||
->orderBy('timestamp_sec')
|
||||
->get()
|
||||
->map(fn($c) => [
|
||||
'id' => $c->id,
|
||||
'user_id' => $c->user_id,
|
||||
'timestamp_sec' => $c->timestamp_sec,
|
||||
'body' => $c->body,
|
||||
'color' => $c->color,
|
||||
'username' => $c->user?->username ?? 'misafir',
|
||||
]);
|
||||
|
||||
return response()->json(['comments' => $comments]);
|
||||
}
|
||||
|
||||
public function timestampCommentStore(Request $request, Episode $episode)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'timestamp_sec' => 'required|integer|min:0|max:86400',
|
||||
'body' => 'required|string|max:100',
|
||||
'color' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
|
||||
]);
|
||||
|
||||
$me = Auth::user();
|
||||
|
||||
$recent = EpisodeTimestampComment::where('user_id', $me->id)
|
||||
->where('episode_id', $episode->id)
|
||||
->where('created_at', '>=', now()->subSeconds(5))
|
||||
->count();
|
||||
|
||||
if ($recent >= 2) {
|
||||
return response()->json(['error' => 'Çok hızlı yorum yapıyorsunuz.'], 429);
|
||||
}
|
||||
|
||||
$comment = EpisodeTimestampComment::create([
|
||||
'episode_id' => $episode->id,
|
||||
'user_id' => $me->id,
|
||||
'timestamp_sec' => $data['timestamp_sec'],
|
||||
'body' => $data['body'],
|
||||
'color' => $data['color'] ?? '#ffffff',
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $comment->id]);
|
||||
}
|
||||
|
||||
// ── Tahmin Oyunu ─────────────────────────────────────────────────────────
|
||||
|
||||
public function predictions(Episode $episode)
|
||||
{
|
||||
$me = Auth::id();
|
||||
|
||||
$predictions = EpisodePrediction::with('user:id,name,username')
|
||||
->where('episode_id', $episode->id)
|
||||
->orderByDesc('vote_count')
|
||||
->get()
|
||||
->map(fn($p) => [
|
||||
'id' => $p->id,
|
||||
'body' => $p->body,
|
||||
'is_correct' => $p->is_correct,
|
||||
'vote_count' => $p->vote_count,
|
||||
'username' => $p->user?->username,
|
||||
'is_mine' => $me && $p->user_id === $me,
|
||||
'voted' => $me
|
||||
? PredictionVote::where('prediction_id', $p->id)->where('user_id', $me)->exists()
|
||||
: false,
|
||||
'created_at' => $p->created_at->diffForHumans(),
|
||||
]);
|
||||
|
||||
$myPrediction = $me
|
||||
? EpisodePrediction::where('episode_id', $episode->id)->where('user_id', $me)->first()?->id
|
||||
: null;
|
||||
|
||||
return response()->json([
|
||||
'predictions' => $predictions,
|
||||
'my_prediction' => $myPrediction,
|
||||
]);
|
||||
}
|
||||
|
||||
public function predictionStore(Request $request, Episode $episode)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$data = $request->validate(['body' => 'required|string|min:5|max:280']);
|
||||
|
||||
$existing = EpisodePrediction::where('episode_id', $episode->id)
|
||||
->where('user_id', $me->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['error' => 'Bu bölüm için zaten bir tahmininiz var.'], 422);
|
||||
}
|
||||
|
||||
$prediction = EpisodePrediction::create([
|
||||
'episode_id' => $episode->id,
|
||||
'user_id' => $me->id,
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $prediction->id]);
|
||||
}
|
||||
|
||||
public function predictionVote(EpisodePrediction $prediction)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$existing = PredictionVote::where('prediction_id', $prediction->id)->where('user_id', $me->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$prediction->decrement('vote_count');
|
||||
return response()->json(['voted' => false, 'vote_count' => $prediction->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
PredictionVote::create(['prediction_id' => $prediction->id, 'user_id' => $me->id]);
|
||||
$prediction->increment('vote_count');
|
||||
return response()->json(['voted' => true, 'vote_count' => $prediction->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
// ── Watch Party ──────────────────────────────────────────────────────────
|
||||
|
||||
public function partyCreate(Request $request)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$data = $request->validate([
|
||||
'episode_id' => 'required|exists:episodes,id',
|
||||
'is_private' => 'boolean',
|
||||
'password' => 'nullable|string|max:30',
|
||||
'max_members' => 'nullable|integer|min:2|max:20',
|
||||
]);
|
||||
|
||||
WatchParty::where('host_user_id', $me->id)->delete();
|
||||
|
||||
$party = WatchParty::create([
|
||||
'room_code' => WatchParty::generateCode(),
|
||||
'host_user_id' => $me->id,
|
||||
'episode_id' => $data['episode_id'],
|
||||
'is_private' => $data['is_private'] ?? false,
|
||||
'password' => isset($data['password']) ? Hash::make($data['password']) : null,
|
||||
'max_members' => $data['max_members'] ?? 10,
|
||||
]);
|
||||
|
||||
WatchPartyMember::create(['party_id' => $party->id, 'user_id' => $me->id]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'room_code' => $party->room_code,
|
||||
'party' => $this->partyData($party),
|
||||
]);
|
||||
}
|
||||
|
||||
public function partyJoin(Request $request, string $roomCode)
|
||||
{
|
||||
$party = WatchParty::where('room_code', $roomCode)->firstOrFail();
|
||||
$me = Auth::user();
|
||||
|
||||
if ($party->is_private && $party->password) {
|
||||
if (!Hash::check($request->input('password', ''), $party->password)) {
|
||||
return response()->json(['error' => 'Yanlış şifre.'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
if ($party->activeMembers()->count() >= $party->max_members) {
|
||||
return response()->json(['error' => 'Oda dolu.'], 403);
|
||||
}
|
||||
|
||||
WatchPartyMember::updateOrCreate(
|
||||
['party_id' => $party->id, 'user_id' => $me->id],
|
||||
['last_ping' => now()]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'party' => $this->partyData($party),
|
||||
]);
|
||||
}
|
||||
|
||||
public function partySync(Request $request, string $roomCode)
|
||||
{
|
||||
$party = WatchParty::where('room_code', $roomCode)->firstOrFail();
|
||||
$me = Auth::user();
|
||||
|
||||
if ($party->host_user_id === $me->id) {
|
||||
$data = $request->validate([
|
||||
'current_sec' => 'required|integer|min:0',
|
||||
'is_playing' => 'required|boolean',
|
||||
]);
|
||||
$party->update([
|
||||
'current_sec' => $data['current_sec'],
|
||||
'is_playing' => $data['is_playing'],
|
||||
]);
|
||||
}
|
||||
|
||||
WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id)
|
||||
->update(['last_ping' => now()]);
|
||||
|
||||
$fresh = $party->fresh();
|
||||
return response()->json([
|
||||
'current_sec' => $fresh->current_sec,
|
||||
'is_playing' => $fresh->is_playing,
|
||||
'members' => $this->memberList($party),
|
||||
]);
|
||||
}
|
||||
|
||||
public function partyLeave(string $roomCode)
|
||||
{
|
||||
$party = WatchParty::where('room_code', $roomCode)->firstOrFail();
|
||||
$me = Auth::user();
|
||||
|
||||
WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id)->delete();
|
||||
|
||||
if ($party->host_user_id === $me->id) {
|
||||
$party->delete();
|
||||
return response()->json(['ok' => true, 'dissolved' => true]);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'dissolved' => false]);
|
||||
}
|
||||
|
||||
public function partyInfo(string $roomCode)
|
||||
{
|
||||
$party = WatchParty::with(['episode.anime', 'episode.season'])
|
||||
->where('room_code', $roomCode)->firstOrFail();
|
||||
return response()->json(['party' => $this->partyData($party)]);
|
||||
}
|
||||
|
||||
private function partyData(WatchParty $party): array
|
||||
{
|
||||
$party->loadMissing(['episode.anime', 'episode.season']);
|
||||
return [
|
||||
'room_code' => $party->room_code,
|
||||
'host_id' => $party->host_user_id,
|
||||
'episode_id' => $party->episode_id,
|
||||
'current_sec' => $party->current_sec,
|
||||
'is_playing' => $party->is_playing,
|
||||
'is_private' => $party->is_private,
|
||||
'max_members' => $party->max_members,
|
||||
'members' => $this->memberList($party),
|
||||
'anime_title' => $party->episode?->anime?->title,
|
||||
'anime_slug' => $party->episode?->anime?->slug,
|
||||
'episode_num' => $party->episode?->episode_number,
|
||||
'season_num' => $party->episode?->season?->season_number ?? 1,
|
||||
];
|
||||
}
|
||||
|
||||
private function memberList(WatchParty $party): array
|
||||
{
|
||||
return $party->activeMembers()->with('user:id,name,username')->get()
|
||||
->map(fn($m) => [
|
||||
'id' => $m->user_id,
|
||||
'name' => $m->user?->name,
|
||||
'username'=> $m->user?->username,
|
||||
'is_host' => $m->user_id === $party->host_user_id,
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
// ── Spoiler Kutular ──────────────────────────────────────────────────────
|
||||
|
||||
public function spoilerBoxes(Episode $episode)
|
||||
{
|
||||
$me = Auth::id();
|
||||
$boxes = SpoilerBox::with('user:id,name,username')
|
||||
->where('episode_id', $episode->id)
|
||||
->orderByDesc('likes')
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($b) => [
|
||||
'id' => $b->id,
|
||||
'body' => $b->body,
|
||||
'is_spoiler' => $b->is_spoiler,
|
||||
'spoiler_score' => $b->spoiler_score,
|
||||
'likes' => $b->likes,
|
||||
'username' => $b->user?->username,
|
||||
'is_mine' => $me && $b->user_id === $me,
|
||||
'liked' => $me ? SpoilerBoxLike::where('box_id', $b->id)->where('user_id', $me)->exists() : false,
|
||||
'created_at' => $b->created_at->diffForHumans(),
|
||||
]);
|
||||
|
||||
return response()->json(['boxes' => $boxes]);
|
||||
}
|
||||
|
||||
public function spoilerBoxStore(Request $request, Episode $episode)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$data = $request->validate(['body' => 'required|string|min:3|max:600']);
|
||||
|
||||
$isSpoiler = false;
|
||||
$spoilerScore = 0;
|
||||
$ai = new DeepSeekService();
|
||||
if ($ai->isConfigured()) {
|
||||
try {
|
||||
$raw = $ai->checkSpoiler($data['body']);
|
||||
if ($raw) {
|
||||
$isSpoiler = $raw['is_spoiler'] ?? false;
|
||||
$spoilerScore = $raw['score'] ?? 0;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
$box = SpoilerBox::create([
|
||||
'episode_id' => $episode->id,
|
||||
'user_id' => $me->id,
|
||||
'body' => $data['body'],
|
||||
'is_spoiler' => $isSpoiler,
|
||||
'spoiler_score' => $spoilerScore,
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $box->id, 'is_spoiler' => $isSpoiler]);
|
||||
}
|
||||
|
||||
public function spoilerBoxLike(SpoilerBox $box)
|
||||
{
|
||||
$me = Auth::id();
|
||||
$existing = SpoilerBoxLike::where('box_id', $box->id)->where('user_id', $me)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$box->decrement('likes');
|
||||
return response()->json(['liked' => false, 'likes' => $box->fresh()->likes]);
|
||||
}
|
||||
|
||||
SpoilerBoxLike::create(['box_id' => $box->id, 'user_id' => $me, 'created_at' => now()]);
|
||||
$box->increment('likes');
|
||||
return response()->json(['liked' => true, 'likes' => $box->fresh()->likes]);
|
||||
}
|
||||
|
||||
// ── Zaman Kapsülü ────────────────────────────────────────────────────────
|
||||
|
||||
public function capsuleIndex()
|
||||
{
|
||||
$capsules = TimeCapsule::with('anime:id,title,slug,cover_image')
|
||||
->where('user_id', Auth::id())
|
||||
->orderBy('unlock_at')
|
||||
->get()
|
||||
->map(fn($c) => [
|
||||
'id' => $c->id,
|
||||
'anime_title' => $c->anime?->title,
|
||||
'anime_slug' => $c->anime?->slug,
|
||||
'cover' => $c->anime?->cover_image ? MediaUrl::fromStoragePath($c->anime->cover_image) : null,
|
||||
'unlock_at' => $c->unlock_at->toIso8601String(),
|
||||
'unlocked' => $c->isUnlocked(),
|
||||
'opened' => $c->isOpened(),
|
||||
'message' => ($c->isOpened() || $c->isUnlocked()) ? $c->message : null,
|
||||
'created_at' => $c->created_at->toIso8601String(),
|
||||
]);
|
||||
|
||||
return response()->json(['capsules' => $capsules]);
|
||||
}
|
||||
|
||||
public function capsuleStore(Request $request)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|exists:animes,id',
|
||||
'message' => 'required|string|min:5|max:1000',
|
||||
'unlock_at' => 'required|date|after:' . now()->addDays(30)->toDateString(),
|
||||
]);
|
||||
|
||||
$data['user_id'] = $me->id;
|
||||
$capsule = TimeCapsule::create($data);
|
||||
return response()->json(['ok' => true, 'id' => $capsule->id]);
|
||||
}
|
||||
|
||||
public function capsuleOpen(TimeCapsule $capsule)
|
||||
{
|
||||
if ($capsule->user_id !== Auth::id()) {
|
||||
return response()->json(['error' => 'Yetkisiz.'], 403);
|
||||
}
|
||||
if (!$capsule->isUnlocked()) {
|
||||
return response()->json(['error' => 'Kapsül henüz açılamaz.'], 422);
|
||||
}
|
||||
|
||||
$capsule->update(['opened_at' => now()]);
|
||||
return response()->json(['ok' => true, 'message' => $capsule->message]);
|
||||
}
|
||||
|
||||
// ── Ruh Hali Motoru ──────────────────────────────────────────────────────
|
||||
|
||||
private static array $moodGenres = [
|
||||
'sad' => ['Drama', 'Romantizm'],
|
||||
'funny' => ['Komedi', 'Slice of Life'],
|
||||
'hype' => ['Aksiyon', 'Shounen', 'Spor'],
|
||||
'think' => ['Bilim Kurgu', 'Gerilim', 'Supernatural'],
|
||||
'romance' => ['Romantizm', 'Shoujo'],
|
||||
'scary' => ['Korku', 'Supernatural', 'Gerilim'],
|
||||
];
|
||||
|
||||
public function moodRecommend(Request $request)
|
||||
{
|
||||
$mood = $request->validate(['mood' => 'required|in:sad,funny,hype,think,romance,scary'])['mood'];
|
||||
$genres = self::$moodGenres[$mood] ?? [];
|
||||
|
||||
$animes = Anime::whereHas('genres', fn($q) => $q->whereIn('name', $genres))
|
||||
->where('is_published', true)
|
||||
->inRandomOrder()
|
||||
->limit(6)
|
||||
->get(['id', 'title', 'cover_image', 'slug', 'rating']);
|
||||
|
||||
return response()->json([
|
||||
'animes' => $animes->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'slug' => $a->slug,
|
||||
'cover' => $a->cover_image ? MediaUrl::fromStoragePath($a->cover_image) : null,
|
||||
'rating' => $a->rating,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Kullanıcı Takip ──────────────────────────────────────────────────────
|
||||
|
||||
public function followToggle(User $user)
|
||||
{
|
||||
$me = Auth::user();
|
||||
if ($me->id === $user->id) {
|
||||
return response()->json(['error' => 'Kendinizi takip edemezsiniz.'], 422);
|
||||
}
|
||||
|
||||
$existing = \App\Models\UserFollow::where('follower_id', $me->id)
|
||||
->where('following_id', $user->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$following = false;
|
||||
} else {
|
||||
\App\Models\UserFollow::create(['follower_id' => $me->id, 'following_id' => $user->id]);
|
||||
$following = true;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'following' => $following,
|
||||
'followers_count' => \App\Models\UserFollow::where('following_id', $user->id)->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tribunal;
|
||||
use App\Models\TribunalArgument;
|
||||
use App\Models\TribunalArgumentVote;
|
||||
use App\Models\TribunalVote;
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class TribunalApiController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Tribunal::with(['anime:id,title,slug,cover_image', 'creator:id,name,username'])
|
||||
->withCount('votes');
|
||||
|
||||
if ($request->filled('anime_id')) {
|
||||
$query->where('anime_id', $request->anime_id);
|
||||
}
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$tribunals = $query->latest()->paginate(15);
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($tribunals->items())->map(fn($t) => $this->formatTribunal($t))->values(),
|
||||
'has_more' => $tribunals->hasMorePages(),
|
||||
'next_page' => $tribunals->hasMorePages() ? $tribunals->currentPage() + 1 : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Tribunal $tribunal)
|
||||
{
|
||||
$tribunal->load(['anime:id,title,slug,cover_image', 'creator:id,name,username']);
|
||||
$me = Auth::id();
|
||||
$sides = $tribunal->allSides();
|
||||
|
||||
$vcounts = [];
|
||||
foreach (array_keys($sides) as $key) {
|
||||
$vcounts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count();
|
||||
}
|
||||
|
||||
$myVote = $me ? TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->value('side') : null;
|
||||
$myArg = $me ? TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first() : null;
|
||||
|
||||
$arguments = TribunalArgument::with('user:id,name,username')
|
||||
->where('tribunal_id', $tribunal->id)
|
||||
->orderByDesc('vote_count')
|
||||
->get()
|
||||
->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'side' => $a->side,
|
||||
'body' => $a->body,
|
||||
'vote_count' => $a->vote_count,
|
||||
'username' => $a->user?->username,
|
||||
'is_mine' => $me && $a->user_id === $me,
|
||||
'voted' => $me ? TribunalArgumentVote::where('argument_id', $a->id)->where('user_id', $me)->exists() : false,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'tribunal' => $this->formatTribunal($tribunal),
|
||||
'sides' => $sides,
|
||||
'vote_counts' => $vcounts,
|
||||
'total_votes' => array_sum($vcounts),
|
||||
'my_vote' => $myVote,
|
||||
'my_argument' => $myArg ? ['id' => $myArg->id, 'side' => $myArg->side, 'body' => $myArg->body] : null,
|
||||
'arguments' => $arguments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|exists:animes,id',
|
||||
'question' => 'required|string|min:10|max:280',
|
||||
'side_a' => 'required|string|min:2|max:100',
|
||||
'side_b' => 'required|string|min:2|max:100',
|
||||
'extra_sides' => 'nullable|array|max:4',
|
||||
'extra_sides.*'=> 'required|string|min:2|max:100',
|
||||
'closes_at' => 'nullable|date|after:today',
|
||||
]);
|
||||
|
||||
$me = Auth::user();
|
||||
|
||||
$tribunal = Tribunal::create([
|
||||
'anime_id' => $data['anime_id'],
|
||||
'created_by' => $me->id,
|
||||
'question' => $data['question'],
|
||||
'side_a' => $data['side_a'],
|
||||
'side_b' => $data['side_b'],
|
||||
'extra_sides' => $data['extra_sides'] ?? [],
|
||||
'status' => 'open',
|
||||
'closes_at' => $data['closes_at'] ?? now()->addDays(7),
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $tribunal->id]);
|
||||
}
|
||||
|
||||
public function vote(Request $request, Tribunal $tribunal)
|
||||
{
|
||||
if ($tribunal->status !== 'open') {
|
||||
return response()->json(['error' => 'Bu dava kapalı.'], 422);
|
||||
}
|
||||
|
||||
$sides = array_keys($tribunal->allSides());
|
||||
$data = $request->validate(['side' => 'required|in:' . implode(',', $sides)]);
|
||||
$me = Auth::id();
|
||||
|
||||
$existing = TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->side === $data['side']) {
|
||||
$existing->delete();
|
||||
$voted = null;
|
||||
} else {
|
||||
$existing->update(['side' => $data['side']]);
|
||||
$voted = $data['side'];
|
||||
}
|
||||
} else {
|
||||
TribunalVote::create(['tribunal_id' => $tribunal->id, 'user_id' => $me, 'side' => $data['side']]);
|
||||
$voted = $data['side'];
|
||||
}
|
||||
|
||||
$vcounts = [];
|
||||
foreach (array_keys($tribunal->allSides()) as $key) {
|
||||
$vcounts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count();
|
||||
}
|
||||
|
||||
return response()->json(['voted' => $voted, 'vote_counts' => $vcounts, 'total_votes' => array_sum($vcounts)]);
|
||||
}
|
||||
|
||||
public function argue(Request $request, Tribunal $tribunal)
|
||||
{
|
||||
if ($tribunal->status !== 'open') {
|
||||
return response()->json(['error' => 'Bu dava kapalı.'], 422);
|
||||
}
|
||||
|
||||
$sides = array_keys($tribunal->allSides());
|
||||
$data = $request->validate([
|
||||
'side' => 'required|in:' . implode(',', $sides),
|
||||
'body' => 'required|string|min:5|max:500',
|
||||
]);
|
||||
|
||||
$me = Auth::user();
|
||||
$existing = TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->update(['side' => $data['side'], 'body' => $data['body']]);
|
||||
return response()->json(['ok' => true, 'id' => $existing->id, 'updated' => true]);
|
||||
}
|
||||
|
||||
$arg = TribunalArgument::create([
|
||||
'tribunal_id' => $tribunal->id,
|
||||
'user_id' => $me->id,
|
||||
'side' => $data['side'],
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $arg->id, 'updated' => false]);
|
||||
}
|
||||
|
||||
public function argVote(TribunalArgument $argument)
|
||||
{
|
||||
$me = Auth::id();
|
||||
$existing = TribunalArgumentVote::where('argument_id', $argument->id)->where('user_id', $me)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$argument->decrement('vote_count');
|
||||
return response()->json(['voted' => false, 'vote_count' => $argument->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
TribunalArgumentVote::create(['argument_id' => $argument->id, 'user_id' => $me]);
|
||||
$argument->increment('vote_count');
|
||||
return response()->json(['voted' => true, 'vote_count' => $argument->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
private function formatTribunal(Tribunal $t): array
|
||||
{
|
||||
return [
|
||||
'id' => $t->id,
|
||||
'question' => $t->question,
|
||||
'status' => $t->status,
|
||||
'sides' => $t->allSides(),
|
||||
'votes_count' => $t->votes_count ?? TribunalVote::where('tribunal_id', $t->id)->count(),
|
||||
'closes_at' => $t->closes_at?->toIso8601String(),
|
||||
'created_at' => $t->created_at->diffForHumans(),
|
||||
'anime' => $t->anime ? [
|
||||
'id' => $t->anime->id,
|
||||
'title' => $t->anime->title,
|
||||
'slug' => $t->anime->slug,
|
||||
'cover' => $t->anime->cover_image ? MediaUrl::fromStoragePath($t->anime->cover_image) : null,
|
||||
] : null,
|
||||
'creator' => $t->creator ? [
|
||||
'name' => $t->creator->name,
|
||||
'username' => $t->creator->username,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\AnimeRating;
|
||||
use App\Models\AnimeFollow;
|
||||
use App\Models\ContinueWatching;
|
||||
use App\Models\EpisodeVote;
|
||||
use App\Models\EpisodeNote;
|
||||
use App\Models\UserNotification;
|
||||
use App\Models\UserAchievement;
|
||||
use App\Models\Achievement;
|
||||
use App\Models\Watchlist;
|
||||
use App\Models\AnimeRequest;
|
||||
use App\Models\AnimeRequestVote;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UserApiController extends Controller
|
||||
{
|
||||
// ── Watchlist ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function watchlist(Request $request)
|
||||
{
|
||||
$status = $request->input('status'); // watching|completed|plan_to_watch|dropped
|
||||
|
||||
$query = Watchlist::where('user_id', $request->user()->id)
|
||||
->with('anime:id,title,slug,cover_image,rating,episode_count,status,release_year');
|
||||
|
||||
if ($status) $query->where('status', $status);
|
||||
|
||||
$items = $query->orderByDesc('updated_at')->paginate(24);
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($items->items())->map(fn($w) => [
|
||||
'id' => $w->id,
|
||||
'status' => $w->status,
|
||||
'anime' => $w->anime ? [
|
||||
'id' => $w->anime->id,
|
||||
'title' => $w->anime->title,
|
||||
'slug' => $w->anime->slug,
|
||||
'cover_url' => \App\Support\MediaUrl::fromStoragePath($w->anime->cover_image),
|
||||
'rating' => $w->anime->rating,
|
||||
'episode_count' => $w->anime->episode_count,
|
||||
'status' => $w->anime->status,
|
||||
'release_year' => $w->anime->release_year,
|
||||
] : null,
|
||||
]),
|
||||
'total' => $items->total(),
|
||||
'last_page'=> $items->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function watchlistToggle(Request $request, Anime $anime)
|
||||
{
|
||||
$user = $request->user();
|
||||
$status = $request->input('status', 'plan_to_watch');
|
||||
|
||||
$existing = Watchlist::where('user_id', $user->id)->where('anime_id', $anime->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->status === $status) {
|
||||
$existing->delete();
|
||||
return response()->json(['in_watchlist' => false, 'status' => null]);
|
||||
}
|
||||
$existing->update(['status' => $status]);
|
||||
return response()->json(['in_watchlist' => true, 'status' => $status]);
|
||||
}
|
||||
|
||||
Watchlist::create(['user_id' => $user->id, 'anime_id' => $anime->id, 'status' => $status]);
|
||||
return response()->json(['in_watchlist' => true, 'status' => $status]);
|
||||
}
|
||||
|
||||
// ── Continue Watching ──────────────────────────────────────────────────────
|
||||
|
||||
public function continueWatchingUpdate(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|exists:animes,id',
|
||||
'season_number' => 'required|integer|min:1',
|
||||
'episode_number' => 'required|integer|min:1',
|
||||
'percent_complete' => 'required|numeric|min:0|max:100',
|
||||
]);
|
||||
|
||||
ContinueWatching::updateOrCreate(
|
||||
['user_id' => $request->user()->id, 'anime_id' => $data['anime_id']],
|
||||
[
|
||||
'season_number' => $data['season_number'],
|
||||
'episode_number' => $data['episode_number'],
|
||||
'percent_complete' => $data['percent_complete'],
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── Anime Rate ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function animeRate(Request $request, Anime $anime)
|
||||
{
|
||||
$data = $request->validate(['rating' => 'required|numeric|min:1|max:10']);
|
||||
|
||||
AnimeRating::updateOrCreate(
|
||||
['user_id' => $request->user()->id, 'anime_id' => $anime->id],
|
||||
['rating' => $data['rating']]
|
||||
);
|
||||
|
||||
$avg = AnimeRating::where('anime_id', $anime->id)->avg('rating');
|
||||
$anime->update(['rating' => round($avg, 1)]);
|
||||
|
||||
return response()->json(['rating' => $data['rating'], 'avg' => round($avg, 1)]);
|
||||
}
|
||||
|
||||
// ── Follow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function followToggle(Request $request, Anime $anime)
|
||||
{
|
||||
$user = $request->user();
|
||||
$existing = AnimeFollow::where('user_id', $user->id)->where('anime_id', $anime->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
return response()->json(['following' => false]);
|
||||
}
|
||||
|
||||
AnimeFollow::create(['user_id' => $user->id, 'anime_id' => $anime->id]);
|
||||
return response()->json(['following' => true]);
|
||||
}
|
||||
|
||||
// ── Notifications ──────────────────────────────────────────────────────────
|
||||
|
||||
public function notifications(Request $request)
|
||||
{
|
||||
$items = UserNotification::where('user_id', $request->user()->id)
|
||||
->orderByDesc('created_at')->paginate(20);
|
||||
|
||||
// Mark all as read
|
||||
UserNotification::where('user_id', $request->user()->id)->whereNull('read_at')->update(['read_at' => now()]);
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($items->items())->map(fn($n) => [
|
||||
'id' => $n->id,
|
||||
'type' => $n->type,
|
||||
'data' => $n->data ?? [],
|
||||
'is_read' => $n->is_read,
|
||||
'created_at' => $n->created_at?->diffForHumans(),
|
||||
]),
|
||||
'total' => $items->total(),
|
||||
'last_page'=> $items->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function notificationsCount(Request $request)
|
||||
{
|
||||
if (!$request->user()) return response()->json(['count' => 0]);
|
||||
$count = UserNotification::where('user_id', $request->user()->id)->whereNull('read_at')->count();
|
||||
return response()->json(['count' => $count]);
|
||||
}
|
||||
|
||||
// ── Achievements ───────────────────────────────────────────────────────────
|
||||
|
||||
public function achievements(Request $request)
|
||||
{
|
||||
$all = Achievement::orderBy('points')->get();
|
||||
$earned = UserAchievement::where('user_id', $request->user()->id)->pluck('achievement_id')->toArray();
|
||||
$total = UserAchievement::where('user_id', $request->user()->id)->join('achievements','achievements.id','=','user_achievements.achievement_id')->sum('achievements.points');
|
||||
|
||||
return response()->json([
|
||||
'total_points' => (int)$total,
|
||||
'data' => $all->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'description' => $a->description,
|
||||
'icon' => $a->icon,
|
||||
'points' => $a->points,
|
||||
'earned' => in_array($a->id, $earned),
|
||||
'earned_at' => in_array($a->id, $earned)
|
||||
? UserAchievement::where('user_id', $request->user()->id)->where('achievement_id', $a->id)->value('created_at')?->toISOString()
|
||||
: null,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Episode Notes ──────────────────────────────────────────────────────────
|
||||
|
||||
public function noteStore(Request $request, $episodeId)
|
||||
{
|
||||
$data = $request->validate(['note' => 'required|string|max:1000']);
|
||||
|
||||
$note = EpisodeNote::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'episode_id' => $episodeId,
|
||||
'note' => $data['note'],
|
||||
]);
|
||||
|
||||
return response()->json(['id' => $note->id, 'note' => $note->note, 'created_at' => $note->created_at?->toISOString()], 201);
|
||||
}
|
||||
|
||||
public function noteDelete(Request $request, $noteId)
|
||||
{
|
||||
$note = EpisodeNote::where('id', $noteId)->where('user_id', $request->user()->id)->firstOrFail();
|
||||
$note->delete();
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function episodeNotesList(Request $request, $episodeId)
|
||||
{
|
||||
$notes = EpisodeNote::where('user_id', $request->user()->id)
|
||||
->where('episode_id', $episodeId)
|
||||
->orderByDesc('created_at')->get();
|
||||
|
||||
return response()->json($notes->map(fn($n) => [
|
||||
'id' => $n->id,
|
||||
'note' => $n->note,
|
||||
'created_at' => $n->created_at?->toISOString(),
|
||||
]));
|
||||
}
|
||||
|
||||
// ── Anime Requests ─────────────────────────────────────────────────────────
|
||||
|
||||
public function requestIndex(Request $request)
|
||||
{
|
||||
$items = AnimeRequest::withCount('votes')
|
||||
->orderByDesc('votes_count')->orderByDesc('created_at')->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($items->items())->map(fn($r) => [
|
||||
'id' => $r->id,
|
||||
'title' => $r->title,
|
||||
'note' => $r->note,
|
||||
'status' => $r->status,
|
||||
'votes_count' => $r->votes_count,
|
||||
'created_at' => $r->created_at?->diffForHumans(),
|
||||
'user_voted' => $request->user()
|
||||
? AnimeRequestVote::where('user_id', $request->user()->id)->where('anime_request_id', $r->id)->exists()
|
||||
: false,
|
||||
]),
|
||||
'total' => $items->total(),
|
||||
'last_page'=> $items->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function requestStore(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:200',
|
||||
'note' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$req = AnimeRequest::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'title' => $data['title'],
|
||||
'note' => $data['note'] ?? null,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return response()->json(['id' => $req->id, 'title' => $req->title], 201);
|
||||
}
|
||||
|
||||
public function requestVote(Request $request, AnimeRequest $animeRequest)
|
||||
{
|
||||
$user = $request->user();
|
||||
$existing = AnimeRequestVote::where('user_id', $user->id)->where('anime_request_id', $animeRequest->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
return response()->json(['voted' => false, 'votes' => $animeRequest->votes()->count()]);
|
||||
}
|
||||
|
||||
AnimeRequestVote::create(['user_id' => $user->id, 'anime_request_id' => $animeRequest->id]);
|
||||
return response()->json(['voted' => true, 'votes' => $animeRequest->votes()->count()]);
|
||||
}
|
||||
|
||||
// ── Profile Stats ──────────────────────────────────────────────────────────
|
||||
|
||||
public function profileStats(Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
|
||||
$watchlistCount = Watchlist::where('user_id', $userId)->count();
|
||||
$completedCount = Watchlist::where('user_id', $userId)->where('status', 'completed')->count();
|
||||
$notifCount = UserNotification::where('user_id', $userId)->where('is_read', false)->count();
|
||||
$achPoints = UserAchievement::where('user_id', $userId)
|
||||
->join('achievements','achievements.id','=','user_achievements.achievement_id')
|
||||
->sum('achievements.points');
|
||||
$achCount = UserAchievement::where('user_id', $userId)->count();
|
||||
$commentCount = \App\Models\Comment::where('user_id', $userId)->count();
|
||||
|
||||
return response()->json([
|
||||
'watchlist_count' => $watchlistCount,
|
||||
'completed_count' => $completedCount,
|
||||
'notif_count' => (int)$notifCount,
|
||||
'achievement_points'=> (int)$achPoints,
|
||||
'achievement_count' => $achCount,
|
||||
'comment_count' => $commentCount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivationCode;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ActivationController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
return view('frontend.premium.activate');
|
||||
}
|
||||
|
||||
public function redeem(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'code' => 'required|string|max:32',
|
||||
], [
|
||||
'code.required' => 'Aktivasyon kodu boş bırakılamaz.',
|
||||
]);
|
||||
|
||||
$rawCode = strtoupper(preg_replace('/[^A-Z0-9\-]/', '', trim($request->code)));
|
||||
|
||||
$code = ActivationCode::with('plan')
|
||||
->where('code', $rawCode)
|
||||
->first();
|
||||
|
||||
if (! $code) {
|
||||
return back()->withInput()->withErrors(['code' => 'Geçersiz aktivasyon kodu. Kodu kontrol edip tekrar deneyin.']);
|
||||
}
|
||||
|
||||
if ($code->isUsed()) {
|
||||
return back()->withInput()->withErrors(['code' => 'Bu aktivasyon kodu daha önce kullanılmış.']);
|
||||
}
|
||||
|
||||
if ($code->isExpired()) {
|
||||
return back()->withInput()->withErrors(['code' => 'Bu aktivasyon kodunun süresi dolmuş.']);
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$plan = $code->plan;
|
||||
|
||||
// Mevcut premium bitiş tarihine ekle (stack), yoksa şimdiden başla
|
||||
$baseDate = ($user->premium_expires_at && $user->premium_expires_at->isFuture())
|
||||
? $user->premium_expires_at
|
||||
: now();
|
||||
$newExpiry = $baseDate->addDays($plan->duration_days);
|
||||
|
||||
DB::transaction(function () use ($code, $user, $plan, $newExpiry) {
|
||||
$code->update([
|
||||
'used_by' => $user->id,
|
||||
'used_at' => now(),
|
||||
]);
|
||||
|
||||
Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'status' => 'active',
|
||||
'starts_at' => now(),
|
||||
'expires_at' => $newExpiry,
|
||||
'payment_method' => 'activation_code',
|
||||
'payment_ref' => $code->code,
|
||||
]);
|
||||
|
||||
$user->update([
|
||||
'membership' => 'premium',
|
||||
'premium_expires_at' => $newExpiry,
|
||||
]);
|
||||
});
|
||||
|
||||
return redirect()->route('premium.plans')->with('activation_success', [
|
||||
'plan' => $plan->name,
|
||||
'expires_at' => $newExpiry->format('d.m.Y'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Analytics\AiQuery;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AiController extends Controller
|
||||
{
|
||||
/**
|
||||
* AI Hub sayfası — kişisel öneri + doğal dil arama.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$genres = Genre::orderBy('name')->get(['id', 'name']);
|
||||
return view('frontend.ai.index', compact('genres'));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /ai/chat — sohbet turu.
|
||||
* Body: { messages: [{role, content}, ...] }
|
||||
*/
|
||||
public function chat(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503);
|
||||
}
|
||||
|
||||
$messages = $request->input('messages', []);
|
||||
if (empty($messages)) {
|
||||
return response()->json(['error' => 'Mesaj boş.'], 422);
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
$messages = array_filter($messages, fn($m) => isset($m['role'], $m['content']) && in_array($m['role'], ['user', 'assistant']));
|
||||
$messages = array_values($messages);
|
||||
|
||||
$context = $ai->getAnimeContext();
|
||||
|
||||
// Sayfa bağlamı — kullanıcı anime/player sayfasındaysa AI'ya söyle
|
||||
$pageCtx = trim($request->input('page_context', ''));
|
||||
if ($pageCtx) {
|
||||
$context .= "\n\n== KULLANICI ŞU AN BU SAYFADA ==\n{$pageCtx}";
|
||||
}
|
||||
|
||||
$rawReply = $ai->chat($messages, $context);
|
||||
|
||||
if (!$rawReply) {
|
||||
return response()->json(['error' => 'AI yanıt vermedi, tekrar dene.'], 500);
|
||||
}
|
||||
|
||||
// [SUGGEST:id1,id2,id3] satırını parse et
|
||||
$animeCards = [];
|
||||
$cleanReply = $rawReply;
|
||||
if (preg_match('/\[SUGGEST:([\d,\s]+)\]\s*$/m', $rawReply, $m)) {
|
||||
$cleanReply = trim(str_replace($m[0], '', $rawReply));
|
||||
$ids = array_filter(array_map('intval', explode(',', $m[1])));
|
||||
if ($ids) {
|
||||
$animes = Anime::whereIn('id', $ids)
|
||||
->where('is_published', true)
|
||||
->with('genres:id,name')
|
||||
->get(['id', 'title', 'slug', 'cover_image', 'rating', 'type', 'episode_count']);
|
||||
$animeMap = $animes->keyBy('id');
|
||||
foreach ($ids as $id) {
|
||||
if ($a = $animeMap[$id] ?? null) {
|
||||
$animeCards[] = [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'slug' => $a->slug,
|
||||
'cover' => $a->cover_url,
|
||||
'rating' => $a->rating,
|
||||
'type' => $a->type,
|
||||
'episode_count' => $a->episode_count,
|
||||
'genres' => $a->genres->pluck('name')->take(3)->join(', '),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log
|
||||
$lastUser = collect($messages)->last(fn($m) => $m['role'] === 'user');
|
||||
AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'chat','query_text'=>substr($lastUser['content']??'',0,500),'created_at'=>now()]);
|
||||
|
||||
return response()->json(['reply' => $cleanReply, 'anime_cards' => $animeCards]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /ai/recommend — kişisel öneri.
|
||||
* Body: { mood?, genres[]?, type? }
|
||||
*/
|
||||
public function recommend(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503);
|
||||
}
|
||||
|
||||
$mood = trim($request->input('mood', ''));
|
||||
$genres = $request->input('genres', []);
|
||||
$type = $request->input('type', '');
|
||||
|
||||
$prefs = [];
|
||||
if ($mood) $prefs[] = "Ruh hali / tema: {$mood}";
|
||||
if ($genres) $prefs[] = 'Tercih edilen türler: ' . implode(', ', array_slice((array)$genres, 0, 6));
|
||||
if ($type) $prefs[] = 'İçerik tipi: ' . ($type === 'movie' ? 'Film' : 'Dizi');
|
||||
$prefStr = $prefs ? implode("\n", $prefs) : 'Genel tavsiye, en beğenilen animeler';
|
||||
|
||||
$animes = Anime::where('is_published', true)
|
||||
->with('genres:id,name')
|
||||
->get(['id', 'title', 'slug', 'type', 'status', 'rating', 'release_year', 'cover_image', 'episode_count']);
|
||||
|
||||
$result = $ai->recommend($prefStr, $animes->toArray());
|
||||
|
||||
if (!$result) {
|
||||
return response()->json(['error' => 'Öneri üretilemedi.'], 500);
|
||||
}
|
||||
|
||||
$animeMap = $animes->keyBy('id');
|
||||
$recs = array_values(array_filter(array_map(function ($item) use ($animeMap) {
|
||||
$anime = $animeMap[$item['id'] ?? 0] ?? null;
|
||||
if (!$anime) return null;
|
||||
return [
|
||||
'id' => $anime->id,
|
||||
'title' => $anime->title,
|
||||
'slug' => $anime->slug,
|
||||
'cover' => $anime->cover_url,
|
||||
'rating' => $anime->rating,
|
||||
'type' => $anime->type,
|
||||
'episode_count' => $anime->episode_count,
|
||||
'reason' => $item['reason'] ?? '',
|
||||
];
|
||||
}, $result)));
|
||||
|
||||
AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'recommend','query_text'=>substr($prefStr,0,500),'created_at'=>now()]);
|
||||
|
||||
return response()->json(['recommendations' => $recs]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /ai/search — doğal dil ile anime ara.
|
||||
* Body: { query }
|
||||
*/
|
||||
public function search(Request $request)
|
||||
{
|
||||
$query = trim($request->input('query', ''));
|
||||
if (!$query) {
|
||||
return response()->json(['error' => 'Sorgu boş.'], 422);
|
||||
}
|
||||
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503);
|
||||
}
|
||||
|
||||
$animes = Anime::where('is_published', true)
|
||||
->with('genres:id,name')
|
||||
->get(['id', 'title', 'slug', 'type', 'rating', 'release_year', 'cover_image']);
|
||||
|
||||
$ids = $ai->naturalSearch($query, $animes->toArray());
|
||||
if (!$ids) {
|
||||
return response()->json(['results' => []]);
|
||||
}
|
||||
|
||||
$animeMap = $animes->keyBy('id');
|
||||
$results = array_values(array_filter(array_map(function ($id) use ($animeMap) {
|
||||
$anime = $animeMap[(int)$id] ?? null;
|
||||
if (!$anime) return null;
|
||||
return [
|
||||
'id' => $anime->id,
|
||||
'title' => $anime->title,
|
||||
'slug' => $anime->slug,
|
||||
'cover' => $anime->cover_url,
|
||||
'rating' => $anime->rating,
|
||||
'type' => $anime->type,
|
||||
];
|
||||
}, $ids)));
|
||||
|
||||
AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'search','query_text'=>substr($query,0,500),'created_at'=>now()]);
|
||||
|
||||
return response()->json(['results' => $results]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /ai/episode-info — bölüm hakkında AI analizi.
|
||||
* Body: { anime_title, episode_number, episode_title?, description? }
|
||||
*/
|
||||
public function episodeInfo(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503);
|
||||
}
|
||||
|
||||
$animeTitle = trim($request->input('anime_title', ''));
|
||||
$episodeNumber = (int) $request->input('episode_number', 1);
|
||||
$episodeTitle = trim($request->input('episode_title', ''));
|
||||
$description = trim($request->input('description', ''));
|
||||
|
||||
if (!$animeTitle) {
|
||||
return response()->json(['error' => 'Anime adı gerekli.'], 422);
|
||||
}
|
||||
|
||||
$info = $ai->episodeInfo($animeTitle, $episodeNumber, $episodeTitle, $description);
|
||||
if (!$info) {
|
||||
return response()->json(['error' => 'Analiz yapılamadı.'], 500);
|
||||
}
|
||||
|
||||
AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'episode_info','query_text'=>"{$animeTitle} E{$episodeNumber}",'created_at'=>now()]);
|
||||
|
||||
return response()->json(['info' => $info]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /ai/similar — benzer animeler.
|
||||
* Body: { anime_id }
|
||||
*/
|
||||
public function similar(Request $request)
|
||||
{
|
||||
$ai = new DeepSeekService();
|
||||
if (!$ai->isConfigured()) {
|
||||
return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503);
|
||||
}
|
||||
|
||||
$anime = Anime::with('genres:id,name')->find($request->input('anime_id'));
|
||||
if (!$anime) {
|
||||
return response()->json(['error' => 'Anime bulunamadı.'], 404);
|
||||
}
|
||||
|
||||
$genres = $anime->genres->pluck('name')->join(', ');
|
||||
$prefStr = "Şu anime ile benzer: {$anime->title}\n"
|
||||
. "Türler: {$genres}\n"
|
||||
. "Tip: " . ($anime->type === 'movie' ? 'Film' : 'Dizi') . "\n"
|
||||
. "Bu animeyi beğenen izleyicilere benzer içerik öner. Aynı animeyi önerme!";
|
||||
|
||||
$animes = Anime::where('is_published', true)
|
||||
->where('id', '!=', $anime->id)
|
||||
->with('genres:id,name')
|
||||
->get(['id', 'title', 'slug', 'type', 'rating', 'release_year', 'cover_image']);
|
||||
|
||||
$result = $ai->recommend($prefStr, $animes->toArray());
|
||||
if (!$result) {
|
||||
return response()->json(['similar' => []]);
|
||||
}
|
||||
|
||||
$animeMap = $animes->keyBy('id');
|
||||
$similar = array_values(array_filter(array_map(function ($item) use ($animeMap) {
|
||||
$a = $animeMap[$item['id'] ?? 0] ?? null;
|
||||
if (!$a) return null;
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'slug' => $a->slug,
|
||||
'cover' => $a->cover_url,
|
||||
'rating' => $a->rating,
|
||||
'type' => $a->type,
|
||||
'reason' => $item['reason'] ?? '',
|
||||
];
|
||||
}, $result)));
|
||||
|
||||
AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'similar','query_text'=>$anime->title,'created_at'=>now()]);
|
||||
|
||||
return response()->json(['similar' => $similar]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Watchlist;
|
||||
use App\Models\AnimeRating;
|
||||
use App\Models\ContinueWatching;
|
||||
use App\Models\AnimeFollow;
|
||||
|
||||
class AnimeController extends Controller
|
||||
{
|
||||
public function show(Anime $anime)
|
||||
{
|
||||
abort_unless($anime->is_published, 404);
|
||||
|
||||
$anime->load([
|
||||
'genres',
|
||||
'seasons' => fn($q) => $q->orderBy('season_number'),
|
||||
'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number'),
|
||||
]);
|
||||
|
||||
$related = Anime::whereHas('genres', fn($q) =>
|
||||
$q->whereIn('genres.id', $anime->genres->pluck('id'))
|
||||
)
|
||||
->where('id', '!=', $anime->id)
|
||||
->where('is_published', true)
|
||||
->take(10)
|
||||
->get();
|
||||
|
||||
// Auth kullanıcı verileri
|
||||
$userWatchlist = null;
|
||||
$userRating = null;
|
||||
$continueEp = null;
|
||||
$userFollowing = false;
|
||||
|
||||
if (auth()->check()) {
|
||||
$userWatchlist = Watchlist::where('user_id', auth()->id())
|
||||
->where('anime_id', $anime->id)->first();
|
||||
$userRating = AnimeRating::where('user_id', auth()->id())
|
||||
->where('anime_id', $anime->id)->value('rating');
|
||||
$continueEp = ContinueWatching::where('user_id', auth()->id())
|
||||
->where('anime_id', $anime->id)
|
||||
->where('percent_complete', '<', 95)
|
||||
->first();
|
||||
$userFollowing = AnimeFollow::where('user_id', auth()->id())
|
||||
->where('anime_id', $anime->id)->exists();
|
||||
}
|
||||
|
||||
// Sosyal: Bu animeyi listeleyen son kullanıcılar
|
||||
$watchers = Watchlist::where('anime_id', $anime->id)
|
||||
->when(auth()->id(), fn($q) => $q->where('user_id', '!=', auth()->id()))
|
||||
->with('user:id,name,username,avatar')
|
||||
->latest()
|
||||
->limit(8)
|
||||
->get()
|
||||
->map(fn($w) => $w->user)
|
||||
->filter();
|
||||
$watcherCount = Watchlist::where('anime_id', $anime->id)->count();
|
||||
|
||||
return view('frontend.anime', compact('anime', 'related', 'userWatchlist', 'userRating', 'continueEp', 'userFollowing', 'watchers', 'watcherCount'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use App\Http\Controllers\Frontend\EmailVerificationController;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin()
|
||||
{
|
||||
return view('frontend.auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
], [
|
||||
'email.required' => 'E-posta zorunludur.',
|
||||
'email.email' => 'Geçerli bir e-posta girin.',
|
||||
'password.required' => 'Şifre zorunludur.',
|
||||
]);
|
||||
|
||||
$credentials = $request->only('email', 'password');
|
||||
$remember = $request->boolean('remember');
|
||||
|
||||
if (Auth::attempt($credentials, $remember)) {
|
||||
$user = Auth::user();
|
||||
if ($user->is_banned) {
|
||||
Auth::logout();
|
||||
return back()->withErrors(['email' => 'Hesabınız yasaklanmıştır: ' . ($user->ban_reason ?: 'İhlal.')]);
|
||||
}
|
||||
$request->session()->regenerate();
|
||||
\App\Support\ActivityLogger::log('login', $user->id, null, null, null, $request);
|
||||
return redirect()->intended(route('home'));
|
||||
}
|
||||
|
||||
return back()->withErrors(['email' => 'E-posta veya şifre hatalı.'])->withInput($request->only('email'));
|
||||
}
|
||||
|
||||
public function showRegister()
|
||||
{
|
||||
return view('frontend.auth.register');
|
||||
}
|
||||
|
||||
public function register(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|min:2|max:60',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'password' => ['required', 'confirmed', Password::min(6)],
|
||||
], [
|
||||
'name.required' => 'İsim zorunludur.',
|
||||
'name.min' => 'İsim en az 2 karakter olmalıdır.',
|
||||
'email.required' => 'E-posta zorunludur.',
|
||||
'email.unique' => 'Bu e-posta zaten kayıtlı.',
|
||||
'password.required' => 'Şifre zorunludur.',
|
||||
'password.confirmed' => 'Şifreler eşleşmiyor.',
|
||||
'password.min' => 'Şifre en az 6 karakter olmalıdır.',
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
'role' => 'user',
|
||||
'membership' => 'free',
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
\App\Support\ActivityLogger::log('register', $user->id, null, null, null, $request);
|
||||
|
||||
// Doğrulama e-postası gönder (SMTP ayarlıysa)
|
||||
try {
|
||||
EmailVerificationController::sendVerificationMail($user);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return redirect(route('home'));
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
return redirect(route('home'));
|
||||
}
|
||||
|
||||
// ── Social Auth ───────────────────────────────────────────────────────────
|
||||
|
||||
private const ALLOWED_PROVIDERS = ['google', 'discord'];
|
||||
|
||||
public function socialRedirect(string $provider)
|
||||
{
|
||||
if (!in_array($provider, self::ALLOWED_PROVIDERS)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return Socialite::driver($provider)->redirect();
|
||||
}
|
||||
|
||||
public function socialCallback(string $provider, Request $request)
|
||||
{
|
||||
if (!in_array($provider, self::ALLOWED_PROVIDERS)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
try {
|
||||
$socialUser = Socialite::driver($provider)->user();
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('frontend.login')
|
||||
->withErrors(['email' => 'Sosyal giriş başarısız, lütfen tekrar deneyin.']);
|
||||
}
|
||||
|
||||
$email = $socialUser->getEmail();
|
||||
$name = $socialUser->getName() ?: $socialUser->getNickname() ?: 'Kullanıcı';
|
||||
$avatar = $socialUser->getAvatar();
|
||||
$socialId = $socialUser->getId();
|
||||
|
||||
// Aynı provider + social_id ile kayıtlı kullanıcı var mı?
|
||||
$user = User::where('social_provider', $provider)
|
||||
->where('social_id', $socialId)
|
||||
->first();
|
||||
|
||||
if (!$user && $email) {
|
||||
// Aynı e-posta ile kayıtlı normal hesap var mı?
|
||||
$user = User::where('email', $email)->first();
|
||||
if ($user) {
|
||||
// Mevcut hesaba sosyal giriş bilgisini bağla
|
||||
$user->update([
|
||||
'social_provider' => $provider,
|
||||
'social_id' => $socialId,
|
||||
'avatar' => $user->avatar ?: $avatar,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$user) {
|
||||
// Yeni kullanıcı oluştur
|
||||
$user = User::create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'avatar' => $avatar,
|
||||
'social_provider' => $provider,
|
||||
'social_id' => $socialId,
|
||||
'password' => null,
|
||||
'role' => 'user',
|
||||
'membership' => 'free',
|
||||
]);
|
||||
\App\Support\ActivityLogger::log('register', $user->id, null, null, null, $request);
|
||||
}
|
||||
|
||||
if ($user->is_banned) {
|
||||
return redirect()->route('frontend.login')
|
||||
->withErrors(['email' => 'Hesabınız yasaklanmıştır: ' . ($user->ban_reason ?: 'İhlal.')]);
|
||||
}
|
||||
|
||||
Auth::login($user, true);
|
||||
$request->session()->regenerate();
|
||||
\App\Support\ActivityLogger::log('login', $user->id, null, null, null, $request);
|
||||
|
||||
return redirect()->intended(route('home'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BlogPost;
|
||||
use App\Models\Anime;
|
||||
|
||||
class BlogController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$posts = BlogPost::with('anime')
|
||||
->published()
|
||||
->orderByDesc('published_at')
|
||||
->paginate(12);
|
||||
|
||||
$recent = BlogPost::published()->orderByDesc('published_at')->limit(5)->get();
|
||||
$popular = BlogPost::published()->orderByDesc('views')->limit(5)->get();
|
||||
|
||||
return view('frontend.blog.index', compact('posts', 'recent', 'popular'));
|
||||
}
|
||||
|
||||
public function show(string $slug)
|
||||
{
|
||||
$post = BlogPost::with('anime.genres')
|
||||
->where('slug', $slug)
|
||||
->where('status', 'published')
|
||||
->firstOrFail();
|
||||
|
||||
$post->increment('views');
|
||||
|
||||
// İlgili yazılar: aynı anime veya benzer anahtar kelimeler
|
||||
$related = BlogPost::published()
|
||||
->where('id', '!=', $post->id)
|
||||
->when($post->anime_id, fn($q) => $q->where('anime_id', $post->anime_id)
|
||||
->orWhere('focus_keyword', 'like', '%' . explode(' ', $post->focus_keyword ?? '')[0] . '%')
|
||||
)
|
||||
->orderByDesc('published_at')
|
||||
->limit(4)
|
||||
->get();
|
||||
|
||||
// Linked anime'ler
|
||||
$linkedAnimes = collect();
|
||||
if (!empty($post->linked_anime_ids)) {
|
||||
$linkedAnimes = Anime::whereIn('id', $post->linked_anime_ids)
|
||||
->where('is_published', true)
|
||||
->get();
|
||||
}
|
||||
|
||||
return view('frontend.blog.show', compact('post', 'related', 'linkedAnimes'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MembershipPlan;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CheckoutController extends Controller
|
||||
{
|
||||
private function options(): \Iyzipay\Options
|
||||
{
|
||||
$opt = new \Iyzipay\Options();
|
||||
$opt->setApiKey(config('iyzico.api_key'));
|
||||
$opt->setSecretKey(config('iyzico.secret_key'));
|
||||
$opt->setBaseUrl(config('iyzico.base_url'));
|
||||
return $opt;
|
||||
}
|
||||
|
||||
public function show(MembershipPlan $plan)
|
||||
{
|
||||
abort_if(!$plan->is_active || !$plan->is_public, 404);
|
||||
return view('frontend.checkout.show', compact('plan'));
|
||||
}
|
||||
|
||||
public function initialize(Request $request, MembershipPlan $plan)
|
||||
{
|
||||
abort_if(!$plan->is_active || !$plan->is_public, 404);
|
||||
|
||||
$v = $request->validate([
|
||||
'full_name' => 'required|string|max:100',
|
||||
'phone' => 'required|string|max:20',
|
||||
'city' => 'required|string|max:80',
|
||||
'address' => 'required|string|max:300',
|
||||
'identity_no' => 'nullable|digits:11',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$conversationId = Str::uuid()->toString();
|
||||
$price = number_format($plan->price, 2, '.', '');
|
||||
|
||||
$parts = explode(' ', trim($v['full_name']), 2);
|
||||
$firstName = $parts[0];
|
||||
$lastName = $parts[1] ?? '-';
|
||||
|
||||
$payment = Payment::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'conversation_id' => $conversationId,
|
||||
'amount' => $plan->price,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$req = new \Iyzipay\Request\CreateCheckoutFormInitializeRequest();
|
||||
$req->setLocale(\Iyzipay\Model\Locale::TR);
|
||||
$req->setConversationId($conversationId);
|
||||
$req->setPrice($price);
|
||||
$req->setPaidPrice($price);
|
||||
$req->setCurrency(\Iyzipay\Model\Currency::TL);
|
||||
$req->setBasketId('payment-' . $payment->id);
|
||||
$req->setPaymentGroup(\Iyzipay\Model\PaymentGroup::PRODUCT);
|
||||
$req->setCallbackUrl(route('checkout.callback'));
|
||||
$req->setEnabledInstallments([1, 2, 3, 6, 9, 12]);
|
||||
|
||||
$buyer = new \Iyzipay\Model\Buyer();
|
||||
$buyer->setId('u' . $user->id);
|
||||
$buyer->setName($firstName);
|
||||
$buyer->setSurname($lastName);
|
||||
$buyer->setGsmNumber('+9' . preg_replace('/\D/', '', $v['phone']));
|
||||
$buyer->setEmail($user->email);
|
||||
$buyer->setIdentityNumber($v['identity_no'] ?: '11111111111');
|
||||
$buyer->setRegistrationAddress($v['address']);
|
||||
$buyer->setIp($request->ip());
|
||||
$buyer->setCity($v['city']);
|
||||
$buyer->setCountry('Turkey');
|
||||
$req->setBuyer($buyer);
|
||||
|
||||
$addr = new \Iyzipay\Model\Address();
|
||||
$addr->setContactName($v['full_name']);
|
||||
$addr->setCity($v['city']);
|
||||
$addr->setCountry('Turkey');
|
||||
$addr->setAddress($v['address']);
|
||||
$req->setBillingAddress($addr);
|
||||
$req->setShippingAddress($addr);
|
||||
|
||||
$item = new \Iyzipay\Model\BasketItem();
|
||||
$item->setId('plan' . $plan->id);
|
||||
$item->setName($plan->name . ' Premium (' . $plan->duration_days . ' gün)');
|
||||
$item->setCategory1('Dijital Ürün');
|
||||
$item->setItemType(\Iyzipay\Model\BasketItemType::VIRTUAL);
|
||||
$item->setPrice($price);
|
||||
$req->setBasketItems([$item]);
|
||||
|
||||
$form = \Iyzipay\Model\CheckoutFormInitialize::create($req, $this->options());
|
||||
|
||||
if ($form->getStatus() !== 'success') {
|
||||
$payment->update(['status' => 'failed', 'error_message' => $form->getErrorMessage()]);
|
||||
return back()->withErrors(['general' => 'Ödeme başlatılamadı: ' . $form->getErrorMessage()]);
|
||||
}
|
||||
|
||||
$payment->update(['token' => $form->getToken()]);
|
||||
|
||||
return view('frontend.checkout.form', [
|
||||
'plan' => $plan,
|
||||
'formContent' => $form->getCheckoutFormContent(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function callback(Request $request)
|
||||
{
|
||||
$token = $request->input('token');
|
||||
|
||||
if (!$token) {
|
||||
return redirect()->route('checkout.failed');
|
||||
}
|
||||
|
||||
$payment = Payment::where('token', $token)->where('status', 'pending')->first();
|
||||
|
||||
if (!$payment) {
|
||||
return redirect()->route('checkout.failed');
|
||||
}
|
||||
|
||||
$req = new \Iyzipay\Request\RetrieveCheckoutFormRequest();
|
||||
$req->setLocale(\Iyzipay\Model\Locale::TR);
|
||||
$req->setConversationId($payment->conversation_id);
|
||||
$req->setToken($token);
|
||||
|
||||
$result = \Iyzipay\Model\CheckoutForm::retrieve($req, $this->options());
|
||||
|
||||
if ($result->getStatus() === 'success' && $result->getPaymentStatus() === 'SUCCESS') {
|
||||
$payment->update([
|
||||
'status' => 'success',
|
||||
'iyzico_payment_id' => $result->getPaymentId(),
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
|
||||
$plan = $payment->plan;
|
||||
$user = $payment->user;
|
||||
$hasEver = Subscription::where('user_id', $user->id)->exists();
|
||||
$bonus = ($hasEver === false && ($plan->trial_days ?? 0) > 0) ? $plan->trial_days : 0;
|
||||
$expiresAt = now()->addDays($plan->duration_days + $bonus);
|
||||
|
||||
Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'status' => 'active',
|
||||
'starts_at' => now(),
|
||||
'expires_at' => $expiresAt,
|
||||
'payment_method' => 'iyzico',
|
||||
'payment_ref' => $result->getPaymentId(),
|
||||
]);
|
||||
|
||||
$user->update([
|
||||
'membership' => 'premium',
|
||||
'premium_expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
session(['checkout_plan_name' => $plan->name]);
|
||||
return redirect()->route('checkout.success');
|
||||
}
|
||||
|
||||
$payment->update([
|
||||
'status' => 'failed',
|
||||
'error_message' => $result->getErrorMessage(),
|
||||
]);
|
||||
|
||||
return redirect()->route('checkout.failed');
|
||||
}
|
||||
|
||||
public function success()
|
||||
{
|
||||
return view('frontend.checkout.success');
|
||||
}
|
||||
|
||||
public function failed()
|
||||
{
|
||||
return view('frontend.checkout.failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Comment;
|
||||
use App\Models\CommentLike;
|
||||
use App\Models\Setting;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /comments — yorum gönder
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$maxLength = ($user && $user->hasPerk('extended_comments')) ? 1000 : 500;
|
||||
|
||||
$request->validate([
|
||||
'commentable_type' => 'required|in:episode,anime',
|
||||
'commentable_id' => 'required|integer',
|
||||
'content' => 'nullable|string|max:' . $maxLength,
|
||||
'gif_url' => 'nullable|url|max:500',
|
||||
'parent_id' => 'nullable|integer|exists:comments,id',
|
||||
]);
|
||||
|
||||
if (empty(trim($request->content ?? '')) && empty($request->gif_url)) {
|
||||
return response()->json(['error' => 'Yorum boş olamaz.'], 422);
|
||||
}
|
||||
|
||||
if (!empty($request->gif_url) && (!$user || !$user->hasPerk('comment_gif'))) {
|
||||
return response()->json(['error' => 'GIF eklemek için premium üyelik gerekiyor.'], 403);
|
||||
}
|
||||
|
||||
$commentsEnabled = Setting::get('comments_enabled', '1') === '1';
|
||||
|
||||
if (!$commentsEnabled) {
|
||||
return response()->json(['error' => 'Yorumlar şu an kapalı.'], 403);
|
||||
}
|
||||
|
||||
$content = trim($request->content ?? '');
|
||||
|
||||
// AI moderasyon (sadece metin içeren yorumlar için, GIF yorumları direkt onaylanır)
|
||||
$aiService = new DeepSeekService();
|
||||
$pendingReason = null;
|
||||
$status = 'approved';
|
||||
|
||||
if (!empty($content) && $aiService->isConfigured()) {
|
||||
$mod = $aiService->moderateComment($content);
|
||||
|
||||
if ($mod['is_rude']) {
|
||||
$status = 'pending';
|
||||
$pendingReason = 'rude';
|
||||
} elseif ($mod['is_spoiler']) {
|
||||
$status = 'pending';
|
||||
$pendingReason = 'spoiler';
|
||||
}
|
||||
} elseif (Setting::get('comments_require_approval', '0') === '1') {
|
||||
$status = 'pending';
|
||||
$pendingReason = 'manual';
|
||||
}
|
||||
|
||||
$comment = Comment::create([
|
||||
'user_id' => Auth::id(),
|
||||
'commentable_type' => $request->commentable_type,
|
||||
'commentable_id' => $request->commentable_id,
|
||||
'parent_id' => $request->parent_id ?: null,
|
||||
'content' => $content,
|
||||
'gif_url' => $request->gif_url ?: null,
|
||||
'status' => $status,
|
||||
'like_count' => 0,
|
||||
]);
|
||||
|
||||
$comment->load('user');
|
||||
|
||||
if ($status !== 'approved') {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'pending' => true,
|
||||
'pending_reason' => $pendingReason,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'pending' => false,
|
||||
'comment' => $this->formatComment($comment, Auth::id()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /comments/{comment}/like — beğen/beğenmekten vazgeç (toggle)
|
||||
*/
|
||||
public function like(Comment $comment)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
$existing = CommentLike::where('user_id', $userId)
|
||||
->where('comment_id', $comment->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$comment->decrement('like_count');
|
||||
$liked = false;
|
||||
} else {
|
||||
CommentLike::create(['user_id' => $userId, 'comment_id' => $comment->id]);
|
||||
$comment->increment('like_count');
|
||||
$liked = true;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'liked' => $liked,
|
||||
'like_count' => $comment->fresh()->like_count,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /comments/gif-search — GIF arama (Giphy öncelikli, Tenor fallback)
|
||||
*/
|
||||
public function gifSearch(Request $request)
|
||||
{
|
||||
$query = $request->query('q', 'anime reaction');
|
||||
$giphyKey = Setting::get('giphy_api_key', '');
|
||||
$tenorKey = Setting::get('tenor_api_key', '');
|
||||
|
||||
// Giphy
|
||||
if (!empty($giphyKey)) {
|
||||
return $this->searchGiphy($query, $giphyKey);
|
||||
}
|
||||
|
||||
// Tenor
|
||||
if (!empty($tenorKey)) {
|
||||
return $this->searchTenor($query, $tenorKey);
|
||||
}
|
||||
|
||||
return response()->json(['results' => [], 'error' => 'no_key']);
|
||||
}
|
||||
|
||||
private function searchGiphy(string $query, string $apiKey)
|
||||
{
|
||||
try {
|
||||
$res = Http::timeout(8)->get('https://api.giphy.com/v1/gifs/search', [
|
||||
'api_key' => $apiKey,
|
||||
'q' => $query,
|
||||
'limit' => 24,
|
||||
'rating' => 'pg-13',
|
||||
'lang' => 'en',
|
||||
]);
|
||||
|
||||
if (!$res->successful()) {
|
||||
\Log::warning('Giphy API failed', ['status' => $res->status()]);
|
||||
return response()->json(['results' => [], 'error' => 'giphy_fail']);
|
||||
}
|
||||
|
||||
$gifs = collect($res->json('data', []))->map(function ($r) {
|
||||
$images = $r['images'] ?? [];
|
||||
$preview = $images['fixed_height_small']['url']
|
||||
?? $images['fixed_height']['url']
|
||||
?? $images['downsized']['url']
|
||||
?? null;
|
||||
$full = $images['downsized_medium']['url']
|
||||
?? $images['fixed_height']['url']
|
||||
?? $images['original']['url']
|
||||
?? $preview;
|
||||
if (!$preview || !$full) return null;
|
||||
return [
|
||||
'id' => $r['id'],
|
||||
'preview' => $preview,
|
||||
'url' => $full,
|
||||
'title' => $r['title'] ?? '',
|
||||
];
|
||||
})->filter()->values();
|
||||
|
||||
return response()->json(['results' => $gifs]);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Giphy error: ' . $e->getMessage());
|
||||
return response()->json(['results' => [], 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function searchTenor(string $query, string $apiKey)
|
||||
{
|
||||
try {
|
||||
$res = Http::timeout(8)->get('https://tenor.googleapis.com/v2/search', [
|
||||
'q' => $query,
|
||||
'key' => $apiKey,
|
||||
'limit' => 24,
|
||||
'media_filter' => 'tinygif,gif',
|
||||
'contentfilter' => 'medium',
|
||||
]);
|
||||
|
||||
if (!$res->successful()) {
|
||||
return response()->json(['results' => [], 'error' => 'tenor_fail']);
|
||||
}
|
||||
|
||||
$gifs = collect($res->json('results', []))->map(function ($r) {
|
||||
$formats = $r['media_formats'] ?? [];
|
||||
$preview = $formats['tinygif']['url'] ?? $formats['mediumgif']['url'] ?? $formats['gif']['url'] ?? null;
|
||||
$full = $formats['gif']['url'] ?? $formats['mediumgif']['url'] ?? $preview ?? null;
|
||||
if (!$preview || !$full) return null;
|
||||
return [
|
||||
'id' => $r['id'],
|
||||
'preview' => $preview,
|
||||
'url' => $full,
|
||||
'title' => $r['content_description'] ?? '',
|
||||
];
|
||||
})->filter()->values();
|
||||
|
||||
return response()->json(['results' => $gifs]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['results' => [], 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /comments — bölüm yorumlarını getir (AJAX sayfalama)
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
$query = Comment::where('commentable_type', $request->type)
|
||||
->where('commentable_id', $request->id)
|
||||
->whereNull('parent_id')
|
||||
->where('status', 'approved')
|
||||
->with(['user', 'replies' => fn($q) => $q->where('status', 'approved')->with('user')->orderBy('created_at')])
|
||||
->orderByDesc('is_pinned')
|
||||
->orderByDesc('like_count')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
$comments = $query->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => $comments->map(fn($c) => $this->formatComment($c, $userId, true)),
|
||||
'has_more' => $comments->hasMorePages(),
|
||||
'next_page'=> $comments->currentPage() + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatComment(Comment $c, ?int $userId, bool $withReplies = false): array
|
||||
{
|
||||
$data = [
|
||||
'id' => $c->id,
|
||||
'content' => $c->content,
|
||||
'gif_url' => $c->gif_url,
|
||||
'like_count' => $c->like_count,
|
||||
'is_liked' => $userId ? $c->likes()->where('user_id', $userId)->exists() : false,
|
||||
'is_pinned' => $c->is_pinned,
|
||||
'parent_id' => $c->parent_id,
|
||||
'created_at' => $c->created_at?->diffForHumans(),
|
||||
'user' => $c->user ? [
|
||||
'id' => $c->user->id,
|
||||
'name' => $c->user->name,
|
||||
'username' => $c->user->username,
|
||||
'avatar' => $c->user->gif_avatar && $c->user->hasPerk('gif_avatar')
|
||||
? $c->user->gif_avatar
|
||||
: ($c->user->avatar ? \App\Support\MediaUrl::fromStoragePath($c->user->avatar) : null),
|
||||
'role' => $c->user->role,
|
||||
'is_following' => $userId && $userId !== $c->user->id
|
||||
? \App\Models\UserFollow::where('follower_id', $userId)->where('following_id', $c->user->id)->exists()
|
||||
: false,
|
||||
'comment_bg' => $c->user->comment_bg,
|
||||
'comment_glow' => $c->user->comment_glow,
|
||||
'comment_signature' => $c->user->comment_signature,
|
||||
'username_color' => $c->user->username_color,
|
||||
'username_effect' => $c->user->username_effect,
|
||||
'profile_frame' => $c->user->profile_frame,
|
||||
'profile_badge' => $c->user->profile_badge,
|
||||
'admin_badge' => $c->user->admin_badge,
|
||||
'watch_rank' => $c->user->watchRank(),
|
||||
] : null,
|
||||
];
|
||||
|
||||
if ($withReplies && $c->relationLoaded('replies')) {
|
||||
$data['replies'] = $c->replies->map(fn($r) => $this->formatComment($r, $userId))->toArray();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\AnimeSwipe;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Watchlist;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DiscoverController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$genres = Genre::where('is_active', true)->orderBy('name')->get(['id', 'name', 'slug']);
|
||||
return view('frontend.discover', compact('genres'));
|
||||
}
|
||||
|
||||
public function cards(Request $request)
|
||||
{
|
||||
$genreSlug = $request->input('genre', '');
|
||||
$type = $request->input('type', '');
|
||||
$limit = min((int) $request->input('limit', 10), 10);
|
||||
|
||||
// Auth state'i al
|
||||
$isAuth = auth()->check();
|
||||
$uid = $isAuth ? auth()->id() : null;
|
||||
|
||||
try {
|
||||
$idQuery = Anime::where('is_published', true)->select('id');
|
||||
|
||||
if ($genreSlug) {
|
||||
$idQuery->whereHas('genres', fn($q) => $q->where('slug', $genreSlug));
|
||||
}
|
||||
if ($type) {
|
||||
$idQuery->where('type', $type);
|
||||
}
|
||||
|
||||
if ($isAuth && $uid) {
|
||||
$exclude = AnimeSwipe::where('user_id', $uid)->pluck('anime_id')
|
||||
->merge(Watchlist::where('user_id', $uid)->pluck('anime_id'))
|
||||
->unique();
|
||||
if ($exclude->isNotEmpty()) {
|
||||
$idQuery->whereNotIn('id', $exclude);
|
||||
}
|
||||
}
|
||||
|
||||
$ids = $idQuery->pluck('id');
|
||||
if ($ids->isEmpty()) {
|
||||
return response()->json(['cards' => [], 'has_more' => false]);
|
||||
}
|
||||
|
||||
$randomIds = $ids->shuffle()->take($limit);
|
||||
|
||||
$animes = Anime::whereIn('id', $randomIds)
|
||||
->with('genres:id,name')
|
||||
->get()
|
||||
->shuffle();
|
||||
|
||||
$cards = $animes->map(function (Anime $anime) {
|
||||
$hook = $anime->discovery_hook
|
||||
?: ($anime->description ? Str::limit(strip_tags($anime->description), 130) : null);
|
||||
|
||||
return [
|
||||
'id' => $anime->id,
|
||||
'slug' => $anime->slug,
|
||||
'title' => $anime->title,
|
||||
'cover_url' => $anime->coverUrl,
|
||||
'banner_url' => $anime->bannerUrl,
|
||||
'rating' => $anime->rating ? number_format($anime->rating, 1) : null,
|
||||
'year' => $anime->release_year,
|
||||
'type' => $anime->type,
|
||||
'status' => $anime->status,
|
||||
'episode_count' => $anime->episode_count,
|
||||
'genres' => $anime->genres->take(3)->pluck('name')->values(),
|
||||
'hook' => $hook,
|
||||
'description' => $anime->description ? Str::limit(strip_tags($anime->description), 420) : null,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'cards' => $cards,
|
||||
'has_more' => $animes->count() === $limit,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Discover cards error: ' . $e->getMessage());
|
||||
return response()->json(['cards' => [], 'has_more' => false, 'error' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
public function swipe(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|integer|exists:animes,id',
|
||||
'direction' => 'required|in:like,skip',
|
||||
]);
|
||||
|
||||
if (auth()->check()) {
|
||||
$uid = auth()->id();
|
||||
AnimeSwipe::updateOrCreate(
|
||||
['user_id' => $uid, 'anime_id' => $data['anime_id']],
|
||||
['direction' => $data['direction']]
|
||||
);
|
||||
|
||||
if ($data['direction'] === 'like') {
|
||||
Watchlist::updateOrCreate(
|
||||
['user_id' => $uid, 'anime_id' => $data['anime_id']],
|
||||
['status' => 'plan']
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$seen = session('guest_swipes', []);
|
||||
$seen[] = $data['anime_id'];
|
||||
session(['guest_swipes' => array_unique(array_slice($seen, -150))]);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function reset()
|
||||
{
|
||||
if (auth()->check()) {
|
||||
AnimeSwipe::where('user_id', auth()->id())->delete();
|
||||
} else {
|
||||
session()->forget('guest_swipes');
|
||||
}
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function results(Request $request)
|
||||
{
|
||||
// Beğenilen animeler
|
||||
if (auth()->check()) {
|
||||
$uid = auth()->id();
|
||||
$swipes = AnimeSwipe::where('user_id', $uid)
|
||||
->with('anime:id,title,slug,cover_image,rating,release_year,type')
|
||||
->orderByDesc('created_at')
|
||||
->get()->filter(fn($s) => $s->anime);
|
||||
|
||||
$likedAnimes = $swipes->where('direction', 'like')
|
||||
->map(fn($s) => $s->anime)
|
||||
->values();
|
||||
|
||||
$allSwipedIds = $swipes->pluck('anime_id');
|
||||
$likeCount = $swipes->where('direction', 'like')->count();
|
||||
$skipCount = $swipes->where('direction', 'skip')->count();
|
||||
} else {
|
||||
$seen = session('guest_swipes', []);
|
||||
$likedAnimes = collect();
|
||||
$allSwipedIds= collect($seen);
|
||||
$likeCount = 0;
|
||||
$skipCount = count($seen);
|
||||
}
|
||||
|
||||
// AI önerileri — beğenilen animelerin türlerine benzer, henüz görülmemiş
|
||||
$recommendations = collect();
|
||||
if ($likedAnimes->isNotEmpty()) {
|
||||
$ai = app(DeepSeekService::class);
|
||||
|
||||
// Beğenilen animelerin genre'larını topla
|
||||
$likedWithGenres = Anime::whereIn('id', $likedAnimes->pluck('id'))
|
||||
->with('genres:id,name')
|
||||
->get();
|
||||
$genreIds = $likedWithGenres->flatMap(fn($a) => $a->genres->pluck('id'))->unique();
|
||||
|
||||
// Benzer ama henüz görülmemiş animeler al — ID shuffle ile ORDER BY RAND() önlenir
|
||||
$candidateIds = Anime::where('is_published', true)
|
||||
->whereNotIn('id', $allSwipedIds)
|
||||
->whereHas('genres', fn($q) => $q->whereIn('id', $genreIds))
|
||||
->pluck('id')
|
||||
->shuffle()
|
||||
->take(30);
|
||||
|
||||
$candidateAnimes = Anime::whereIn('id', $candidateIds)
|
||||
->with('genres:id,name')
|
||||
->withCount('episodes')
|
||||
->get()
|
||||
->shuffle();
|
||||
|
||||
if ($ai->isConfigured() && $candidateAnimes->isNotEmpty()) {
|
||||
$likedTitles = $likedAnimes->pluck('title')->take(5)->join(', ');
|
||||
$preferences = "Kullanıcının beğendiği animeler: {$likedTitles}. Bunlara benzer, aynı türde ya da aynı atmosferde animeler öner.";
|
||||
|
||||
$candidateData = $candidateAnimes->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'type' => $a->type,
|
||||
'release_year' => $a->release_year,
|
||||
'rating' => $a->rating,
|
||||
'genres' => $a->genres->map(fn($g) => ['name' => $g->name])->toArray(),
|
||||
])->values()->toArray();
|
||||
|
||||
$aiRecs = Cache::remember(
|
||||
'dsc_recs_' . md5($likedAnimes->pluck('id')->sort()->join(',')),
|
||||
60 * 60 * 6,
|
||||
fn() => $ai->recommend($preferences, $candidateData)
|
||||
);
|
||||
|
||||
if ($aiRecs) {
|
||||
$recIds = collect($aiRecs)->pluck('id')->map('intval');
|
||||
$recAnimes = $candidateAnimes->whereIn('id', $recIds)->keyBy('id');
|
||||
|
||||
$recommendations = collect($aiRecs)->take(6)->map(function ($rec) use ($recAnimes) {
|
||||
$anime = $recAnimes->get((int)$rec['id']);
|
||||
if (!$anime) return null;
|
||||
return [
|
||||
'id' => $anime->id,
|
||||
'slug' => $anime->slug,
|
||||
'title' => $anime->title,
|
||||
'cover_url' => $anime->coverUrl,
|
||||
'rating' => $anime->rating ? number_format($anime->rating, 1) : null,
|
||||
'year' => $anime->release_year,
|
||||
'genres' => $anime->genres->take(2)->pluck('name')->values(),
|
||||
'reason' => $rec['reason'] ?? null,
|
||||
];
|
||||
})->filter()->values();
|
||||
}
|
||||
}
|
||||
|
||||
// AI yoksa genre-based fallback
|
||||
if ($recommendations->isEmpty()) {
|
||||
$recommendations = $candidateAnimes->take(6)->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'slug' => $a->slug,
|
||||
'title' => $a->title,
|
||||
'cover_url' => $a->coverUrl,
|
||||
'rating' => $a->rating ? number_format($a->rating, 1) : null,
|
||||
'year' => $a->release_year,
|
||||
'genres' => $a->genres->take(2)->pluck('name')->values(),
|
||||
'reason' => null,
|
||||
])->values();
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'liked' => $likedAnimes->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'slug' => $a->slug,
|
||||
'title' => $a->title,
|
||||
'cover_url' => $a->coverUrl,
|
||||
])->values(),
|
||||
'recommendations' => $recommendations,
|
||||
'like_count' => $likeCount,
|
||||
'skip_count' => $skipCount,
|
||||
'is_auth' => auth()->check(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\VerifyEmailMail;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class EmailVerificationController extends Controller
|
||||
{
|
||||
public function notice()
|
||||
{
|
||||
if (auth()->user()->email_verified_at) {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
return view('frontend.auth.verify-email');
|
||||
}
|
||||
|
||||
public function verify(Request $request, int $id, string $hash)
|
||||
{
|
||||
$user = \App\Models\User::findOrFail($id);
|
||||
|
||||
if (!hash_equals(sha1($user->email), $hash)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$user->email_verified_at) {
|
||||
$user->email_verified_at = now();
|
||||
$user->save();
|
||||
}
|
||||
|
||||
return redirect()->route('home')->with('status', 'E-posta adresin doğrulandı!');
|
||||
}
|
||||
|
||||
public function resend(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->email_verified_at) {
|
||||
return back()->with('status', 'E-posta zaten doğrulanmış.');
|
||||
}
|
||||
|
||||
$url = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addHours(24),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
);
|
||||
|
||||
Mail::to($user->email)->send(new VerifyEmailMail($url, $user->name));
|
||||
|
||||
return back()->with('status', 'Doğrulama e-postası tekrar gönderildi.');
|
||||
}
|
||||
|
||||
public static function sendVerificationMail(\App\Models\User $user): void
|
||||
{
|
||||
$url = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addHours(24),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
);
|
||||
Mail::to($user->email)->send(new VerifyEmailMail($url, $user->name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Admin\TrendingController;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Banner;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Genre;
|
||||
use App\Models\ContinueWatching;
|
||||
use App\Models\User;
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// ── Cache key: 15 dakikada bir rotasyon ──────────────────────────────
|
||||
$rotationSlot = (int) floor(now()->timestamp / 900); // 15 dk = 900 sn
|
||||
|
||||
// ── Latest + Top Rated (cache'li) ────────────────────────────────────
|
||||
$latest = cache()->remember("home.latest.{$rotationSlot}", 900, fn() =>
|
||||
Anime::where('is_published', true)->latest()->take(20)->get()
|
||||
);
|
||||
|
||||
$topRated = cache()->remember("home.toprated.{$rotationSlot}", 900, fn() =>
|
||||
Anime::where('is_published', true)->where('rating', '>=', 7)
|
||||
->orderByDesc('rating')->take(14)->get()
|
||||
);
|
||||
|
||||
$genres = cache()->remember('home.genres', 3600, fn() =>
|
||||
Genre::where('is_active', true)
|
||||
->withCount(['animes' => fn($q) => $q->where('is_published', true)])
|
||||
->orderByDesc('animes_count')
|
||||
->take(16)->get()
|
||||
);
|
||||
|
||||
$newEpisodes = cache()->remember("home.newepisodes.{$rotationSlot}", 900, fn() =>
|
||||
Episode::with(['anime', 'season'])
|
||||
->where('is_published', true)
|
||||
->latest()->take(14)->get()
|
||||
);
|
||||
|
||||
// ── Trending: YouTube-benzeri skor ───────────────────────────────────
|
||||
$trending = cache()->remember("home.trending.{$rotationSlot}", 900, function () use ($latest) {
|
||||
try {
|
||||
// trending_score kolonu varsa kullan (migration çalıştırıldıysa)
|
||||
$byScore = Anime::where('is_published', true)
|
||||
->where(fn($q) => $q->where('trending_score', '>', 0)->orWhere('is_trending', true))
|
||||
->orderByDesc('trending_score')
|
||||
->take(12)
|
||||
->get();
|
||||
|
||||
if ($byScore->count() >= 6) return $byScore;
|
||||
} catch (\Throwable) {}
|
||||
|
||||
// Fallback: manuel + view_count bazlı
|
||||
$manual = Anime::where('is_trending', true)->where('is_published', true)
|
||||
->orderBy('trending_order')->take(12)->get();
|
||||
if ($manual->count() >= 6) return $manual->take(12);
|
||||
|
||||
$autoFill = Anime::where('is_published', true)
|
||||
->whereNotIn('id', $manual->pluck('id'))
|
||||
->withSum(['episodes as recent_views' => fn($q) =>
|
||||
$q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30))
|
||||
], 'view_count')
|
||||
->orderByDesc('recent_views')
|
||||
->take(12 - $manual->count())->get();
|
||||
|
||||
$merged = $manual->concat($autoFill);
|
||||
return $merged->isEmpty() ? $latest->take(12) : $merged;
|
||||
});
|
||||
|
||||
// Rotasyon: top 8 sabit, son 4 her 15dk'da shuffle
|
||||
$top8 = $trending->take(8)->values();
|
||||
$bottom4 = $trending->slice(8)->shuffle()->values();
|
||||
$trending = $top8->concat($bottom4)->take(12)->values();
|
||||
|
||||
// ── Devam Ediyor (Bu Sezon) ───────────────────────────────────────────
|
||||
$ongoing = cache()->remember("home.ongoing.{$rotationSlot}", 900, fn() =>
|
||||
Anime::where('is_published', true)->where('status', 'ongoing')
|
||||
->orderByDesc('rating')->take(12)->get()
|
||||
);
|
||||
|
||||
// ── Popüler Filmler ───────────────────────────────────────────────────
|
||||
$popularMovies = cache()->remember("home.movies.{$rotationSlot}", 900, fn() =>
|
||||
Anime::where('is_published', true)->where('type', 'movie')
|
||||
->where('rating', '>=', 6)->orderByDesc('rating')->take(12)->get()
|
||||
);
|
||||
|
||||
// ── Türkçe Dublaj ─────────────────────────────────────────────────────
|
||||
$dubbed = cache()->remember("home.dubbed.{$rotationSlot}", 900, fn() =>
|
||||
Anime::where('is_published', true)->where('is_dubbed', true)
|
||||
->orderByDesc('rating')->take(20)->get()
|
||||
);
|
||||
|
||||
// ── Tür Spotlight (2 farklı tür, her birinde top 8 anime) ────────────
|
||||
$genreSpotlights = cache()->remember("home.genre_spots.{$rotationSlot}", 900, function () {
|
||||
$spotGenres = Genre::where('is_active', true)
|
||||
->whereIn('name', ['Aksiyon', 'Fantezi', 'Romantik', 'Psikolojik', 'Komedi', 'Spor', 'Macera', 'Drama'])
|
||||
->inRandomOrder()->take(3)->get();
|
||||
|
||||
return $spotGenres->map(fn($g) => [
|
||||
'genre' => $g,
|
||||
'animes' => $g->animes()
|
||||
->where('is_published', true)
|
||||
->where('rating', '>=', 6)
|
||||
->orderByDesc('rating')
|
||||
->take(8)->get(),
|
||||
])->filter(fn($s) => $s['animes']->count() >= 3)->values();
|
||||
});
|
||||
|
||||
// ── Featured Hero Slider: YouTube-benzeri trending algoritması ─────────
|
||||
$featured = cache()->remember("home.featured.{$rotationSlot}", 900, function () use ($trending, $topRated, $latest) {
|
||||
// trending_score kolonu var mı? (migration çalıştırılmamışsa fallback)
|
||||
$hasTrendingScore = \Illuminate\Support\Facades\Schema::hasColumn('animes', 'trending_score');
|
||||
|
||||
$orderBy = fn($q) => $hasTrendingScore
|
||||
? $q->orderByDesc('trending_score')
|
||||
: $q->orderByDesc('rating');
|
||||
|
||||
$used = collect();
|
||||
|
||||
// TIER 1: Son 24 saatte yeni bölüm + trend skoru yüksek + banner
|
||||
try {
|
||||
$tier1Ids = Episode::where('is_published', true)
|
||||
->where('created_at', '>=', now()->subDay())
|
||||
->pluck('anime_id')->unique()->toArray();
|
||||
|
||||
$tier1 = $orderBy(Anime::where('is_published', true)
|
||||
->whereIn('id', $tier1Ids)
|
||||
->whereNotNull('banner_image'))
|
||||
->take(6)->get();
|
||||
$used = $used->concat($tier1->pluck('id'));
|
||||
} catch (\Throwable) {
|
||||
$tier1 = collect();
|
||||
}
|
||||
|
||||
// TIER 2: Son 3 günde yeni bölüm + banner
|
||||
$tier2 = collect();
|
||||
if ($tier1->count() < 6) {
|
||||
try {
|
||||
$tier2Ids = Episode::where('is_published', true)
|
||||
->where('created_at', '>=', now()->subDays(3))
|
||||
->pluck('anime_id')->unique()->diff($used)->toArray();
|
||||
|
||||
$tier2 = $orderBy(Anime::where('is_published', true)
|
||||
->whereIn('id', $tier2Ids)
|
||||
->whereNotNull('banner_image'))
|
||||
->take(6 - $tier1->count())->get();
|
||||
$used = $used->concat($tier2->pluck('id'));
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
$combined = $tier1->concat($tier2);
|
||||
|
||||
// TIER 3: Yüksek skor + banner
|
||||
if ($combined->count() < 6) {
|
||||
try {
|
||||
$tier3 = $orderBy(Anime::where('is_published', true)
|
||||
->whereNotIn('id', $used->toArray())
|
||||
->whereNotNull('banner_image'))
|
||||
->take(6 - $combined->count())->get();
|
||||
$used = $used->concat($tier3->pluck('id'));
|
||||
$combined = $combined->concat($tier3);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
// TIER 4: Trending + topRated (banner olmadan)
|
||||
if ($combined->count() < 5) {
|
||||
$fill = $trending->whereNotIn('id', $used->toArray())->take(5 - $combined->count());
|
||||
$combined = $combined->concat($fill);
|
||||
}
|
||||
if ($combined->count() < 5) {
|
||||
$fill2 = $topRated->whereNotIn('id', $combined->pluck('id'))->take(5 - $combined->count());
|
||||
$combined = $combined->concat($fill2);
|
||||
}
|
||||
|
||||
return $combined->isEmpty() ? $latest->take(5)->values() : $combined->values();
|
||||
});
|
||||
|
||||
$featured->load('genres', 'seasons', 'episodes');
|
||||
|
||||
// ── Hero slider JSON ──────────────────────────────────────────────────
|
||||
$statusLabel = ['ongoing' => 'Devam Ediyor', 'completed' => 'Tamamlandı', 'upcoming' => 'Yakında'];
|
||||
$featuredSlider = $featured->values()->map(function ($a) use ($statusLabel) {
|
||||
$firstSeason = $a->seasons->sortBy('season_number')->first();
|
||||
$firstEp = $firstSeason
|
||||
? $a->episodes->where('season_id', $firstSeason->id)->where('is_published', true)->sortBy('episode_number')->first()
|
||||
: null;
|
||||
return [
|
||||
'title' => $a->title,
|
||||
'description' => $a->description,
|
||||
'rating' => $a->rating,
|
||||
'year' => $a->release_year,
|
||||
'episodes' => $a->episode_count,
|
||||
'status' => $a->status,
|
||||
'statusLabel' => $statusLabel[$a->status] ?? $a->status,
|
||||
'slug' => $a->slug,
|
||||
'genres' => $a->genres->pluck('name')->values(),
|
||||
'coverUrl' => $a->coverUrl,
|
||||
'bannerUrl' => $a->bannerUrl,
|
||||
'studio' => $a->studio,
|
||||
'watchUrl' => ($firstSeason && $firstEp)
|
||||
? route('watch', [$a->slug, $firstSeason->season_number, $firstEp->episode_number])
|
||||
: null,
|
||||
'detailUrl' => route('anime.show', $a->slug),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
// ── Devam Et (auth) ───────────────────────────────────────────────────
|
||||
$continueWatching = collect();
|
||||
$recommended = collect();
|
||||
$userWatchTitles = '';
|
||||
|
||||
if (auth()->check()) {
|
||||
try {
|
||||
$continueWatching = ContinueWatching::where('user_id', auth()->id())
|
||||
->with('anime:id,title,slug,cover_image')
|
||||
->where('percent_complete', '>=', 5)
|
||||
->where('percent_complete', '<', 95)
|
||||
->orderByDesc('updated_at')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
$watchedIds = ContinueWatching::where('user_id', auth()->id())->pluck('anime_id');
|
||||
|
||||
if ($watchedIds->isNotEmpty()) {
|
||||
$topGenreIds = DB::table('anime_genre')
|
||||
->whereIn('anime_id', $watchedIds)
|
||||
->select('genre_id', DB::raw('count(*) as cnt'))
|
||||
->groupBy('genre_id')
|
||||
->orderByDesc('cnt')
|
||||
->limit(5)
|
||||
->pluck('genre_id');
|
||||
|
||||
if ($topGenreIds->isNotEmpty()) {
|
||||
$recommended = Anime::where('is_published', true)
|
||||
->whereNotIn('id', $watchedIds)
|
||||
->whereHas('genres', fn($q) => $q->whereIn('genres.id', $topGenreIds))
|
||||
->inRandomOrder()
|
||||
->take(14)
|
||||
->get();
|
||||
|
||||
// Yeterli değilse rating'e göre topRated'dan dolduralım
|
||||
if ($recommended->count() < 6) {
|
||||
$fallback = Anime::where('is_published', true)
|
||||
->whereNotIn('id', $watchedIds->merge($recommended->pluck('id')))
|
||||
->where('rating', '>=', 6)
|
||||
->inRandomOrder()
|
||||
->take(14 - $recommended->count())
|
||||
->get();
|
||||
$recommended = $recommended->concat($fallback)->shuffle()->values();
|
||||
}
|
||||
}
|
||||
|
||||
$userWatchTitles = Anime::whereIn('id', $watchedIds->take(8))
|
||||
->pluck('title')->join(', ');
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────────
|
||||
$statsAnime = cache()->remember('home.stats.anime', 3600, fn() => Anime::where('is_published', true)->count());
|
||||
$statsEpisode = cache()->remember('home.stats.episode', 3600, fn() => Episode::where('is_published', true)->count());
|
||||
$statsUser = cache()->remember('home.stats.user', 3600, fn() => User::count());
|
||||
$statsGenre = cache()->remember('home.stats.genre', 3600, fn() => Genre::where('is_active', true)->count());
|
||||
|
||||
$apkUrl = \App\Models\Setting::get('mobile_apk_url', '');
|
||||
|
||||
// ── Banner reklamlar (premium görmez) ────────────────────────────────
|
||||
$bannerAds = ['home_mid' => null, 'home_bottom' => null];
|
||||
if (\App\Models\Setting::get('banner_ads_enabled', '0') === '1'
|
||||
&& !(auth()->check() && auth()->user()->isPremium())) {
|
||||
try {
|
||||
$bannerAds['home_mid'] = \App\Models\Ad::pickBanner('home_mid');
|
||||
$bannerAds['home_bottom'] = \App\Models\Ad::pickBanner('home_bottom');
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
return view('frontend.home', compact(
|
||||
'featured', 'featuredSlider', 'latest', 'topRated', 'genres',
|
||||
'newEpisodes', 'trending', 'continueWatching', 'recommended', 'userWatchTitles',
|
||||
'statsAnime', 'statsEpisode', 'statsUser', 'statsGenre',
|
||||
'ongoing', 'popularMovies', 'genreSpotlights', 'dubbed', 'apkUrl', 'bannerAds'
|
||||
));
|
||||
}
|
||||
|
||||
public function search()
|
||||
{
|
||||
$q = request('q', '');
|
||||
$genre = request('genre');
|
||||
$type = request('type');
|
||||
$status = request('status');
|
||||
$year = request('year');
|
||||
$sort = request('sort', 'popular');
|
||||
|
||||
$query = Anime::where('is_published', true)
|
||||
->whereNotNull('slug')
|
||||
->where('slug', '!=', '');
|
||||
|
||||
if ($q) {
|
||||
$query->where(function ($qb) use ($q) {
|
||||
$qb->where('title', 'like', "%$q%")
|
||||
->orWhere('title_en', 'like', "%$q%")
|
||||
->orWhere('title_jp', 'like', "%$q%");
|
||||
});
|
||||
}
|
||||
if ($genre) {
|
||||
$query->whereHas('genres', fn($qb) => $qb->where('slug', $genre));
|
||||
}
|
||||
if ($type) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
if ($status) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
if ($year) {
|
||||
$query->where('release_year', $year);
|
||||
}
|
||||
|
||||
// JSON autocomplete modu
|
||||
if (request()->boolean('json') || request()->expectsJson()) {
|
||||
$animes = $query->select('id', 'title', 'title_en', 'cover_image', 'type')
|
||||
->latest()->limit(8)->get()
|
||||
->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'cover' => $a->coverUrl,
|
||||
'type' => $a->type,
|
||||
]);
|
||||
return response()->json(['animes' => $animes]);
|
||||
}
|
||||
|
||||
// ── Sıralama ──────────────────────────────────────────────────────────
|
||||
switch ($sort) {
|
||||
case 'popular':
|
||||
$query->withSum(['episodes as total_views' => fn($q) =>
|
||||
$q->where('is_published', true)
|
||||
], 'view_count')->orderByDesc('total_views');
|
||||
break;
|
||||
|
||||
case 'rating':
|
||||
$query->orderByDesc('rating')->orderByDesc('created_at');
|
||||
break;
|
||||
|
||||
case 'newest':
|
||||
$query->orderByDesc('release_year')->orderByDesc('created_at');
|
||||
break;
|
||||
|
||||
case 'oldest':
|
||||
$query->orderBy('release_year')->orderBy('created_at');
|
||||
break;
|
||||
|
||||
case 'az':
|
||||
$query->orderBy('title');
|
||||
break;
|
||||
|
||||
case 'za':
|
||||
$query->orderByDesc('title');
|
||||
break;
|
||||
|
||||
case 'personalized':
|
||||
if (auth()->check()) {
|
||||
$watchedIds = \App\Models\ContinueWatching::where('user_id', auth()->id())
|
||||
->pluck('anime_id');
|
||||
|
||||
$topGenreIds = $watchedIds->isNotEmpty()
|
||||
? DB::table('anime_genre')
|
||||
->whereIn('anime_id', $watchedIds)
|
||||
->select('genre_id', DB::raw('count(*) as cnt'))
|
||||
->groupBy('genre_id')
|
||||
->orderByDesc('cnt')
|
||||
->limit(6)
|
||||
->pluck('genre_id')
|
||||
: collect();
|
||||
|
||||
if ($topGenreIds->isNotEmpty()) {
|
||||
$matchingIds = DB::table('anime_genre')
|
||||
->whereIn('genre_id', $topGenreIds)
|
||||
->pluck('anime_id')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$idList = $matchingIds->isEmpty() ? '0' : $matchingIds->join(',');
|
||||
$query->orderByRaw("CASE WHEN animes.id IN ($idList) THEN 0 ELSE 1 END")
|
||||
->orderByDesc('rating');
|
||||
} else {
|
||||
$query->orderByDesc('rating');
|
||||
}
|
||||
} else {
|
||||
$query->orderByDesc('rating');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$query->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
$results = $query->paginate(24)->withQueryString();
|
||||
$genres = Genre::where('is_active', true)->get();
|
||||
$years = Anime::where('is_published', true)->whereNotNull('release_year')
|
||||
->distinct()->orderByDesc('release_year')->pluck('release_year');
|
||||
|
||||
return view('frontend.search', compact(
|
||||
'results', 'genres', 'years', 'q', 'genre', 'type', 'status', 'year', 'sort'
|
||||
));
|
||||
}
|
||||
|
||||
public function searchSuggest()
|
||||
{
|
||||
$q = trim(request('q', ''));
|
||||
if (strlen($q) < 2) {
|
||||
return response()->json(['results' => []]);
|
||||
}
|
||||
$animes = Anime::where('is_published', true)
|
||||
->where(function ($qb) use ($q) {
|
||||
$qb->where('title', 'like', "%$q%")
|
||||
->orWhere('title_en', 'like', "%$q%")
|
||||
->orWhere('title_jp', 'like', "%$q%");
|
||||
})
|
||||
->select('id', 'title', 'title_en', 'slug', 'cover_image', 'type', 'release_year', 'episode_count')
|
||||
->orderByRaw("CASE WHEN title LIKE ? THEN 0 ELSE 1 END, title ASC", ["$q%"])
|
||||
->limit(7)
|
||||
->get()
|
||||
->map(fn($a) => [
|
||||
'title' => $a->title,
|
||||
'title_en' => $a->title_en,
|
||||
'slug' => $a->slug,
|
||||
'cover' => $a->coverUrl,
|
||||
'type' => $a->type,
|
||||
'year' => $a->release_year,
|
||||
'episodes' => $a->episode_count,
|
||||
]);
|
||||
|
||||
return response()->json(['results' => $animes]);
|
||||
}
|
||||
|
||||
public function genre(Genre $genre)
|
||||
{
|
||||
$animes = $genre->animes()->where('is_published', true)->latest()->paginate(24);
|
||||
return view('frontend.genre', compact('genre', 'animes'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MessageController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
try {
|
||||
$conversations = $user->conversations()
|
||||
->with(['participants', 'lastMessage.user'])
|
||||
->orderByDesc('conversations.updated_at')
|
||||
->get()
|
||||
->map(function ($conv) use ($user) {
|
||||
$other = $conv->participants->firstWhere('id', '!=', $user->id);
|
||||
return [
|
||||
'id' => $conv->id,
|
||||
'other' => $other,
|
||||
'last_message' => $conv->lastMessage,
|
||||
'unread' => $conv->unreadCountFor($user->id),
|
||||
'updated_at' => $conv->updated_at,
|
||||
];
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$conversations = collect();
|
||||
}
|
||||
|
||||
return view('frontend.messages.index', compact('conversations'));
|
||||
}
|
||||
|
||||
public function show(Conversation $conversation)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
abort_unless(
|
||||
$conversation->participants()->where('user_id', $user->id)->exists(),
|
||||
403
|
||||
);
|
||||
|
||||
$other = $conversation->participants()->where('user_id', '!=', $user->id)->first();
|
||||
|
||||
$messages = $conversation->messages()
|
||||
->with('user')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
// Mark as read
|
||||
$conversation->participants()
|
||||
->updateExistingPivot($user->id, ['last_read_at' => now()]);
|
||||
|
||||
return view('frontend.messages.show', compact('conversation', 'messages', 'other'));
|
||||
}
|
||||
|
||||
public function startOrOpen(User $user)
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
if ($me->id === $user->id) abort(422);
|
||||
|
||||
// Find existing conversation between these two users
|
||||
$conv = Conversation::whereHas('participants', fn($q) => $q->where('user_id', $me->id))
|
||||
->whereHas('participants', fn($q) => $q->where('user_id', $user->id))
|
||||
->first();
|
||||
|
||||
if (!$conv) {
|
||||
$conv = DB::transaction(function () use ($me, $user) {
|
||||
$c = Conversation::create();
|
||||
$c->participants()->attach([$me->id, $user->id]);
|
||||
return $c;
|
||||
});
|
||||
}
|
||||
|
||||
return redirect()->route('messages.show', $conv);
|
||||
}
|
||||
|
||||
public function send(Request $request, Conversation $conversation)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
abort_unless(
|
||||
$conversation->participants()->where('user_id', $user->id)->exists(),
|
||||
403
|
||||
);
|
||||
|
||||
$request->validate(['body' => 'required|string|max:5000']);
|
||||
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conversation->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => $request->body,
|
||||
]);
|
||||
|
||||
$conversation->touch();
|
||||
|
||||
// Mark sender as read
|
||||
$conversation->participants()
|
||||
->updateExistingPivot($user->id, ['last_read_at' => now()]);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'id' => $message->id,
|
||||
'body' => $message->body,
|
||||
'user_id' => $user->id,
|
||||
'created_at' => $message->created_at->format('H:i'),
|
||||
'avatar' => $user->avatar ? \App\Support\MediaUrl::fromStoragePath($user->avatar) : null,
|
||||
'name' => $user->name,
|
||||
]);
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function poll(Request $request, Conversation $conversation)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
abort_unless(
|
||||
$conversation->participants()->where('user_id', $user->id)->exists(),
|
||||
403
|
||||
);
|
||||
|
||||
$after = $request->query('after', 0);
|
||||
|
||||
$messages = $conversation->messages()
|
||||
->with('user')
|
||||
->where('id', '>', $after)
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(fn($m) => [
|
||||
'id' => $m->id,
|
||||
'body' => $m->body,
|
||||
'user_id' => $m->user_id,
|
||||
'created_at' => $m->created_at->format('H:i'),
|
||||
'avatar' => $m->user->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null,
|
||||
'name' => $m->user->name,
|
||||
]);
|
||||
|
||||
// Update last_read
|
||||
$conversation->participants()
|
||||
->updateExistingPivot($user->id, ['last_read_at' => now()]);
|
||||
|
||||
return response()->json(['messages' => $messages]);
|
||||
}
|
||||
|
||||
public function unreadCount()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) return response()->json(['count' => 0]);
|
||||
|
||||
$count = 0;
|
||||
foreach ($user->conversations()->with(['messages'])->get() as $conv) {
|
||||
$count += $conv->unreadCountFor($user->id);
|
||||
}
|
||||
|
||||
return response()->json(['count' => $count]);
|
||||
}
|
||||
|
||||
public function conversationsJson()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$convs = $user->conversations()
|
||||
->with(['participants', 'lastMessage.user'])
|
||||
->orderByDesc('conversations.updated_at')
|
||||
->limit(30)
|
||||
->get()
|
||||
->map(function ($conv) use ($user) {
|
||||
$other = $conv->participants->firstWhere('id', '!=', $user->id);
|
||||
$last = $conv->lastMessage;
|
||||
$unread = $conv->unreadCountFor($user->id);
|
||||
|
||||
$preview = null;
|
||||
if ($last) {
|
||||
if (str_starts_with($last->body, 'ANIMESHARE::')) {
|
||||
try { $sd = json_decode(substr($last->body, 12), true); $preview = '🎬 ' . ($sd['title'] ?? 'Anime paylaştı'); } catch(\Throwable) {}
|
||||
} elseif (str_starts_with($last->body, 'IMAGE::')) {
|
||||
$preview = ($last->user_id === $user->id ? 'Sen: ' : '') . '📷 Fotoğraf';
|
||||
} elseif (str_starts_with($last->body, 'GIF::')) {
|
||||
$preview = ($last->user_id === $user->id ? 'Sen: ' : '') . '🎞 GIF';
|
||||
} else {
|
||||
$isMine = $last->user_id === $user->id;
|
||||
$preview = ($isMine ? 'Sen: ' : '') . \Illuminate\Support\Str::limit($last->body, 50);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'conv_id' => $conv->id,
|
||||
'id' => $other?->id,
|
||||
'name' => $other?->name ?? 'Silinmiş',
|
||||
'avatar' => $other?->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null,
|
||||
'last_preview' => $preview,
|
||||
'unread' => $unread,
|
||||
'time' => $conv->updated_at ? $conv->updated_at->diffForHumans(null, true) : null,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($convs);
|
||||
}
|
||||
|
||||
public function uploadImage(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'image' => 'required|file|image|max:8192|mimes:jpeg,jpg,png,gif,webp',
|
||||
]);
|
||||
|
||||
$path = $request->file('image')->store('chat-images', 'public');
|
||||
$url = Storage::disk('public')->url($path);
|
||||
|
||||
return response()->json(['url' => $url]);
|
||||
}
|
||||
|
||||
public function quickShare(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'to_user_id' => 'required|integer|exists:users,id',
|
||||
'body' => 'required|string|max:3000',
|
||||
]);
|
||||
|
||||
$me = Auth::user();
|
||||
$target = User::findOrFail($request->to_user_id);
|
||||
|
||||
if ($me->id === $target->id) abort(422, 'Kendinize gönderemezsiniz.');
|
||||
|
||||
$conv = Conversation::whereHas('participants', fn($q) => $q->where('user_id', $me->id))
|
||||
->whereHas('participants', fn($q) => $q->where('user_id', $target->id))
|
||||
->first();
|
||||
|
||||
if (!$conv) {
|
||||
$conv = DB::transaction(function () use ($me, $target) {
|
||||
$c = Conversation::create();
|
||||
$c->participants()->attach([$me->id, $target->id]);
|
||||
return $c;
|
||||
});
|
||||
}
|
||||
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'user_id' => $me->id,
|
||||
'body' => $request->body,
|
||||
]);
|
||||
|
||||
$conv->touch();
|
||||
$conv->participants()->updateExistingPivot($me->id, ['last_read_at' => now()]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'conversation_id' => $conv->id,
|
||||
'message_id' => $message->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
|
||||
class PasswordResetController extends Controller
|
||||
{
|
||||
public function showForgot()
|
||||
{
|
||||
return view('frontend.auth.forgot-password');
|
||||
}
|
||||
|
||||
public function sendResetLink(Request $request)
|
||||
{
|
||||
$request->validate(['email' => 'required|email'], [
|
||||
'email.required' => 'E-posta zorunludur.',
|
||||
'email.email' => 'Geçerli bir e-posta girin.',
|
||||
]);
|
||||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
// Kullanıcı bulunamasa bile aynı mesajı göster (güvenlik)
|
||||
if ($user) {
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email'),
|
||||
function (User $user, string $token) {
|
||||
$url = url(route('password.reset', ['token' => $token, 'email' => $user->email], false));
|
||||
Mail::to($user->email)->send(new ResetPasswordMail($url, $user->name));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return back()->with('status', 'Eğer bu e-posta adresine kayıtlı bir hesap varsa şifre sıfırlama bağlantısı gönderildi.');
|
||||
}
|
||||
|
||||
public function showReset(Request $request, string $token)
|
||||
{
|
||||
return view('frontend.auth.reset-password', [
|
||||
'token' => $token,
|
||||
'email' => $request->query('email', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reset(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'token' => 'required',
|
||||
'email' => 'required|email',
|
||||
'password' => ['required', 'confirmed', PasswordRule::min(6)],
|
||||
], [
|
||||
'password.required' => 'Şifre zorunludur.',
|
||||
'password.confirmed' => 'Şifreler eşleşmiyor.',
|
||||
'password.min' => 'Şifre en az 6 karakter olmalıdır.',
|
||||
]);
|
||||
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user, string $password) {
|
||||
$user->forceFill(['password' => Hash::make($password)])->save();
|
||||
}
|
||||
);
|
||||
|
||||
if ($status === Password::PASSWORD_RESET) {
|
||||
return redirect()->route('frontend.login')
|
||||
->with('status', 'Şifreniz başarıyla sıfırlandı. Giriş yapabilirsiniz.');
|
||||
}
|
||||
|
||||
return back()->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Season;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Comment;
|
||||
use App\Services\BunnyCdnSigner;
|
||||
use App\Services\AniSkipService;
|
||||
|
||||
class PlayerController extends Controller
|
||||
{
|
||||
// Public wrapper so API controller can call it
|
||||
public static function resolveDubSourcesPublic(?string $m3u8Url, ?array $availableDubs = null): array
|
||||
{
|
||||
return self::resolveDubSourcesFromM3u8($m3u8Url, $availableDubs);
|
||||
}
|
||||
|
||||
public function watch(Anime $anime, int $season, int $episode)
|
||||
{
|
||||
abort_unless($anime->is_published, 404);
|
||||
|
||||
$seasonModel = Season::where('anime_id', $anime->id)
|
||||
->where('season_number', $season)
|
||||
->firstOrFail();
|
||||
|
||||
$ep = Episode::where('season_id', $seasonModel->id)
|
||||
->where('episode_number', $episode)
|
||||
->where('is_published', true)
|
||||
->firstOrFail();
|
||||
|
||||
$ep->increment('view_count');
|
||||
$ep->load('subtitles');
|
||||
|
||||
// Tüm sezonlar + bölümler (playlist için) + türler (bilgi paneli)
|
||||
$anime->load([
|
||||
'seasons' => fn($q) => $q->orderBy('season_number'),
|
||||
'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number'),
|
||||
'genres',
|
||||
]);
|
||||
|
||||
// Önceki / sonraki bölüm
|
||||
$prev = Episode::with('season')
|
||||
->where('season_id', $seasonModel->id)
|
||||
->where('episode_number', $episode - 1)
|
||||
->where('is_published', true)
|
||||
->first();
|
||||
|
||||
$next = Episode::with('season')
|
||||
->where('season_id', $seasonModel->id)
|
||||
->where('episode_number', $episode + 1)
|
||||
->where('is_published', true)
|
||||
->first();
|
||||
|
||||
// Sonraki bölüm yoksa bir sonraki sezona geç
|
||||
if (!$next) {
|
||||
$nextSeason = Season::where('anime_id', $anime->id)
|
||||
->where('season_number', $season + 1)
|
||||
->first();
|
||||
if ($nextSeason) {
|
||||
$next = Episode::where('season_id', $nextSeason->id)
|
||||
->where('episode_number', 1)
|
||||
->where('is_published', true)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
// JSON-safe subtitle data (proxy URL for CORS bypass)
|
||||
$subtitlesData = $ep->subtitles->map(fn($s) => [
|
||||
'label' => $s->label,
|
||||
'lang' => $s->language,
|
||||
'url' => route('vtt.proxy', ['url' => $s->url]),
|
||||
'is_default' => (bool) $s->is_default,
|
||||
])->values()->toArray();
|
||||
|
||||
// Dublaj kaynakları — CDN’deki .../{720p|1080p}-{dub}[/master.m3u8] kalıbından türet
|
||||
// Embed modda URL video_url’de olabilir (m3u8_url null) — video_url’e fallback
|
||||
$raw = $ep->available_dubs;
|
||||
$availableDubs = is_array($raw) ? $raw : null; // null = unknown (show all), array = restrict to listed
|
||||
$sourceForDubs = $ep->m3u8_url ?: $ep->video_url;
|
||||
[$dubSources, $activeDub] = self::resolveDubSourcesFromM3u8($sourceForDubs, $availableDubs);
|
||||
|
||||
// Tüm video URL’lerini imzala (BunnyCDN Token Auth)
|
||||
BunnyCdnSigner::signAll($dubSources);
|
||||
$ep->m3u8_url = BunnyCdnSigner::sign($ep->m3u8_url);
|
||||
$ep->video_url = BunnyCdnSigner::sign($ep->video_url);
|
||||
|
||||
// Proxy all external HLS streams through our server (CORS + SSL bypass).
|
||||
// anizium.co + aniziumserver.* CDN'leri doğrudan yükle (tarayıcı üzerinden).
|
||||
$isOwn = fn(?string $u) => !$u || str_contains($u, 'b-cdn.net') || str_contains($u, 'animexe.com')
|
||||
|| str_contains($u, 'anizium.co') || str_contains($u, 'aniziumserver.sbs');
|
||||
if ($ep->m3u8_url && !$isOwn($ep->m3u8_url)) {
|
||||
$ep->m3u8_url = route("stream.proxy", ["u" => base64_encode((string) $ep->m3u8_url)]);
|
||||
}
|
||||
// video_url — embed modda anizium HLS URL olabilir; HLS ise proxy'den geçir, MP4 ise bırak.
|
||||
if ($ep->video_url && !$isOwn($ep->video_url)) {
|
||||
if (str_ends_with((string) $ep->video_url, '.m3u8')) {
|
||||
$ep->video_url = route("stream.proxy", ["u" => base64_encode((string) $ep->video_url)]);
|
||||
}
|
||||
}
|
||||
foreach (array_keys($dubSources) as $idx) {
|
||||
$dubUrl = (string) ($dubSources[$idx]["url"] ?? "");
|
||||
if ($dubUrl && !$isOwn($dubUrl)) {
|
||||
$dubSources[$idx]["url"] = route("stream.proxy", ["u" => base64_encode($dubUrl)]);
|
||||
}
|
||||
}
|
||||
|
||||
// Video sources — Anizium 1080p → 720p → 4K/diğer, sonra AnimeCix
|
||||
$videoSourcesData = \App\Models\VideoSource::where('episode_id', $ep->id)
|
||||
->orderBy('sort_order')
|
||||
->get(['id', 'label', 'url', 'type', 'quality', 'translator_id', 'is_default', 'source', 'sort_order', 'is_hevc'])
|
||||
->groupBy(fn($vs) => $vs->translator_id ?: $vs->label)
|
||||
->map(function ($group) {
|
||||
$default = $group->firstWhere('is_default', true) ?? $group->first();
|
||||
return [
|
||||
'id' => $default->id,
|
||||
'key' => $default->translator_id ?: \Illuminate\Support\Str::slug($default->label),
|
||||
'label' => $default->label ?: 'Kaynak',
|
||||
'url' => $default->url,
|
||||
'type' => $default->type ?? 'hls',
|
||||
'source' => $default->source ?? 'animecix',
|
||||
'quality' => $default->quality ?? '',
|
||||
'sort_order' => $default->sort_order ?? 99,
|
||||
'is_hevc' => (bool) $default->is_hevc,
|
||||
];
|
||||
})
|
||||
->sortBy(function ($item) {
|
||||
$isAnizium = ($item['source'] === 'anizium');
|
||||
$q = strtolower($item['quality'] ?? '');
|
||||
if ($isAnizium) {
|
||||
if (str_contains($q, '1080')) return 0;
|
||||
if (str_contains($q, '720')) return 1;
|
||||
return 1000; // 4K / H.265 / diğer → en sona
|
||||
}
|
||||
return 10 + ($item['sort_order'] ?? 99); // AnimeCix
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
// Sonraki bölüm URL'si
|
||||
$nextUrl = null;
|
||||
if ($next) {
|
||||
$nextSeasonNum = $next->season?->season_number ?? $seasonModel->season_number;
|
||||
if (!$next->season) {
|
||||
$nextSeason2 = Season::find($next->season_id);
|
||||
$nextSeasonNum = $nextSeason2?->season_number ?? $seasonModel->season_number;
|
||||
}
|
||||
$nextUrl = route('watch', [$anime->slug, $nextSeasonNum, $next->episode_number]);
|
||||
}
|
||||
|
||||
// Intro video ayarları
|
||||
$introUrl = Setting::get('intro_enabled') == '1' ? (Setting::get('intro_video_url') ?: null) : null;
|
||||
$introSkipAfter = (int) Setting::get('intro_skip_after', 5);
|
||||
$mainVideoSkipSec = (int) Setting::get('main_video_skip_seconds', 10);
|
||||
$wmCoverSeconds = (int) Setting::get('watermark_cover_seconds', 11);
|
||||
|
||||
// İntro atla: önce bölüme elle girilmiş zamanlar, yoksa AniSkip API
|
||||
$aniSkip = null;
|
||||
|
||||
if ($ep->intro_start !== null && $ep->intro_end !== null && $ep->intro_end > $ep->intro_start) {
|
||||
// Manuel giriş — en güvenilir
|
||||
$aniSkip = ['op' => ['start' => (float)$ep->intro_start, 'end' => (float)$ep->intro_end]];
|
||||
} else {
|
||||
$seasonMalId = $seasonModel->mal_id;
|
||||
|
||||
// season.mal_id yoksa akıllı fallback (Jikan'a gitme, bloke olur)
|
||||
if (!$seasonMalId && $anime->mal_id) {
|
||||
// Sezon 1 için anime.mal_id direkt kullanılabilir
|
||||
// Diğer sezonlar için background job yerine cache'li Jikan
|
||||
if ($seasonModel->season_number === 1) {
|
||||
$seasonMalId = $anime->mal_id;
|
||||
$seasonModel->update(['mal_id' => $seasonMalId]);
|
||||
} else {
|
||||
// Sequel chain'i sadece cache'li olarak dene (timeout kısa, bloke etmez)
|
||||
try {
|
||||
$cacheKey = "jikan_chain_{$anime->mal_id}";
|
||||
$chain = \Illuminate\Support\Facades\Cache::get($cacheKey);
|
||||
if (!$chain) {
|
||||
// Cache yoksa arka planda doldur, bu istek için atla
|
||||
dispatch(function () use ($anime) {
|
||||
$chain = (new \App\Services\JikanService())->fetchSeasonMalIds($anime->mal_id);
|
||||
if ($chain) {
|
||||
\Illuminate\Support\Facades\Cache::put("jikan_chain_{$anime->mal_id}", $chain, 60 * 60 * 24 * 7);
|
||||
foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $s) {
|
||||
if (!$s->mal_id && isset($chain[$i])) $s->update(['mal_id' => $chain[$i]]);
|
||||
}
|
||||
}
|
||||
})->afterResponse();
|
||||
} else {
|
||||
$idx = $seasonModel->season_number - 1;
|
||||
$seasonMalId = $chain[$idx] ?? $chain[0] ?? null;
|
||||
if ($seasonMalId) $seasonModel->update(['mal_id' => $seasonMalId]);
|
||||
}
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
}
|
||||
|
||||
// anime.mal_id de yoksa Jikan title search (sadece bir kez, cache'lenir)
|
||||
if (!$seasonMalId && !$anime->mal_id) {
|
||||
try {
|
||||
$found = (new AniSkipService())->searchByTitle($anime->title, $anime->title_en, $anime->title_jp);
|
||||
if ($found) {
|
||||
$anime->update(['mal_id' => $found]);
|
||||
$seasonMalId = $found;
|
||||
if ($seasonModel->season_number === 1) $seasonModel->update(['mal_id' => $found]);
|
||||
}
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
if ($seasonMalId) {
|
||||
try {
|
||||
$aniSkip = (new AniSkipService())->getSkipTimes((string)$seasonMalId, $ep->episode_number);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
}
|
||||
|
||||
// İzleme ilerlemeleri (sidebar progress bar için)
|
||||
$watchProgress = [];
|
||||
if (auth()->check()) {
|
||||
$progRows = \App\Models\ContinueWatching::where('user_id', auth()->id())
|
||||
->where('anime_id', $anime->id)
|
||||
->get(['episode_id', 'percent_complete']);
|
||||
foreach ($progRows as $row) {
|
||||
$watchProgress[$row->episode_id] = (int) $row->percent_complete;
|
||||
}
|
||||
}
|
||||
|
||||
// Premium kullanıcı HİÇBİR reklam görmez (hem eski VAST hem yeni MP4 sistemi)
|
||||
$isPremiumUser = auth()->check() && auth()->user()->isPremium();
|
||||
|
||||
$adsConfig = [
|
||||
'enabled' => !$isPremiumUser && Setting::get('ads_enabled', '0') === '1',
|
||||
'vast_url' => Setting::get('ads_vast_url', ''),
|
||||
'freq_episodes' => (int) Setting::get('ads_freq_episodes', 4),
|
||||
'freq_minutes' => (int) Setting::get('ads_freq_minutes', 10),
|
||||
];
|
||||
|
||||
// ── Kendi MP4 pre-roll reklam sistemi ────────────────────────────────
|
||||
// Premium kullanıcı reklam görmez. mode: 'ad' | 'upsell' | null
|
||||
$vadConfig = ['mode' => null];
|
||||
if (!$isPremiumUser && Setting::get('vad_enabled', '0') === '1') {
|
||||
$upsellPercent = (int) Setting::get('vad_upsell_percent', 20);
|
||||
$ad = null;
|
||||
$mode = null;
|
||||
if (random_int(1, 100) <= $upsellPercent) {
|
||||
$mode = 'upsell';
|
||||
} else {
|
||||
$ad = \App\Models\Ad::pickVideo();
|
||||
if ($ad && $ad->media_url) {
|
||||
$mode = 'ad';
|
||||
} elseif ($upsellPercent > 0) {
|
||||
$mode = 'upsell'; // hiç video reklam yoksa upsell göster
|
||||
}
|
||||
}
|
||||
$vadConfig = [
|
||||
'mode' => $mode,
|
||||
'ad' => $mode === 'ad' ? [
|
||||
'id' => $ad->id,
|
||||
'url' => $ad->media_url,
|
||||
'click_url' => $ad->click_url,
|
||||
'skip_after' => (int) $ad->skip_after,
|
||||
] : null,
|
||||
'freq_episodes' => (int) Setting::get('vad_freq_episodes', 2),
|
||||
'freq_minutes' => (int) Setting::get('vad_freq_minutes', 5),
|
||||
'premium_url' => route('premium.plans'),
|
||||
];
|
||||
}
|
||||
|
||||
// Kendi reklamımız gösterilecekse IMA/VAST devreye girmesin
|
||||
if (!empty($vadConfig['mode'])) {
|
||||
$adsConfig['enabled'] = false;
|
||||
}
|
||||
|
||||
return response()
|
||||
->view('frontend.player', compact(
|
||||
'anime', 'ep', 'seasonModel', 'prev', 'next',
|
||||
'subtitlesData', 'nextUrl', 'dubSources', 'activeDub',
|
||||
'introUrl', 'introSkipAfter', 'mainVideoSkipSec', 'wmCoverSeconds',
|
||||
'aniSkip', 'watchProgress', 'videoSourcesData', 'adsConfig', 'vadConfig'
|
||||
))
|
||||
->header('Cache-Control', 'private, no-store, no-cache, must-revalidate')
|
||||
->header('Pragma', 'no-cache')
|
||||
->header('X-Player-Version', '3');
|
||||
}
|
||||
|
||||
/**
|
||||
* m3u8 URL içinden kalite+dublaj klasörünü bulup diğer dublaj varyantlarının URL'lerini üretir.
|
||||
* Örnekler:
|
||||
* - https://f.aniziumserver.sbs/85937/1/1/1080p-original/master.m3u8
|
||||
* - https://host/cdn/x/1/01/720p-trdub/
|
||||
* - https://xxx.b-cdn.net/.../1080p_endub/index.m3u8
|
||||
*
|
||||
* @return array{0: array<int, array{key:string,label:string,url:string,active:bool}>, 1: ?string}
|
||||
*/
|
||||
protected static function resolveDubSourcesFromM3u8(?string $m3u8Url, ?array $availableDubs = null): array
|
||||
{
|
||||
if (!$m3u8Url || ! is_string($m3u8Url)) {
|
||||
return [[], null];
|
||||
}
|
||||
|
||||
$u = rtrim(preg_replace('/[?#].*$/', '', trim($m3u8Url)), '/');
|
||||
if ($u === '') {
|
||||
return [[], null];
|
||||
}
|
||||
|
||||
// Sondaki playlist dosyasını çıkar (master.m3u8, index.m3u8, video.m3u8, …)
|
||||
if (preg_match('#/[^/]+\.m3u8$#i', $u)) {
|
||||
$u = rtrim(preg_replace('#/[^/]+\.m3u8$#i', '', $u), '/');
|
||||
}
|
||||
|
||||
// Son segment: 720p-original, 1080p_trdub, 480p-endub
|
||||
if (! preg_match('#^(.*)/(\d{3,4}p)([-_])([a-zA-Z0-9_-]+)$#', $u, $m)) {
|
||||
return [[], null];
|
||||
}
|
||||
|
||||
$parent = $m[1];
|
||||
$quality = $m[2];
|
||||
$sep = $m[3];
|
||||
$activeDub = strtolower($m[4]);
|
||||
|
||||
$dubLabels = [
|
||||
'trdub' => 'Türkçe Dublaj',
|
||||
'original' => 'Japonca (Orijinal)',
|
||||
'endub' => 'İngilizce Dublaj',
|
||||
// Dynamic: any unrecognised key gets a generic label below
|
||||
];
|
||||
|
||||
$raw = rtrim(preg_replace('/[?#].*$/', '', trim($m3u8Url)), '/');
|
||||
$suffix = '';
|
||||
if (preg_match('#/(\d{3,4}p)([-_])([a-zA-Z0-9_-]+)(/.*)$#i', $raw, $tail)) {
|
||||
$suffix = $tail[4];
|
||||
}
|
||||
|
||||
// Which dub keys to include:
|
||||
// • null → column not yet migrated (legacy): show all standard dubs
|
||||
// • [] empty array → no dub info, show only active
|
||||
// • ['trdub','original',...] → restrict to listed keys
|
||||
if ($availableDubs === null) {
|
||||
// Bilinmiyor: aktif dub trdub/endub ise orijinal (JP) de büyük ihtimalle var.
|
||||
// Aktif dub zaten original ise başka dub olmadığını varsay (false positive önle).
|
||||
$keys = $activeDub !== 'original' ? [$activeDub, 'original'] : [$activeDub];
|
||||
} elseif (count($availableDubs) === 0) {
|
||||
// Bot açıkça "dub bilgisi yok" dedi — sadece aktif
|
||||
$keys = [$activeDub];
|
||||
} else {
|
||||
$keys = $availableDubs;
|
||||
}
|
||||
|
||||
// Tekrarları at, aktif dub'ı öne al
|
||||
$seen = [];
|
||||
$sources = [];
|
||||
// Aktif dub her zaman ilk sıraya
|
||||
if (!in_array($activeDub, $keys)) array_unshift($keys, $activeDub);
|
||||
foreach ($keys as $key) {
|
||||
if (isset($seen[$key])) continue;
|
||||
$seen[$key] = true;
|
||||
$label = $dubLabels[$key] ?? ucfirst($key) . ' Dublaj';
|
||||
$url = $parent . '/' . $quality . $sep . $key . $suffix;
|
||||
$sources[] = [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'url' => $url,
|
||||
'active' => $key === $activeDub,
|
||||
];
|
||||
}
|
||||
|
||||
return [$sources, $activeDub];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\PremiumFeatures;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PremiumController extends Controller
|
||||
{
|
||||
/** Kullanıcının premium kozmetik ayarlarını kaydet */
|
||||
public function saveCosmetics(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user->isPremium()) {
|
||||
return back()->with('error', 'Bu özellik için premium üyelik gerekiyor.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'comment_bg' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::COMMENT_BACKGROUNDS)),
|
||||
'comment_glow' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::COMMENT_GLOWS)),
|
||||
'username_color' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::USERNAME_COLORS)),
|
||||
'username_effect' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::USERNAME_EFFECTS)),
|
||||
'profile_frame' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::PROFILE_FRAMES)),
|
||||
'profile_badge' => 'nullable|string|max:32',
|
||||
'profile_bg' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::PROFILE_BACKGROUNDS)),
|
||||
'gif_avatar' => 'nullable|url|max:500',
|
||||
'profile_music_url' => 'nullable|url|max:500',
|
||||
'comment_signature' => 'nullable|string|max:100',
|
||||
'entry_effect' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::ENTRY_EFFECTS)),
|
||||
'animated_banner' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// Her alanı sadece ilgili perk varsa kaydet
|
||||
$updates = [];
|
||||
|
||||
if ($user->hasPerk('comment_bg')) {
|
||||
$updates['comment_bg'] = $validated['comment_bg'] ?? null;
|
||||
}
|
||||
if ($user->hasPerk('comment_glow')) {
|
||||
$updates['comment_glow'] = $validated['comment_glow'] ?? null;
|
||||
}
|
||||
if ($user->hasPerk('username_color')) {
|
||||
$updates['username_color'] = $validated['username_color'] ?? null;
|
||||
}
|
||||
if ($user->hasPerk('username_effect')) {
|
||||
$updates['username_effect'] = $validated['username_effect'] ?? null;
|
||||
}
|
||||
if ($user->hasPerk('profile_frame')) {
|
||||
$updates['profile_frame'] = $validated['profile_frame'] ?? null;
|
||||
}
|
||||
if ($user->hasPerk('profile_badge')) {
|
||||
$updates['profile_badge'] = $validated['profile_badge'] ?? null;
|
||||
}
|
||||
if ($user->hasPerk('profile_bg')) {
|
||||
$updates['profile_bg'] = $validated['profile_bg'] ?? null;
|
||||
}
|
||||
if ($user->hasPerk('gif_avatar')) {
|
||||
$updates['gif_avatar'] = $validated['gif_avatar'] ?? null;
|
||||
}
|
||||
if ($user->hasPerk('profile_music') && Schema::hasColumn('users', 'profile_music_url')) {
|
||||
$updates['profile_music_url'] = $validated['profile_music_url'] ?? null;
|
||||
}
|
||||
if ($user->hasPerk('comment_signature')) {
|
||||
$updates['comment_signature'] = $validated['comment_signature'] ?? null;
|
||||
}
|
||||
if ($user->hasPerk('entry_effect')) {
|
||||
$updates['entry_effect'] = $validated['entry_effect'] ?? null;
|
||||
}
|
||||
if ($user->hasPerk('animated_banner')) {
|
||||
$updates['animated_banner'] = $request->boolean('animated_banner');
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
$user->update($updates);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Premium ayarların kaydedildi!');
|
||||
}
|
||||
|
||||
/** Public plans/pricing sayfası */
|
||||
public function plans()
|
||||
{
|
||||
$plans = \App\Models\MembershipPlan::where('is_active', true)
|
||||
->where('is_public', true)
|
||||
->where(fn($q) => $q->whereNull('visible_until')->orWhere('visible_until', '>', now()))
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
$allFeatures = PremiumFeatures::grouped();
|
||||
|
||||
return view('frontend.premium.plans', compact('plans', 'allFeatures'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AnimeSwipe;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Watchlist;
|
||||
use App\Models\ContinueWatching;
|
||||
use App\Models\UserAchievement;
|
||||
use App\Models\EpisodeNote;
|
||||
use App\Services\AchievementService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$recentComments = Comment::where('user_id', $user->id)
|
||||
->where('status', 'approved')
|
||||
->orderByDesc('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$commentCount = Comment::where('user_id', $user->id)
|
||||
->where('status', 'approved')
|
||||
->count();
|
||||
|
||||
// İzleme listesi (status gruplu)
|
||||
$watchlistItems = Watchlist::where('user_id', $user->id)
|
||||
->with('anime:id,title,slug,cover_image,type,rating')
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->filter(fn($wl) => $wl->anime !== null)
|
||||
->groupBy('status');
|
||||
|
||||
// Devam et listesi
|
||||
$continueItems = ContinueWatching::where('user_id', $user->id)
|
||||
->with('anime:id,title,slug,cover_image')
|
||||
->where('percent_complete', '<', 95)
|
||||
->orderByDesc('updated_at')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
// İzleme istatistikleri
|
||||
$watchStats = [
|
||||
'episodes' => ContinueWatching::where('user_id', $user->id)->where('percent_complete', '>=', 70)->count(),
|
||||
'hours' => round(ContinueWatching::where('user_id', $user->id)->sum('seconds_watched') / 3600, 1),
|
||||
'watchlist'=> Watchlist::where('user_id', $user->id)->count(),
|
||||
'ratings' => DB::table('anime_ratings')->where('user_id', $user->id)->count(),
|
||||
];
|
||||
|
||||
// Başarımlar
|
||||
AchievementService::check($user); // yeni kazanılanları kontrol et
|
||||
$achievements = UserAchievement::where('user_id', $user->id)
|
||||
->with('achievement')
|
||||
->orderByDesc('earned_at')
|
||||
->get();
|
||||
|
||||
$allAchievements = \App\Models\Achievement::all();
|
||||
|
||||
// İzleme Heatmap (son 365 gün)
|
||||
$heatmapRaw = DB::table('analytics_watch_events')
|
||||
->where('user_id', $user->id)
|
||||
->where('created_at', '>=', now()->subDays(365))
|
||||
->selectRaw('DATE(created_at) as d, COUNT(DISTINCT episode_id) as cnt')
|
||||
->groupBy('d')
|
||||
->pluck('cnt', 'd')
|
||||
->toArray();
|
||||
|
||||
// Bölüm notları (son 20)
|
||||
$episodeNotes = EpisodeNote::where('user_id', $user->id)
|
||||
->with('episode:id,title,episode_number,anime_id', 'anime:id,title,slug')
|
||||
->orderByDesc('created_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Keşfet geçmişi (beğenilenler + geçilenler)
|
||||
$swipeHistory = AnimeSwipe::where('user_id', $user->id)
|
||||
->with('anime:id,title,slug,cover_image,rating,release_year,type')
|
||||
->orderByDesc('created_at')
|
||||
->limit(60)
|
||||
->get()
|
||||
->filter(fn($s) => $s->anime !== null);
|
||||
|
||||
return view('frontend.profile', compact(
|
||||
'user', 'recentComments', 'commentCount',
|
||||
'watchlistItems', 'continueItems', 'watchStats',
|
||||
'achievements', 'allAchievements',
|
||||
'heatmapRaw', 'episodeNotes', 'swipeHistory'
|
||||
));
|
||||
}
|
||||
|
||||
public function publicProfile(\App\Models\User $user)
|
||||
{
|
||||
$commentCount = Comment::where('user_id', $user->id)->where('status', 'approved')->count();
|
||||
|
||||
$watchlistItems = Watchlist::where('user_id', $user->id)
|
||||
->with('anime:id,title,slug,cover_image,type,rating')
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->filter(fn($wl) => $wl->anime !== null)
|
||||
->groupBy('status');
|
||||
|
||||
$watchStats = [
|
||||
'episodes' => ContinueWatching::where('user_id', $user->id)->where('percent_complete', '>=', 70)->count(),
|
||||
'hours' => round(ContinueWatching::where('user_id', $user->id)->sum('seconds_watched') / 3600, 1),
|
||||
'watchlist'=> Watchlist::where('user_id', $user->id)->count(),
|
||||
'ratings' => DB::table('anime_ratings')->where('user_id', $user->id)->count(),
|
||||
];
|
||||
|
||||
$achievements = UserAchievement::where('user_id', $user->id)
|
||||
->with('achievement')
|
||||
->where('earned_at', '!=', null)
|
||||
->orderByDesc('earned_at')
|
||||
->get();
|
||||
|
||||
$recentComments = Comment::where('user_id', $user->id)
|
||||
->where('status', 'approved')
|
||||
->orderByDesc('created_at')
|
||||
->limit(6)
|
||||
->get();
|
||||
|
||||
$isOwnProfile = Auth::id() === $user->id;
|
||||
$isFollowing = Auth::check() && !$isOwnProfile ? Auth::user()->isFollowing($user->id) : false;
|
||||
$followerCount = \App\Models\UserFollow::where('following_id', $user->id)->count();
|
||||
$followingCount= \App\Models\UserFollow::where('follower_id', $user->id)->count();
|
||||
$compatibility = (Auth::check() && !$isOwnProfile)
|
||||
? Auth::user()->compatibilityWith($user)
|
||||
: null;
|
||||
|
||||
return view('frontend.public-profile', compact(
|
||||
'user', 'commentCount', 'watchlistItems',
|
||||
'watchStats', 'achievements', 'recentComments', 'isOwnProfile',
|
||||
'isFollowing', 'followerCount', 'followingCount', 'compatibility'
|
||||
));
|
||||
}
|
||||
|
||||
public function settings()
|
||||
{
|
||||
return view('frontend.profile-settings', ['user' => Auth::user()]);
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:60',
|
||||
'username' => 'nullable|string|max:30|alpha_dash|unique:users,username,' . $user->id,
|
||||
'bio' => 'nullable|string|max:300',
|
||||
'website' => 'nullable|url|max:200',
|
||||
'twitter' => 'nullable|string|max:50',
|
||||
'instagram' => 'nullable|string|max:50',
|
||||
'discord' => 'nullable|string|max:80',
|
||||
'profile_color' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
|
||||
'show_watchlist' => 'boolean',
|
||||
'show_activity' => 'boolean',
|
||||
]);
|
||||
|
||||
// Checkboxlar false gelince request'te bulunmaz
|
||||
$data['show_watchlist'] = $request->boolean('show_watchlist');
|
||||
$data['show_activity'] = $request->boolean('show_activity');
|
||||
|
||||
// @ işaretlerini temizle
|
||||
if (isset($data['twitter'])) $data['twitter'] = ltrim($data['twitter'], '@');
|
||||
if (isset($data['instagram'])) $data['instagram'] = ltrim($data['instagram'], '@');
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return back()->with('success', 'Profil güncellendi.');
|
||||
}
|
||||
|
||||
public function updateAvatar(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'avatar' => 'required|image|mimes:jpg,jpeg,png,webp,gif|max:2048',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
// Eski avatarı sil
|
||||
if ($user->avatar && Storage::disk('public')->exists($user->avatar)) {
|
||||
Storage::disk('public')->delete($user->avatar);
|
||||
}
|
||||
|
||||
$path = $request->file('avatar')->store('avatars', 'public');
|
||||
$user->update(['avatar' => $path]);
|
||||
|
||||
return back()->with('success', 'Profil fotoğrafı güncellendi.');
|
||||
}
|
||||
|
||||
public function updateBanner(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'banner' => 'required|image|mimes:jpg,jpeg,png,webp|max:5120',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user->banner_image && Storage::disk('public')->exists($user->banner_image)) {
|
||||
Storage::disk('public')->delete($user->banner_image);
|
||||
}
|
||||
|
||||
$path = $request->file('banner')->store('banners', 'public');
|
||||
$user->update(['banner_image' => $path]);
|
||||
|
||||
return back()->with('success', 'Profil kapak fotoğrafı güncellendi.');
|
||||
}
|
||||
|
||||
public function updatePassword(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'current_password' => 'required',
|
||||
'password' => ['required', 'confirmed', Password::min(8)],
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if (!Hash::check($request->current_password, $user->password)) {
|
||||
return back()->withErrors(['current_password' => 'Mevcut şifre yanlış.']);
|
||||
}
|
||||
|
||||
$user->update(['password' => $request->password]);
|
||||
|
||||
return back()->with('success', 'Şifre güncellendi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use App\Models\EpisodePrediction;
|
||||
use App\Models\EpisodeTimestampComment;
|
||||
use App\Models\FirstWatchSession;
|
||||
use App\Models\PredictionVote;
|
||||
use App\Models\SpoilerBox;
|
||||
use App\Models\SpoilerBoxLike;
|
||||
use App\Models\TimeCapsule;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFollow;
|
||||
use App\Models\WatchParty;
|
||||
use App\Models\WatchPartyMember;
|
||||
use App\Services\DeepSeekService;
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class SocialController extends Controller
|
||||
{
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Kullanıcı takip
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public function followToggle(User $user)
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
if ($me->id === $user->id) {
|
||||
return response()->json(['error' => 'Kendinizi takip edemezsiniz.'], 422);
|
||||
}
|
||||
|
||||
$existing = UserFollow::where('follower_id', $me->id)
|
||||
->where('following_id', $user->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$following = false;
|
||||
} else {
|
||||
UserFollow::create(['follower_id' => $me->id, 'following_id' => $user->id]);
|
||||
$following = true;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'following' => $following,
|
||||
'followers_count' => UserFollow::where('following_id', $user->id)->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function card(User $user)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$isFollowing = $me
|
||||
? UserFollow::where('follower_id', $me->id)->where('following_id', $user->id)->exists()
|
||||
: false;
|
||||
|
||||
return response()->json([
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
'avatar' => $user->avatar ? MediaUrl::fromStoragePath($user->avatar) : null,
|
||||
'followers' => UserFollow::where('following_id', $user->id)->count(),
|
||||
'following' => UserFollow::where('follower_id', $user->id)->count(),
|
||||
'is_following' => $isFollowing,
|
||||
'profile_url' => route('user.profile', $user),
|
||||
'follow_url' => ($me && $me->id !== $user->id) ? route('user.follow', $user) : null,
|
||||
'msg_url' => ($me && $me->id !== $user->id) ? route('messages.start', $user) : null,
|
||||
'is_me' => $me && $me->id === $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function compatibility(User $user)
|
||||
{
|
||||
$me = Auth::user();
|
||||
if (!$me) return response()->json(['score' => 0]);
|
||||
|
||||
return response()->json([
|
||||
'score' => $me->compatibilityWith($user),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// NicoNico — Timestamp Yorumları
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public function timestampComments(Episode $episode)
|
||||
{
|
||||
$comments = EpisodeTimestampComment::with('user:id,name,username')
|
||||
->where('episode_id', $episode->id)
|
||||
->where('is_hidden', false)
|
||||
->orderBy('timestamp_sec')
|
||||
->get()
|
||||
->map(fn($c) => [
|
||||
'id' => $c->id,
|
||||
'user_id' => $c->user_id,
|
||||
'timestamp_sec' => $c->timestamp_sec,
|
||||
'body' => $c->body,
|
||||
'color' => $c->color,
|
||||
'username' => $c->user?->username ?? 'misafir',
|
||||
'created_at' => $c->created_at,
|
||||
]);
|
||||
|
||||
return response()->json(['comments' => $comments]);
|
||||
}
|
||||
|
||||
public function timestampCommentStore(Request $request, Episode $episode)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'timestamp_sec' => 'required|integer|min:0|max:86400',
|
||||
'body' => 'required|string|max:100',
|
||||
'color' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
|
||||
]);
|
||||
|
||||
$me = Auth::user();
|
||||
|
||||
// Flood koruması: aynı kullanıcı 5 saniye içinde 2+ yorum atmasın
|
||||
$recent = EpisodeTimestampComment::where('user_id', $me->id)
|
||||
->where('episode_id', $episode->id)
|
||||
->where('created_at', '>=', now()->subSeconds(5))
|
||||
->count();
|
||||
|
||||
if ($recent >= 2) {
|
||||
return response()->json(['error' => 'Çok hızlı yorum yapıyorsunuz.'], 429);
|
||||
}
|
||||
|
||||
$comment = EpisodeTimestampComment::create([
|
||||
'episode_id' => $episode->id,
|
||||
'user_id' => $me->id,
|
||||
'timestamp_sec' => $data['timestamp_sec'],
|
||||
'body' => $data['body'],
|
||||
'color' => $data['color'] ?? '#ffffff',
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $comment->id]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Tahmin Oyunu
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public function predictions(Episode $episode)
|
||||
{
|
||||
$me = Auth::id();
|
||||
|
||||
$predictions = EpisodePrediction::with('user:id,name,username')
|
||||
->where('episode_id', $episode->id)
|
||||
->orderByDesc('vote_count')
|
||||
->get()
|
||||
->map(fn($p) => [
|
||||
'id' => $p->id,
|
||||
'body' => $p->body,
|
||||
'is_correct' => $p->is_correct,
|
||||
'vote_count' => $p->vote_count,
|
||||
'username' => $p->user?->username,
|
||||
'is_mine' => $me && $p->user_id === $me,
|
||||
'voted' => $me
|
||||
? PredictionVote::where('prediction_id', $p->id)->where('user_id', $me)->exists()
|
||||
: false,
|
||||
'created_at' => $p->created_at->diffForHumans(),
|
||||
]);
|
||||
|
||||
$myPrediction = $me
|
||||
? EpisodePrediction::where('episode_id', $episode->id)->where('user_id', $me)->first()?->id
|
||||
: null;
|
||||
|
||||
return response()->json([
|
||||
'predictions' => $predictions,
|
||||
'my_prediction' => $myPrediction,
|
||||
]);
|
||||
}
|
||||
|
||||
public function predictionStore(Request $request, Episode $episode)
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
$data = $request->validate([
|
||||
'body' => 'required|string|min:5|max:280',
|
||||
]);
|
||||
|
||||
$existing = EpisodePrediction::where('episode_id', $episode->id)
|
||||
->where('user_id', $me->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['error' => 'Bu bölüm için zaten bir tahmininiz var.'], 422);
|
||||
}
|
||||
|
||||
$prediction = EpisodePrediction::create([
|
||||
'episode_id' => $episode->id,
|
||||
'user_id' => $me->id,
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $prediction->id]);
|
||||
}
|
||||
|
||||
public function predictionVote(Request $request, EpisodePrediction $prediction)
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
$existing = PredictionVote::where('prediction_id', $prediction->id)
|
||||
->where('user_id', $me->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$prediction->decrement('vote_count');
|
||||
return response()->json(['voted' => false, 'vote_count' => $prediction->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
PredictionVote::create(['prediction_id' => $prediction->id, 'user_id' => $me->id]);
|
||||
$prediction->increment('vote_count');
|
||||
|
||||
return response()->json(['voted' => true, 'vote_count' => $prediction->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Watch Party
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public function partyCreate(Request $request)
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
$data = $request->validate([
|
||||
'episode_id' => 'required|exists:episodes,id',
|
||||
'is_private' => 'boolean',
|
||||
'password' => 'nullable|string|max:30',
|
||||
'max_members'=> 'nullable|integer|min:2|max:20',
|
||||
]);
|
||||
|
||||
// Kullanıcının zaten aktif bir odası varsa sil
|
||||
WatchParty::where('host_user_id', $me->id)->delete();
|
||||
|
||||
$party = WatchParty::create([
|
||||
'room_code' => WatchParty::generateCode(),
|
||||
'host_user_id' => $me->id,
|
||||
'episode_id' => $data['episode_id'],
|
||||
'is_private' => $data['is_private'] ?? false,
|
||||
'password' => isset($data['password']) ? Hash::make($data['password']) : null,
|
||||
'max_members' => $data['max_members'] ?? 10,
|
||||
]);
|
||||
|
||||
WatchPartyMember::create([
|
||||
'party_id' => $party->id,
|
||||
'user_id' => $me->id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'room_code' => $party->room_code,
|
||||
'party_url' => route('watch.party', $party->room_code),
|
||||
]);
|
||||
}
|
||||
|
||||
public function partyJoin(Request $request, string $roomCode)
|
||||
{
|
||||
$party = WatchParty::where('room_code', $roomCode)->firstOrFail();
|
||||
$me = Auth::user();
|
||||
|
||||
// Şifre kontrolü
|
||||
if ($party->is_private && $party->password) {
|
||||
$pw = $request->input('password', '');
|
||||
if (!Hash::check($pw, $party->password)) {
|
||||
return response()->json(['error' => 'Yanlış şifre.'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Kapasite
|
||||
$activeCount = $party->activeMembers()->count();
|
||||
if ($activeCount >= $party->max_members) {
|
||||
return response()->json(['error' => 'Oda dolu.'], 403);
|
||||
}
|
||||
|
||||
WatchPartyMember::updateOrCreate(
|
||||
['party_id' => $party->id, 'user_id' => $me->id],
|
||||
['last_ping' => now()]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'current_sec' => $party->current_sec,
|
||||
'is_playing' => $party->is_playing,
|
||||
'host_id' => $party->host_user_id,
|
||||
'members' => $this->partyMemberList($party),
|
||||
]);
|
||||
}
|
||||
|
||||
public function partySync(Request $request, string $roomCode)
|
||||
{
|
||||
$party = WatchParty::where('room_code', $roomCode)->firstOrFail();
|
||||
$me = Auth::user();
|
||||
|
||||
// Sadece host senkron durumu güncelleyebilir
|
||||
if ($party->host_user_id === $me->id) {
|
||||
$data = $request->validate([
|
||||
'current_sec' => 'required|integer|min:0',
|
||||
'is_playing' => 'required|boolean',
|
||||
]);
|
||||
$party->update([
|
||||
'current_sec' => $data['current_sec'],
|
||||
'is_playing' => $data['is_playing'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Herkes ping atar
|
||||
WatchPartyMember::where('party_id', $party->id)
|
||||
->where('user_id', $me->id)
|
||||
->update(['last_ping' => now()]);
|
||||
|
||||
return response()->json([
|
||||
'current_sec' => $party->fresh()->current_sec,
|
||||
'is_playing' => $party->fresh()->is_playing,
|
||||
'members' => $this->partyMemberList($party),
|
||||
]);
|
||||
}
|
||||
|
||||
public function partyLeave(string $roomCode)
|
||||
{
|
||||
$party = WatchParty::where('room_code', $roomCode)->firstOrFail();
|
||||
$me = Auth::user();
|
||||
|
||||
WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id)->delete();
|
||||
|
||||
if ($party->host_user_id === $me->id) {
|
||||
$party->delete();
|
||||
return response()->json(['ok' => true, 'dissolved' => true]);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'dissolved' => false]);
|
||||
}
|
||||
|
||||
public function partyShow(string $roomCode)
|
||||
{
|
||||
$party = WatchParty::with(['episode.anime', 'host'])->where('room_code', $roomCode)->firstOrFail();
|
||||
return view('frontend.watch-party', compact('party'));
|
||||
}
|
||||
|
||||
private function partyMemberList(WatchParty $party): array
|
||||
{
|
||||
return $party->activeMembers()->with('user:id,name,username')->get()
|
||||
->map(fn($m) => [
|
||||
'id' => $m->user_id,
|
||||
'name' => $m->user?->name,
|
||||
'username' => $m->user?->username,
|
||||
'is_host' => $m->user_id === $party->host_user_id,
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// İlk Kez İzleyenler
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public function firstWatchRegister(Request $request, Episode $episode)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$sessionId = $request->header('X-Session-ID') ?? session()->getId();
|
||||
|
||||
FirstWatchSession::updateOrCreate(
|
||||
[
|
||||
'episode_id' => $episode->id,
|
||||
'user_id' => $me?->id,
|
||||
'session_id' => $me ? null : $sessionId,
|
||||
],
|
||||
[
|
||||
'is_first_time' => (bool)$request->input('is_first_time', true),
|
||||
'last_seen' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
$count = FirstWatchSession::where('episode_id', $episode->id)
|
||||
->where('is_first_time', true)
|
||||
->where('last_seen', '>=', now()->subMinutes(10))
|
||||
->count();
|
||||
|
||||
return response()->json(['ok' => true, 'first_watch_count' => $count]);
|
||||
}
|
||||
|
||||
public function firstWatchCount(Episode $episode)
|
||||
{
|
||||
$count = FirstWatchSession::where('episode_id', $episode->id)
|
||||
->where('is_first_time', true)
|
||||
->where('last_seen', '>=', now()->subMinutes(10))
|
||||
->count();
|
||||
|
||||
return response()->json(['count' => $count]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Ruh Hali Motoru
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private static array $moodGenres = [
|
||||
'sad' => ['Drama', 'Romantizm'],
|
||||
'funny' => ['Komedi', 'Slice of Life'],
|
||||
'hype' => ['Aksiyon', 'Shounen', 'Spor'],
|
||||
'think' => ['Bilim Kurgu', 'Gerilim', 'Supernatural'],
|
||||
'romance' => ['Romantizm', 'Shoujo'],
|
||||
'scary' => ['Korku', 'Supernatural', 'Gerilim'],
|
||||
];
|
||||
|
||||
public function moodRecommend(Request $request)
|
||||
{
|
||||
$mood = $request->validate(['mood' => 'required|in:sad,funny,hype,think,romance,scary'])['mood'];
|
||||
$genres = self::$moodGenres[$mood] ?? [];
|
||||
|
||||
$animes = Anime::whereHas('genres', fn($q) => $q->whereIn('name', $genres))
|
||||
->where('is_published', true)
|
||||
->inRandomOrder()
|
||||
->limit(6)
|
||||
->get(['id', 'title', 'cover_image', 'slug', 'rating']);
|
||||
|
||||
return response()->json([
|
||||
'animes' => $animes->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'cover' => $a->cover_image ? \App\Support\MediaUrl::fromStoragePath($a->cover_image) : null,
|
||||
'url' => route('anime.show', $a->slug),
|
||||
'rating'=> $a->rating,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Zaman Kapsülü
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public function capsuleStore(Request $request)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|exists:animes,id',
|
||||
'message' => 'required|string|min:5|max:1000',
|
||||
'unlock_at' => 'required|date|after:' . now()->addDays(30)->toDateString(),
|
||||
]);
|
||||
|
||||
$data['user_id'] = $me->id;
|
||||
|
||||
$capsule = TimeCapsule::create($data);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $capsule->id]);
|
||||
}
|
||||
|
||||
public function capsuleIndex()
|
||||
{
|
||||
$capsules = TimeCapsule::with('anime:id,title,slug,cover_image')
|
||||
->where('user_id', Auth::id())
|
||||
->orderBy('unlock_at')
|
||||
->get()
|
||||
->map(fn($c) => [
|
||||
'id' => $c->id,
|
||||
'anime' => $c->anime?->title,
|
||||
'anime_url' => $c->anime ? route('anime.show', $c->anime->slug) : null,
|
||||
'cover' => $c->anime?->cover_image ? MediaUrl::fromStoragePath($c->anime->cover_image) : null,
|
||||
'unlock_at' => $c->unlock_at->format('d.m.Y'),
|
||||
'unlocked' => $c->isUnlocked(),
|
||||
'opened' => $c->isOpened(),
|
||||
'message' => $c->isOpened() || $c->isUnlocked() ? $c->message : null,
|
||||
'created_at' => $c->created_at->format('d.m.Y'),
|
||||
]);
|
||||
|
||||
return view('frontend.capsules', compact('capsules'));
|
||||
}
|
||||
|
||||
public function capsuleOpen(TimeCapsule $capsule)
|
||||
{
|
||||
if ($capsule->user_id !== Auth::id()) {
|
||||
return response()->json(['error' => 'Yetkisiz.'], 403);
|
||||
}
|
||||
if (!$capsule->isUnlocked()) {
|
||||
return response()->json(['error' => 'Kapsül henüz açılamaz.'], 422);
|
||||
}
|
||||
|
||||
$capsule->update(['opened_at' => now()]);
|
||||
|
||||
return response()->json(['ok' => true, 'message' => $capsule->message]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Spoiler Kilitli Kutu
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public function spoilerBoxes(Episode $episode)
|
||||
{
|
||||
$me = Auth::id();
|
||||
$boxes = SpoilerBox::with('user:id,name,username')
|
||||
->where('episode_id', $episode->id)
|
||||
->orderByDesc('likes')
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($b) => [
|
||||
'id' => $b->id,
|
||||
'body' => $b->body,
|
||||
'is_spoiler' => $b->is_spoiler,
|
||||
'spoiler_score' => $b->spoiler_score,
|
||||
'likes' => $b->likes,
|
||||
'username' => $b->user?->username,
|
||||
'name' => $b->user?->name,
|
||||
'is_mine' => $me && $b->user_id === $me,
|
||||
'liked' => $me ? SpoilerBoxLike::where('box_id', $b->id)->where('user_id', $me)->exists() : false,
|
||||
'created_at' => $b->created_at->diffForHumans(),
|
||||
]);
|
||||
|
||||
return response()->json(['boxes' => $boxes]);
|
||||
}
|
||||
|
||||
public function spoilerBoxStore(Request $request, Episode $episode)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$data = $request->validate([
|
||||
'body' => 'required|string|min:3|max:600',
|
||||
]);
|
||||
|
||||
// AI spoiler tespiti
|
||||
$isSpoiler = false;
|
||||
$spoilerScore = 0;
|
||||
$ai = new DeepSeekService();
|
||||
if ($ai->isConfigured()) {
|
||||
$prompt = "Aşağıdaki metin bir anime bölümü hakkında yazılmış. Bu metin spoiler içeriyor mu? "
|
||||
. "Sadece JSON döndür: {\"is_spoiler\": true/false, \"score\": 0-100}\n\nMetin: " . $data['body'];
|
||||
try {
|
||||
$raw = $ai->checkSpoiler($data['body']);
|
||||
if ($raw) {
|
||||
$isSpoiler = $raw['is_spoiler'] ?? false;
|
||||
$spoilerScore = $raw['score'] ?? 0;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
$box = SpoilerBox::create([
|
||||
'episode_id' => $episode->id,
|
||||
'user_id' => $me->id,
|
||||
'body' => $data['body'],
|
||||
'is_spoiler' => $isSpoiler,
|
||||
'spoiler_score' => $spoilerScore,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'id' => $box->id,
|
||||
'is_spoiler' => $isSpoiler,
|
||||
]);
|
||||
}
|
||||
|
||||
public function spoilerBoxLike(SpoilerBox $box)
|
||||
{
|
||||
$me = Auth::id();
|
||||
|
||||
$existing = SpoilerBoxLike::where('box_id', $box->id)->where('user_id', $me)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$box->decrement('likes');
|
||||
return response()->json(['liked' => false, 'likes' => $box->fresh()->likes]);
|
||||
}
|
||||
|
||||
SpoilerBoxLike::create(['box_id' => $box->id, 'user_id' => $me, 'created_at' => now()]);
|
||||
$box->increment('likes');
|
||||
|
||||
return response()->json(['liked' => true, 'likes' => $box->fresh()->likes]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\PageView;
|
||||
use App\Models\Analytics\WatchEvent;
|
||||
use App\Models\Analytics\VisitorSession;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class TrackingController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /track/pageview
|
||||
*/
|
||||
public function pageview(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'page_type' => 'nullable|string|max:30',
|
||||
'anime_id' => 'nullable|integer',
|
||||
'episode_id' => 'nullable|integer',
|
||||
'referrer' => 'nullable|string|max:500',
|
||||
'url' => 'nullable|string|max:500',
|
||||
'time_on_page'=> 'nullable|integer|min:0|max:86400',
|
||||
]);
|
||||
|
||||
$ip = $request->ip();
|
||||
$ua = $request->userAgent() ?? '';
|
||||
$isBot = (bool) $request->attributes->get('is_bot', false);
|
||||
$botType= $request->attributes->get('bot_type', null);
|
||||
$geo = self::geoIp($ip);
|
||||
$sessId = session()->getId();
|
||||
|
||||
PageView::create([
|
||||
'user_id' => auth()->id(),
|
||||
'session_id' => $sessId,
|
||||
'url' => mb_substr($data['url'] ?? $request->header('Referer', ''), 0, 500),
|
||||
'page_type' => $data['page_type'] ?? 'other',
|
||||
'anime_id' => $data['anime_id'] ?? null,
|
||||
'episode_id' => $data['episode_id'] ?? null,
|
||||
'ip' => $ip,
|
||||
'country' => $geo['country'] ?? null,
|
||||
'city' => $geo['city'] ?? null,
|
||||
'device' => self::detectDevice($ua),
|
||||
'browser' => self::detectBrowser($ua),
|
||||
'referrer' => mb_substr($data['referrer'] ?? '', 0, 500) ?: null,
|
||||
'is_bot' => $isBot ? 1 : 0,
|
||||
'user_agent' => mb_substr($ua, 0, 500),
|
||||
'time_on_page'=> $data['time_on_page'] ?? 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// Oturum kaydını oluştur / güncelle
|
||||
$this->trackSession($sessId, $ip, $ua, $geo, $isBot, $botType, $data);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /track/watch
|
||||
*/
|
||||
public function watch(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|integer',
|
||||
'episode_id' => 'nullable|integer',
|
||||
'season_number' => 'required|integer|min:1',
|
||||
'episode_number' => 'required|integer|min:1',
|
||||
'seconds' => 'required|integer|min:0',
|
||||
'total' => 'nullable|integer|min:0',
|
||||
'percent' => 'nullable|integer|min:0|max:100',
|
||||
]);
|
||||
|
||||
WatchEvent::create([
|
||||
'user_id' => auth()->id(),
|
||||
'session_id' => session()->getId(),
|
||||
'anime_id' => $data['anime_id'],
|
||||
'episode_id' => $data['episode_id'] ?? null,
|
||||
'season_number' => $data['season_number'],
|
||||
'episode_number' => $data['episode_number'],
|
||||
'seconds_watched' => $data['seconds'],
|
||||
'total_seconds' => $data['total'] ?? 0,
|
||||
'percent_complete'=> $data['percent'] ?? 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// Oturum izleme süresini güncelle
|
||||
try {
|
||||
DB::table('analytics_sessions')
|
||||
->where('session_id', session()->getId())
|
||||
->increment('total_seconds', (int)$data['seconds']);
|
||||
} catch (\Exception) {}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /track/session-end — sayfa kapanırken JS'ten gönderilir
|
||||
*/
|
||||
public function sessionEnd(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'time_on_page' => 'nullable|integer|min:0|max:86400',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::table('analytics_sessions')
|
||||
->where('session_id', session()->getId())
|
||||
->update([
|
||||
'last_seen_at' => now(),
|
||||
'total_seconds'=> DB::raw('total_seconds + ' . (int)($data['time_on_page'] ?? 0)),
|
||||
]);
|
||||
} catch (\Exception) {}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function trackSession(string $sessId, string $ip, string $ua, array $geo, bool $isBot, ?string $botType, array $data): void
|
||||
{
|
||||
try {
|
||||
$existing = DB::table('analytics_sessions')->where('session_id', $sessId)->first();
|
||||
|
||||
if ($existing) {
|
||||
DB::table('analytics_sessions')
|
||||
->where('session_id', $sessId)
|
||||
->update([
|
||||
'pages_visited' => DB::raw('pages_visited + 1'),
|
||||
'last_seen_at' => now(),
|
||||
'user_id' => auth()->id() ?? $existing->user_id,
|
||||
]);
|
||||
} else {
|
||||
DB::table('analytics_sessions')->insert([
|
||||
'session_id' => $sessId,
|
||||
'user_id' => auth()->id(),
|
||||
'ip' => $ip,
|
||||
'country' => $geo['country'] ?? null,
|
||||
'city' => $geo['city'] ?? null,
|
||||
'device' => self::detectDevice($ua),
|
||||
'browser' => self::detectBrowser($ua),
|
||||
'referrer' => mb_substr($data['referrer'] ?? '', 0, 500) ?: null,
|
||||
'landing_page' => mb_substr($data['url'] ?? '', 0, 500) ?: null,
|
||||
'pages_visited'=> 1,
|
||||
'total_seconds'=> 0,
|
||||
'is_bot' => $isBot ? 1 : 0,
|
||||
'bot_type' => $botType,
|
||||
'user_agent' => mb_substr($ua, 0, 500),
|
||||
'started_at' => now(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
}
|
||||
} catch (\Exception) {}
|
||||
}
|
||||
|
||||
private static function geoIp(string $ip): array
|
||||
{
|
||||
if ($ip === '127.0.0.1' || str_starts_with($ip, '192.168.') || str_starts_with($ip, '10.')) {
|
||||
return ['country' => 'Yerel', 'city' => 'Localhost'];
|
||||
}
|
||||
|
||||
return Cache::remember("geo_{$ip}", 86400 * 7, function () use ($ip) {
|
||||
try {
|
||||
$r = Http::timeout(2)->get("http://ip-api.com/json/{$ip}?fields=country,city,status");
|
||||
if ($r->ok() && $r->json('status') === 'success') {
|
||||
return ['country' => $r->json('country'), 'city' => $r->json('city')];
|
||||
}
|
||||
} catch (\Exception) {}
|
||||
return ['country' => null, 'city' => null];
|
||||
});
|
||||
}
|
||||
|
||||
private static function detectDevice(string $ua): string
|
||||
{
|
||||
$ua = strtolower($ua);
|
||||
if (str_contains($ua, 'tablet') || str_contains($ua, 'ipad')) return 'tablet';
|
||||
if (str_contains($ua, 'mobile') || str_contains($ua, 'android') || str_contains($ua, 'iphone')) return 'mobile';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
private static function detectBrowser(string $ua): string
|
||||
{
|
||||
if (str_contains($ua, 'Edg/')) return 'Edge';
|
||||
if (str_contains($ua, 'OPR/') || str_contains($ua, 'Opera')) return 'Opera';
|
||||
if (str_contains($ua, 'Chrome')) return 'Chrome';
|
||||
if (str_contains($ua, 'Firefox')) return 'Firefox';
|
||||
if (str_contains($ua, 'Safari')) return 'Safari';
|
||||
if (str_contains($ua, 'MSIE') || str_contains($ua, 'Trident')) return 'IE';
|
||||
return 'Other';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Tribunal;
|
||||
use App\Models\TribunalArgument;
|
||||
use App\Models\TribunalArgumentVote;
|
||||
use App\Models\TribunalVote;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class TribunalController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$tribunals = Tribunal::with(['anime', 'creator'])
|
||||
->withCount('votes')
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
return view('frontend.tribunal.index', compact('tribunals'));
|
||||
}
|
||||
|
||||
public function show(Tribunal $tribunal)
|
||||
{
|
||||
$tribunal->load(['anime', 'episode', 'creator']);
|
||||
|
||||
$me = Auth::id();
|
||||
|
||||
$myVote = $me
|
||||
? TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->value('side')
|
||||
: null;
|
||||
|
||||
$myArgument = $me
|
||||
? TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first()
|
||||
: null;
|
||||
|
||||
// Tüm tarafların oy sayımları
|
||||
$allSides = $tribunal->allSides();
|
||||
$voteCounts = [];
|
||||
$total = 0;
|
||||
foreach (array_keys($allSides) as $key) {
|
||||
$cnt = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count();
|
||||
$voteCounts[$key] = $cnt;
|
||||
$total += $cnt;
|
||||
}
|
||||
|
||||
$arguments = TribunalArgument::with('user:id,name,username')
|
||||
->where('tribunal_id', $tribunal->id)
|
||||
->orderByDesc('vote_count')
|
||||
->get()
|
||||
->map(function ($arg) use ($me) {
|
||||
$voted = $me
|
||||
? TribunalArgumentVote::where('argument_id', $arg->id)->where('user_id', $me)->exists()
|
||||
: false;
|
||||
return [
|
||||
'id' => $arg->id,
|
||||
'side' => $arg->side,
|
||||
'body' => $arg->body,
|
||||
'vote_count' => $arg->vote_count,
|
||||
'username' => $arg->user?->username,
|
||||
'name' => $arg->user?->name,
|
||||
'is_mine' => $me && $arg->user_id === $me,
|
||||
'voted' => $voted,
|
||||
'created_at' => $arg->created_at->diffForHumans(),
|
||||
];
|
||||
});
|
||||
|
||||
return view('frontend.tribunal.show', compact(
|
||||
'tribunal', 'myVote', 'myArgument', 'allSides', 'voteCounts', 'total', 'arguments'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|exists:animes,id',
|
||||
'episode_id' => 'nullable|exists:episodes,id',
|
||||
'question' => 'required|string|min:10|max:280',
|
||||
'side_a' => 'required|string|min:2|max:100',
|
||||
'side_b' => 'required|string|min:2|max:100',
|
||||
'extra_sides' => 'nullable|array|max:4',
|
||||
'extra_sides.*' => 'required|string|min:2|max:100',
|
||||
'closes_at' => 'nullable|date|after:now',
|
||||
]);
|
||||
|
||||
$data['created_by'] = Auth::id();
|
||||
$data['closes_at'] = $data['closes_at'] ?? now()->addDays(7);
|
||||
$data['extra_sides'] = array_values(array_filter($data['extra_sides'] ?? []));
|
||||
|
||||
$tribunal = Tribunal::create($data);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'url' => route('tribunal.show', $tribunal),
|
||||
]);
|
||||
}
|
||||
|
||||
public function vote(Request $request, Tribunal $tribunal)
|
||||
{
|
||||
if ($tribunal->status === 'closed') {
|
||||
return response()->json(['error' => 'Bu dava kapandı.'], 422);
|
||||
}
|
||||
|
||||
$validSides = array_keys($tribunal->allSides());
|
||||
$data = $request->validate(['side' => 'required|in:' . implode(',', $validSides)]);
|
||||
$me = Auth::id();
|
||||
|
||||
$existing = TribunalVote::where('tribunal_id', $tribunal->id)
|
||||
->where('user_id', $me)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->side === $data['side']) {
|
||||
$existing->delete();
|
||||
$voted = null;
|
||||
} else {
|
||||
$existing->update(['side' => $data['side']]);
|
||||
$voted = $data['side'];
|
||||
}
|
||||
} else {
|
||||
TribunalVote::create([
|
||||
'tribunal_id' => $tribunal->id,
|
||||
'user_id' => $me,
|
||||
'side' => $data['side'],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$voted = $data['side'];
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
foreach (array_keys($tribunal->allSides()) as $key) {
|
||||
$counts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'voted' => $voted,
|
||||
'counts' => $counts,
|
||||
'total' => array_sum($counts),
|
||||
]);
|
||||
}
|
||||
|
||||
public function argue(Request $request, Tribunal $tribunal)
|
||||
{
|
||||
if ($tribunal->status === 'closed') {
|
||||
return response()->json(['error' => 'Bu dava kapandı.'], 422);
|
||||
}
|
||||
|
||||
$validSides = array_keys($tribunal->allSides());
|
||||
$data = $request->validate([
|
||||
'side' => 'required|in:' . implode(',', $validSides),
|
||||
'body' => 'required|string|min:10|max:500',
|
||||
]);
|
||||
|
||||
$me = Auth::id();
|
||||
|
||||
$existing = TribunalArgument::where('tribunal_id', $tribunal->id)
|
||||
->where('user_id', $me)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['error' => 'Bu dava için zaten bir argüman girdiniz.'], 422);
|
||||
}
|
||||
|
||||
$arg = TribunalArgument::create([
|
||||
'tribunal_id' => $tribunal->id,
|
||||
'user_id' => $me,
|
||||
'side' => $data['side'],
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $arg->id]);
|
||||
}
|
||||
|
||||
public function argVote(Request $request, TribunalArgument $argument)
|
||||
{
|
||||
$me = Auth::id();
|
||||
|
||||
$existing = TribunalArgumentVote::where('argument_id', $argument->id)
|
||||
->where('user_id', $me)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$argument->decrement('vote_count');
|
||||
return response()->json(['voted' => false, 'vote_count' => $argument->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
TribunalArgumentVote::create([
|
||||
'argument_id' => $argument->id,
|
||||
'user_id' => $me,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$argument->increment('vote_count');
|
||||
|
||||
return response()->json(['voted' => true, 'vote_count' => $argument->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
public function forAnime(Anime $anime)
|
||||
{
|
||||
$tribunals = Tribunal::where('anime_id', $anime->id)
|
||||
->withCount('votes')
|
||||
->latest()
|
||||
->get()
|
||||
->map(fn($t) => [
|
||||
'id' => $t->id,
|
||||
'question' => $t->question,
|
||||
'side_a' => $t->side_a,
|
||||
'side_b' => $t->side_b,
|
||||
'status' => $t->status,
|
||||
'url' => route('tribunal.show', $t),
|
||||
'votes' => $t->votes_count,
|
||||
]);
|
||||
|
||||
return response()->json(['tribunals' => $tribunals]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Watchlist;
|
||||
use App\Models\EpisodeVote;
|
||||
use App\Models\AnimeRating;
|
||||
use App\Models\AnimeRequest;
|
||||
use App\Models\AnimeRequestVote;
|
||||
use App\Models\ContinueWatching;
|
||||
use App\Models\UserAchievement;
|
||||
use App\Models\AnimeFollow;
|
||||
use App\Models\UserNotification;
|
||||
use App\Models\EpisodeNote;
|
||||
use App\Services\AchievementService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserFeatureController extends Controller
|
||||
{
|
||||
// ── Watchlist ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function watchlistIndex()
|
||||
{
|
||||
$items = Watchlist::where('user_id', auth()->id())
|
||||
->with(['anime.genres'])
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->groupBy('status');
|
||||
|
||||
$continues = ContinueWatching::where('user_id', auth()->id())
|
||||
->with(['anime', 'episode'])
|
||||
->where('percent_complete', '<', 95)
|
||||
->orderByDesc('updated_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
$achievements = UserAchievement::where('user_id', auth()->id())
|
||||
->with('achievement')
|
||||
->orderByDesc('earned_at')
|
||||
->get();
|
||||
|
||||
return view('frontend.profile', compact('items', 'continues', 'achievements'));
|
||||
}
|
||||
|
||||
public function watchlistToggle(Request $request, Anime $anime)
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$status = $request->input('status', 'plan');
|
||||
if (!array_key_exists($status, Watchlist::STATUSES)) {
|
||||
$status = 'plan';
|
||||
}
|
||||
|
||||
$existing = Watchlist::where('user_id', auth()->id())
|
||||
->where('anime_id', $anime->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->status === $status) {
|
||||
$existing->delete();
|
||||
$inList = false;
|
||||
$newStatus = null;
|
||||
} else {
|
||||
$existing->update(['status' => $status]);
|
||||
$inList = true;
|
||||
$newStatus = $status;
|
||||
}
|
||||
} else {
|
||||
Watchlist::create([
|
||||
'user_id' => auth()->id(),
|
||||
'anime_id' => $anime->id,
|
||||
'status' => $status,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$inList = true;
|
||||
$newStatus = $status;
|
||||
}
|
||||
|
||||
$newlyEarned = AchievementService::check(auth()->user());
|
||||
|
||||
return response()->json([
|
||||
'in_list' => $inList,
|
||||
'status' => $newStatus,
|
||||
'status_label' => $newStatus ? (Watchlist::STATUSES[$newStatus] ?? '') : null,
|
||||
'achievements' => array_map(fn($a) => ['title' => $a->title, 'icon' => $a->icon, 'color' => $a->color], $newlyEarned),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Watchlist Export ─────────────────────────────────────────────────────
|
||||
|
||||
public function watchlistExport(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user->hasPerk('watchlist_export')) {
|
||||
abort(403, 'Bu özellik için premium üyelik gerekiyor.');
|
||||
}
|
||||
|
||||
$format = in_array($request->query('format'), ['csv', 'json']) ? $request->query('format') : 'json';
|
||||
|
||||
$items = Watchlist::where('user_id', $user->id)
|
||||
->with('anime:id,title,mal_score,genres')
|
||||
->orderBy('status')
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($w) => [
|
||||
'title' => $w->anime->title ?? '',
|
||||
'status' => $w->status,
|
||||
'added_at' => $w->created_at?->toDateString(),
|
||||
'mal_score' => $w->anime->mal_score ?? null,
|
||||
]);
|
||||
|
||||
if ($format === 'csv') {
|
||||
$csv = "title,status,added_at,mal_score\n";
|
||||
foreach ($items as $row) {
|
||||
$csv .= '"' . str_replace('"', '""', $row['title']) . '",'
|
||||
. $row['status'] . ','
|
||||
. $row['added_at'] . ','
|
||||
. $row['mal_score'] . "\n";
|
||||
}
|
||||
return response($csv, 200, [
|
||||
'Content-Type' => 'text/csv; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="watchlist.csv"',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($items, 200, [
|
||||
'Content-Disposition' => 'attachment; filename="watchlist.json"',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Episode Vote ──────────────────────────────────────────────────────────
|
||||
|
||||
public function episodeVote(Request $request, Episode $episode)
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$vote = $request->input('vote') == 1 ? 1 : -1;
|
||||
|
||||
$existing = EpisodeVote::where('user_id', auth()->id())
|
||||
->where('episode_id', $episode->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->vote === $vote) {
|
||||
$existing->delete(); // toggle off
|
||||
} else {
|
||||
$existing->update(['vote' => $vote]);
|
||||
}
|
||||
} else {
|
||||
EpisodeVote::create([
|
||||
'user_id' => auth()->id(),
|
||||
'episode_id' => $episode->id,
|
||||
'vote' => $vote,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$likes = EpisodeVote::where('episode_id', $episode->id)->where('vote', 1)->count();
|
||||
$dislikes = EpisodeVote::where('episode_id', $episode->id)->where('vote', -1)->count();
|
||||
$myVote = EpisodeVote::where('user_id', auth()->id())->where('episode_id', $episode->id)->value('vote');
|
||||
|
||||
return response()->json([
|
||||
'likes' => $likes,
|
||||
'dislikes' => $dislikes,
|
||||
'my_vote' => $myVote,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Anime Rating ─────────────────────────────────────────────────────────
|
||||
|
||||
public function animeRate(Request $request, Anime $anime)
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$rating = (int) $request->input('rating');
|
||||
if ($rating < 1 || $rating > 10) {
|
||||
return response()->json(['error' => 'Geçersiz puan'], 422);
|
||||
}
|
||||
|
||||
AnimeRating::updateOrCreate(
|
||||
['user_id' => auth()->id(), 'anime_id' => $anime->id],
|
||||
['rating' => $rating]
|
||||
);
|
||||
|
||||
$avg = AnimeRating::where('anime_id', $anime->id)->avg('rating');
|
||||
$count = AnimeRating::where('anime_id', $anime->id)->count();
|
||||
|
||||
// Anime tablosunu güncelle (ağırlıklı ortalama)
|
||||
$anime->update(['rating' => round($avg, 1)]);
|
||||
|
||||
$newlyEarned = AchievementService::check(auth()->user());
|
||||
|
||||
return response()->json([
|
||||
'avg' => round($avg, 1),
|
||||
'count' => $count,
|
||||
'my_rating' => $rating,
|
||||
'achievements' => array_map(fn($a) => ['title' => $a->title, 'icon' => $a->icon, 'color' => $a->color], $newlyEarned),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Continue Watching (güncelleme) ───────────────────────────────────────
|
||||
|
||||
public function continueWatchingUpdate(Request $request)
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return response()->json(['ok' => false]);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|integer',
|
||||
'episode_id' => 'required|integer',
|
||||
'season_number' => 'required|integer',
|
||||
'episode_number' => 'required|integer',
|
||||
'seconds' => 'required|integer|min:0',
|
||||
'total' => 'nullable|integer|min:0',
|
||||
'percent' => 'nullable|integer|min:0|max:100',
|
||||
]);
|
||||
|
||||
$userId = auth()->id();
|
||||
|
||||
ContinueWatching::updateOrCreate(
|
||||
['user_id' => $userId, 'anime_id' => $data['anime_id']],
|
||||
[
|
||||
'episode_id' => $data['episode_id'],
|
||||
'season_number' => $data['season_number'],
|
||||
'episode_number' => $data['episode_number'],
|
||||
'seconds_watched' => $data['seconds'],
|
||||
'total_seconds' => $data['total'] ?? 0,
|
||||
'percent_complete'=> $data['percent'] ?? 0,
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
// stream_history perki yoksa en eski kayıtları silerek 30 limiti uygula
|
||||
if (!auth()->user()->hasPerk('stream_history')) {
|
||||
$count = ContinueWatching::where('user_id', $userId)->count();
|
||||
if ($count > 30) {
|
||||
$idsToDelete = ContinueWatching::where('user_id', $userId)
|
||||
->orderBy('updated_at')
|
||||
->limit($count - 30)
|
||||
->pluck('id');
|
||||
ContinueWatching::whereIn('id', $idsToDelete)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Başarım kontrolü (her 5 bölümde bir — performans için)
|
||||
if ($data['seconds'] % 300 < 35) {
|
||||
AchievementService::check(auth()->user());
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── Anime İsteği ─────────────────────────────────────────────────────────
|
||||
|
||||
public function requestIndex()
|
||||
{
|
||||
$requests = AnimeRequest::withCount('votes')
|
||||
->whereIn('status', ['pending', 'approved', 'added'])
|
||||
->orderByDesc('vote_count')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
$myRequests = auth()->check()
|
||||
? AnimeRequest::where('user_id', auth()->id())->orderByDesc('id')->limit(5)->get()
|
||||
: collect();
|
||||
|
||||
$votedIds = [];
|
||||
if (auth()->check()) {
|
||||
$votedIds = AnimeRequestVote::where('user_id', auth()->id())
|
||||
->pluck('anime_request_id')->toArray();
|
||||
}
|
||||
|
||||
return view('frontend.anime-request', compact('requests', 'myRequests', 'votedIds'));
|
||||
}
|
||||
|
||||
public function requestStore(Request $request)
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:200',
|
||||
'original_title' => 'nullable|string|max:200',
|
||||
'note' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Benzer istek var mı?
|
||||
$existing = AnimeRequest::whereRaw('LOWER(title) = ?', [strtolower($data['title'])])->first();
|
||||
if ($existing) {
|
||||
// Oy ekle
|
||||
$voted = AnimeRequestVote::where('anime_request_id', $existing->id)
|
||||
->where('user_id', auth()->id())
|
||||
->exists();
|
||||
if (!$voted) {
|
||||
AnimeRequestVote::create(['anime_request_id' => $existing->id, 'user_id' => auth()->id(), 'created_at' => now()]);
|
||||
$existing->increment('vote_count');
|
||||
}
|
||||
return response()->json(['ok' => true, 'merged' => true, 'request_id' => $existing->id, 'vote_count' => $existing->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
$req = AnimeRequest::create([
|
||||
'user_id' => auth()->id(),
|
||||
'title' => $data['title'],
|
||||
'original_title' => $data['original_title'] ?? null,
|
||||
'note' => $data['note'] ?? null,
|
||||
'status' => 'pending',
|
||||
'vote_count' => 1,
|
||||
]);
|
||||
|
||||
AnimeRequestVote::create(['anime_request_id' => $req->id, 'user_id' => auth()->id(), 'created_at' => now()]);
|
||||
|
||||
AchievementService::check(auth()->user());
|
||||
|
||||
return response()->json(['ok' => true, 'merged' => false, 'request_id' => $req->id, 'vote_count' => 1]);
|
||||
}
|
||||
|
||||
public function requestVote(AnimeRequest $animeRequest)
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$voted = AnimeRequestVote::where('anime_request_id', $animeRequest->id)
|
||||
->where('user_id', auth()->id())
|
||||
->exists();
|
||||
|
||||
if ($voted) {
|
||||
AnimeRequestVote::where('anime_request_id', $animeRequest->id)
|
||||
->where('user_id', auth()->id())
|
||||
->delete();
|
||||
$animeRequest->decrement('vote_count');
|
||||
$isVoted = false;
|
||||
} else {
|
||||
AnimeRequestVote::create(['anime_request_id' => $animeRequest->id, 'user_id' => auth()->id(), 'created_at' => now()]);
|
||||
$animeRequest->increment('vote_count');
|
||||
$isVoted = true;
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'voted' => $isVoted, 'vote_count' => $animeRequest->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
// ── Anime Takip ──────────────────────────────────────────────────────────
|
||||
|
||||
public function followToggle(Anime $anime)
|
||||
{
|
||||
$this->requireAuth();
|
||||
$userId = auth()->id();
|
||||
|
||||
$existing = AnimeFollow::where('user_id', $userId)->where('anime_id', $anime->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$following = false;
|
||||
} else {
|
||||
AnimeFollow::create(['user_id' => $userId, 'anime_id' => $anime->id]);
|
||||
$following = true;
|
||||
}
|
||||
|
||||
$count = AnimeFollow::where('anime_id', $anime->id)->count();
|
||||
|
||||
return response()->json(['following' => $following, 'count' => $count]);
|
||||
}
|
||||
|
||||
// ── Bildirimler ───────────────────────────────────────────────────────────
|
||||
|
||||
public function notificationsIndex()
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$notifications = UserNotification::where('user_id', auth()->id())
|
||||
->orderByDesc('created_at')
|
||||
->paginate(30);
|
||||
|
||||
// Görüntülenince hepsini okundu yap
|
||||
UserNotification::where('user_id', auth()->id())
|
||||
->whereNull('read_at')
|
||||
->update(['read_at' => now()]);
|
||||
|
||||
return view('frontend.notifications', compact('notifications'));
|
||||
}
|
||||
|
||||
public function notificationsCount()
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return response()->json(['count' => 0]);
|
||||
}
|
||||
$count = UserNotification::where('user_id', auth()->id())->whereNull('read_at')->count();
|
||||
return response()->json(['count' => $count]);
|
||||
}
|
||||
|
||||
// ── Bölüm Notları ─────────────────────────────────────────────────────────
|
||||
|
||||
public function noteStore(Request $request, Episode $episode)
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$data = $request->validate([
|
||||
'content' => 'required|string|max:500',
|
||||
'timestamp_at' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$note = EpisodeNote::create([
|
||||
'user_id' => auth()->id(),
|
||||
'episode_id' => $episode->id,
|
||||
'anime_id' => $episode->anime_id,
|
||||
'content' => $data['content'],
|
||||
'timestamp_at' => $data['timestamp_at'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'note' => [
|
||||
'id' => $note->id,
|
||||
'content' => $note->content,
|
||||
'timestamp_label' => $note->timestamp_label,
|
||||
'timestamp_at' => $note->timestamp_at,
|
||||
'created_at' => $note->created_at->format('d.m.Y H:i'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function noteDelete(EpisodeNote $note)
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
if ($note->user_id !== auth()->id()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$note->delete();
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function episodeNotesList(Episode $episode)
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$notes = EpisodeNote::where('user_id', auth()->id())
|
||||
->where('episode_id', $episode->id)
|
||||
->orderBy('timestamp_at')
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(fn($n) => [
|
||||
'id' => $n->id,
|
||||
'content' => $n->content,
|
||||
'timestamp_label' => $n->timestamp_label,
|
||||
'timestamp_at' => $n->timestamp_at,
|
||||
'created_at' => $n->created_at->format('d.m.Y H:i'),
|
||||
]);
|
||||
|
||||
return response()->json(['notes' => $notes]);
|
||||
}
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function requireAuth()
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
abort(401);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\VoiceCall;
|
||||
use App\Models\User;
|
||||
use App\Services\AgoraTokenService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class VoiceCallController extends Controller
|
||||
{
|
||||
public function initiate(Request $request)
|
||||
{
|
||||
$request->validate(['callee_id' => 'required|integer|exists:users,id']);
|
||||
$caller = Auth::user();
|
||||
$callee = User::findOrFail($request->callee_id);
|
||||
|
||||
if ($caller->id === $callee->id) {
|
||||
return response()->json(['error' => 'Kendinizi arayamazsınız.'], 422);
|
||||
}
|
||||
|
||||
// End any previous active calls
|
||||
VoiceCall::where('caller_id', $caller->id)
|
||||
->whereIn('status', ['ringing', 'active'])
|
||||
->update(['status' => 'ended', 'ended_at' => now()]);
|
||||
|
||||
$channelName = 'vc_' . Str::random(20);
|
||||
$call = VoiceCall::create([
|
||||
'caller_id' => $caller->id,
|
||||
'callee_id' => $callee->id,
|
||||
'channel_name' => $channelName,
|
||||
'status' => 'ringing',
|
||||
]);
|
||||
|
||||
$callerToken = AgoraTokenService::generateToken($channelName, $caller->id);
|
||||
$calleeToken = AgoraTokenService::generateToken($channelName, $callee->id);
|
||||
|
||||
return response()->json([
|
||||
'call_id' => $call->id,
|
||||
'channel_name' => $channelName,
|
||||
'token' => $callerToken,
|
||||
'callee' => [
|
||||
'id' => $callee->id,
|
||||
'name' => $callee->name,
|
||||
'avatar' => $callee->avatar ? \App\Support\MediaUrl::fromStoragePath($callee->avatar) : null,
|
||||
],
|
||||
'agora_app_id' => env('AGORA_APP_ID', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function answer(VoiceCall $call)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($call->callee_id === $user->id, 403);
|
||||
abort_unless($call->status === 'ringing', 422, 'Call is no longer ringing.');
|
||||
|
||||
$call->update(['status' => 'active', 'answered_at' => now()]);
|
||||
|
||||
$token = AgoraTokenService::generateToken($call->channel_name, $user->id);
|
||||
|
||||
return response()->json([
|
||||
'channel_name' => $call->channel_name,
|
||||
'token' => $token,
|
||||
'agora_app_id' => env('AGORA_APP_ID', ''),
|
||||
'caller' => [
|
||||
'id' => $call->caller->id,
|
||||
'name' => $call->caller->name,
|
||||
'avatar' => $call->caller->avatar ? \App\Support\MediaUrl::fromStoragePath($call->caller->avatar) : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function decline(VoiceCall $call)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($call->callee_id === $user->id || $call->caller_id === $user->id, 403);
|
||||
abort_unless($call->status === 'ringing', 422);
|
||||
|
||||
$call->update(['status' => 'declined', 'ended_at' => now()]);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function end(VoiceCall $call)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($call->callee_id === $user->id || $call->caller_id === $user->id, 403);
|
||||
|
||||
$call->update(['status' => 'ended', 'ended_at' => now()]);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function poll(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Check for incoming ringing call
|
||||
$incoming = VoiceCall::where('callee_id', $user->id)
|
||||
->where('status', 'ringing')
|
||||
->with('caller')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($incoming) {
|
||||
return response()->json([
|
||||
'type' => 'incoming',
|
||||
'call_id' => $incoming->id,
|
||||
'caller' => [
|
||||
'id' => $incoming->caller->id,
|
||||
'name' => $incoming->caller->name,
|
||||
'avatar' => $incoming->caller->avatar ? \App\Support\MediaUrl::fromStoragePath($incoming->caller->avatar) : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if an active call we're in has been ended by the other side
|
||||
$call_id = $request->query('call_id');
|
||||
if ($call_id) {
|
||||
$call = VoiceCall::find($call_id);
|
||||
if ($call && in_array($user->id, [$call->caller_id, $call->callee_id])) {
|
||||
return response()->json([
|
||||
'type' => 'status',
|
||||
'status' => $call->status,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['type' => 'none']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MediaController extends Controller
|
||||
{
|
||||
public function show(string $path)
|
||||
{
|
||||
$path = MediaUrl::normalize(rawurldecode($path));
|
||||
|
||||
abort_if($path === '' || str_contains($path, '..'), 404);
|
||||
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
abort_unless($disk->exists($path), 404);
|
||||
|
||||
return response()->file($disk->path($path), [
|
||||
'Cache-Control' => 'public, max-age=31536000',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Anime;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Setting;
|
||||
use App\Models\BlogPost;
|
||||
|
||||
class SitemapController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/');
|
||||
|
||||
return response()
|
||||
->view('sitemap_index', compact('domain'))
|
||||
->header('Content-Type', 'application/xml; charset=utf-8');
|
||||
}
|
||||
|
||||
public function main()
|
||||
{
|
||||
$domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/');
|
||||
|
||||
$genres = Genre::where('is_active', true)->select('slug', 'updated_at')->get();
|
||||
|
||||
$staticPages = [
|
||||
['loc' => '/', 'priority' => '1.0', 'changefreq' => 'daily'],
|
||||
['loc' => '/search', 'priority' => '0.8', 'changefreq' => 'daily'],
|
||||
['loc' => '/anime-request','priority' => '0.5', 'changefreq' => 'weekly'],
|
||||
['loc' => '/blog', 'priority' => '0.8', 'changefreq' => 'daily'],
|
||||
];
|
||||
|
||||
return response()
|
||||
->view('sitemaps.main', compact('domain', 'genres', 'staticPages'))
|
||||
->header('Content-Type', 'application/xml; charset=utf-8');
|
||||
}
|
||||
|
||||
public function animes()
|
||||
{
|
||||
$domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/');
|
||||
|
||||
$animes = Anime::where('is_published', true)
|
||||
->select('slug', 'updated_at', 'rating', 'cover_image', 'title')
|
||||
->orderByDesc('rating')
|
||||
->get()
|
||||
->each(function ($anime) use ($domain) {
|
||||
$anime->cover_image_url = $anime->cover_image
|
||||
? (str_starts_with($anime->cover_image, 'http') ? $anime->cover_image : $domain . '/storage/' . $anime->cover_image)
|
||||
: null;
|
||||
});
|
||||
|
||||
return response()
|
||||
->view('sitemaps.animes', compact('domain', 'animes'))
|
||||
->header('Content-Type', 'application/xml; charset=utf-8');
|
||||
}
|
||||
|
||||
public function blog()
|
||||
{
|
||||
$domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/');
|
||||
|
||||
$posts = BlogPost::published()
|
||||
->select('slug', 'updated_at', 'published_at', 'cover_image', 'title', 'excerpt')
|
||||
->orderByDesc('published_at')
|
||||
->get();
|
||||
|
||||
return response()
|
||||
->view('sitemaps.blog', compact('domain', 'posts'))
|
||||
->header('Content-Type', 'application/xml; charset=utf-8');
|
||||
}
|
||||
|
||||
public function videos()
|
||||
{
|
||||
$domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/');
|
||||
|
||||
$animes = Anime::where('is_published', true)
|
||||
->with(['episodes' => fn($q) => $q->with('season:id,season_number')->orderBy('episode_number')->limit(1)])
|
||||
->select('id', 'slug', 'title', 'description', 'cover_image', 'updated_at', 'rating')
|
||||
->orderByDesc('rating')
|
||||
->limit(200)
|
||||
->get()
|
||||
->each(function ($anime) use ($domain) {
|
||||
$anime->cover_image_url = $anime->cover_image
|
||||
? (str_starts_with($anime->cover_image, 'http') ? $anime->cover_image : $domain . '/storage/' . $anime->cover_image)
|
||||
: null;
|
||||
// İlk bölümün izleme URL'si — player_loc için (loc'tan farklı, gerçek player sayfası)
|
||||
$firstEp = $anime->episodes->first();
|
||||
$sNum = $firstEp?->season?->season_number ?? 1;
|
||||
$eNum = $firstEp?->episode_number ?? 1;
|
||||
$anime->first_ep_watch_url = $domain . '/watch/' . $anime->slug . '/' . $sNum . '/' . $eNum;
|
||||
});
|
||||
|
||||
return response()
|
||||
->view('sitemaps.videos', compact('domain', 'animes'))
|
||||
->header('Content-Type', 'application/xml; charset=utf-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminAccessMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
|
||||
if (!auth()->user()->isModerator()) {
|
||||
abort(403, 'Bu alana erişim yetkiniz yok.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminMiddleware
|
||||
{
|
||||
/**
|
||||
* Usage:
|
||||
* ->middleware(['admin']) → admin ONLY (moderators denied)
|
||||
* ->middleware(['admin:animes.edit']) → admin, OR moderator WITH that permission
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string $permission = null)
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
// Admins always pass
|
||||
if ($user->isAdmin()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Must be at least a moderator
|
||||
if ($user->role !== 'moderator') {
|
||||
abort(403, 'Bu alana erişim yetkiniz yok.');
|
||||
}
|
||||
|
||||
// Moderators always need a specific permission — no blanket access
|
||||
if (!$permission || !$user->can_mod($permission)) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['error' => 'Bu işlem için yetkiniz yok.'], 403);
|
||||
}
|
||||
return back()->with('error', 'Bu sayfaya erişim için gerekli izne sahip değilsiniz.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BotDetector
|
||||
{
|
||||
// Tamamen engelle — 403
|
||||
private const BAD_BOTS = [
|
||||
'scrapy', 'httrack', 'webcopier', 'webzip', 'teleportpro', 'webstripper',
|
||||
'offline explorer', 'larbin', 'libwww-perl',
|
||||
'masscan', 'nikto', 'sqlmap', 'nmap', 'zgrab', 'nuclei',
|
||||
'dirsearch', 'gobuster', 'feroxbuster', 'ffuf',
|
||||
'ahrefsbot', 'semrushbot', 'dotbot', 'mj12bot', 'blexbot',
|
||||
'bytespider', 'gptbot', 'claudebot', 'chatgpt-user',
|
||||
'ccbot', 'anthropic-ai', 'cohere-ai', 'omgili', 'omgilibot',
|
||||
'pinterestbot', 'petalbot', 'proximic', 'mediatoolkitbot',
|
||||
'dataforseobot', 'sitechecker', 'seokicks', 'linkfluence',
|
||||
'serpstatbot', 'serendeputy', 'riddler', 'netcraftsurveyagent',
|
||||
'netsystemsresearch', 'ioncrawl', 'brandverity',
|
||||
'wp_is_mobile', 'wordpress', 'wpbot',
|
||||
];
|
||||
|
||||
// Bu IP prefix'leri için tüm kontroller atlanır (güvenilir crawler'lar)
|
||||
private const TRUSTED_IP_PREFIXES = [
|
||||
'66.249.', // Googlebot
|
||||
'64.233.', // Google
|
||||
'74.125.', // Google
|
||||
'209.85.', // Google
|
||||
'216.239.', // Google
|
||||
'34.68.', // Google Cloud us-central1
|
||||
'34.64.', // Google Cloud
|
||||
'35.187.', // Google Cloud
|
||||
'35.190.', // Google Cloud
|
||||
];
|
||||
|
||||
// İzin ver ama bot olarak işaretle (SEO crawler'lar)
|
||||
private const GOOD_BOTS = [
|
||||
'googlebot', 'google-inspectiontool', 'google-structured-data-testing-tool',
|
||||
'adsbot-google', 'adsbot-google-mobile', 'mediapartners-google',
|
||||
'apis-google', 'feedfetcher-google', 'google-adwords-instant',
|
||||
'bingbot', 'msnbot', 'adidxbot', 'bingpreview',
|
||||
'duckduckbot', 'baiduspider', 'yandexbot', 'yandexmobilebot',
|
||||
'slurp', 'teoma', 'ia_archiver',
|
||||
'facebot', 'facebookexternalhit',
|
||||
'twitterbot', 'linkedinbot', 'whatsapp',
|
||||
'applebot', 'discordbot', 'telegrambot',
|
||||
'slackbot', 'pingdom',
|
||||
];
|
||||
|
||||
// Rate-limit uygula (dakikada 10'dan fazla → uyarı, 30'dan fazla → otomatik engel)
|
||||
private const GENERIC_BOTS = [
|
||||
'curl/', 'wget/', 'python-requests', 'python-urllib',
|
||||
'go-http-client', 'java/', 'okhttp/', 'libcurl',
|
||||
'node-fetch', 'node.js', 'axios', 'got/',
|
||||
'apache-httpclient', 'php/', 'perl/', 'ruby',
|
||||
'postman', 'insomnia', 'httpie',
|
||||
];
|
||||
|
||||
// Bu path'ler için sadece log tut, engelleme yapma
|
||||
private const SKIP_PATHS = [
|
||||
'/up', '/api/', '/sitemap',
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$ip = $request->ip();
|
||||
$ua = strtolower($request->userAgent() ?? '');
|
||||
$path = $request->path();
|
||||
|
||||
// Skip paths
|
||||
foreach (self::SKIP_PATHS as $skip) {
|
||||
if (str_starts_with('/' . $path, $skip)) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
// Güvenilir IP (Google vb.) — tüm kontrolleri atla
|
||||
foreach (self::TRUSTED_IP_PREFIXES as $prefix) {
|
||||
if (str_starts_with($ip, $prefix)) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
// Manuel engelli IP kontrolü
|
||||
if ($this->isBlockedIp($ip)) {
|
||||
$this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'ip_blocked', 'blocked_ip');
|
||||
return response('Erişim engellendi.', 403);
|
||||
}
|
||||
|
||||
// UA boşsa bot olarak işaretle
|
||||
if (empty($ua)) {
|
||||
$request->attributes->set('is_bot', true);
|
||||
$request->attributes->set('bot_type', 'noua');
|
||||
$this->logBot($ip, '', '/' . $path, $request->method(), 'allowed', 'no_ua');
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Kötü bot mu?
|
||||
foreach (self::BAD_BOTS as $pattern) {
|
||||
if (str_contains($ua, $pattern)) {
|
||||
$this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'blocked', $pattern);
|
||||
return response('', 403);
|
||||
}
|
||||
}
|
||||
|
||||
// İyi bot mu?
|
||||
foreach (self::GOOD_BOTS as $pattern) {
|
||||
if (str_contains($ua, $pattern)) {
|
||||
$request->attributes->set('is_bot', true);
|
||||
$request->attributes->set('bot_type', 'good');
|
||||
// İyi botlar için çok agresif rate limit (dakikada 60)
|
||||
if ($this->isRateLimited($ip, 60, 'good_bot')) {
|
||||
return response('', 429);
|
||||
}
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
// Generic araç mı?
|
||||
foreach (self::GENERIC_BOTS as $pattern) {
|
||||
if (str_contains($ua, $pattern)) {
|
||||
$request->attributes->set('is_bot', true);
|
||||
$request->attributes->set('bot_type', 'generic');
|
||||
if ($this->isRateLimited($ip, 10, 'generic')) {
|
||||
$this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'rate_limited', $pattern);
|
||||
// 30+ istek → otomatik engelle
|
||||
$count = Cache::get("bot_count_{$ip}", 0);
|
||||
if ($count > 30) {
|
||||
$this->autoBlock($ip, 'Otomatik: dakikada 30+ generic bot isteği');
|
||||
}
|
||||
return response('', 429);
|
||||
}
|
||||
$this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'allowed', $pattern);
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
// Normal kullanıcı — genel rate limit (dakikada 120 istek)
|
||||
if ($this->isRateLimited($ip, 120, 'human')) {
|
||||
$this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'rate_limited', 'human_flood');
|
||||
$count = Cache::get("bot_count_{$ip}", 0);
|
||||
if ($count > 200) {
|
||||
$this->autoBlock($ip, 'Otomatik: dakikada 200+ istek flood');
|
||||
}
|
||||
return response('', 429);
|
||||
}
|
||||
|
||||
$request->attributes->set('is_bot', false);
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function isBlockedIp(string $ip): bool
|
||||
{
|
||||
return Cache::remember("blocked_ip_{$ip}", 300, function () use ($ip) {
|
||||
try {
|
||||
return DB::table('blocked_ips')
|
||||
->where('ip', $ip)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->exists();
|
||||
} catch (\Exception) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function isRateLimited(string $ip, int $maxPerMinute, string $type): bool
|
||||
{
|
||||
$key = "rl_{$type}_{$ip}";
|
||||
$count = Cache::get($key, 0);
|
||||
|
||||
if ($count === 0) {
|
||||
Cache::put($key, 1, 60);
|
||||
} else {
|
||||
Cache::increment($key);
|
||||
}
|
||||
|
||||
// Bot count ayrı izle
|
||||
Cache::put("bot_count_{$ip}", Cache::get("bot_count_{$ip}", 0) + 1, 60);
|
||||
|
||||
return $count >= $maxPerMinute;
|
||||
}
|
||||
|
||||
private function autoBlock(string $ip, string $reason): void
|
||||
{
|
||||
try {
|
||||
DB::table('blocked_ips')->insertOrIgnore([
|
||||
'ip' => $ip,
|
||||
'reason' => $reason,
|
||||
'auto_blocked' => 1,
|
||||
'blocked_at' => now(),
|
||||
'expires_at' => now()->addHours(24),
|
||||
]);
|
||||
Cache::forget("blocked_ip_{$ip}");
|
||||
} catch (\Exception) {}
|
||||
}
|
||||
|
||||
private function logBot(string $ip, ?string $ua, string $path, string $method, string $action, string $botName): void
|
||||
{
|
||||
try {
|
||||
DB::table('analytics_bot_logs')->insert([
|
||||
'ip' => $ip,
|
||||
'user_agent' => mb_substr($ua ?? '', 0, 500),
|
||||
'path' => mb_substr($path, 0, 500),
|
||||
'method' => $method,
|
||||
'action' => $action,
|
||||
'bot_name' => mb_substr($botName, 0, 100),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
} catch (\Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ImportApiMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$apiKey = config('app.import_api_key');
|
||||
$provided = $request->header('X-Import-Key') ?? $request->query('api_key');
|
||||
|
||||
if (!$apiKey || $provided !== $apiKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Video player sayfasını başka sitelere embed edilmekten korur.
|
||||
* X-Frame-Options: SAMEORIGIN → sadece kendi domainimizden iframe açılabilir.
|
||||
*/
|
||||
class SecurePlayer
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\SeoRedirect;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SeoRedirectMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if ($request->isMethod('GET')) {
|
||||
try {
|
||||
$path = '/' . ltrim($request->path(), '/');
|
||||
$redirect = cache()->remember('seo_redirect_' . md5($path), 300, function () use ($path) {
|
||||
return SeoRedirect::where('from_path', $path)->where('is_active', true)->first();
|
||||
});
|
||||
|
||||
if ($redirect) {
|
||||
SeoRedirect::where('id', $redirect->id)->increment('hits');
|
||||
cache()->forget('seo_redirect_' . md5($path));
|
||||
return redirect($redirect->to_path, $redirect->type);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// DB/cache hatası — redirect yerine normal akışa devam et, site çökmesin
|
||||
\Illuminate\Support\Facades\Log::error('SeoRedirectMiddleware DB error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
class ResetPasswordMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $resetUrl,
|
||||
public readonly string $userName,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(subject: 'Animexe — Şifre Sıfırlama');
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.reset-password');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
class TestMail extends Mailable
|
||||
{
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(subject: 'Animexe — SMTP Test E-postası');
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.test');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
class VerifyEmailMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $verifyUrl,
|
||||
public readonly string $userName,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(subject: 'Animexe — E-posta Adresini Doğrula');
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.verify-email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Achievement extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'key', 'title', 'description', 'icon', 'color',
|
||||
'condition_type', 'condition_value',
|
||||
];
|
||||
|
||||
public function userAchievements()
|
||||
{
|
||||
return $this->hasMany(UserAchievement::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ActivationCode extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'code', 'plan_id', 'used_by', 'used_at',
|
||||
'created_by', 'expires_at', 'batch', 'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'used_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MembershipPlan::class, 'plan_id');
|
||||
}
|
||||
|
||||
public function usedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'used_by');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function isUsed(): bool
|
||||
{
|
||||
return ! is_null($this->used_at);
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at && $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return ! $this->isUsed() && ! $this->isExpired();
|
||||
}
|
||||
|
||||
public static function generateCode(): string
|
||||
{
|
||||
do {
|
||||
$hex = strtoupper(bin2hex(random_bytes(6)));
|
||||
$code = implode('-', str_split($hex, 4));
|
||||
} while (self::where('code', $code)->exists());
|
||||
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Ad extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name', 'type', 'placement', 'file_path', 'external_url', 'click_url',
|
||||
'skip_after', 'weight', 'is_active', 'starts_at', 'ends_at',
|
||||
'impressions', 'clicks',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
];
|
||||
|
||||
/** Aktif + zamanlaması uygun reklamlar */
|
||||
public function scopeLive(Builder $q): Builder
|
||||
{
|
||||
return $q->where('is_active', true)
|
||||
->where(fn($s) => $s->whereNull('starts_at')->orWhere('starts_at', '<=', now()))
|
||||
->where(fn($s) => $s->whereNull('ends_at')->orWhere('ends_at', '>=', now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Medya URL'si — yüklenen dosya veya dış URL.
|
||||
* Yüklenen dosyalar /media/{path} route'undan servis edilir (MediaController);
|
||||
* public/storage symlink'ine bağımlı değil — kapaklar/avatarlarla aynı yol.
|
||||
*/
|
||||
public function getMediaUrlAttribute(): ?string
|
||||
{
|
||||
if ($this->file_path) return MediaUrl::fromStoragePath($this->file_path);
|
||||
if ($this->external_url) return $this->external_url;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** CTR yüzdesi */
|
||||
public function getCtrAttribute(): float
|
||||
{
|
||||
return $this->impressions > 0
|
||||
? round($this->clicks / $this->impressions * 100, 2)
|
||||
: 0.0;
|
||||
}
|
||||
|
||||
/** Ağırlıklı rastgele seçim — pre-roll video reklam */
|
||||
public static function pickVideo(): ?self
|
||||
{
|
||||
return self::weightedPick(
|
||||
self::live()->where('type', 'video')->where('placement', 'preroll')->get()
|
||||
);
|
||||
}
|
||||
|
||||
/** Ağırlıklı rastgele seçim — banner (placement bazlı) */
|
||||
public static function pickBanner(string $placement): ?self
|
||||
{
|
||||
return self::weightedPick(
|
||||
self::live()->where('type', 'banner')->where('placement', $placement)->get()
|
||||
);
|
||||
}
|
||||
|
||||
private static function weightedPick($ads): ?self
|
||||
{
|
||||
if ($ads->isEmpty()) return null;
|
||||
$total = max(1, $ads->sum('weight'));
|
||||
$roll = random_int(1, $total);
|
||||
foreach ($ads as $ad) {
|
||||
$roll -= max(1, $ad->weight);
|
||||
if ($roll <= 0) return $ad;
|
||||
}
|
||||
return $ads->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Models\User;
|
||||
|
||||
class AiQuery extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'analytics_ai_queries';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'query_type', 'query_text', 'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BotLog extends Model
|
||||
{
|
||||
protected $table = 'analytics_bot_logs';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'ip', 'user_agent', 'path', 'method', 'action', 'bot_name', 'created_at',
|
||||
];
|
||||
|
||||
protected $casts = ['created_at' => 'datetime'];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Models\User;
|
||||
use App\Models\Anime;
|
||||
|
||||
class PageView extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'analytics_pageviews';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'session_id', 'url', 'page_type',
|
||||
'anime_id', 'episode_id', 'ip', 'country', 'city',
|
||||
'device', 'browser', 'referrer', 'is_bot', 'user_agent',
|
||||
'time_on_page', 'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
public function anime() { return $this->belongsTo(Anime::class); }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Models\User;
|
||||
|
||||
class VisitorSession extends Model
|
||||
{
|
||||
protected $table = 'analytics_sessions';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'session_id', 'user_id', 'ip', 'country', 'city',
|
||||
'device', 'browser', 'referrer', 'landing_page',
|
||||
'pages_visited', 'total_seconds', 'is_bot', 'bot_type',
|
||||
'user_agent', 'started_at', 'last_seen_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_bot' => 'boolean',
|
||||
'started_at' => 'datetime',
|
||||
'last_seen_at'=> 'datetime',
|
||||
];
|
||||
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Models\User;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
|
||||
class WatchEvent extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'analytics_watch_events';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'session_id', 'anime_id', 'episode_id',
|
||||
'season_number', 'episode_number',
|
||||
'seconds_watched', 'total_seconds', 'percent_complete',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
public function anime() { return $this->belongsTo(Anime::class); }
|
||||
public function episode() { return $this->belongsTo(Episode::class); }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Anime extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'title', 'title_en', 'title_jp', 'slug', 'description',
|
||||
'cover_image', 'banner_image', 'trailer_url',
|
||||
'release_year', 'type', 'status', 'episode_count',
|
||||
'rating', 'studio', 'mal_id', 'is_featured', 'is_published', 'is_dubbed',
|
||||
'is_trending', 'trending_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_featured' => 'boolean',
|
||||
'is_published' => 'boolean',
|
||||
'is_dubbed' => 'boolean',
|
||||
'is_trending' => 'boolean',
|
||||
'rating' => 'float',
|
||||
'trending_order' => 'integer',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
static::creating(function ($anime) {
|
||||
if (empty($anime->slug)) {
|
||||
$base = Str::slug($anime->title ?: 'anime');
|
||||
$slug = $base;
|
||||
$i = 2;
|
||||
while (static::where('slug', $slug)->exists()) {
|
||||
$slug = $base . '-' . $i++;
|
||||
}
|
||||
$anime->slug = $slug;
|
||||
}
|
||||
});
|
||||
static::saving(function ($anime) {
|
||||
if (empty($anime->slug)) {
|
||||
$base = Str::slug($anime->title ?: 'anime');
|
||||
$slug = $base;
|
||||
$i = 2;
|
||||
while (static::where('slug', $slug)->whereKeyNot($anime->id ?? 0)->exists()) {
|
||||
$slug = $base . '-' . $i++;
|
||||
}
|
||||
$anime->slug = $slug;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Güvenli detail URL — slug null olsa bile çökmez. */
|
||||
public function getDetailUrlAttribute(): string
|
||||
{
|
||||
return $this->slug ? route('anime.show', $this->slug) : '#';
|
||||
}
|
||||
|
||||
public function genres()
|
||||
{
|
||||
return $this->belongsToMany(Genre::class, 'anime_genre');
|
||||
}
|
||||
|
||||
public function seasons()
|
||||
{
|
||||
return $this->hasMany(Season::class)->orderBy('season_number');
|
||||
}
|
||||
|
||||
public function episodes()
|
||||
{
|
||||
return $this->hasMany(Episode::class);
|
||||
}
|
||||
|
||||
public function importJobs()
|
||||
{
|
||||
return $this->hasMany(ImportJob::class);
|
||||
}
|
||||
|
||||
public function permissions()
|
||||
{
|
||||
return $this->morphMany(ContentPermission::class, 'content', 'content_type', 'content_id');
|
||||
}
|
||||
|
||||
/** Cover veya banner URL'sini döndürür (storage veya dış URL) */
|
||||
private function imageUrl(?string $path): ?string
|
||||
{
|
||||
return MediaUrl::fromStoragePath($path);
|
||||
}
|
||||
|
||||
public function getCoverUrlAttribute(): ?string { return $this->imageUrl($this->cover_image); }
|
||||
public function getBannerUrlAttribute(): ?string { return $this->imageUrl($this->banner_image); }
|
||||
|
||||
public function getPermission(string $key): string
|
||||
{
|
||||
$override = $this->permissions()->where('permission_key', $key)->first();
|
||||
if ($override) return $override->required_membership;
|
||||
|
||||
$global = PermissionSetting::where('key', $key)->first();
|
||||
return $global ? $global->required_membership : 'free';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AnimeFollow extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['user_id', 'anime_id'];
|
||||
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
public function anime() { return $this->belongsTo(Anime::class); }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AnimeRating extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'anime_id', 'rating'];
|
||||
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
public function anime() { return $this->belongsTo(Anime::class); }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AnimeRequest extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id', 'title', 'original_title', 'note',
|
||||
'status', 'admin_note', 'vote_count',
|
||||
];
|
||||
|
||||
const STATUSES = [
|
||||
'pending' => ['label' => 'Bekliyor', 'color' => '#f0883e'],
|
||||
'approved' => ['label' => 'Onaylandı', 'color' => '#3fb950'],
|
||||
'rejected' => ['label' => 'Reddedildi', 'color' => '#f85149'],
|
||||
'added' => ['label' => 'Eklendi', 'color' => '#79c0ff'],
|
||||
];
|
||||
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
public function votes() { return $this->hasMany(AnimeRequestVote::class); }
|
||||
|
||||
public function hasVotedBy(?User $user, string $ip): bool
|
||||
{
|
||||
if ($user) {
|
||||
return $this->votes()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
return $this->votes()->where('ip', $ip)->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AnimeRequestVote extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['anime_request_id', 'user_id', 'ip', 'created_at'];
|
||||
|
||||
protected $casts = ['created_at' => 'datetime'];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AnimeSwipe extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
protected $fillable = ['user_id', 'anime_id', 'direction'];
|
||||
protected $casts = ['created_at' => 'datetime'];
|
||||
|
||||
public function anime() { return $this->belongsTo(Anime::class); }
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Banner extends Model
|
||||
{
|
||||
protected $fillable = ['title', 'image', 'link', 'is_active', 'sort_order'];
|
||||
protected $casts = ['is_active' => 'boolean'];
|
||||
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
return MediaUrl::fromStoragePath($this->image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BlogPost extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'title', 'slug', 'excerpt', 'content', 'cover_image',
|
||||
'focus_keyword', 'meta_title', 'meta_description', 'meta_keywords',
|
||||
'status', 'ai_generated', 'anime_id', 'linked_anime_ids', 'faq',
|
||||
'views', 'reading_time', 'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'linked_anime_ids' => 'array',
|
||||
'faq' => 'array',
|
||||
'ai_generated' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getCoverUrlAttribute(): ?string
|
||||
{
|
||||
return MediaUrl::fromStoragePath($this->cover_image);
|
||||
}
|
||||
|
||||
public function anime(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Anime::class);
|
||||
}
|
||||
|
||||
public function scopePublished($q)
|
||||
{
|
||||
return $q->where('status', 'published')->whereNotNull('published_at');
|
||||
}
|
||||
|
||||
public function getReadableTimeAttribute(): string
|
||||
{
|
||||
return $this->reading_time . ' dk okuma';
|
||||
}
|
||||
|
||||
public static function generateSlug(string $title): string
|
||||
{
|
||||
$slug = Str::slug($title, '-', 'tr');
|
||||
$base = $slug;
|
||||
$i = 1;
|
||||
while (static::where('slug', $slug)->exists()) {
|
||||
$slug = $base . '-' . $i++;
|
||||
}
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Comment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id', 'commentable_type', 'commentable_id',
|
||||
'parent_id', 'content', 'gif_url', 'status', 'is_pinned', 'like_count',
|
||||
];
|
||||
|
||||
protected $casts = ['is_pinned' => 'boolean'];
|
||||
|
||||
public function likes()
|
||||
{
|
||||
return $this->hasMany(CommentLike::class);
|
||||
}
|
||||
|
||||
public function isLikedBy(?int $userId): bool
|
||||
{
|
||||
if (!$userId) return false;
|
||||
return $this->likes()->where('user_id', $userId)->exists();
|
||||
}
|
||||
|
||||
public function commentable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function parent()
|
||||
{
|
||||
return $this->belongsTo(Comment::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function replies()
|
||||
{
|
||||
return $this->hasMany(Comment::class, 'parent_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CommentLike extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'comment_id'];
|
||||
|
||||
public function comment()
|
||||
{
|
||||
return $this->belongsTo(Comment::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ContentPermission extends Model
|
||||
{
|
||||
protected $fillable = ['content_type', 'content_id', 'permission_key', 'required_membership'];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ContinueWatching extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'continue_watching';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'anime_id', 'episode_id',
|
||||
'season_number', 'episode_number',
|
||||
'seconds_watched', 'total_seconds', 'percent_complete',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
public function anime() { return $this->belongsTo(Anime::class); }
|
||||
public function episode() { return $this->belongsTo(Episode::class); }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Conversation extends Model
|
||||
{
|
||||
public function participants()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'conversation_participants')
|
||||
->withPivot('last_read_at');
|
||||
}
|
||||
|
||||
public function messages()
|
||||
{
|
||||
return $this->hasMany(Message::class)->orderBy('created_at');
|
||||
}
|
||||
|
||||
public function lastMessage()
|
||||
{
|
||||
return $this->hasOne(Message::class)->latestOfMany('created_at');
|
||||
}
|
||||
|
||||
public function unreadCountFor(int $userId): int
|
||||
{
|
||||
$pivot = $this->participants->firstWhere('id', $userId)?->pivot;
|
||||
$lastRead = $pivot?->last_read_at;
|
||||
|
||||
$q = $this->messages()->where('user_id', '!=', $userId);
|
||||
if ($lastRead) {
|
||||
$q->where('created_at', '>', $lastRead);
|
||||
}
|
||||
return $q->count();
|
||||
}
|
||||
|
||||
// Find existing DM between two users or return null
|
||||
public static function between(int $a, int $b): ?self
|
||||
{
|
||||
return self::whereHas('participants', fn($q) => $q->where('user_id', $a))
|
||||
->whereHas('participants', fn($q) => $q->where('user_id', $b))
|
||||
->whereHas('participants', fn($q) => $q->havingRaw('COUNT(*) = 2'), null, null, fn($q) => $q->select(\DB::raw('COUNT(*)')))
|
||||
->first();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user