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