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
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Services;
use App\Models\Achievement;
use App\Models\UserAchievement;
use App\Models\User;
use App\Models\ContinueWatching;
use App\Models\Watchlist;
use Illuminate\Support\Facades\DB;
class AchievementService
{
/**
* Kullanıcının kazanması gereken başarımları kontrol et ve ver.
* Yeni kazanılan başarımları döndürür (popup için).
*/
public static function check(User $user): array
{
$allAchievements = Achievement::all();
$earned = UserAchievement::where('user_id', $user->id)->pluck('achievement_id')->toArray();
$newlyEarned = [];
foreach ($allAchievements as $ach) {
if (in_array($ach->id, $earned)) continue;
$met = match ($ach->condition_type) {
'episodes_watched' => self::episodesWatched($user) >= $ach->condition_value,
'hours_watched' => self::hoursWatched($user) >= $ach->condition_value,
'watchlist_count' => Watchlist::where('user_id', $user->id)->count() >= $ach->condition_value,
'anime_rated' => DB::table('anime_ratings')->where('user_id', $user->id)->count() >= $ach->condition_value,
'request_sent' => DB::table('anime_requests')->where('user_id', $user->id)->count() >= $ach->condition_value,
'first_login' => true,
default => false,
};
if ($met) {
UserAchievement::firstOrCreate([
'user_id' => $user->id,
'achievement_id' => $ach->id,
], ['earned_at' => now()]);
$newlyEarned[] = $ach;
}
}
return $newlyEarned;
}
private static function episodesWatched(User $user): int
{
return ContinueWatching::where('user_id', $user->id)
->where('percent_complete', '>=', 70)
->count();
}
private static function hoursWatched(User $user): float
{
return round(
ContinueWatching::where('user_id', $user->id)->sum('seconds_watched') / 3600, 1
);
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Services;
class AgoraTokenService
{
private const VERSION = '006';
private const PRIVILEGE_JOIN_CHANNEL = 1;
private const PRIVILEGE_PUBLISH_AUDIO_STREAM = 2;
private const PRIVILEGE_PUBLISH_VIDEO_STREAM = 3;
private const PRIVILEGE_PUBLISH_DATA_STREAM = 4;
public static function generateToken(string $channelName, int $uid, int $expireSeconds = 3600): string
{
$appId = config('services.agora.app_id', env('AGORA_APP_ID', ''));
$appCertificate = config('services.agora.app_certificate', env('AGORA_APP_CERTIFICATE', ''));
if (!$appId || !$appCertificate) {
// Development fallback — no token required when cert is empty
return '';
}
$currentTimestamp = time();
$expireTimestamp = $currentTimestamp + $expireSeconds;
$nonce = random_int(1, 16777216);
$privileges = [
self::PRIVILEGE_JOIN_CHANNEL => $expireTimestamp,
self::PRIVILEGE_PUBLISH_AUDIO_STREAM => $expireTimestamp,
self::PRIVILEGE_PUBLISH_VIDEO_STREAM => 0,
self::PRIVILEGE_PUBLISH_DATA_STREAM => $expireTimestamp,
];
// Pack message
$message = self::packUint16(1); // version: 1 (AccessToken)
$message .= self::packUint32($currentTimestamp);
$message .= self::packUint32($nonce);
$message .= self::packString($channelName);
$message .= self::packUint32($uid);
$message .= self::packPrivileges($privileges);
// HMAC-SHA256 signature
$signature = hash_hmac('sha256', $appId . $currentTimestamp . $nonce . $channelName . $uid . self::packPrivileges($privileges), $appCertificate, true);
$content = self::packString($signature) . $message;
return self::VERSION . $appId . base64_encode($content);
}
private static function packUint16(int $v): string
{
return pack('n', $v);
}
private static function packUint32(int $v): string
{
return pack('N', $v);
}
private static function packString(string $v): string
{
return pack('n', strlen($v)) . $v;
}
private static function packPrivileges(array $privileges): string
{
ksort($privileges);
$packed = pack('n', count($privileges));
foreach ($privileges as $key => $value) {
$packed .= pack('n', $key) . pack('N', $value);
}
return $packed;
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace App\Services;
use App\Models\Anime;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AniListService
{
const ENDPOINT = 'https://graphql.anilist.co';
const CACHE_TTL = 60 * 60 * 24 * 7;
// Maksimum genişlik (px) — oran korunur, WebP'ye çevrilir
const COVER_MAX_W = 600;
const BANNER_MAX_W = 1920;
const WEBP_QUALITY = 90;
private function query(array $variables, string $gql): ?array
{
try {
$res = Http::timeout(10)
->withHeaders(['Content-Type' => 'application/json', 'Accept' => 'application/json'])
->post(self::ENDPOINT, ['query' => $gql, 'variables' => $variables]);
if (!$res->ok()) return null;
if (!empty($res->json('errors'))) return null;
return $res->json('data.Media');
} catch (\Throwable $e) {
Log::debug('AniList query failed', ['err' => $e->getMessage()]);
return null;
}
}
public function fetchByMalId(int $malId): ?array
{
return Cache::remember("anilist_mal_{$malId}", self::CACHE_TTL, function () use ($malId) {
return $this->query(
['malId' => $malId],
'query($malId:Int){Media(idMal:$malId,type:ANIME){coverImage{extraLarge}bannerImage}}'
);
});
}
public function fetchByTitle(string $title): ?array
{
return Cache::remember('anilist_title_' . md5($title), self::CACHE_TTL, function () use ($title) {
return $this->query(
['search' => $title],
'query($search:String){Media(search:$search,type:ANIME){coverImage{extraLarge}bannerImage}}'
);
});
}
/**
* Resmi indir, yeniden boyutlandır, WebP olarak storage'a kaydet.
* Başarılıysa storage-relative yolu döner (örn. anime/covers/123.webp).
*/
/**
* Resmi indir, max genişliğe orantılı küçült (asla büyütme), WebP kaydet.
* Orijinalden küçükse olduğu gibi bırakır.
*/
private function downloadAndResize(string $url, string $storagePath, int $maxW): ?string
{
try {
$response = Http::timeout(20)->withHeaders([
'User-Agent' => 'Mozilla/5.0',
'Referer' => 'https://anilist.co/',
])->get($url);
if (!$response->ok()) return null;
$raw = $response->body();
$src = @imagecreatefromstring($raw);
if (!$src) return null;
$srcW = imagesx($src);
$srcH = imagesy($src);
if ($srcW > $maxW) {
// Orantılı küçült
$newW = $maxW;
$newH = (int) round($srcH * ($maxW / $srcW));
$dst = imagecreatetruecolor($newW, $newH);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $srcW, $srcH);
imagedestroy($src);
} else {
// Zaten küçük — olduğu gibi kullan
$dst = $src;
}
$absPath = storage_path('app/public/' . $storagePath);
@mkdir(dirname($absPath), 0755, true);
$ok = imagewebp($dst, $absPath, self::WEBP_QUALITY);
imagedestroy($dst);
return $ok ? $storagePath : null;
} catch (\Throwable $e) {
Log::debug('AniList image download failed', ['url' => $url, 'err' => $e->getMessage()]);
return null;
}
}
/**
* Anime'nin boş kapak/banner alanlarını AniList'ten doldur.
* Resimleri indirir, boyutlandırır, WebP olarak storage'a kaydeder.
* Dolu alanların üzerine yazmaz.
*/
public function fillImages(Anime $anime): bool
{
$needCover = empty($anime->cover_image);
$needBanner = empty($anime->banner_image);
if (!$needCover && !$needBanner) return false;
$data = null;
if ($anime->mal_id) {
$data = $this->fetchByMalId((int) $anime->mal_id);
}
if (!$data) {
$data = $this->fetchByTitle($anime->title);
}
if (!$data) return false;
$updates = [];
if ($needCover && !empty($data['coverImage']['extraLarge'])) {
$path = $this->downloadAndResize(
$data['coverImage']['extraLarge'],
"anime/covers/{$anime->id}.webp",
self::COVER_MAX_W
);
if ($path) $updates['cover_image'] = $path;
}
if ($needBanner && !empty($data['bannerImage'])) {
$path = $this->downloadAndResize(
$data['bannerImage'],
"anime/banners/{$anime->id}.webp",
self::BANNER_MAX_W
);
if ($path) $updates['banner_image'] = $path;
}
if (empty($updates)) return false;
$anime->update($updates);
return true;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
class AniSkipService
{
// v2 kapalı, v1 çalışıyor
const BASE = 'https://api.aniskip.com/v1';
const CACHE_HIT = 60 * 60 * 24;
const CACHE_MISS = 60 * 15; // miss → 15 dk (kısa retry)
public function getSkipTimes(string $malId, int $episodeNumber): ?array
{
$key = "aniskip_v1_{$malId}_{$episodeNumber}";
if (Cache::has($key)) {
return Cache::get($key);
}
try {
// v1: types[]=op&types[]=ed şeklinde gönderilmeli
$url = self::BASE . "/skip-times/{$malId}/{$episodeNumber}?types[]=op&types[]=ed&episodeLength=0";
$res = Http::timeout(6)->get($url);
if (!$res->ok() || empty($res->json('results'))) {
Cache::put($key, null, self::CACHE_MISS);
return null;
}
$result = [];
foreach ($res->json('results') as $item) {
$type = $item['skip_type'] ?? null;
$interval = $item['interval'] ?? null;
if (!$type || !$interval) continue;
$result[$type] = [
'start' => round((float)($interval['start_time'] ?? $interval['startTime'] ?? 0), 2),
'end' => round((float)($interval['end_time'] ?? $interval['endTime'] ?? 0), 2),
];
}
$data = empty($result) ? null : $result;
Cache::put($key, $data, $data ? self::CACHE_HIT : self::CACHE_MISS);
return $data;
} catch (\Throwable) {
return null;
}
}
public function searchByTitle(string $title, ?string $titleEn = null, ?string $titleJp = null): ?string
{
$jikan = new JikanService();
return $jikan->searchMalId($title, $titleEn, $titleJp);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Services;
use App\Models\Setting;
class BunnyCdnSigner
{
/**
* BunnyCDN Token Auth ile imzalı URL üret.
* Token Auth açık değilse orijinal URL'yi döndür.
*
* BunnyCDN token format:
* token = base64url( sha256( securityKey + urlPath + expires ) )
* final = https://cdn.example.com/path?token={token}&expires={timestamp}
*/
public static function sign(?string $url): ?string
{
if (!$url) return null;
$securityKey = Setting::get('bunnycdn_token_key', '');
if (!$securityKey) return $url; // token auth kapalı
// Only sign BunnyCDN URLs - other domains pass through unchanged.
$host = parse_url($url, PHP_URL_HOST) ?? "";
$bunny = str_ends_with($host, "b-cdn.net") || str_contains($host, "bunnycdn.com");
if (!$bunny) return $url;
$ttl = (int) Setting::get('bunnycdn_token_ttl', 120);
$expires = time() + ($ttl * 60);
// URL'den path kısmını al
$parsed = parse_url($url);
$path = $parsed['path'] ?? '/';
// BunnyCDN token hesapla
$hashableBase = $securityKey . $path . $expires;
$token = base64_encode(hash('sha256', $hashableBase, true));
$token = str_replace(['+', '/', '='], ['-', '_', ''], $token);
// Mevcut query string varsa koru
$separator = isset($parsed['query']) ? '&' : '?';
$base = $parsed['scheme'] . '://' . $parsed['host'] . $path;
if (isset($parsed['query'])) {
$base .= '?' . $parsed['query'];
}
return $base . $separator . 'token=' . $token . '&expires=' . $expires;
}
/**
* Birden fazla URL imzala (dub sources için)
*/
public static function signAll(array &$sources): void
{
foreach ($sources as &$s) {
if (isset($s['url'])) {
$s['url'] = self::sign($s['url']);
}
}
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Services;
use App\Models\Setting;
class BunnyCdnStorage
{
private static function creds(): ?array
{
$zone = Setting::get('bunnycdn_zone', '');
$apiKey = Setting::get('bunnycdn_api_key', '');
$pullUrl = rtrim(Setting::get('bunnycdn_pull_url', ''), '/');
if (!$zone || !$apiKey || !$pullUrl) return null;
return ['zone' => $zone, 'apiKey' => $apiKey, 'pullUrl' => $pullUrl];
}
/**
* Pull URL'den dosya yolunu çıkar, CDN'den sil.
* Altyazı ve MP4 gibi tekil dosyalar için.
*/
public static function deleteFile(?string $url): void
{
if (!$url) return;
$creds = self::creds();
if (!$creds) return;
if (!str_starts_with($url, $creds['pullUrl'])) return;
$path = ltrim(substr($url, strlen($creds['pullUrl'])), '/');
if (!$path) return;
self::delete($creds, $path);
}
/**
* Video URL'sindeki anime klasörünü (anime_XXXXX/) tamamen sil.
* Anime silindiğinde tüm sezon/bölüm dosyaları tek seferde temizlenir.
*/
public static function deleteAnimeFolder(?string $anyVideoUrl): void
{
if (!$anyVideoUrl) return;
$creds = self::creds();
if (!$creds) return;
if (!str_starts_with($anyVideoUrl, $creds['pullUrl'])) return;
$path = ltrim(substr($anyVideoUrl, strlen($creds['pullUrl'])), '/');
$folder = explode('/', $path)[0] ?? '';
if (!$folder) return;
// Trailing slash = klasör silme
self::delete($creds, $folder . '/');
}
private static function delete(array $creds, string $remotePath): void
{
$url = "https://storage.bunnycdn.com/{$creds['zone']}/{$remotePath}";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ["AccessKey: {$creds['apiKey']}"],
]);
curl_exec($ch);
curl_close($ch);
}
}
+636
View File
@@ -0,0 +1,636 @@
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Http;
class DeepSeekService
{
private string $apiKey;
public function __construct()
{
$this->apiKey = Setting::get('deepseek_api_key', '');
}
public function isConfigured(): bool
{
return !empty($this->apiKey);
}
/**
* Anime için Türkçe özet/açıklama üret.
*/
public function generateAnimeDescription(string $title, string $titleJp = '', string $genres = ''): ?string
{
$prompt = "Sen bir anime veritabanı editörüsün. Aşağıdaki anime için Türkçe, akıcı ve bilgilendirici bir özet/açıklama yaz (3-5 cümle, 120-220 kelime arası). Spoiler verme, merak uyandır.\n\n"
. "Anime adı: {$title}" . ($titleJp ? " ({$titleJp})" : '') . "\n"
. ($genres ? "Türler: {$genres}\n" : '')
. "\nSadece açıklama metnini yaz, başka hiçbir şey ekleme.";
return $this->call($prompt);
}
/**
* Bölüm için Türkçe açıklama üret.
*/
public function generateEpisodeDescription(string $animeTitle, int $episodeNumber, string $episodeTitle = ''): ?string
{
$prompt = "Sen bir anime veritabanı editörüsün. Aşağıdaki anime bölümü için kısa, akıcı ve spoiler içermeyen Türkçe bir açıklama yaz (2-4 cümle, 80-160 kelime arası).\n\n"
. "Anime: {$animeTitle}\n"
. "Bölüm: {$episodeNumber}. Bölüm" . ($episodeTitle ? "{$episodeTitle}" : '') . "\n\n"
. "Sadece açıklama metnini yaz, başka hiçbir şey ekleme.";
return $this->call($prompt);
}
/**
* Anime için tüm meta verileri JSON olarak döndür.
* Dönen alanlar: description, release_year, studio, type, status, rating, title_en, title_jp, genres[]
*/
public function generateAnimeMeta(string $title, string $titleJp = ''): ?array
{
$prompt = <<<PROMPT
Sen bir anime veritabanı asistanısın. Aşağıdaki anime hakkında tüm bilgileri JSON formatında döndür.
Anime adı: {$title}
{$titleJp}
Döndüreceğin JSON şöyle olmalı (bilinmiyorsa null):
{
"description": "Türkçe, akıcı, spoiler içermeyen 3-5 cümlelik özet (120-220 kelime)",
"release_year": 2021,
"studio": "Stüdyo adı",
"type": "series|movie|ova|ona|special",
"status": "completed|ongoing|upcoming",
"rating": 8.5,
"title_en": "İngilizce başlık",
"title_jp": "日本語タイトル",
"genres": ["Aksiyon", "Drama"]
}
Genres listesi için sadece şu kategorileri kullan (uygunları seç): Aksiyon, Macera, Komedi, Drama, Fantezi, Bilim Kurgu, Korku, Romantizm, Spor, Supernatural, Gerilim, Slice of Life, Mecha, Müzik, Tarihsel, Okul, Isekai, Ecchi, Shounen, Shoujo, Seinen, Josei, Dövüş Sanatları.
SADECE geçerli JSON döndür, hiçbir açıklama veya markdown ekleme.
PROMPT;
$raw = $this->callJson($prompt);
return $raw;
}
public function checkSpoiler(string $text): ?array
{
$result = $this->moderateComment($text);
return ['is_spoiler' => $result['is_spoiler'], 'score' => $result['spoiler_score']];
}
/**
* Yorum moderasyonu: spoiler + küfür/hakaret kontrolü.
* Döner: ['is_spoiler'=>bool, 'spoiler_score'=>int, 'is_rude'=>bool, 'rude_score'=>int]
*/
public function moderateComment(string $text): array
{
$default = ['is_spoiler' => false, 'spoiler_score' => 0, 'is_rude' => false, 'rude_score' => 0];
$prompt = "Aşağıdaki metin bir anime platformuna yazılmış kullanıcı yorumudur. İki şeyi kontrol et:\n"
. "1) Anime bölümüne ait SPOILER içeriyor mu? (olay örgüsü açıklama, karakter ölümü, sürpriz sahne ifşası vb.)\n"
. "2) KABA/HAKARET içeriyor mu? (küfür, nefret söylemi, ağır hakaret, cinsel içerik)\n\n"
. "Sadece JSON döndür:\n"
. "{\"is_spoiler\": false, \"spoiler_score\": 10, \"is_rude\": false, \"rude_score\": 5}\n"
. "score değerleri 0-100 arası olasılık.\n\n"
. "Metin: " . mb_substr($text, 0, 400);
$raw = $this->callJson($prompt, 80);
if (!$raw) return $default;
return [
'is_spoiler' => (bool)($raw['is_spoiler'] ?? false),
'spoiler_score' => (int)($raw['spoiler_score'] ?? $raw['score'] ?? 0),
'is_rude' => (bool)($raw['is_rude'] ?? false),
'rude_score' => (int)($raw['rude_score'] ?? 0),
];
}
public string $lastError = '';
private function callJson(string $prompt, int $maxTokens = 600): ?array
{
if (!$this->isConfigured()) {
$this->lastError = 'API anahtarı ayarlanmamış';
return null;
}
try {
$response = Http::withToken($this->apiKey)
->timeout(90)
->post('https://api.deepseek.com/chat/completions', [
'model' => 'deepseek-chat',
'messages' => [['role' => 'user', 'content' => $prompt]],
'max_tokens' => $maxTokens,
'temperature' => 0.3,
'response_format' => ['type' => 'json_object'],
]);
if (!$response->successful()) {
$this->lastError = 'HTTP ' . $response->status() . ': ' . $response->json('error.message', $response->body());
\Log::error('DeepSeek API hatası', ['status' => $response->status(), 'body' => $response->body()]);
return null;
}
$content = trim($response->json('choices.0.message.content', ''));
if (!$content) {
$this->lastError = 'API boş yanıt döndürdü';
return null;
}
$content = preg_replace('/^```json\s*/i', '', $content);
$content = preg_replace('/\s*```$/i', '', $content);
$data = json_decode($content, true);
if (!is_array($data)) {
$this->lastError = 'JSON parse hatası: ' . substr($content, 0, 200);
return null;
}
return $data;
} catch (\Exception $e) {
$this->lastError = $e->getMessage();
\Log::error('DeepSeek exception', ['message' => $e->getMessage()]);
return null;
}
}
// ── Frontend AI methods ──────────────────────────────────────────────────
/**
* Anime kataloğunu AI context string olarak döndür (1 saat önbellek).
*/
public function getAnimeContext(): string
{
return \Illuminate\Support\Facades\Cache::remember('ai_anime_context', 3600, function () {
$animes = \App\Models\Anime::where('is_published', true)
->with('genres:id,name')
->get(['id', 'title', 'type', 'status', 'rating', 'release_year']);
return $animes->map(function ($a) {
$genres = $a->genres->pluck('name')->join(', ');
$type = $a->type === 'movie' ? 'Film' : 'Dizi';
return "ID:{$a->id}|{$a->title}|{$type}|{$a->release_year}|{$a->rating}"
. ($genres ? "|{$genres}" : '');
})->join("\n");
});
}
/**
* Çok turlu sohbet (sistem mesajı + anime kataloğu ile).
* $messages = [['role'=>'user','content'=>'...'], ...]
*/
public function chat(array $messages, string $animeContext = ''): ?string
{
$sys = "Sen Animexe'nin AI anime asistanısın. Animexe, Türkçe altyazılı/dublajlı ücretsiz anime izleme platformudur (animexe.com).\n\n";
$sys .= "== PLATFORM BİLGİLERİ (kullanıcı sorarsa bunları kullan) ==\n"
. "- Kayıt: Ücretsiz, e-posta ile. Kayıt olmadan bazı içerikler kısıtlı.\n"
. "- Premium üyelik: Aylık ücretli. Avantajları: reklamsız izleme, 1080p HD, erken bölüm erişimi.\n"
. "- Altyazı/Dublaj: Türkçe altyazı ve Türkçe dublaj seçenekleri mevcuttur. Player'da seçilebilir.\n"
. "- Takip/Favori: Anime sayfasında kalp veya 'Takip' butonuna tıkla. Yeni bölüm bildirimi gelir.\n"
. "- İzleme geçmişi: Otomatik kaydedilir. Profil > Geçmiş kısmından görebilirsin.\n"
. "- Arama: Üst menüdeki arama kutusuna anime adını yaz.\n"
. "- Anime isteği: 'Anime İste' sayfasından eksik animeleri talep edebilirsin.\n"
. "- Mobil: Tarayıcıdan tam destek. Android uygulaması da mevcut.\n"
. "- Dil seçimi: Player'da ses ve altyazı dili değiştirilebilir.\n"
. "- Yorumlar: Her bölümün altında yorum yapılabilir, spoiler işaretlenebilir.\n\n";
if ($animeContext) {
$sys .= "== PLATFORM KATALOĞU (ID|Başlık|Tip|Yıl|Puan|Türler) ==\n{$animeContext}\n\n";
}
$sys .= "== KURALLAR ==\n"
. "- Türkçe, samimi, kısa ve net cevap ver. Emoji kullanabilirsin.\n"
. "- Sadece platformdaki animeleri öner (katalogdan ID'si olan).\n"
. "- Spoiler verme. Merak uyandır.\n"
. "- Anime önerirken cevabının en sonuna şu formatı ekle (başka yere koyma): [SUGGEST:id1,id2,id3]\n"
. " Örnek: 'Attack on Titan harika! [SUGGEST:42]' — max 5 anime ID.\n"
. "- Eğer anime önermiyorsan [SUGGEST:...] satırını HİÇ EKLEME.\n"
. "- Site hakkında soruları yukarıdaki platform bilgilerini kullanarak cevapla.\n";
$apiMessages = array_merge(
[['role' => 'system', 'content' => $sys]],
array_slice($messages, -12)
);
return $this->callMessages($apiMessages, 700);
}
/**
* Kullanıcı tercihlerine göre 6 anime öner.
* Döner: [['id'=>1,'reason'=>'...'], ...]
*/
public function recommend(string $preferences, array $animes): ?array
{
$list = implode("\n", array_map(function ($a) {
$genres = isset($a['genres']) ? implode(', ', array_column($a['genres'], 'name')) : '';
return "ID:{$a['id']}|{$a['title']}|" . ($a['type'] === 'movie' ? 'Film' : 'Dizi')
. "|{$a['release_year']}|{$a['rating']}" . ($genres ? "|{$genres}" : '');
}, array_slice($animes, 0, 250)));
$prompt = "Anime öneri sistemi: Kullanıcı tercihlerine göre listeden EN İYİ 6 animeyi seç.\n\n"
. "Tercihler:\n{$preferences}\n\n"
. "Animeler:\n{$list}\n\n"
. "Yanıt: {\"recommendations\":[{\"id\":1,\"reason\":\"Kısa Türkçe neden (max 12 kelime)\"}]}\n"
. "SADECE JSON.";
$result = $this->callJson($prompt, 500);
if (!is_array($result)) return null;
if (isset($result['recommendations']) && is_array($result['recommendations'])) {
return $result['recommendations'];
}
if (isset($result[0]['id'])) return $result;
return null;
}
/**
* Doğal dil sorgusu ile anime ara.
* Döner: [id1, id2, ...]
*/
public function naturalSearch(string $query, array $animes): ?array
{
$list = implode("\n", array_map(function ($a) {
$genres = isset($a['genres']) ? implode(', ', array_column($a['genres'], 'name')) : '';
return "ID:{$a['id']}|{$a['title']}" . ($genres ? "|{$genres}" : '');
}, array_slice($animes, 0, 300)));
$prompt = "Kullanıcı sorgusu: \"{$query}\"\n\nAnimeler:\n{$list}\n\n"
. "En uygun max 12 animeyi bul: {\"ids\":[1,5,12]}\nSADECE JSON.";
$result = $this->callJson($prompt, 150);
if (!is_array($result)) return null;
if (isset($result['ids']) && is_array($result['ids'])) return array_map('intval', $result['ids']);
return null;
}
/**
* Bölüm hakkında spoilersız AI analizi.
*/
public function episodeInfo(string $animeTitle, int $episodeNumber, string $episodeTitle = '', string $description = ''): ?string
{
$prompt = "Sen bir anime uzmanısın. Aşağıdaki bölüm hakkında Türkçe, kısa ve ilgi çekici bir analiz yaz (3-4 cümle). "
. "Spoiler içerme. Bölümün atmosferini, önemini ve izleyiciyi neden heyecanlandırabileceğini anlat.\n\n"
. "Anime: {$animeTitle}\n"
. "Bölüm: {$episodeNumber}." . ($episodeTitle ? "{$episodeTitle}" : '') . "\n"
. ($description ? "Açıklama: {$description}\n" : '')
. "\nSadece analiz metnini yaz.";
return $this->call($prompt, 350);
}
// ── Blog Generation ──────────────────────────────────────────────────────
/**
* Bir anime için SEO blog yazısı üret.
* Döner: ['title','slug','excerpt','content','focus_keyword','meta_description','faq','linked_slugs']
*/
public function generateBlogPost(\App\Models\Anime $anime, array $relatedAnimes = []): ?array
{
$genreList = $anime->genres->pluck('name')->join(', ');
$type = $anime->type === 'movie' ? 'anime film' : 'anime dizi';
$year = $anime->release_year ?? '';
$status = match($anime->status ?? '') {
'ongoing' => 'devam ediyor',
'completed' => 'tamamlandı',
'upcoming' => 'yakında çıkacak',
default => '',
};
$relatedList = '';
if (!empty($relatedAnimes)) {
$relatedList = "\nİlgili animeler (içerik içinde bunlara link ver, format: [LINK:slug]Anime Adı[/LINK]):\n";
foreach (array_slice($relatedAnimes, 0, 5) as $r) {
$relatedList .= "- {$r['slug']}: {$r['title']}\n";
}
}
$prompt = <<<PROMPT
Sen Animexe platformu için SEO uyumlu Türkçe blog yazıları yazan bir editörsün.
Animexe, Türkçe altyazılı/dublajlı anime izleme platformudur.
Aşağıdaki anime için çok kapsamlı, SEO uyumlu bir blog yazısı yaz:
- Anime adı: {$anime->title}
- Tür: {$type}
- Yıl: {$year}
- Türler: {$genreList}
- Durum: {$status}
- Açıklama: {$anime->description}
{$relatedList}
Blog yazısı gereksinimleri:
1. 400-600 kelime, sade ve akıcı Türkçe
2. HTML formatında yaz: <h2>, <p>, <ul>, <li>, <strong> etiketleri kullan
3. Yapı: Giriş (1-2 paragraf) Ana içerik (2 H2 bölümü) FAQ (2 soru-cevap, <div class="faq-item"><h3>...</h3><p>...</p></div>)
4. {$anime->title} anahtar kelimesini doğal şekilde 3-5 kez kullan
5. İlgili animelere [LINK:slug]Anime Adı[/LINK] formatında link ekle (varsa, max 2)
6. Spoiler verme, merak uyandır
7. Sonunda kısa bir CTA ekle
Yanıt JSON formatında:
{{
"title": "SEO başlığı (50-60 karakter, anime adını içermeli)",
"excerpt": "Meta description (150-160 karakter)",
"focus_keyword": "Ana anahtar kelime",
"meta_description": "SEO meta açıklaması (150-160 karakter)",
"content": "Tam HTML blog içeriği",
"faq": [
{{"q": "Soru", "a": "Cevap"}},
{{"q": "Soru", "a": "Cevap"}},
{{"q": "Soru", "a": "Cevap"}}
],
"linked_slugs": ["slug1", "slug2"]
}}
SADECE geçerli JSON döndür.
PROMPT;
return $this->callJson($prompt, 4096);
}
// ── SEO AI Methods ───────────────────────────────────────────────────────
/**
* SEO danışman sohbeti sitenin kontekstini bilen uzman
*/
public function seoChat(array $messages, array $siteContext = []): ?string
{
$stats = $siteContext;
$sys = <<<SYS
Sen Animexe için çalışan kıdemli bir SEO danışmanısın. Animexe, Türkçe altyazılı ve dublajlı anime izleme platformudur (animexe.com).
Site istatistikleri:
- Yayında anime sayısı: {$stats['anime_count']}
- SEO başlığı olan anime: {$stats['seo_covered']} / {$stats['anime_count']}
- SEO skoru: {$stats['seo_score']}/100
- Sitemap URL sayısı: {$stats['sitemap_urls']}
Uzmanlık alanların:
- Teknik SEO (Core Web Vitals, canonicals, structured data)
- İçerik SEO (keyword araştırması, meta etiketler, başlık optimizasyonu)
- Yerel SEO (Türkiye pazarı için anime arama trendleri)
- E-A-T (Expertise, Authoritativeness, Trustworthiness)
- Schema.org (TVSeries, VideoObject, FAQPage, BreadcrumbList, Organization)
- Backlink stratejisi ve internal linking
Kurallar:
- Türkçe cevap ver, kısa ve aksiyona yönelik ol
- Spesifik, ölçülebilir öneriler sun
- Animexe'nin bulunduğu nişe (anime, Türkiye, ücretsiz izleme) odaklan
- Rakip analizi yapabilirsin: anizm.tv, turkanime.co, animeshark.net
- Markdown kullanabilirsin (bold, liste, başlık)
SYS;
$apiMessages = array_merge(
[['role' => 'system', 'content' => $sys]],
array_slice($messages, -16)
);
return $this->callMessages($apiMessages, 800);
}
/**
* Anime için AI destekli SEO başlığı + meta açıklama üret
*/
public function generateAnimeSeoMeta(\App\Models\Anime $anime): ?array
{
$genres = $anime->genres?->pluck('name')->join(', ') ?? '';
$type = $anime->type === 'movie' ? 'anime film' : ($anime->type === 'ova' ? 'OVA' : 'anime dizi');
$year = $anime->release_year ?? '';
$desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 300) : '';
$prompt = <<<PROMPT
Sen bir SEO uzmanısın. Aşağıdaki anime için Türkiye pazarına yönelik, tıklanma oranını (CTR) maksimize eden SEO başlığı ve meta açıklaması yaz.
Anime bilgileri:
- Ad: {$anime->title}
- Tür: {$type}
- Yıl: {$year}
- Kategoriler: {$genres}
- Açıklama: {$desc}
Kurallar:
- SEO Başlığı: 50-65 karakter, anahtar kelimeyi başa al, duygusal tetikleyici ekle, "Türkçe" kelimesi kullan
- Meta Açıklama: 145-158 karakter, aksiyon çağrısı içersin, "ücretsiz", "HD" gibi değer önerileri ekle
- Anahtar Kelimeler: 3-5 adet, virgülle ayrılmış, Türkçe arama trendlerine uygun
- Kullanıcı niyeti: anime izlemek isteyen Türkçe konuşan kullanıcılar
JSON formatında yanıt ver:
{
"seo_title": "...",
"seo_meta_desc": "...",
"seo_keywords": "...",
"primary_keyword": "...",
"search_intent": "transactional|informational|navigational"
}
SADECE JSON.
PROMPT;
return $this->callJson($prompt, 400);
}
/**
* Belirli bir konu için anahtar kelime önerileri
*/
public function suggestKeywords(string $topic, string $niche = 'anime'): ?array
{
$prompt = <<<PROMPT
Sen bir keyword araştırma uzmanısın. "{$topic}" konusu için Türkiye pazarında arama yapan kullanıcıların kullandığı anahtar kelimeleri bul.
Platform nişi: {$niche} izleme platformu (Türkçe altyazılı, ücretsiz)
Şu kategorilerde kelimeler öner:
1. Head keywords (yüksek hacim, yüksek rekabet) - 3 adet
2. Body keywords (orta hacim, orta rekabet) - 4 adet
3. Long-tail keywords (düşük hacim, düşük rekabet, yüksek niyet) - 5 adet
4. Question keywords (soru formatı, featured snippet fırsatı) - 3 adet
JSON formatı:
{
"head": [{"keyword":"...", "volume":"Yüksek/Orta/Düşük", "difficulty":75, "intent":"..."}],
"body": [...],
"long_tail": [...],
"questions": [...],
"content_ideas": ["Bu kelimeler için 3 içerik fikri"]
}
SADECE JSON.
PROMPT;
return $this->callJson($prompt, 1200);
}
/**
* URL'nin SEO sorunlarını AI ile analiz et
*/
public function analyzePageSeo(string $url, string $title, string $description, string $content): ?array
{
$contentSnippet = mb_substr(strip_tags($content), 0, 500);
$prompt = <<<PROMPT
Sen bir teknik SEO denetçisisin. Aşağıdaki web sayfasını SEO açısından analiz et.
URL: {$url}
Sayfa Başlığı: {$title}
Meta Açıklama: {$description}
İçerik (ilk 500 karakter): {$contentSnippet}
Şunları değerlendir ve JSON döndür:
{
"overall_score": 75,
"title_analysis": {"score":80, "issues":["Sorun1"], "suggestions":["Öneri1"]},
"description_analysis": {"score":70, "issues":[], "suggestions":[]},
"content_analysis": {"score":65, "keyword_density":"iyi/zayıf/aşırı", "issues":[], "suggestions":[]},
"technical_issues": ["Liste halinde teknik sorunlar"],
"quick_wins": ["Hemen yapılabilecek 3-5 iyileştirme"],
"priority_actions": ["Öncelikli aksiyonlar (sıralı)"]
}
SADECE JSON.
PROMPT;
return $this->callJson($prompt, 800);
}
/**
* Anime için FAQ Schema (JSON-LD) üret
*/
public function generateFaqSchema(\App\Models\Anime $anime): ?array
{
$type = $anime->type === 'movie' ? 'film' : 'dizi';
$desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 200) : $anime->title;
$genres = $anime->genres?->pluck('name')->join(', ') ?? '';
$prompt = <<<PROMPT
Sen bir schema.org uzmanısın. Aşağıdaki anime sayfası için Google'ın featured snippet'larını hedefleyen 5 adet FAQPage sorusu ve cevabı yaz.
Anime: {$anime->title} ({$type})
Kategoriler: {$genres}
Açıklama: {$desc}
Kurallar:
- Kullanıcıların gerçekten sorduğu sorular ("nerede izlenir", "kaç bölüm", "türkçe var mı" gibi)
- Cevaplar 1-3 cümle, net ve bilgilendirici
- Türkçe
- Animexe platformuna yönlendiren cevaplar (animexe.com'da izleyebilirsiniz)
JSON formatı:
{
"faqs": [
{"question": "...", "answer": "..."},
...
]
}
SADECE JSON.
PROMPT;
return $this->callJson($prompt, 600);
}
/**
* Site için genel içerik stratejisi öner
*/
public function generateContentStrategy(array $siteStats, array $weakKeywords = []): ?string
{
$kwList = !empty($weakKeywords) ? implode(', ', array_slice($weakKeywords, 0, 10)) : 'genel anime';
$score = $siteStats['seo_score'] ?? 0;
$total = $siteStats['anime_count'] ?? 0;
$covered = $siteStats['seo_covered'] ?? 0;
$prompt = <<<PROMPT
Sen Animexe için çalışan bir SEO içerik stratejisti olarak, 90 günlük bir SEO eylem planı hazırla.
Mevcut durum:
- SEO skoru: {$score}/100
- Toplam anime: {$total}
- SEO başlığı olan: {$covered}/{$total}
- Zayıf kelimeler / fırsatlar: {$kwList}
- Platform: Türkçe anime izleme (animexe.com)
- Rakipler: anizm.tv, turkanime.co
Plan şunları içersin:
1. **İlk 30 gün Teknik temel** (hızlı kazançlar)
2. **31-60 gün İçerik genişletme** (keyword hedefleme)
3. **61-90 gün Otorite inşası** (backlink, sosyal sinyal)
4. **KPI hedefleri** (organik trafik artışı, sıralama hedefleri)
5. **Öncelikli içerik türleri** (anime review, top listeler, karşılaştırma yazıları)
Markdown formatında yaz. Somut, ölçülebilir ve Türkiye pazarına özgü ol.
PROMPT;
return $this->call($prompt, 1200);
}
/**
* Robots.txt için AI önerisi
*/
public function generateRobotsTxt(string $domain, array $paths = []): ?string
{
$pathList = !empty($paths) ? implode(', ', $paths) : '/admin, /api, /storage, /profile';
$prompt = "Sen bir teknik SEO uzmanısın. {$domain} domaini için optimal robots.txt içeriği oluştur. "
. "Platform bir anime izleme sitesi. Korunacak dizinler: {$pathList}. "
. "Sadece robots.txt içeriğini döndür, açıklama ekleme.";
return $this->call($prompt, 300);
}
/**
* Keşfet sayfası için anime başına kısa, çekici hook metni üret (2 cümle, spoiler yok).
*/
public function generateDiscoveryHook(\App\Models\Anime $anime): ?string
{
$genres = $anime->relationLoaded('genres')
? $anime->genres->pluck('name')->join(', ')
: '';
$info = $anime->title;
if ($anime->release_year) $info .= " ({$anime->release_year})";
if ($genres) $info .= ", {$genres}";
if ($anime->description) $info .= ". " . \Illuminate\Support\Str::limit(strip_tags($anime->description), 180);
$prompt = "Sen bir anime tanıtım yazarısın. Aşağıdaki anime için 1-2 cümlelik, merak uyandırıcı, spoiler içermeyen Türkçe bir tanıtım yaz. Emoji kullanma. Sadece tanıtım metnini yaz.\n\n{$info}";
return $this->call($prompt, 80);
}
// ── Private helpers ──────────────────────────────────────────────────────
private function callMessages(array $messages, int $maxTokens = 400): ?string
{
if (!$this->isConfigured()) return null;
try {
$r = Http::withToken($this->apiKey)->timeout(35)
->post('https://api.deepseek.com/chat/completions', [
'model' => 'deepseek-chat', 'messages' => $messages,
'max_tokens' => $maxTokens, 'temperature' => 0.75,
]);
if (!$r->successful()) return null;
return trim($r->json('choices.0.message.content', '')) ?: null;
} catch (\Exception $e) { return null; }
}
private function call(string $prompt, int $maxTokens = 400): ?string
{
if (!$this->isConfigured()) {
return null;
}
try {
$response = Http::withToken($this->apiKey)
->timeout(30)
->post('https://api.deepseek.com/chat/completions', [
'model' => 'deepseek-chat',
'messages' => [['role' => 'user', 'content' => $prompt]],
'max_tokens' => $maxTokens,
'temperature' => 0.7,
]);
if (!$response->successful()) {
return null;
}
return trim($response->json('choices.0.message.content', '')) ?: null;
} catch (\Exception $e) {
return null;
}
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FcmService
{
private string $projectId;
private ?string $serverKey;
public function __construct()
{
$this->projectId = config('services.firebase.project_id', 'animexeapp');
$this->serverKey = config('services.firebase.server_key');
}
/**
* Send push notification to a single FCM token.
*/
public function sendToToken(string $token, string $title, string $body, array $data = []): bool
{
if (!$this->serverKey) {
Log::warning('FCM server key not configured');
return false;
}
try {
$response = Http::withHeaders([
'Authorization' => 'key=' . $this->serverKey,
'Content-Type' => 'application/json',
])->post('https://fcm.googleapis.com/fcm/send', [
'to' => $token,
'notification' => [
'title' => $title,
'body' => $body,
'sound' => 'default',
],
'data' => $data,
'priority' => 'high',
]);
return $response->successful();
} catch (\Throwable $e) {
Log::error('FCM send error: ' . $e->getMessage());
return false;
}
}
/**
* Send to multiple tokens (batch).
*/
public function sendToTokens(array $tokens, string $title, string $body, array $data = []): int
{
if (!$this->serverKey || empty($tokens)) return 0;
$sent = 0;
// FCM supports max 1000 tokens per batch
foreach (array_chunk($tokens, 1000) as $chunk) {
try {
$response = Http::withHeaders([
'Authorization' => 'key=' . $this->serverKey,
'Content-Type' => 'application/json',
])->post('https://fcm.googleapis.com/fcm/send', [
'registration_ids' => $chunk,
'notification' => [
'title' => $title,
'body' => $body,
'sound' => 'default',
],
'data' => $data,
'priority' => 'high',
]);
if ($response->successful()) {
$sent += count($chunk);
}
} catch (\Throwable $e) {
Log::error('FCM batch send error: ' . $e->getMessage());
}
}
return $sent;
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class JikanService
{
const BASE = 'https://api.jikan.moe/v4';
const CACHE_TTL = 60 * 60 * 24 * 7; // 1 week
/**
* Given a root MAL ID, walk the sequel chain and return
* an ordered array of MAL IDs: [s1_mal_id, s2_mal_id, ...]
*/
public function fetchSeasonMalIds(string $rootMalId): array
{
$chain = [];
$visited = [];
$current = $rootMalId;
// Walk up to 10 sequels (safety limit)
for ($i = 0; $i < 10; $i++) {
if (in_array($current, $visited)) break;
$visited[] = $current;
$chain[] = $current;
$sequel = $this->getSequel($current);
if (!$sequel) break;
$current = $sequel;
// Jikan rate limit: max 3 req/s — small sleep between calls
usleep(400_000); // 400ms
}
return $chain;
}
/**
* Returns the MAL ID of the direct "Sequel" relation, or null.
*/
public function getSequel(string $malId): ?string
{
$key = "jikan_relations_{$malId}";
$data = Cache::remember($key, self::CACHE_TTL, function () use ($malId) {
$res = Http::timeout(10)->get(self::BASE . "/anime/{$malId}/relations");
if (!$res->ok()) return null;
return $res->json();
});
if (!$data || empty($data['data'])) return null;
foreach ($data['data'] as $rel) {
if (strtolower($rel['relation']) === 'sequel') {
foreach ($rel['entry'] as $entry) {
if ($entry['type'] === 'anime') {
return (string) $entry['mal_id'];
}
}
}
}
return null;
}
/**
* Search Jikan by title, return best-matching MAL ID or null.
* Tries title_jp first, then title_en, then title.
* $animeType: 'series'|'movie'|'ova'|'ona'|'special' (optional, improves accuracy)
*/
public function searchMalId(string $title, ?string $titleEn = null, ?string $titleJp = null, ?string $animeType = null): ?string
{
$queries = array_values(array_filter(array_unique([$titleJp, $titleEn, $title])));
foreach ($queries as $i => $q) {
$malId = $this->searchByQuery($q, $animeType);
if ($malId) return $malId;
if ($i < count($queries) - 1) usleep(350_000);
}
return null;
}
private function searchByQuery(string $query, ?string $animeType = null): ?string
{
// Map our type → Jikan type; try specific first then fallback to any
$jikanType = match ($animeType) {
'movie' => 'movie',
'ova' => 'ova',
'ona' => 'ona',
'special' => 'special',
default => 'tv',
};
// Try with specific type, then without type restriction (catches edge cases)
$typesToTry = array_unique([$jikanType, null]);
foreach ($typesToTry as $type) {
$cacheKey = 'jikan_s2_' . md5($query . '_' . ($type ?? 'any'));
$data = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($query, $type) {
$params = ['q' => $query, 'limit' => 8, 'sfw' => false];
if ($type) $params['type'] = $type;
$res = Http::timeout(10)->get(self::BASE . '/anime', $params);
if (!$res->ok()) return null;
return $res->json('data');
});
if (!empty($data)) {
$best = $this->bestMatch($query, $data);
if ($best) return (string) $best['mal_id'];
}
if ($type !== null) usleep(300_000); // rate limit between type fallback
}
return null;
}
/**
* Pick the result whose title best matches the query via similar_text.
* Falls back to first result if nothing scores > 40%.
*/
private function bestMatch(string $query, array $results): ?array
{
$q = mb_strtolower(trim($query));
$best = null;
$bestScore = 0;
foreach ($results as $item) {
$candidates = array_filter([
$item['title'] ?? null,
$item['title_english'] ?? null,
$item['title_japanese'] ?? null,
]);
foreach ($candidates as $t) {
similar_text($q, mb_strtolower(trim($t)), $pct);
if ($pct > $bestScore) {
$bestScore = $pct;
$best = $item;
}
}
}
// If best match is decent or we have no choice, return it
return ($best && $bestScore >= 35) ? $best : ($results[0] ?? null);
}
/**
* Fetch anime details (title_english, title, episodes count etc.)
*/
public function getAnimeDetails(string $malId): ?array
{
$key = "jikan_anime_{$malId}";
$data = Cache::remember($key, self::CACHE_TTL, function () use ($malId) {
$res = Http::timeout(10)->get(self::BASE . "/anime/{$malId}");
if (!$res->ok()) return null;
return $res->json('data');
});
return $data;
}
}
+235
View File
@@ -0,0 +1,235 @@
<?php
namespace App\Services;
class PremiumFeatures
{
// Tüm mevcut premium özellikler — key → metadata
public const ALL = [
// ── İzleme ────────────────────────────────────────────────────────────
'watchlist_export' => [
'name' => 'İzleme Listesi Dışa Aktarımı',
'description' => 'Tüm izleme listeni CSV veya JSON olarak indir',
'category' => 'İzleme',
'icon' => 'bi-download',
],
'anime_notes' => [
'name' => 'Kişisel Anime Notları',
'description' => 'Her bölüm için sadece sana görünen gizli notlar bırak',
'category' => 'İzleme',
'icon' => 'bi-journal-text',
],
'stream_history' => [
'name' => 'Sınırsız İzleme Geçmişi',
'description' => 'Tüm geçmiş saklanır; standart üyede 30 kayıt limiti',
'category' => 'İzleme',
'icon' => 'bi-clock-history',
],
// ── Sosyal / Yorum ────────────────────────────────────────────────────
'comment_bg' => [
'name' => 'Yorum Arkaplanı Efekti',
'description' => 'Yorumlarına özel animasyonlu arkaplan ekle',
'category' => 'Sosyal',
'icon' => 'bi-fire',
],
'extended_comments' => [
'name' => 'Uzun Yorum (1000 karakter)',
'description' => 'Ücretsiz kullanıcıların 2 katı yorum uzunluğu',
'category' => 'Sosyal',
'icon' => 'bi-chat-text-fill',
],
'comment_gif' => [
'name' => 'Yoruma GIF Ekle',
'description' => 'Yorumlarına Tenor/GIPHY GIFleri ekleyebilirsin',
'category' => 'Sosyal',
'icon' => 'bi-filetype-gif',
],
'comment_glow' => [
'name' => 'Yorum Aura / Parıltı',
'description' => 'Yorumlarının çevresinde renkli parlayan enerji halkası',
'category' => 'Sosyal',
'icon' => 'bi-brightness-high-fill',
],
'comment_signature' => [
'name' => 'Yorum İmzası',
'description' => 'Her yorumun altında görünen kişisel imza satırı',
'category' => 'Sosyal',
'icon' => 'bi-pen-fill',
],
// ── Profil / Kozmetik ─────────────────────────────────────────────────
'gif_avatar' => [
'name' => 'GIF Profil Fotoğrafı',
'description' => 'Hareketli GIF\'i profil resmi olarak ayarla',
'category' => 'Profil',
'icon' => 'bi-image-fill',
],
'username_color' => [
'name' => 'Renkli Kullanıcı Adı',
'description' => 'Yorumlarda kullanıcı adın özel renkte görünsün',
'category' => 'Profil',
'icon' => 'bi-palette-fill',
],
'username_effect' => [
'name' => 'Kullanıcı Adı Animasyonu',
'description' => 'Shimmer, dalga, glitch gibi özel animasyon efektleri',
'category' => 'Profil',
'icon' => 'bi-lightning-charge-fill',
],
'profile_frame' => [
'name' => 'Profil Çerçevesi',
'description' => 'Avatarının çevresinde animasyonlu çerçeve',
'category' => 'Profil',
'icon' => 'bi-circle-fill',
],
'profile_badge' => [
'name' => 'Özel Rozet/Unvan',
'description' => 'Kullanıcı adının yanında özel rozet veya unvan',
'category' => 'Profil',
'icon' => 'bi-award-fill',
],
'profile_bg' => [
'name' => 'Animasyonlu Profil Arka Planı',
'description' => 'Profil sayfanda canlı animasyonlu arka plan',
'category' => 'Profil',
'icon' => 'bi-stars',
],
'animated_banner' => [
'name' => 'Animasyonlu Profil Bannerı',
'description' => 'Profil bannerın parçacık ve dalga efektiyle canlanır',
'category' => 'Profil',
'icon' => 'bi-image-alt',
],
'entry_effect' => [
'name' => 'Sayfa Giriş Efekti',
'description' => 'Sayfalara girerken özel giriş animasyonu',
'category' => 'Profil',
'icon' => 'bi-play-circle-fill',
],
'watch_rank' => [
'name' => 'İzleme Rank Rozeti',
'description' => 'İzleme saatine göre Acemi→Efsane arası özel rank',
'category' => 'Profil',
'icon' => 'bi-trophy-fill',
],
// ── Hesap ─────────────────────────────────────────────────────────────
'custom_profile_url' => [
'name' => 'Özel Profil URL\'i',
'description' => 'animexe.com/u/senin-adin gibi kişisel URL',
'category' => 'Hesap',
'icon' => 'bi-link-45deg',
],
'profile_music' => [
'name' => 'Profil Müziği',
'description' => 'Profil sayfanda bir anime OST çal',
'category' => 'Hesap',
'icon' => 'bi-music-note-beamed',
],
];
// Kategori sıralaması
public const CATEGORIES = ['İzleme', 'Sosyal', 'Profil', 'Hesap'];
// Yorum aura/parıltı efektleri
public const COMMENT_GLOWS = [
'cyan' => ['label' => 'Siyan', 'color' => '#00f5ff'],
'pink' => ['label' => 'Pembe', 'color' => '#ff2d7d'],
'gold' => ['label' => 'Altın', 'color' => '#ffd700'],
'green' => ['label' => 'Yeşil', 'color' => '#00f564'],
'purple' => ['label' => 'Mor', 'color' => '#b84dff'],
'fire' => ['label' => 'Alev', 'color' => '#ff6b35'],
];
// Kullanıcı adı animasyon efektleri
public const USERNAME_EFFECTS = [
'shimmer' => ['label' => 'Işıltı'],
'wave' => ['label' => 'Dalga'],
'pulse' => ['label' => 'Nabız'],
'glitch' => ['label' => 'Glitch'],
'bounce' => ['label' => 'Zıplama'],
];
// Sayfa giriş efektleri
public const ENTRY_EFFECTS = [
'fade' => ['label' => 'Solma'],
'slide' => ['label' => 'Kayma'],
'zoom' => ['label' => 'Yakınlaştırma'],
'glitch' => ['label' => 'Glitch'],
'wave' => ['label' => 'Dalga'],
];
// Profil arka plan stilleri
public const PROFILE_BACKGROUNDS = [
'fire' => ['label' => 'Alev', 'preview' => '#ff6b35,#ff2d7d'],
'galaxy' => ['label' => 'Galaksi', 'preview' => '#0d0520,#050510'],
'aurora' => ['label' => 'Aurora', 'preview' => '#040e0e,#071a1a'],
'ice' => ['label' => 'Buz', 'preview' => '#040d11,#071420'],
'sakura' => ['label' => 'Sakura', 'preview' => '#ff9ec4,#ffd6e7'],
'neon' => ['label' => 'Neon', 'preview' => '#00f5ff,#b84dff'],
'stars' => ['label' => 'Yıldızlar', 'preview' => '#0a0a2e,#7c3aed'],
];
// Yorum arkaplan stilleri
public const COMMENT_BACKGROUNDS = [
'fire' => ['label' => 'Alev', 'preview' => '#ff6b35,#ff2d7d'],
'aurora' => ['label' => 'Aurora', 'preview' => '#00f5b4,#00f5ff'],
'stars' => ['label' => 'Yıldızlar', 'preview' => '#0a0a2e,#7c3aed'],
'sakura' => ['label' => 'Sakura', 'preview' => '#ff9ec4,#ffd6e7'],
'neon' => ['label' => 'Neon', 'preview' => '#00f5ff,#b84dff'],
'galaxy' => ['label' => 'Galaksi', 'preview' => '#0d0d2e,#4a1d96'],
'ice' => ['label' => 'Buz', 'preview' => '#a8edff,#e0f7ff'],
];
// Profil çerçeve stilleri
public const PROFILE_FRAMES = [
'neon' => ['label' => 'Neon', 'color' => '#00f5ff'],
'fire' => ['label' => 'Alev', 'color' => '#ff6b35'],
'sakura' => ['label' => 'Sakura', 'color' => '#ff9ec4'],
'galaxy' => ['label' => 'Galaksi', 'color' => '#b84dff'],
'gold' => ['label' => 'Altın', 'color' => '#ffd700'],
'ice' => ['label' => 'Buz', 'color' => '#a8edff'],
'blood' => ['label' => 'Kan', 'color' => '#dc143c'],
'mint' => ['label' => 'Mint', 'color' => '#00ff7f'],
'rainbow' => ['label' => 'Gökkuşağı', 'color' => '#ff0000'],
'ocean' => ['label' => 'Okyanus', 'color' => '#006fbf'],
'poison' => ['label' => 'Zehir', 'color' => '#9400d3'],
];
// Kullanıcı adı renk presetleri
public const USERNAME_COLORS = [
'fire' => ['label' => 'Alev', 'css' => 'linear-gradient(90deg,#ff6b35,#ff2d7d)'],
'aurora' => ['label' => 'Aurora', 'css' => 'linear-gradient(90deg,#00f5b4,#00f5ff)'],
'sakura' => ['label' => 'Sakura', 'css' => 'linear-gradient(90deg,#ff9ec4,#ff2d7d)'],
'neon' => ['label' => 'Neon', 'css' => 'linear-gradient(90deg,#00f5ff,#b84dff)'],
'galaxy' => ['label' => 'Galaksi', 'css' => 'linear-gradient(90deg,#7c3aed,#b84dff)'],
'gold' => ['label' => 'Altın', 'css' => 'linear-gradient(90deg,#ffd700,#ff8c00)'],
'ice' => ['label' => 'Buz', 'css' => 'linear-gradient(90deg,#a8edff,#60cfff)'],
'blood' => ['label' => 'Kan', 'css' => 'linear-gradient(90deg,#8b0000,#dc143c)'],
'sunset' => ['label' => 'Gün Batımı', 'css' => 'linear-gradient(90deg,#ff6b00,#ff0080)'],
'ocean' => ['label' => 'Okyanus', 'css' => 'linear-gradient(90deg,#006fbf,#00d2ff)'],
'poison' => ['label' => 'Zehir', 'css' => 'linear-gradient(90deg,#6b0ac9,#c300ff)'],
'silver' => ['label' => 'Gümüş', 'css' => 'linear-gradient(90deg,#9ca3af,#e5e7eb)'],
'rainbow' => ['label' => 'Gökkuşağı', 'css' => 'linear-gradient(90deg,#ff0000,#ff8c00,#ffd700,#00c800,#0088ff,#8b00ff)'],
];
/** Feature key'e göre metadata döner, yoksa null */
public static function get(string $key): ?array
{
return self::ALL[$key] ?? null;
}
/** Kategoriye göre gruplanmış feature listesi */
public static function grouped(): array
{
$groups = [];
foreach (self::CATEGORIES as $cat) {
$groups[$cat] = [];
}
foreach (self::ALL as $key => $meta) {
$groups[$meta['category']][$key] = $meta;
}
return array_filter($groups);
}
}