Initial commit: Animexe Laravel platform
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[compose.yaml]
|
||||||
|
indent_size = 4
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
APP_NAME=Laravel
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost
|
||||||
|
|
||||||
|
APP_LOCALE=en
|
||||||
|
APP_FALLBACK_LOCALE=en
|
||||||
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
|
# PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DB_CONNECTION=sqlite
|
||||||
|
# DB_HOST=127.0.0.1
|
||||||
|
# DB_PORT=3306
|
||||||
|
# DB_DATABASE=laravel
|
||||||
|
# DB_USERNAME=root
|
||||||
|
# DB_PASSWORD=
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
CACHE_STORE=database
|
||||||
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_SCHEME=null
|
||||||
|
MAIL_HOST=127.0.0.1
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
.styleci.yml export-ignore
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
/.fleet
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.phpunit.cache
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
/auth.json
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/vendor
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteRule ^(.*)$ public/$1 [L]
|
||||||
|
</IfModule>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## About Laravel
|
||||||
|
|
||||||
|
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||||
|
|
||||||
|
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||||
|
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||||
|
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||||
|
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||||
|
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||||
|
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||||
|
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||||
|
|
||||||
|
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||||
|
|
||||||
|
## Learning Laravel
|
||||||
|
|
||||||
|
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||||
|
|
||||||
|
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||||
|
|
||||||
|
## Laravel Sponsors
|
||||||
|
|
||||||
|
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||||
|
|
||||||
|
### Premium Partners
|
||||||
|
|
||||||
|
- **[Vehikl](https://vehikl.com)**
|
||||||
|
- **[Tighten Co.](https://tighten.co)**
|
||||||
|
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||||
|
- **[64 Robots](https://64robots.com)**
|
||||||
|
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||||
|
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||||
|
- **[Redberry](https://redberry.international/laravel-development)**
|
||||||
|
- **[Active Logic](https://activelogic.com)**
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||||
|
|
||||||
|
## Security Vulnerabilities
|
||||||
|
|
||||||
|
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user