Initial commit: Animexe Laravel platform
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Ad;
|
||||
|
||||
class AdApiController extends Controller
|
||||
{
|
||||
// POST /api/ads/{ad}/impression
|
||||
public function impression(Ad $ad)
|
||||
{
|
||||
$ad->increment('impressions');
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// POST /api/ads/{ad}/click
|
||||
public function click(Ad $ad)
|
||||
{
|
||||
$ad->increment('clicks');
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontend\AiController;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AiApiController extends Controller
|
||||
{
|
||||
private AiController $ai;
|
||||
|
||||
public function __construct(AiController $ai)
|
||||
{
|
||||
$this->ai = $ai;
|
||||
}
|
||||
|
||||
public function chat(Request $request)
|
||||
{
|
||||
return $this->ai->chat($request);
|
||||
}
|
||||
|
||||
public function recommend(Request $request)
|
||||
{
|
||||
return $this->ai->recommend($request);
|
||||
}
|
||||
|
||||
public function similar(Request $request)
|
||||
{
|
||||
return $this->ai->similar($request);
|
||||
}
|
||||
|
||||
public function search(Request $request)
|
||||
{
|
||||
return $this->ai->search($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Banner;
|
||||
use App\Models\ContinueWatching;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AnimeApiController extends Controller
|
||||
{
|
||||
// GET /api/home
|
||||
public function home(Request $request)
|
||||
{
|
||||
$featured = Anime::where('is_featured', true)->where('is_published', true)
|
||||
->with('genres', 'seasons', 'episodes')
|
||||
->latest()->take(5)->get();
|
||||
|
||||
if ($featured->isEmpty()) {
|
||||
$featured = Anime::where('is_published', true)->with('genres', 'seasons', 'episodes')
|
||||
->where('rating', '>=', 1)->orderByDesc('rating')->take(5)->get();
|
||||
}
|
||||
|
||||
$latest = Anime::where('is_published', true)->latest()->take(20)->get();
|
||||
$topRated = Anime::where('is_published', true)->where('rating', '>=', 7)
|
||||
->orderByDesc('rating')->take(12)->get();
|
||||
|
||||
try {
|
||||
$manualTrending = Anime::where('is_trending', true)->where('is_published', true)
|
||||
->orderBy('trending_order')->take(12)->get();
|
||||
|
||||
if ($manualTrending->count() >= 6) {
|
||||
$trending = $manualTrending->take(12);
|
||||
} else {
|
||||
$autoIds = $manualTrending->pluck('id')->toArray();
|
||||
$autoFill = Anime::where('is_published', true)
|
||||
->whereNotIn('id', $autoIds)
|
||||
->withSum(['episodes as recent_views' => fn($q) =>
|
||||
$q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30))
|
||||
], 'view_count')
|
||||
->orderByDesc('recent_views')
|
||||
->take(12 - $manualTrending->count())->get();
|
||||
$trending = $manualTrending->concat($autoFill);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$trending = collect();
|
||||
}
|
||||
|
||||
if ($trending->isEmpty()) $trending = $latest->take(12);
|
||||
|
||||
$newEpisodes = Episode::with(['anime', 'season'])
|
||||
->where('is_published', true)->latest()->take(12)->get()
|
||||
->filter(fn($e) => $e->anime && $e->season)->values();
|
||||
|
||||
$genres = Genre::where('is_active', true)->take(16)->get();
|
||||
|
||||
$continueWatching = collect();
|
||||
$recommended = collect();
|
||||
|
||||
$authUser = auth('sanctum')->user();
|
||||
if ($authUser) {
|
||||
try {
|
||||
$continueWatching = ContinueWatching::where('user_id', $authUser->id)
|
||||
->with('anime:id,title,slug,cover_image')
|
||||
->where('percent_complete', '>=', 5)
|
||||
->where('percent_complete', '<', 95)
|
||||
->orderByDesc('updated_at')->limit(10)->get();
|
||||
|
||||
$watchedIds = ContinueWatching::where('user_id', $authUser->id)->pluck('anime_id');
|
||||
if ($watchedIds->isNotEmpty()) {
|
||||
$topGenreIds = DB::table('anime_genre')
|
||||
->whereIn('anime_id', $watchedIds)
|
||||
->select('genre_id', DB::raw('count(*) as cnt'))
|
||||
->groupBy('genre_id')->orderByDesc('cnt')->limit(3)->pluck('genre_id');
|
||||
|
||||
if ($topGenreIds->isNotEmpty()) {
|
||||
$recommended = Anime::where('is_published', true)
|
||||
->whereNotIn('id', $watchedIds)
|
||||
->whereHas('genres', fn($q) => $q->whereIn('genres.id', $topGenreIds))
|
||||
->where('rating', '>=', 6)->inRandomOrder()->take(12)->get();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'featured' => $featured->map(fn($a) => $this->animeResource($a, true)),
|
||||
'trending' => $trending->values()->map(fn($a) => $this->animeResource($a)),
|
||||
'latest' => $latest->map(fn($a) => $this->animeResource($a)),
|
||||
'top_rated' => $topRated->map(fn($a) => $this->animeResource($a)),
|
||||
'new_episodes' => $newEpisodes->map(fn($e) => $this->episodeCardResource($e)),
|
||||
'genres' => $genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug]),
|
||||
'continue_watching'=> $continueWatching->map(fn($cw) => $this->continueWatchingResource($cw)),
|
||||
'recommended' => $recommended->map(fn($a) => $this->animeResource($a)),
|
||||
]);
|
||||
}
|
||||
|
||||
// GET /api/animes
|
||||
public function index(Request $request)
|
||||
{
|
||||
$q = $request->input('q', '');
|
||||
$genre = $request->input('genre');
|
||||
$type = $request->input('type');
|
||||
$status = $request->input('status');
|
||||
$year = $request->input('year');
|
||||
$sort = $request->input('sort', 'latest'); // latest|rating|views
|
||||
|
||||
$query = Anime::where('is_published', true)->with('genres');
|
||||
|
||||
if ($q) {
|
||||
$query->where(function ($qb) use ($q) {
|
||||
$qb->where('title', 'like', "%$q%")
|
||||
->orWhere('title_en', 'like', "%$q%")
|
||||
->orWhere('title_jp', 'like', "%$q%");
|
||||
});
|
||||
}
|
||||
if ($genre) $query->whereHas('genres', fn($qb) => $qb->where('slug', $genre));
|
||||
if ($type) $query->where('type', $type);
|
||||
if ($status) $query->where('status', $status);
|
||||
if ($year) $query->where('release_year', $year);
|
||||
|
||||
match ($sort) {
|
||||
'rating' => $query->orderByDesc('rating'),
|
||||
default => $query->latest(),
|
||||
};
|
||||
|
||||
$results = $query->paginate(24)->withQueryString();
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($results->items())->map(fn($a) => $this->animeResource($a)),
|
||||
'total' => $results->total(),
|
||||
'per_page' => $results->perPage(),
|
||||
'current_page'=> $results->currentPage(),
|
||||
'last_page' => $results->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// GET /api/animes/{slug}
|
||||
public function show(Request $request, string $slug)
|
||||
{
|
||||
$anime = Anime::where('slug', $slug)->where('is_published', true)
|
||||
->with(['genres', 'seasons', 'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number')])
|
||||
->firstOrFail();
|
||||
|
||||
$userId = $request->user()?->id;
|
||||
|
||||
$inWatchlist = false;
|
||||
$userRating = null;
|
||||
$isFollowing = false;
|
||||
|
||||
if ($userId) {
|
||||
$wl = \App\Models\Watchlist::where('user_id', $userId)->where('anime_id', $anime->id)->first();
|
||||
$inWatchlist = $wl !== null;
|
||||
$watchlistStatus = $wl?->status;
|
||||
$userRating = \App\Models\AnimeRating::where('user_id', $userId)->where('anime_id', $anime->id)->value('rating');
|
||||
$isFollowing = \App\Models\AnimeFollow::where('user_id', $userId)->where('anime_id', $anime->id)->exists();
|
||||
}
|
||||
|
||||
$seasons = $anime->seasons->map(function ($season) {
|
||||
return [
|
||||
'id' => $season->id,
|
||||
'season_number' => $season->season_number,
|
||||
'title' => $season->title,
|
||||
'episodes' => $season->episodes->map(fn($ep) => $this->episodeResource($ep)),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'anime' => $this->animeResource($anime, true),
|
||||
'seasons' => $seasons,
|
||||
'in_watchlist' => $inWatchlist,
|
||||
'watchlist_status'=> $watchlistStatus ?? null,
|
||||
'user_rating' => $userRating,
|
||||
'is_following' => $isFollowing,
|
||||
]);
|
||||
}
|
||||
|
||||
// GET /api/genres/{slug}
|
||||
public function genre(Request $request, string $slug)
|
||||
{
|
||||
$genre = Genre::where('slug', $slug)->where('is_active', true)->firstOrFail();
|
||||
$animes = $genre->animes()->where('is_published', true)->latest()->paginate(24);
|
||||
|
||||
return response()->json([
|
||||
'genre' => ['id'=>$genre->id,'name'=>$genre->name,'slug'=>$genre->slug],
|
||||
'data' => collect($animes->items())->map(fn($a) => $this->animeResource($a)),
|
||||
'total' => $animes->total(),
|
||||
'last_page' => $animes->lastPage(),
|
||||
'current_page' => $animes->currentPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// GET /api/genres
|
||||
public function genres()
|
||||
{
|
||||
$genres = Genre::where('is_active', true)->orderBy('name')->get();
|
||||
return response()->json($genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug]));
|
||||
}
|
||||
|
||||
// GET /api/watch/{slug}/{season}/{episode}
|
||||
public function watch(Request $request, string $slug, int $season, int $episode)
|
||||
{
|
||||
$anime = Anime::where('slug', $slug)->where('is_published', true)->firstOrFail();
|
||||
$seasonModel = $anime->seasons()->where('season_number', $season)->firstOrFail();
|
||||
$ep = $seasonModel->episodes()->where('episode_number', $episode)->where('is_published', true)->firstOrFail();
|
||||
|
||||
$ep->increment('view_count');
|
||||
|
||||
$prev = $seasonModel->episodes()->where('episode_number', '<', $episode)->where('is_published', true)->orderByDesc('episode_number')->first();
|
||||
$next = $seasonModel->episodes()->where('episode_number', '>', $episode)->where('is_published', true)->orderBy('episode_number')->first();
|
||||
|
||||
// Cross-season next
|
||||
if (!$next) {
|
||||
$nextSeason = $anime->seasons()->where('season_number', $season + 1)->first();
|
||||
if ($nextSeason) {
|
||||
$next = $nextSeason->episodes()->where('episode_number', 1)->where('is_published', true)->first();
|
||||
}
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
$subtitles = [];
|
||||
if (method_exists($ep, 'subtitles')) {
|
||||
$subtitles = $ep->subtitles()->get()->map(fn($s) => [
|
||||
'label' => $s->label,
|
||||
'lang' => $s->language,
|
||||
'url' => $s->url,
|
||||
'is_default' => (bool)($s->is_default ?? false),
|
||||
])->values()->toArray();
|
||||
}
|
||||
|
||||
// Dub sources from m3u8 URL
|
||||
$dubSources = [];
|
||||
if ($ep->m3u8_url) {
|
||||
$rawDubs = $ep->available_dubs ?? null;
|
||||
$availableDubs = is_string($rawDubs) ? json_decode($rawDubs, true) : (is_array($rawDubs) ? $rawDubs : null);
|
||||
[$dubSources] = \App\Http\Controllers\Frontend\PlayerController::resolveDubSourcesPublic(
|
||||
$ep->m3u8_url, $availableDubs
|
||||
);
|
||||
}
|
||||
|
||||
// Quality sources (legacy — episode.video_url / m3u8_url)
|
||||
$sources = collect();
|
||||
if ($ep->m3u8_url) $sources->push(['quality'=>'Auto (HLS)','url'=>$ep->m3u8_url,'type'=>'hls']);
|
||||
if ($ep->video_url) {
|
||||
$isHls = str_ends_with($ep->video_url, '.m3u8') || str_contains($ep->video_url, 'master.m3u8');
|
||||
$type = $isHls ? 'hls' : 'mp4';
|
||||
$sources->push(['quality'=>'Auto','url'=>$ep->video_url,'type'=>$type]);
|
||||
}
|
||||
if ($ep->video_url_1080 ?? null) $sources->push(['quality'=>'1080p','url'=>$ep->video_url_1080,'type'=>'mp4']);
|
||||
if ($ep->video_url_720 ?? null) $sources->push(['quality'=>'720p','url'=>$ep->video_url_720,'type'=>'mp4']);
|
||||
if ($ep->video_url_480 ?? null) $sources->push(['quality'=>'480p','url'=>$ep->video_url_480,'type'=>'mp4']);
|
||||
|
||||
// Çok kaynak desteği (video_sources tablosu)
|
||||
// Her translator/kaynak bir grup → [{key, label, url, type, quality}]
|
||||
$multiSources = \App\Models\VideoSource::where('episode_id', $ep->id)
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->groupBy(fn($vs) => $vs->translator_id ?: $vs->label)
|
||||
->map(function ($group) {
|
||||
$default = $group->firstWhere('is_default', true) ?? $group->first();
|
||||
// Tüm kaliteler (1080p, 720p, vb.)
|
||||
$qualities = $group->map(fn($vs) => [
|
||||
'quality' => $vs->quality ?: 'Auto',
|
||||
'url' => $vs->url,
|
||||
'type' => $vs->type ?? 'mp4',
|
||||
])->values()->toArray();
|
||||
|
||||
return [
|
||||
'key' => $default->translator_id
|
||||
?: \Illuminate\Support\Str::slug($default->label ?? 'kaynak'),
|
||||
'label' => $default->label ?: 'Kaynak',
|
||||
'url' => $default->url,
|
||||
'type' => $default->type ?? 'mp4',
|
||||
'quality' => $default->quality ?: 'Auto',
|
||||
'source' => $default->source ?? 'animecix',
|
||||
'qualities' => $qualities,
|
||||
'is_default'=> (bool) $default->is_default,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
// Player settings
|
||||
$skipSeconds = (int) \App\Models\Setting::get('main_video_skip_seconds', 10);
|
||||
$wmCoverSeconds = (int) \App\Models\Setting::get('watermark_cover_seconds', 11);
|
||||
$introEnabled = \App\Models\Setting::get('intro_enabled') == '1';
|
||||
$introUrl = $introEnabled ? (\App\Models\Setting::get('intro_video_url') ?: null) : null;
|
||||
$introSkipAfter = (int) \App\Models\Setting::get('intro_skip_after', 5);
|
||||
|
||||
// AniSkip is fetched via separate /api/aniskip endpoint to avoid blocking video load
|
||||
$aniSkipData = null;
|
||||
|
||||
// All episodes list for in-player episode switcher
|
||||
$allEpisodes = $seasonModel->episodes()->where('is_published', true)->orderBy('episode_number')
|
||||
->get()->map(fn($e) => [
|
||||
'id' => $e->id,
|
||||
'episode_number' => $e->episode_number,
|
||||
'season_number' => $season,
|
||||
'title' => $e->title,
|
||||
'thumbnail_url' => $e->thumbnail_url ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'anime' => ['id'=>$anime->id,'title'=>$anime->title,'slug'=>$anime->slug,'cover_url'=>$anime->coverUrl],
|
||||
'season' => ['id'=>$seasonModel->id,'season_number'=>$seasonModel->season_number,'title'=>$seasonModel->title],
|
||||
'episode' => $this->episodeResource($ep),
|
||||
'sources' => $sources->values(),
|
||||
'multi_sources'=> $multiSources, // Çok kaynak (Anizium "4K" + AnimeCix çevirmenler)
|
||||
'dub_sources' => $dubSources,
|
||||
'subtitles' => $subtitles,
|
||||
'episodes' => $allEpisodes,
|
||||
'prev_episode' => $prev ? ['season'=>$prev->season?->season_number ?? $season,'episode'=>$prev->episode_number] : null,
|
||||
'next_episode' => $next ? ['season'=>$next->season?->season_number ?? $season,'episode'=>$next->episode_number] : null,
|
||||
'settings' => [
|
||||
'skip_seconds' => $skipSeconds,
|
||||
'wm_cover_seconds' => $wmCoverSeconds,
|
||||
'intro_url' => $introUrl,
|
||||
'intro_skip_after' => $introSkipAfter,
|
||||
// AniSkip timestamps (null if not available)
|
||||
'aniskip' => $aniSkipData, // {'op':{'start':X,'end':Y}, 'ed':{'start':X,'end':Y}}
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Resources ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function animeResource(Anime $a, bool $full = false): array
|
||||
{
|
||||
$base = [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'title_en' => $a->title_en,
|
||||
'title_jp' => $a->title_jp,
|
||||
'slug' => $a->slug,
|
||||
'cover_url' => $a->coverUrl,
|
||||
'banner_url' => $a->bannerUrl,
|
||||
'type' => $a->type,
|
||||
'status' => $a->status,
|
||||
'rating' => $a->rating ? (float)$a->rating : null,
|
||||
'release_year' => $a->release_year,
|
||||
'episode_count' => $a->episode_count,
|
||||
'genres' => $a->relationLoaded('genres')
|
||||
? $a->genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug])->values()
|
||||
: [],
|
||||
];
|
||||
|
||||
if ($full) {
|
||||
$base['description'] = $a->description;
|
||||
$base['studio'] = $a->studio ?? null;
|
||||
$base['duration'] = $a->duration ?? null;
|
||||
$base['is_featured'] = $a->is_featured;
|
||||
|
||||
// First episode for "watch now" button
|
||||
if ($a->relationLoaded('seasons') && $a->seasons->isNotEmpty()) {
|
||||
$firstSeason = $a->seasons->first();
|
||||
$eps = $a->relationLoaded('episodes') ? $a->episodes : $firstSeason->episodes;
|
||||
$firstEp = $eps->where('season_id', $firstSeason->id)->where('is_published', true)->sortBy('episode_number')->first();
|
||||
$base['first_watch'] = ($firstSeason && $firstEp) ? [
|
||||
'season' => $firstSeason->season_number,
|
||||
'episode' => $firstEp->episode_number,
|
||||
] : null;
|
||||
}
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function episodeResource(Episode $ep): array
|
||||
{
|
||||
return [
|
||||
'id' => $ep->id,
|
||||
'episode_number' => $ep->episode_number,
|
||||
'title' => $ep->title,
|
||||
'thumbnail_url' => $ep->thumbnailUrl ?? null,
|
||||
'duration' => $ep->duration,
|
||||
'view_count' => $ep->view_count,
|
||||
'created_at' => $ep->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
|
||||
private function episodeCardResource(Episode $ep): array
|
||||
{
|
||||
return [
|
||||
'id' => $ep->id,
|
||||
'episode_number' => $ep->episode_number,
|
||||
'season_number' => $ep->season?->season_number,
|
||||
'title' => $ep->title,
|
||||
'thumbnail_url' => $ep->thumbnailUrl ?? null,
|
||||
'created_at' => $ep->created_at?->diffForHumans(),
|
||||
'anime' => [
|
||||
'id' => $ep->anime->id,
|
||||
'title' => $ep->anime->title,
|
||||
'slug' => $ep->anime->slug,
|
||||
'cover_url' => $ep->anime->coverUrl,
|
||||
'rating' => $ep->anime->rating ? (float)$ep->anime->rating : null,
|
||||
'description' => $ep->anime->description,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ── AniSkip endpoint ─────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/aniskip/{slug}/{season}/{episode}
|
||||
// Fully automatic: finds MAL ID by title if missing, caches everything
|
||||
public function aniSkip(string $slug, int $season, int $episode)
|
||||
{
|
||||
$anime = Anime::where('slug', $slug)->first();
|
||||
if (!$anime) return response()->json(['aniskip' => null]);
|
||||
|
||||
$seasonModel = $anime->seasons()->where('season_number', $season)->first();
|
||||
if (!$seasonModel) return response()->json(['aniskip' => null]);
|
||||
|
||||
$seasonMalId = $seasonModel->mal_id;
|
||||
|
||||
try {
|
||||
// anime.mal_id yoksa title search (bir kez, cache'lenir)
|
||||
if (!$anime->mal_id) {
|
||||
$found = (new \App\Services\JikanService())->searchMalId($anime->title, $anime->title_en, $anime->title_jp);
|
||||
if ($found) $anime->update(['mal_id' => $found]);
|
||||
}
|
||||
|
||||
if (!$seasonMalId && $anime->mal_id) {
|
||||
// S1 için anime.mal_id direkt kullan — Jikan'a gitme
|
||||
if ($season === 1) {
|
||||
$seasonMalId = $anime->mal_id;
|
||||
$seasonModel->update(['mal_id' => $seasonMalId]);
|
||||
} else {
|
||||
// Diğer sezonlar: sadece cache'ten bak, yoksa null dön (page load bloke olmasın)
|
||||
$chain = \Illuminate\Support\Facades\Cache::get("jikan_chain_{$anime->mal_id}");
|
||||
if ($chain) {
|
||||
$seasonMalId = $chain[$season - 1] ?? $chain[0] ?? null;
|
||||
if ($seasonMalId) $seasonModel->update(['mal_id' => $seasonMalId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($seasonMalId) {
|
||||
$data = (new \App\Services\AniSkipService())->getSkipTimes((string)$seasonMalId, $episode);
|
||||
return response()->json(['aniskip' => $data]);
|
||||
}
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json(['aniskip' => null]);
|
||||
}
|
||||
|
||||
// ── Skip segments ─────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/episodes/{episode}/skip-event (anonim OK, rate-limited)
|
||||
public function recordSkipEvent(Request $request, Episode $episode)
|
||||
{
|
||||
$from = (int) $request->input('from_sec', 0);
|
||||
$to = (int) $request->input('to_sec', 0);
|
||||
|
||||
// Basic sanity: must skip forward at least 5s, not more than 10 min
|
||||
if ($to <= $from + 4 || ($to - $from) > 600) {
|
||||
return response()->json(['ok' => false]);
|
||||
}
|
||||
|
||||
DB::table('episode_skip_events')->insert([
|
||||
'episode_id' => $episode->id,
|
||||
'from_sec' => $from,
|
||||
'to_sec' => $to,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// GET /api/episodes/{episode}/skip-segments
|
||||
// Returns segments where >= 10 users skipped from within a 30-second window
|
||||
public function skipSegments(Episode $episode)
|
||||
{
|
||||
// İntro tespiti: ilk 3 dakika içinde 45-150sn ileri atlama = intro skip
|
||||
// Kümeleme: 20sn bucket, to_sec standart sapması <= 15sn, en az 2 farklı kullanıcı
|
||||
$rows = DB::table('episode_skip_events')
|
||||
->where('episode_id', $episode->id)
|
||||
->where('from_sec', '<', 180)
|
||||
->whereRaw('(to_sec - from_sec) BETWEEN 45 AND 150')
|
||||
->selectRaw('
|
||||
FLOOR(from_sec / 20) * 20 AS bucket_start,
|
||||
AVG(to_sec) AS avg_to,
|
||||
STDDEV_POP(to_sec) AS stddev_to,
|
||||
COUNT(*) AS cnt
|
||||
')
|
||||
->groupByRaw('FLOOR(from_sec / 20) * 20')
|
||||
->havingRaw('cnt >= 2 AND (STDDEV_POP(to_sec) <= 15 OR cnt = 1)')
|
||||
->orderBy('cnt', 'desc')
|
||||
->limit(1)
|
||||
->get();
|
||||
|
||||
$segments = $rows->map(fn($r) => [
|
||||
'from' => (int) $r->bucket_start,
|
||||
'to' => (int) round($r->avg_to),
|
||||
'count'=> (int) $r->cnt,
|
||||
])->values();
|
||||
|
||||
return response()->json(['segments' => $segments]);
|
||||
}
|
||||
|
||||
private function continueWatchingResource($cw): array
|
||||
{
|
||||
return [
|
||||
'id' => $cw->id,
|
||||
'season_number' => $cw->season_number,
|
||||
'episode_number' => $cw->episode_number,
|
||||
'percent_complete' => $cw->percent_complete,
|
||||
'anime' => $cw->anime ? [
|
||||
'id' => $cw->anime->id,
|
||||
'title' => $cw->anime->title,
|
||||
'slug' => $cw->anime->slug,
|
||||
'cover_url' => \App\Support\MediaUrl::fromStoragePath($cw->anime->cover_image),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// POST /api/sources/flag-hevc
|
||||
// Player tarafından çağrılır: HEVC hatası alınan kaynak URL'sini DB'ye işler
|
||||
public function flagHevc(Request $request)
|
||||
{
|
||||
$url = $request->input('url');
|
||||
if (!$url) return response()->json(['ok' => false]);
|
||||
|
||||
\App\Models\VideoSource::where('url', $url)->update([
|
||||
'is_hevc' => true,
|
||||
'hevc_checked_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthApiController extends Controller
|
||||
{
|
||||
public function register(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'username' => 'required|string|max:50|unique:users|alpha_dash',
|
||||
'email' => 'required|email|unique:users',
|
||||
'password' => 'required|string|min:6|confirmed',
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'username' => $data['username'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'role' => 'user',
|
||||
'membership' => 'free',
|
||||
]);
|
||||
|
||||
$token = $user->createToken('animexe-app')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'token' => $token,
|
||||
'user' => $this->userResource($user),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
$user = User::where('email', $data['email'])->first();
|
||||
|
||||
if (!$user || !Hash::check($data['password'], $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => ['E-posta veya şifre hatalı.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($user->is_banned) {
|
||||
return response()->json([
|
||||
'message' => 'Hesabınız yasaklandı. Sebep: ' . ($user->ban_reason ?? 'Belirtilmedi'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
$token = $user->createToken('animexe-app')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'token' => $token,
|
||||
'user' => $this->userResource($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
return response()->json(['message' => 'Çıkış yapıldı.']);
|
||||
}
|
||||
|
||||
public function me(Request $request)
|
||||
{
|
||||
return response()->json(['user' => $this->userResource($request->user())]);
|
||||
}
|
||||
|
||||
public function saveFcmToken(Request $request)
|
||||
{
|
||||
$data = $request->validate(['token' => 'required|string|max:500']);
|
||||
$request->user()->update(['fcm_token' => $data['token']]);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function updateProfile(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$request->validate([
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'username' => 'sometimes|string|max:50|unique:users,username,' . $user->id . '|alpha_dash',
|
||||
'password' => 'sometimes|string|min:6|confirmed',
|
||||
'bio' => 'sometimes|nullable|string|max:300',
|
||||
'website' => 'sometimes|nullable|string|max:100',
|
||||
'twitter' => 'sometimes|nullable|string|max:50',
|
||||
'instagram' => 'sometimes|nullable|string|max:50',
|
||||
'discord' => 'sometimes|nullable|string|max:50',
|
||||
'avatar' => 'sometimes|nullable|image|max:3072',
|
||||
'banner' => 'sometimes|nullable|image|max:6144',
|
||||
]);
|
||||
|
||||
$data = $request->only(['name', 'username', 'bio', 'website', 'twitter', 'instagram', 'discord']);
|
||||
$data = array_filter($data, fn($v) => $v !== null);
|
||||
|
||||
if ($request->filled('password')) {
|
||||
$data['password'] = bcrypt($request->input('password'));
|
||||
}
|
||||
|
||||
if ($request->hasFile('avatar')) {
|
||||
$data['avatar'] = $request->file('avatar')->store('avatars', 'public');
|
||||
}
|
||||
|
||||
if ($request->hasFile('banner')) {
|
||||
$data['banner_image'] = $request->file('banner')->store('banners', 'public');
|
||||
}
|
||||
|
||||
if (!empty($data)) {
|
||||
$user->update($data);
|
||||
}
|
||||
|
||||
return response()->json(['user' => $this->userResource($user->fresh())]);
|
||||
}
|
||||
|
||||
private function userResource(User $user): array
|
||||
{
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
'email' => $user->email,
|
||||
'bio' => $user->bio,
|
||||
'website' => $user->website,
|
||||
'twitter' => $user->twitter,
|
||||
'instagram' => $user->instagram,
|
||||
'discord' => $user->discord,
|
||||
'avatar' => $user->avatar
|
||||
? (\App\Support\MediaUrl::fromStoragePath($user->avatar))
|
||||
: null,
|
||||
'banner_image' => $user->banner_image
|
||||
? (\App\Support\MediaUrl::fromStoragePath($user->banner_image))
|
||||
: null,
|
||||
'role' => $user->role,
|
||||
'membership' => $user->membership,
|
||||
'is_premium' => $user->isPremium(),
|
||||
'premium_expires_at' => $user->premium_expires_at?->toISOString(),
|
||||
'created_at' => $user->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Comment;
|
||||
use App\Models\CommentLike;
|
||||
use App\Models\Anime;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CommentApiController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$animeId = $request->input('anime_id');
|
||||
$episodeId = $request->input('episode_id');
|
||||
|
||||
$query = Comment::with('user:id,name,username,avatar')
|
||||
->where('status', 'approved')
|
||||
->orderByDesc('is_pinned')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($episodeId) {
|
||||
$query->where('commentable_type', \App\Models\Episode::class)
|
||||
->where('commentable_id', $episodeId);
|
||||
} elseif ($animeId) {
|
||||
$query->where('commentable_type', Anime::class)
|
||||
->where('commentable_id', $animeId);
|
||||
}
|
||||
|
||||
$items = $query->paginate(20);
|
||||
$userId = $request->user()?->id;
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($items->items())->map(fn($c) => $this->fmt($c, $userId)),
|
||||
'total' => $items->total(),
|
||||
'last_page' => $items->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'nullable|exists:animes,id',
|
||||
'episode_id' => 'nullable|exists:episodes,id',
|
||||
'body' => 'required|string|max:1000',
|
||||
'gif_url' => 'nullable|url|max:500',
|
||||
]);
|
||||
|
||||
if (empty($data['anime_id']) && empty($data['episode_id'])) {
|
||||
return response()->json(['error' => 'anime_id veya episode_id gerekli.'], 422);
|
||||
}
|
||||
|
||||
$isEpisode = !empty($data['episode_id']);
|
||||
$comment = Comment::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'commentable_type' => $isEpisode ? \App\Models\Episode::class : Anime::class,
|
||||
'commentable_id' => $isEpisode ? $data['episode_id'] : $data['anime_id'],
|
||||
'content' => $data['body'],
|
||||
'gif_url' => $data['gif_url'] ?? null,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$comment->load('user:id,name,username,avatar');
|
||||
|
||||
return response()->json($this->fmt($comment, $request->user()->id), 201);
|
||||
}
|
||||
|
||||
public function like(Request $request, Comment $comment)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$existing = CommentLike::where('user_id', $userId)
|
||||
->where('comment_id', $comment->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$comment->decrement('like_count');
|
||||
return response()->json(['liked' => false, 'likes' => $comment->fresh()->like_count]);
|
||||
}
|
||||
|
||||
CommentLike::create(['user_id' => $userId, 'comment_id' => $comment->id]);
|
||||
$comment->increment('like_count');
|
||||
return response()->json(['liked' => true, 'likes' => $comment->fresh()->like_count]);
|
||||
}
|
||||
|
||||
private function fmt(Comment $c, ?int $userId): array
|
||||
{
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'body' => $c->content,
|
||||
'gif_url' => $c->gif_url,
|
||||
'likes_count' => $c->like_count ?? 0,
|
||||
'is_pinned' => $c->is_pinned ?? false,
|
||||
'created_at' => $c->created_at?->diffForHumans(),
|
||||
'user_liked' => $userId
|
||||
? CommentLike::where('user_id', $userId)->where('comment_id', $c->id)->exists()
|
||||
: false,
|
||||
'user' => $c->user ? [
|
||||
'id' => $c->user->id,
|
||||
'name' => $c->user->name,
|
||||
'username' => $c->user->username,
|
||||
'avatar' => $c->user->avatar
|
||||
? \App\Support\MediaUrl::fromStoragePath($c->user->avatar)
|
||||
: null,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MessageApiController extends Controller
|
||||
{
|
||||
// GET /api/messages — conversations list
|
||||
public function conversations()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$convs = $user->conversations()
|
||||
->with(['participants', 'lastMessage.user'])
|
||||
->orderByDesc('conversations.updated_at')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(function ($conv) use ($user) {
|
||||
$other = $conv->participants->firstWhere('id', '!=', $user->id);
|
||||
$last = $conv->lastMessage;
|
||||
$unread = $conv->unreadCountFor($user->id);
|
||||
|
||||
$preview = null;
|
||||
if ($last) {
|
||||
if (str_starts_with($last->body, 'IMAGE::')) $preview = '📷 Fotoğraf';
|
||||
elseif (str_starts_with($last->body, 'GIF::')) $preview = '🎞 GIF';
|
||||
elseif (str_starts_with($last->body, 'ANIMESHARE::')) {
|
||||
try { $sd = json_decode(substr($last->body, 12), true); $preview = '🎬 ' . ($sd['title'] ?? 'Anime'); } catch (\Throwable) {}
|
||||
} else {
|
||||
$isMine = $last->user_id === $user->id;
|
||||
$preview = ($isMine ? 'Sen: ' : '') . \Illuminate\Support\Str::limit($last->body, 60);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $conv->id,
|
||||
'other_user' => $other ? [
|
||||
'id' => $other->id,
|
||||
'name' => $other->name,
|
||||
'username' => $other->username,
|
||||
'avatar' => $other->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null,
|
||||
] : null,
|
||||
'last_message' => $last ? [
|
||||
'body' => $preview ?? '',
|
||||
'user_id' => $last->user_id,
|
||||
'created_at' => $last->created_at?->toISOString(),
|
||||
] : null,
|
||||
'unread_count' => $unread,
|
||||
'updated_at' => $conv->updated_at?->toISOString(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['conversations' => $convs]);
|
||||
}
|
||||
|
||||
// GET /api/messages/{conversation} — messages in a conversation
|
||||
public function show(Conversation $conversation)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403);
|
||||
|
||||
$other = $conversation->participants()->where('user_id', '!=', $user->id)->first();
|
||||
|
||||
$messages = $conversation->messages()
|
||||
->with('user')
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(fn($m) => [
|
||||
'id' => $m->id,
|
||||
'user_id' => $m->user_id,
|
||||
'body' => $m->body,
|
||||
'created_at' => $m->created_at?->toISOString(),
|
||||
'author' => [
|
||||
'id' => $m->user?->id,
|
||||
'name' => $m->user?->name,
|
||||
'avatar' => $m->user?->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null,
|
||||
],
|
||||
]);
|
||||
|
||||
// Mark as read
|
||||
$conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]);
|
||||
|
||||
return response()->json([
|
||||
'messages' => $messages,
|
||||
'other_user' => $other ? [
|
||||
'id' => $other->id,
|
||||
'name' => $other->name,
|
||||
'username' => $other->username,
|
||||
'avatar' => $other->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
// POST /api/messages/{conversation} — send a message
|
||||
public function send(Request $request, Conversation $conversation)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403);
|
||||
|
||||
$request->validate(['body' => 'required|string|max:5000']);
|
||||
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conversation->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => $request->body,
|
||||
]);
|
||||
|
||||
$conversation->touch();
|
||||
$conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $message->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => $message->body,
|
||||
'created_at' => $message->created_at->toISOString(),
|
||||
'author' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'avatar' => $user->avatar ? \App\Support\MediaUrl::fromStoragePath($user->avatar) : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// POST /api/messages/start/{user} — start or open conversation
|
||||
public function startConversation(User $user)
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
if ($me->id === $user->id) abort(422, 'Kendinize mesaj gönderemezsiniz.');
|
||||
|
||||
$conv = Conversation::whereHas('participants', fn($q) => $q->where('user_id', $me->id))
|
||||
->whereHas('participants', fn($q) => $q->where('user_id', $user->id))
|
||||
->first();
|
||||
|
||||
if (!$conv) {
|
||||
$conv = DB::transaction(function () use ($me, $user) {
|
||||
$c = Conversation::create();
|
||||
$c->participants()->attach([$me->id, $user->id]);
|
||||
return $c;
|
||||
});
|
||||
}
|
||||
|
||||
return response()->json(['conversation_id' => $conv->id]);
|
||||
}
|
||||
|
||||
// GET /api/messages/{conv}/poll?after={id} — poll for new messages (mobile)
|
||||
public function poll(Request $request, Conversation $conversation)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403);
|
||||
|
||||
$after = (int) $request->query('after', 0);
|
||||
|
||||
$messages = $conversation->messages()
|
||||
->with('user')
|
||||
->where('id', '>', $after)
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(fn($m) => [
|
||||
'id' => $m->id,
|
||||
'user_id' => $m->user_id,
|
||||
'body' => $m->body,
|
||||
'created_at' => $m->created_at?->toISOString(),
|
||||
'author' => [
|
||||
'id' => $m->user?->id,
|
||||
'name' => $m->user?->name,
|
||||
'avatar' => $m->user?->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null,
|
||||
],
|
||||
]);
|
||||
|
||||
$conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]);
|
||||
|
||||
return response()->json(['messages' => $messages]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MembershipPlan;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PlanApiController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$plans = MembershipPlan::where('is_active', true)
|
||||
->where('is_public', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('price')
|
||||
->get()
|
||||
->map(fn($p) => $this->fmtPlan($p));
|
||||
|
||||
$subscription = null;
|
||||
$user = $request->user();
|
||||
if ($user) {
|
||||
$sub = Subscription::where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->where('expires_at', '>', now())
|
||||
->with('plan')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($sub) {
|
||||
$subscription = [
|
||||
'plan_id' => $sub->plan_id,
|
||||
'plan_name' => $sub->plan?->name,
|
||||
'plan_slug' => $sub->plan?->slug,
|
||||
'status' => $sub->status,
|
||||
'expires_at' => $sub->expires_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'plans' => $plans,
|
||||
'subscription' => $subscription,
|
||||
'is_premium' => $user?->isPremium() ?? false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function fmtPlan(MembershipPlan $p): array
|
||||
{
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'slug' => $p->slug,
|
||||
'description' => $p->description,
|
||||
'price' => $p->price,
|
||||
'purchase_link' => $p->purchase_link,
|
||||
'duration_days' => $p->duration_days,
|
||||
'trial_days' => $p->trial_days,
|
||||
'features' => $p->features ?? [],
|
||||
'perks' => $p->perks ?? [],
|
||||
'badge_label' => $p->badge_label,
|
||||
'accent_color' => $p->accent_color,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use App\Models\EpisodePrediction;
|
||||
use App\Models\EpisodeTimestampComment;
|
||||
use App\Models\PredictionVote;
|
||||
use App\Models\SpoilerBox;
|
||||
use App\Models\SpoilerBoxLike;
|
||||
use App\Models\TimeCapsule;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFollow;
|
||||
use App\Models\WatchParty;
|
||||
use App\Models\WatchPartyMember;
|
||||
use App\Services\DeepSeekService;
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class SocialApiController extends Controller
|
||||
{
|
||||
// ── NicoNico Timestamp Yorumları ─────────────────────────────────────────
|
||||
|
||||
public function timestampComments(Episode $episode)
|
||||
{
|
||||
$comments = EpisodeTimestampComment::with('user:id,name,username')
|
||||
->where('episode_id', $episode->id)
|
||||
->where('is_hidden', false)
|
||||
->orderBy('timestamp_sec')
|
||||
->get()
|
||||
->map(fn($c) => [
|
||||
'id' => $c->id,
|
||||
'user_id' => $c->user_id,
|
||||
'timestamp_sec' => $c->timestamp_sec,
|
||||
'body' => $c->body,
|
||||
'color' => $c->color,
|
||||
'username' => $c->user?->username ?? 'misafir',
|
||||
]);
|
||||
|
||||
return response()->json(['comments' => $comments]);
|
||||
}
|
||||
|
||||
public function timestampCommentStore(Request $request, Episode $episode)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'timestamp_sec' => 'required|integer|min:0|max:86400',
|
||||
'body' => 'required|string|max:100',
|
||||
'color' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
|
||||
]);
|
||||
|
||||
$me = Auth::user();
|
||||
|
||||
$recent = EpisodeTimestampComment::where('user_id', $me->id)
|
||||
->where('episode_id', $episode->id)
|
||||
->where('created_at', '>=', now()->subSeconds(5))
|
||||
->count();
|
||||
|
||||
if ($recent >= 2) {
|
||||
return response()->json(['error' => 'Çok hızlı yorum yapıyorsunuz.'], 429);
|
||||
}
|
||||
|
||||
$comment = EpisodeTimestampComment::create([
|
||||
'episode_id' => $episode->id,
|
||||
'user_id' => $me->id,
|
||||
'timestamp_sec' => $data['timestamp_sec'],
|
||||
'body' => $data['body'],
|
||||
'color' => $data['color'] ?? '#ffffff',
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $comment->id]);
|
||||
}
|
||||
|
||||
// ── Tahmin Oyunu ─────────────────────────────────────────────────────────
|
||||
|
||||
public function predictions(Episode $episode)
|
||||
{
|
||||
$me = Auth::id();
|
||||
|
||||
$predictions = EpisodePrediction::with('user:id,name,username')
|
||||
->where('episode_id', $episode->id)
|
||||
->orderByDesc('vote_count')
|
||||
->get()
|
||||
->map(fn($p) => [
|
||||
'id' => $p->id,
|
||||
'body' => $p->body,
|
||||
'is_correct' => $p->is_correct,
|
||||
'vote_count' => $p->vote_count,
|
||||
'username' => $p->user?->username,
|
||||
'is_mine' => $me && $p->user_id === $me,
|
||||
'voted' => $me
|
||||
? PredictionVote::where('prediction_id', $p->id)->where('user_id', $me)->exists()
|
||||
: false,
|
||||
'created_at' => $p->created_at->diffForHumans(),
|
||||
]);
|
||||
|
||||
$myPrediction = $me
|
||||
? EpisodePrediction::where('episode_id', $episode->id)->where('user_id', $me)->first()?->id
|
||||
: null;
|
||||
|
||||
return response()->json([
|
||||
'predictions' => $predictions,
|
||||
'my_prediction' => $myPrediction,
|
||||
]);
|
||||
}
|
||||
|
||||
public function predictionStore(Request $request, Episode $episode)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$data = $request->validate(['body' => 'required|string|min:5|max:280']);
|
||||
|
||||
$existing = EpisodePrediction::where('episode_id', $episode->id)
|
||||
->where('user_id', $me->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['error' => 'Bu bölüm için zaten bir tahmininiz var.'], 422);
|
||||
}
|
||||
|
||||
$prediction = EpisodePrediction::create([
|
||||
'episode_id' => $episode->id,
|
||||
'user_id' => $me->id,
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $prediction->id]);
|
||||
}
|
||||
|
||||
public function predictionVote(EpisodePrediction $prediction)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$existing = PredictionVote::where('prediction_id', $prediction->id)->where('user_id', $me->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$prediction->decrement('vote_count');
|
||||
return response()->json(['voted' => false, 'vote_count' => $prediction->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
PredictionVote::create(['prediction_id' => $prediction->id, 'user_id' => $me->id]);
|
||||
$prediction->increment('vote_count');
|
||||
return response()->json(['voted' => true, 'vote_count' => $prediction->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
// ── Watch Party ──────────────────────────────────────────────────────────
|
||||
|
||||
public function partyCreate(Request $request)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$data = $request->validate([
|
||||
'episode_id' => 'required|exists:episodes,id',
|
||||
'is_private' => 'boolean',
|
||||
'password' => 'nullable|string|max:30',
|
||||
'max_members' => 'nullable|integer|min:2|max:20',
|
||||
]);
|
||||
|
||||
WatchParty::where('host_user_id', $me->id)->delete();
|
||||
|
||||
$party = WatchParty::create([
|
||||
'room_code' => WatchParty::generateCode(),
|
||||
'host_user_id' => $me->id,
|
||||
'episode_id' => $data['episode_id'],
|
||||
'is_private' => $data['is_private'] ?? false,
|
||||
'password' => isset($data['password']) ? Hash::make($data['password']) : null,
|
||||
'max_members' => $data['max_members'] ?? 10,
|
||||
]);
|
||||
|
||||
WatchPartyMember::create(['party_id' => $party->id, 'user_id' => $me->id]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'room_code' => $party->room_code,
|
||||
'party' => $this->partyData($party),
|
||||
]);
|
||||
}
|
||||
|
||||
public function partyJoin(Request $request, string $roomCode)
|
||||
{
|
||||
$party = WatchParty::where('room_code', $roomCode)->firstOrFail();
|
||||
$me = Auth::user();
|
||||
|
||||
if ($party->is_private && $party->password) {
|
||||
if (!Hash::check($request->input('password', ''), $party->password)) {
|
||||
return response()->json(['error' => 'Yanlış şifre.'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
if ($party->activeMembers()->count() >= $party->max_members) {
|
||||
return response()->json(['error' => 'Oda dolu.'], 403);
|
||||
}
|
||||
|
||||
WatchPartyMember::updateOrCreate(
|
||||
['party_id' => $party->id, 'user_id' => $me->id],
|
||||
['last_ping' => now()]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'party' => $this->partyData($party),
|
||||
]);
|
||||
}
|
||||
|
||||
public function partySync(Request $request, string $roomCode)
|
||||
{
|
||||
$party = WatchParty::where('room_code', $roomCode)->firstOrFail();
|
||||
$me = Auth::user();
|
||||
|
||||
if ($party->host_user_id === $me->id) {
|
||||
$data = $request->validate([
|
||||
'current_sec' => 'required|integer|min:0',
|
||||
'is_playing' => 'required|boolean',
|
||||
]);
|
||||
$party->update([
|
||||
'current_sec' => $data['current_sec'],
|
||||
'is_playing' => $data['is_playing'],
|
||||
]);
|
||||
}
|
||||
|
||||
WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id)
|
||||
->update(['last_ping' => now()]);
|
||||
|
||||
$fresh = $party->fresh();
|
||||
return response()->json([
|
||||
'current_sec' => $fresh->current_sec,
|
||||
'is_playing' => $fresh->is_playing,
|
||||
'members' => $this->memberList($party),
|
||||
]);
|
||||
}
|
||||
|
||||
public function partyLeave(string $roomCode)
|
||||
{
|
||||
$party = WatchParty::where('room_code', $roomCode)->firstOrFail();
|
||||
$me = Auth::user();
|
||||
|
||||
WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id)->delete();
|
||||
|
||||
if ($party->host_user_id === $me->id) {
|
||||
$party->delete();
|
||||
return response()->json(['ok' => true, 'dissolved' => true]);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'dissolved' => false]);
|
||||
}
|
||||
|
||||
public function partyInfo(string $roomCode)
|
||||
{
|
||||
$party = WatchParty::with(['episode.anime', 'episode.season'])
|
||||
->where('room_code', $roomCode)->firstOrFail();
|
||||
return response()->json(['party' => $this->partyData($party)]);
|
||||
}
|
||||
|
||||
private function partyData(WatchParty $party): array
|
||||
{
|
||||
$party->loadMissing(['episode.anime', 'episode.season']);
|
||||
return [
|
||||
'room_code' => $party->room_code,
|
||||
'host_id' => $party->host_user_id,
|
||||
'episode_id' => $party->episode_id,
|
||||
'current_sec' => $party->current_sec,
|
||||
'is_playing' => $party->is_playing,
|
||||
'is_private' => $party->is_private,
|
||||
'max_members' => $party->max_members,
|
||||
'members' => $this->memberList($party),
|
||||
'anime_title' => $party->episode?->anime?->title,
|
||||
'anime_slug' => $party->episode?->anime?->slug,
|
||||
'episode_num' => $party->episode?->episode_number,
|
||||
'season_num' => $party->episode?->season?->season_number ?? 1,
|
||||
];
|
||||
}
|
||||
|
||||
private function memberList(WatchParty $party): array
|
||||
{
|
||||
return $party->activeMembers()->with('user:id,name,username')->get()
|
||||
->map(fn($m) => [
|
||||
'id' => $m->user_id,
|
||||
'name' => $m->user?->name,
|
||||
'username'=> $m->user?->username,
|
||||
'is_host' => $m->user_id === $party->host_user_id,
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
// ── Spoiler Kutular ──────────────────────────────────────────────────────
|
||||
|
||||
public function spoilerBoxes(Episode $episode)
|
||||
{
|
||||
$me = Auth::id();
|
||||
$boxes = SpoilerBox::with('user:id,name,username')
|
||||
->where('episode_id', $episode->id)
|
||||
->orderByDesc('likes')
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($b) => [
|
||||
'id' => $b->id,
|
||||
'body' => $b->body,
|
||||
'is_spoiler' => $b->is_spoiler,
|
||||
'spoiler_score' => $b->spoiler_score,
|
||||
'likes' => $b->likes,
|
||||
'username' => $b->user?->username,
|
||||
'is_mine' => $me && $b->user_id === $me,
|
||||
'liked' => $me ? SpoilerBoxLike::where('box_id', $b->id)->where('user_id', $me)->exists() : false,
|
||||
'created_at' => $b->created_at->diffForHumans(),
|
||||
]);
|
||||
|
||||
return response()->json(['boxes' => $boxes]);
|
||||
}
|
||||
|
||||
public function spoilerBoxStore(Request $request, Episode $episode)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$data = $request->validate(['body' => 'required|string|min:3|max:600']);
|
||||
|
||||
$isSpoiler = false;
|
||||
$spoilerScore = 0;
|
||||
$ai = new DeepSeekService();
|
||||
if ($ai->isConfigured()) {
|
||||
try {
|
||||
$raw = $ai->checkSpoiler($data['body']);
|
||||
if ($raw) {
|
||||
$isSpoiler = $raw['is_spoiler'] ?? false;
|
||||
$spoilerScore = $raw['score'] ?? 0;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
$box = SpoilerBox::create([
|
||||
'episode_id' => $episode->id,
|
||||
'user_id' => $me->id,
|
||||
'body' => $data['body'],
|
||||
'is_spoiler' => $isSpoiler,
|
||||
'spoiler_score' => $spoilerScore,
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $box->id, 'is_spoiler' => $isSpoiler]);
|
||||
}
|
||||
|
||||
public function spoilerBoxLike(SpoilerBox $box)
|
||||
{
|
||||
$me = Auth::id();
|
||||
$existing = SpoilerBoxLike::where('box_id', $box->id)->where('user_id', $me)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$box->decrement('likes');
|
||||
return response()->json(['liked' => false, 'likes' => $box->fresh()->likes]);
|
||||
}
|
||||
|
||||
SpoilerBoxLike::create(['box_id' => $box->id, 'user_id' => $me, 'created_at' => now()]);
|
||||
$box->increment('likes');
|
||||
return response()->json(['liked' => true, 'likes' => $box->fresh()->likes]);
|
||||
}
|
||||
|
||||
// ── Zaman Kapsülü ────────────────────────────────────────────────────────
|
||||
|
||||
public function capsuleIndex()
|
||||
{
|
||||
$capsules = TimeCapsule::with('anime:id,title,slug,cover_image')
|
||||
->where('user_id', Auth::id())
|
||||
->orderBy('unlock_at')
|
||||
->get()
|
||||
->map(fn($c) => [
|
||||
'id' => $c->id,
|
||||
'anime_title' => $c->anime?->title,
|
||||
'anime_slug' => $c->anime?->slug,
|
||||
'cover' => $c->anime?->cover_image ? MediaUrl::fromStoragePath($c->anime->cover_image) : null,
|
||||
'unlock_at' => $c->unlock_at->toIso8601String(),
|
||||
'unlocked' => $c->isUnlocked(),
|
||||
'opened' => $c->isOpened(),
|
||||
'message' => ($c->isOpened() || $c->isUnlocked()) ? $c->message : null,
|
||||
'created_at' => $c->created_at->toIso8601String(),
|
||||
]);
|
||||
|
||||
return response()->json(['capsules' => $capsules]);
|
||||
}
|
||||
|
||||
public function capsuleStore(Request $request)
|
||||
{
|
||||
$me = Auth::user();
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|exists:animes,id',
|
||||
'message' => 'required|string|min:5|max:1000',
|
||||
'unlock_at' => 'required|date|after:' . now()->addDays(30)->toDateString(),
|
||||
]);
|
||||
|
||||
$data['user_id'] = $me->id;
|
||||
$capsule = TimeCapsule::create($data);
|
||||
return response()->json(['ok' => true, 'id' => $capsule->id]);
|
||||
}
|
||||
|
||||
public function capsuleOpen(TimeCapsule $capsule)
|
||||
{
|
||||
if ($capsule->user_id !== Auth::id()) {
|
||||
return response()->json(['error' => 'Yetkisiz.'], 403);
|
||||
}
|
||||
if (!$capsule->isUnlocked()) {
|
||||
return response()->json(['error' => 'Kapsül henüz açılamaz.'], 422);
|
||||
}
|
||||
|
||||
$capsule->update(['opened_at' => now()]);
|
||||
return response()->json(['ok' => true, 'message' => $capsule->message]);
|
||||
}
|
||||
|
||||
// ── Ruh Hali Motoru ──────────────────────────────────────────────────────
|
||||
|
||||
private static array $moodGenres = [
|
||||
'sad' => ['Drama', 'Romantizm'],
|
||||
'funny' => ['Komedi', 'Slice of Life'],
|
||||
'hype' => ['Aksiyon', 'Shounen', 'Spor'],
|
||||
'think' => ['Bilim Kurgu', 'Gerilim', 'Supernatural'],
|
||||
'romance' => ['Romantizm', 'Shoujo'],
|
||||
'scary' => ['Korku', 'Supernatural', 'Gerilim'],
|
||||
];
|
||||
|
||||
public function moodRecommend(Request $request)
|
||||
{
|
||||
$mood = $request->validate(['mood' => 'required|in:sad,funny,hype,think,romance,scary'])['mood'];
|
||||
$genres = self::$moodGenres[$mood] ?? [];
|
||||
|
||||
$animes = Anime::whereHas('genres', fn($q) => $q->whereIn('name', $genres))
|
||||
->where('is_published', true)
|
||||
->inRandomOrder()
|
||||
->limit(6)
|
||||
->get(['id', 'title', 'cover_image', 'slug', 'rating']);
|
||||
|
||||
return response()->json([
|
||||
'animes' => $animes->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'slug' => $a->slug,
|
||||
'cover' => $a->cover_image ? MediaUrl::fromStoragePath($a->cover_image) : null,
|
||||
'rating' => $a->rating,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Kullanıcı Takip ──────────────────────────────────────────────────────
|
||||
|
||||
public function followToggle(User $user)
|
||||
{
|
||||
$me = Auth::user();
|
||||
if ($me->id === $user->id) {
|
||||
return response()->json(['error' => 'Kendinizi takip edemezsiniz.'], 422);
|
||||
}
|
||||
|
||||
$existing = \App\Models\UserFollow::where('follower_id', $me->id)
|
||||
->where('following_id', $user->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$following = false;
|
||||
} else {
|
||||
\App\Models\UserFollow::create(['follower_id' => $me->id, 'following_id' => $user->id]);
|
||||
$following = true;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'following' => $following,
|
||||
'followers_count' => \App\Models\UserFollow::where('following_id', $user->id)->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tribunal;
|
||||
use App\Models\TribunalArgument;
|
||||
use App\Models\TribunalArgumentVote;
|
||||
use App\Models\TribunalVote;
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class TribunalApiController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Tribunal::with(['anime:id,title,slug,cover_image', 'creator:id,name,username'])
|
||||
->withCount('votes');
|
||||
|
||||
if ($request->filled('anime_id')) {
|
||||
$query->where('anime_id', $request->anime_id);
|
||||
}
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$tribunals = $query->latest()->paginate(15);
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($tribunals->items())->map(fn($t) => $this->formatTribunal($t))->values(),
|
||||
'has_more' => $tribunals->hasMorePages(),
|
||||
'next_page' => $tribunals->hasMorePages() ? $tribunals->currentPage() + 1 : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Tribunal $tribunal)
|
||||
{
|
||||
$tribunal->load(['anime:id,title,slug,cover_image', 'creator:id,name,username']);
|
||||
$me = Auth::id();
|
||||
$sides = $tribunal->allSides();
|
||||
|
||||
$vcounts = [];
|
||||
foreach (array_keys($sides) as $key) {
|
||||
$vcounts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count();
|
||||
}
|
||||
|
||||
$myVote = $me ? TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->value('side') : null;
|
||||
$myArg = $me ? TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first() : null;
|
||||
|
||||
$arguments = TribunalArgument::with('user:id,name,username')
|
||||
->where('tribunal_id', $tribunal->id)
|
||||
->orderByDesc('vote_count')
|
||||
->get()
|
||||
->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'side' => $a->side,
|
||||
'body' => $a->body,
|
||||
'vote_count' => $a->vote_count,
|
||||
'username' => $a->user?->username,
|
||||
'is_mine' => $me && $a->user_id === $me,
|
||||
'voted' => $me ? TribunalArgumentVote::where('argument_id', $a->id)->where('user_id', $me)->exists() : false,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'tribunal' => $this->formatTribunal($tribunal),
|
||||
'sides' => $sides,
|
||||
'vote_counts' => $vcounts,
|
||||
'total_votes' => array_sum($vcounts),
|
||||
'my_vote' => $myVote,
|
||||
'my_argument' => $myArg ? ['id' => $myArg->id, 'side' => $myArg->side, 'body' => $myArg->body] : null,
|
||||
'arguments' => $arguments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|exists:animes,id',
|
||||
'question' => 'required|string|min:10|max:280',
|
||||
'side_a' => 'required|string|min:2|max:100',
|
||||
'side_b' => 'required|string|min:2|max:100',
|
||||
'extra_sides' => 'nullable|array|max:4',
|
||||
'extra_sides.*'=> 'required|string|min:2|max:100',
|
||||
'closes_at' => 'nullable|date|after:today',
|
||||
]);
|
||||
|
||||
$me = Auth::user();
|
||||
|
||||
$tribunal = Tribunal::create([
|
||||
'anime_id' => $data['anime_id'],
|
||||
'created_by' => $me->id,
|
||||
'question' => $data['question'],
|
||||
'side_a' => $data['side_a'],
|
||||
'side_b' => $data['side_b'],
|
||||
'extra_sides' => $data['extra_sides'] ?? [],
|
||||
'status' => 'open',
|
||||
'closes_at' => $data['closes_at'] ?? now()->addDays(7),
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $tribunal->id]);
|
||||
}
|
||||
|
||||
public function vote(Request $request, Tribunal $tribunal)
|
||||
{
|
||||
if ($tribunal->status !== 'open') {
|
||||
return response()->json(['error' => 'Bu dava kapalı.'], 422);
|
||||
}
|
||||
|
||||
$sides = array_keys($tribunal->allSides());
|
||||
$data = $request->validate(['side' => 'required|in:' . implode(',', $sides)]);
|
||||
$me = Auth::id();
|
||||
|
||||
$existing = TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->side === $data['side']) {
|
||||
$existing->delete();
|
||||
$voted = null;
|
||||
} else {
|
||||
$existing->update(['side' => $data['side']]);
|
||||
$voted = $data['side'];
|
||||
}
|
||||
} else {
|
||||
TribunalVote::create(['tribunal_id' => $tribunal->id, 'user_id' => $me, 'side' => $data['side']]);
|
||||
$voted = $data['side'];
|
||||
}
|
||||
|
||||
$vcounts = [];
|
||||
foreach (array_keys($tribunal->allSides()) as $key) {
|
||||
$vcounts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count();
|
||||
}
|
||||
|
||||
return response()->json(['voted' => $voted, 'vote_counts' => $vcounts, 'total_votes' => array_sum($vcounts)]);
|
||||
}
|
||||
|
||||
public function argue(Request $request, Tribunal $tribunal)
|
||||
{
|
||||
if ($tribunal->status !== 'open') {
|
||||
return response()->json(['error' => 'Bu dava kapalı.'], 422);
|
||||
}
|
||||
|
||||
$sides = array_keys($tribunal->allSides());
|
||||
$data = $request->validate([
|
||||
'side' => 'required|in:' . implode(',', $sides),
|
||||
'body' => 'required|string|min:5|max:500',
|
||||
]);
|
||||
|
||||
$me = Auth::user();
|
||||
$existing = TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->update(['side' => $data['side'], 'body' => $data['body']]);
|
||||
return response()->json(['ok' => true, 'id' => $existing->id, 'updated' => true]);
|
||||
}
|
||||
|
||||
$arg = TribunalArgument::create([
|
||||
'tribunal_id' => $tribunal->id,
|
||||
'user_id' => $me->id,
|
||||
'side' => $data['side'],
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'id' => $arg->id, 'updated' => false]);
|
||||
}
|
||||
|
||||
public function argVote(TribunalArgument $argument)
|
||||
{
|
||||
$me = Auth::id();
|
||||
$existing = TribunalArgumentVote::where('argument_id', $argument->id)->where('user_id', $me)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$argument->decrement('vote_count');
|
||||
return response()->json(['voted' => false, 'vote_count' => $argument->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
TribunalArgumentVote::create(['argument_id' => $argument->id, 'user_id' => $me]);
|
||||
$argument->increment('vote_count');
|
||||
return response()->json(['voted' => true, 'vote_count' => $argument->fresh()->vote_count]);
|
||||
}
|
||||
|
||||
private function formatTribunal(Tribunal $t): array
|
||||
{
|
||||
return [
|
||||
'id' => $t->id,
|
||||
'question' => $t->question,
|
||||
'status' => $t->status,
|
||||
'sides' => $t->allSides(),
|
||||
'votes_count' => $t->votes_count ?? TribunalVote::where('tribunal_id', $t->id)->count(),
|
||||
'closes_at' => $t->closes_at?->toIso8601String(),
|
||||
'created_at' => $t->created_at->diffForHumans(),
|
||||
'anime' => $t->anime ? [
|
||||
'id' => $t->anime->id,
|
||||
'title' => $t->anime->title,
|
||||
'slug' => $t->anime->slug,
|
||||
'cover' => $t->anime->cover_image ? MediaUrl::fromStoragePath($t->anime->cover_image) : null,
|
||||
] : null,
|
||||
'creator' => $t->creator ? [
|
||||
'name' => $t->creator->name,
|
||||
'username' => $t->creator->username,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\AnimeRating;
|
||||
use App\Models\AnimeFollow;
|
||||
use App\Models\ContinueWatching;
|
||||
use App\Models\EpisodeVote;
|
||||
use App\Models\EpisodeNote;
|
||||
use App\Models\UserNotification;
|
||||
use App\Models\UserAchievement;
|
||||
use App\Models\Achievement;
|
||||
use App\Models\Watchlist;
|
||||
use App\Models\AnimeRequest;
|
||||
use App\Models\AnimeRequestVote;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UserApiController extends Controller
|
||||
{
|
||||
// ── Watchlist ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function watchlist(Request $request)
|
||||
{
|
||||
$status = $request->input('status'); // watching|completed|plan_to_watch|dropped
|
||||
|
||||
$query = Watchlist::where('user_id', $request->user()->id)
|
||||
->with('anime:id,title,slug,cover_image,rating,episode_count,status,release_year');
|
||||
|
||||
if ($status) $query->where('status', $status);
|
||||
|
||||
$items = $query->orderByDesc('updated_at')->paginate(24);
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($items->items())->map(fn($w) => [
|
||||
'id' => $w->id,
|
||||
'status' => $w->status,
|
||||
'anime' => $w->anime ? [
|
||||
'id' => $w->anime->id,
|
||||
'title' => $w->anime->title,
|
||||
'slug' => $w->anime->slug,
|
||||
'cover_url' => \App\Support\MediaUrl::fromStoragePath($w->anime->cover_image),
|
||||
'rating' => $w->anime->rating,
|
||||
'episode_count' => $w->anime->episode_count,
|
||||
'status' => $w->anime->status,
|
||||
'release_year' => $w->anime->release_year,
|
||||
] : null,
|
||||
]),
|
||||
'total' => $items->total(),
|
||||
'last_page'=> $items->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function watchlistToggle(Request $request, Anime $anime)
|
||||
{
|
||||
$user = $request->user();
|
||||
$status = $request->input('status', 'plan_to_watch');
|
||||
|
||||
$existing = Watchlist::where('user_id', $user->id)->where('anime_id', $anime->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->status === $status) {
|
||||
$existing->delete();
|
||||
return response()->json(['in_watchlist' => false, 'status' => null]);
|
||||
}
|
||||
$existing->update(['status' => $status]);
|
||||
return response()->json(['in_watchlist' => true, 'status' => $status]);
|
||||
}
|
||||
|
||||
Watchlist::create(['user_id' => $user->id, 'anime_id' => $anime->id, 'status' => $status]);
|
||||
return response()->json(['in_watchlist' => true, 'status' => $status]);
|
||||
}
|
||||
|
||||
// ── Continue Watching ──────────────────────────────────────────────────────
|
||||
|
||||
public function continueWatchingUpdate(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'anime_id' => 'required|exists:animes,id',
|
||||
'season_number' => 'required|integer|min:1',
|
||||
'episode_number' => 'required|integer|min:1',
|
||||
'percent_complete' => 'required|numeric|min:0|max:100',
|
||||
]);
|
||||
|
||||
ContinueWatching::updateOrCreate(
|
||||
['user_id' => $request->user()->id, 'anime_id' => $data['anime_id']],
|
||||
[
|
||||
'season_number' => $data['season_number'],
|
||||
'episode_number' => $data['episode_number'],
|
||||
'percent_complete' => $data['percent_complete'],
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── Anime Rate ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function animeRate(Request $request, Anime $anime)
|
||||
{
|
||||
$data = $request->validate(['rating' => 'required|numeric|min:1|max:10']);
|
||||
|
||||
AnimeRating::updateOrCreate(
|
||||
['user_id' => $request->user()->id, 'anime_id' => $anime->id],
|
||||
['rating' => $data['rating']]
|
||||
);
|
||||
|
||||
$avg = AnimeRating::where('anime_id', $anime->id)->avg('rating');
|
||||
$anime->update(['rating' => round($avg, 1)]);
|
||||
|
||||
return response()->json(['rating' => $data['rating'], 'avg' => round($avg, 1)]);
|
||||
}
|
||||
|
||||
// ── Follow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function followToggle(Request $request, Anime $anime)
|
||||
{
|
||||
$user = $request->user();
|
||||
$existing = AnimeFollow::where('user_id', $user->id)->where('anime_id', $anime->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
return response()->json(['following' => false]);
|
||||
}
|
||||
|
||||
AnimeFollow::create(['user_id' => $user->id, 'anime_id' => $anime->id]);
|
||||
return response()->json(['following' => true]);
|
||||
}
|
||||
|
||||
// ── Notifications ──────────────────────────────────────────────────────────
|
||||
|
||||
public function notifications(Request $request)
|
||||
{
|
||||
$items = UserNotification::where('user_id', $request->user()->id)
|
||||
->orderByDesc('created_at')->paginate(20);
|
||||
|
||||
// Mark all as read
|
||||
UserNotification::where('user_id', $request->user()->id)->whereNull('read_at')->update(['read_at' => now()]);
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($items->items())->map(fn($n) => [
|
||||
'id' => $n->id,
|
||||
'type' => $n->type,
|
||||
'data' => $n->data ?? [],
|
||||
'is_read' => $n->is_read,
|
||||
'created_at' => $n->created_at?->diffForHumans(),
|
||||
]),
|
||||
'total' => $items->total(),
|
||||
'last_page'=> $items->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function notificationsCount(Request $request)
|
||||
{
|
||||
if (!$request->user()) return response()->json(['count' => 0]);
|
||||
$count = UserNotification::where('user_id', $request->user()->id)->whereNull('read_at')->count();
|
||||
return response()->json(['count' => $count]);
|
||||
}
|
||||
|
||||
// ── Achievements ───────────────────────────────────────────────────────────
|
||||
|
||||
public function achievements(Request $request)
|
||||
{
|
||||
$all = Achievement::orderBy('points')->get();
|
||||
$earned = UserAchievement::where('user_id', $request->user()->id)->pluck('achievement_id')->toArray();
|
||||
$total = UserAchievement::where('user_id', $request->user()->id)->join('achievements','achievements.id','=','user_achievements.achievement_id')->sum('achievements.points');
|
||||
|
||||
return response()->json([
|
||||
'total_points' => (int)$total,
|
||||
'data' => $all->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'description' => $a->description,
|
||||
'icon' => $a->icon,
|
||||
'points' => $a->points,
|
||||
'earned' => in_array($a->id, $earned),
|
||||
'earned_at' => in_array($a->id, $earned)
|
||||
? UserAchievement::where('user_id', $request->user()->id)->where('achievement_id', $a->id)->value('created_at')?->toISOString()
|
||||
: null,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Episode Notes ──────────────────────────────────────────────────────────
|
||||
|
||||
public function noteStore(Request $request, $episodeId)
|
||||
{
|
||||
$data = $request->validate(['note' => 'required|string|max:1000']);
|
||||
|
||||
$note = EpisodeNote::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'episode_id' => $episodeId,
|
||||
'note' => $data['note'],
|
||||
]);
|
||||
|
||||
return response()->json(['id' => $note->id, 'note' => $note->note, 'created_at' => $note->created_at?->toISOString()], 201);
|
||||
}
|
||||
|
||||
public function noteDelete(Request $request, $noteId)
|
||||
{
|
||||
$note = EpisodeNote::where('id', $noteId)->where('user_id', $request->user()->id)->firstOrFail();
|
||||
$note->delete();
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function episodeNotesList(Request $request, $episodeId)
|
||||
{
|
||||
$notes = EpisodeNote::where('user_id', $request->user()->id)
|
||||
->where('episode_id', $episodeId)
|
||||
->orderByDesc('created_at')->get();
|
||||
|
||||
return response()->json($notes->map(fn($n) => [
|
||||
'id' => $n->id,
|
||||
'note' => $n->note,
|
||||
'created_at' => $n->created_at?->toISOString(),
|
||||
]));
|
||||
}
|
||||
|
||||
// ── Anime Requests ─────────────────────────────────────────────────────────
|
||||
|
||||
public function requestIndex(Request $request)
|
||||
{
|
||||
$items = AnimeRequest::withCount('votes')
|
||||
->orderByDesc('votes_count')->orderByDesc('created_at')->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($items->items())->map(fn($r) => [
|
||||
'id' => $r->id,
|
||||
'title' => $r->title,
|
||||
'note' => $r->note,
|
||||
'status' => $r->status,
|
||||
'votes_count' => $r->votes_count,
|
||||
'created_at' => $r->created_at?->diffForHumans(),
|
||||
'user_voted' => $request->user()
|
||||
? AnimeRequestVote::where('user_id', $request->user()->id)->where('anime_request_id', $r->id)->exists()
|
||||
: false,
|
||||
]),
|
||||
'total' => $items->total(),
|
||||
'last_page'=> $items->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function requestStore(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:200',
|
||||
'note' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$req = AnimeRequest::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'title' => $data['title'],
|
||||
'note' => $data['note'] ?? null,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return response()->json(['id' => $req->id, 'title' => $req->title], 201);
|
||||
}
|
||||
|
||||
public function requestVote(Request $request, AnimeRequest $animeRequest)
|
||||
{
|
||||
$user = $request->user();
|
||||
$existing = AnimeRequestVote::where('user_id', $user->id)->where('anime_request_id', $animeRequest->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
return response()->json(['voted' => false, 'votes' => $animeRequest->votes()->count()]);
|
||||
}
|
||||
|
||||
AnimeRequestVote::create(['user_id' => $user->id, 'anime_request_id' => $animeRequest->id]);
|
||||
return response()->json(['voted' => true, 'votes' => $animeRequest->votes()->count()]);
|
||||
}
|
||||
|
||||
// ── Profile Stats ──────────────────────────────────────────────────────────
|
||||
|
||||
public function profileStats(Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
|
||||
$watchlistCount = Watchlist::where('user_id', $userId)->count();
|
||||
$completedCount = Watchlist::where('user_id', $userId)->where('status', 'completed')->count();
|
||||
$notifCount = UserNotification::where('user_id', $userId)->where('is_read', false)->count();
|
||||
$achPoints = UserAchievement::where('user_id', $userId)
|
||||
->join('achievements','achievements.id','=','user_achievements.achievement_id')
|
||||
->sum('achievements.points');
|
||||
$achCount = UserAchievement::where('user_id', $userId)->count();
|
||||
$commentCount = \App\Models\Comment::where('user_id', $userId)->count();
|
||||
|
||||
return response()->json([
|
||||
'watchlist_count' => $watchlistCount,
|
||||
'completed_count' => $completedCount,
|
||||
'notif_count' => (int)$notifCount,
|
||||
'achievement_points'=> (int)$achPoints,
|
||||
'achievement_count' => $achCount,
|
||||
'comment_count' => $commentCount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user