Initial commit: Animexe Laravel platform
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user