Initial commit: Animexe Laravel platform

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 00:01:48 +03:00
co-authored by Claude Opus 4.8
commit a63515cfc6
366 changed files with 74773 additions and 0 deletions
@@ -0,0 +1,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ı — CDNdeki .../{720p|1080p}-{dub}[/master.m3u8] kalıbından türet
// Embed modda URL video_urlde olabilir (m3u8_url null) — video_urle 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 URLlerini 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']);
}
}