mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update series page
This commit is contained in:
@@ -7,26 +7,20 @@ namespace App\Http\Controllers;
|
||||
use App\Models\TvEpisode;
|
||||
use App\Models\UserSerie;
|
||||
use App\Models\Video;
|
||||
use App\Services\EpisodeHydrationService;
|
||||
use App\Services\Releases\ReleaseSearchService;
|
||||
use App\Services\SeriesReleaseService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SeriesController extends BasePageController
|
||||
{
|
||||
private ReleaseSearchService $releaseSearchService;
|
||||
private SeriesReleaseService $seriesReleaseService;
|
||||
|
||||
private EpisodeHydrationService $episodeHydrationService;
|
||||
|
||||
public function __construct(ReleaseSearchService $releaseSearchService, EpisodeHydrationService $episodeHydrationService)
|
||||
public function __construct(SeriesReleaseService $seriesReleaseService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->releaseSearchService = $releaseSearchService;
|
||||
$this->episodeHydrationService = $episodeHydrationService;
|
||||
$this->seriesReleaseService = $seriesReleaseService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,49 +39,58 @@ class SeriesController extends BasePageController
|
||||
$catarray = [];
|
||||
$catarray[] = $category;
|
||||
|
||||
$seriesLimit = (int) config('nntmux.series_view_limit', 200);
|
||||
$seriesLimit = (int) config('nntmux.series_view_limit', config('nntmux.items_per_page', 50));
|
||||
$page = $this->resolvePage($request);
|
||||
$offset = $seriesLimit > 0 ? $this->paginationOffset($page, $seriesLimit) : 0;
|
||||
|
||||
$rel = $this->releaseSearchService->tvSearch(['id' => $id], '', '', '', $offset, $seriesLimit, '', $catarray, -1);
|
||||
|
||||
$show = Video::getByVideoID($id);
|
||||
|
||||
$nodata = '';
|
||||
$seasons = [];
|
||||
$seasonTabs = [];
|
||||
$selectedSeason = null;
|
||||
$myshows = null;
|
||||
$seriestitles = '';
|
||||
$seriessummary = '';
|
||||
$seriescountry = '';
|
||||
$totalRows = 0;
|
||||
|
||||
if (! $show) {
|
||||
$nodata = 'No video information for this series.';
|
||||
} elseif (! $rel) {
|
||||
$nodata = 'No releases for this series.';
|
||||
} else {
|
||||
$myshows = UserSerie::getShow($this->userdata->id, $show['id']);
|
||||
$categoryIds = $this->seriesReleaseService->categoryIds($catarray);
|
||||
$seasonCounts = $this->seriesReleaseService->seasonCounts((int) $show['id'], $categoryIds);
|
||||
|
||||
$this->episodeHydrationService->hydrateEpisodeMetadata($rel);
|
||||
if ($rel instanceof EloquentCollection && $rel->isNotEmpty()) {
|
||||
$rel->loadCount('failed');
|
||||
if ($seasonCounts === []) {
|
||||
$nodata = 'No releases for this series.';
|
||||
} else {
|
||||
$requestedSeason = $this->resolveSeason($request);
|
||||
$selectedSeason = $requestedSeason !== null && array_key_exists($requestedSeason, $seasonCounts)
|
||||
? $requestedSeason
|
||||
: array_key_first($seasonCounts);
|
||||
|
||||
$seasonReleaseResult = $this->seriesReleaseService->releasesForSeason(
|
||||
(int) $show['id'],
|
||||
(int) $selectedSeason,
|
||||
$offset,
|
||||
$seriesLimit,
|
||||
$categoryIds
|
||||
);
|
||||
|
||||
$series = [];
|
||||
foreach ($seasonReleaseResult['releases'] as $release) {
|
||||
$series[(int) $selectedSeason][(int) $release->getAttribute('episode')][] = $release;
|
||||
}
|
||||
if (isset($series[(int) $selectedSeason])) {
|
||||
ksort($series[(int) $selectedSeason], SORT_NUMERIC);
|
||||
}
|
||||
|
||||
$seasons = $series;
|
||||
$totalRows = $seasonReleaseResult['total'];
|
||||
$seasonTabs = $this->buildSeasonTabs($request, $seasonCounts, (int) $selectedSeason);
|
||||
}
|
||||
|
||||
// Sort releases by season, episode, date posted.
|
||||
$series = $episode = $posted = [];
|
||||
foreach ($rel as $rlk => $rlv) {
|
||||
$series[$rlk] = $rlv->series;
|
||||
$episode[$rlk] = $rlv->episode;
|
||||
$posted[$rlk] = $rlv->postdate;
|
||||
}
|
||||
Arr::sort($series, [[$episode, false], [$posted, false], $rel]);
|
||||
|
||||
$series = [];
|
||||
foreach ($rel as $r) {
|
||||
$series[$r->series][$r->episode][] = $r;
|
||||
}
|
||||
|
||||
$seasons = Arr::sortRecursive($series);
|
||||
|
||||
// get series name(s), description, country and genre
|
||||
$seriestitlesArray = $seriessummaryArray = $seriescountryArray = [];
|
||||
$seriestitlesArray[] = $show['title'];
|
||||
@@ -107,7 +110,7 @@ class SeriesController extends BasePageController
|
||||
|
||||
// Calculate statistics
|
||||
$episodeCount = 0;
|
||||
$seasonCount = count($seasons);
|
||||
$seasonCount = count($seasonTabs);
|
||||
$totalSeasonsAvailable = $seasonCount;
|
||||
|
||||
// Get first and last aired dates from TV episodes
|
||||
@@ -141,11 +144,12 @@ class SeriesController extends BasePageController
|
||||
}
|
||||
|
||||
$catid = $category !== -1 ? $category : '';
|
||||
$totalRows = ($rel && $rel->count() > 0) ? ($rel[0]->_totalrows ?? $rel->count()) : 0;
|
||||
$totalPages = $seriesLimit > 0 ? (int) ceil(max($totalRows, 1) / $seriesLimit) : 1;
|
||||
|
||||
$this->viewData = array_merge($this->viewData, [
|
||||
'seasons' => $seasons,
|
||||
'seasonTabs' => $seasonTabs,
|
||||
'selectedSeason' => $selectedSeason,
|
||||
'show' => $show,
|
||||
'myshows' => $myshows,
|
||||
'seriestitles' => $seriestitles,
|
||||
@@ -171,6 +175,17 @@ class SeriesController extends BasePageController
|
||||
'meta_description' => 'View TV Series',
|
||||
]);
|
||||
|
||||
if ($this->scalarInput($request, '_fragment') === 'season') {
|
||||
return response()->json([
|
||||
'selectedSeason' => $selectedSeason,
|
||||
'contentHtml' => view('series.partials.season-content', $this->viewData)->render(),
|
||||
'paginationHtml' => view('series.partials.season-pagination', $this->viewData)->render(),
|
||||
'url' => $selectedSeason !== null
|
||||
? $this->seriesUrlWithQuery($request, ['season' => (int) $selectedSeason, 'page' => $page])
|
||||
: $this->seriesUrlWithQuery($request),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('series.viewseries', $this->viewData);
|
||||
} else {
|
||||
$letter = ($id && preg_match('/^(0-9|[A-Z])$/i', $id)) ? $id : '0-9';
|
||||
@@ -211,6 +226,52 @@ class SeriesController extends BasePageController
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveSeason(Request $request): ?int
|
||||
{
|
||||
$seasonInput = $this->scalarInput($request, 'season');
|
||||
if ($seasonInput === '' || preg_match('/^\d+$/', $seasonInput) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $seasonInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $seasonCounts
|
||||
* @return array<int, array{season:int,count:int,url:string,active:bool}>
|
||||
*/
|
||||
private function buildSeasonTabs(Request $request, array $seasonCounts, int $selectedSeason): array
|
||||
{
|
||||
$tabs = [];
|
||||
foreach ($seasonCounts as $season => $count) {
|
||||
$tabs[] = [
|
||||
'season' => (int) $season,
|
||||
'count' => (int) $count,
|
||||
'url' => $this->seriesUrlWithQuery($request, [
|
||||
'season' => (int) $season,
|
||||
'page' => 1,
|
||||
]).'#series-episodes',
|
||||
'active' => (int) $season === $selectedSeason,
|
||||
];
|
||||
}
|
||||
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private function seriesUrlWithQuery(Request $request, array $query = []): string
|
||||
{
|
||||
$currentQuery = $request->query();
|
||||
unset($currentQuery['_fragment']);
|
||||
|
||||
$merged = array_merge($currentQuery, $query);
|
||||
$queryString = http_build_query($merged);
|
||||
|
||||
return url()->current().($queryString !== '' ? '?'.$queryString : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show trending TV shows (top 15 most downloaded in last 48 hours)
|
||||
*
|
||||
|
||||
@@ -39,6 +39,7 @@ const lazyComponentMap = {
|
||||
'copyToClipboard': () => import('./components/profile-edit.js'), // same file
|
||||
'cartPage': () => import('./components/cart-page.js'),
|
||||
'releaseMultiOps': () => import('./components/cart-page.js'), // same file
|
||||
'seriesSeasonLoader': () => import('./components/series-season-loader.js'),
|
||||
'authPage': () => import('./components/auth-page.js'),
|
||||
'otpInput': () => import('./components/auth-page.js'), // same file
|
||||
'loginMode': () => import('./components/login-mode.js'),
|
||||
|
||||
@@ -198,9 +198,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Episodes by Season - Tabbed Interface -->
|
||||
@if(!empty($seasons))
|
||||
@if(!empty($seasonTabs))
|
||||
<div id="series-episodes" x-data="seriesSeasonLoader" data-series-season-loader>
|
||||
<form id="nzb_multi_operations_form" method="get" x-data="releaseMultiOps">
|
||||
<div class="series-episodes-card bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-sm" x-data="seasonSwitcher">
|
||||
<div class="series-episodes-card bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-sm">
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<h5 class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
@@ -233,125 +234,35 @@
|
||||
<label for="chkSelectAll" class="text-sm text-gray-700 dark:text-gray-300 cursor-pointer">Select All</label>
|
||||
</div>
|
||||
<nav class="flex flex-wrap -mb-px px-4" aria-label="Tabs">
|
||||
@foreach($seasons as $seasonNumber => $episodes)
|
||||
<button type="button"
|
||||
class="season-tab whitespace-nowrap py-4 px-6 border-b-2 font-medium text-sm transition-colors duration-200"
|
||||
:class="activeSeason === '{{ $seasonNumber }}' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-300 hover:border-gray-300'"
|
||||
data-season="{{ $seasonNumber }}">
|
||||
Season {{ $seasonNumber }}
|
||||
<span class="ml-2 px-2 py-0.5 rounded-full text-xs"
|
||||
:class="activeSeason === '{{ $seasonNumber }}' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 dark:bg-gray-800 text-gray-600'">
|
||||
{{ count($episodes) }}
|
||||
@foreach($seasonTabs as $tab)
|
||||
<a href="{{ $tab['url'] }}"
|
||||
class="series-season-tab whitespace-nowrap py-4 px-6 border-b-2 font-medium text-sm transition-colors duration-200 {{ $tab['active'] ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-300 hover:border-gray-300' }}"
|
||||
data-season="{{ $tab['season'] }}"
|
||||
data-series-season-link
|
||||
@if($tab['active']) aria-current="page" @endif>
|
||||
Season {{ $tab['season'] }}
|
||||
<span class="ml-2 px-2 py-0.5 rounded-full text-xs {{ $tab['active'] ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 dark:bg-gray-800 text-gray-600' }}">
|
||||
{{ $tab['count'] }}
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Season Content -->
|
||||
<div class="p-4">
|
||||
@foreach($seasons as $seasonNumber => $episodes)
|
||||
<div class="season-content" data-season="{{ $seasonNumber }}" x-show="activeSeason === '{{ $seasonNumber }}'">
|
||||
@foreach($episodes as $episodeNumber => $releases)
|
||||
<div class="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700 last:border-b-0">
|
||||
<h6 class="font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Episode {{ $episodeNumber }}
|
||||
</h6>
|
||||
<div class="space-y-2">
|
||||
@foreach($releases as $release)
|
||||
<div class="series-episode-card flex items-center gap-3 bg-gray-50 dark:bg-gray-900 rounded-lg p-3 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<div class="shrink-0">
|
||||
<input type="checkbox" class="chkRelease rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-700" name="release[]" value="{{ $release->guid }}" @change="onCheckboxChange()">
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<a href="{{ url('/details/' . $release->guid) }}"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-medium wrap-break-word break-all">
|
||||
{{ $release->searchname }}
|
||||
</a>
|
||||
@if(($release->failed_count ?? 0) > 0)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800"
|
||||
title="{{ $release->failed_count }} user(s) reported download failure">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i> Failed ({{ $release->failed_count }})
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1 flex flex-wrap gap-2">
|
||||
<span class="mr-3">
|
||||
<i class="fa fa-hdd-o mr-1"></i>{{ formatBytes($release->size) }}
|
||||
</span>
|
||||
<span>
|
||||
<i class="fa fa-clock-o mr-1"></i>Added: {{ userDateDiffForHumans($release->adddate) }}
|
||||
</span>
|
||||
@if(!empty($release->postdate))
|
||||
<span>
|
||||
<i class="fas fa-calendar mr-1"></i> Posted: {{ userDate($release->postdate, 'M d, Y H:i') }}
|
||||
</span>
|
||||
@endif
|
||||
@if(!empty($release->fromname))
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 font-mono">
|
||||
<i class="fas fa-user mr-1"></i>{{ $release->fromname }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url('/getnzb?id=' . $release->guid) }}"
|
||||
class="download-nzb px-3 py-1 bg-green-600 dark:bg-green-700 text-white rounded hover:bg-green-700 dark:hover:bg-green-800 text-sm"
|
||||
title="Download NZB">
|
||||
<i class="fa fa-download"></i>
|
||||
</a>
|
||||
<a href="{{ url('/details/' . $release->guid) }}"
|
||||
class="px-3 py-1 bg-blue-600 dark:bg-blue-700 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-800 text-sm"
|
||||
title="View Details">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
</a>
|
||||
<a href="#" class="add-to-cart px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 text-sm"
|
||||
data-guid="{{ $release->guid }}"
|
||||
title="Add to cart">
|
||||
<i class="icon_cart fa fa-shopping-basket"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Season switcher functionality moved to csp-safe.js --}}
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@include('series.partials.season-content')
|
||||
</div>
|
||||
</form>
|
||||
<div data-series-pagination-container>
|
||||
@include('series.partials.season-pagination')
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-blue-800">
|
||||
<i class="fa fa-info-circle mr-2"></i>
|
||||
No episodes/releases found for this series.
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($pagination['per_page']) && $pagination['per_page'] > 0 && ($pagination['total_pages'] ?? 1) > 1)
|
||||
<div class="series-stat-card mt-6 flex items-center justify-between bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Page {{ $pagination['current_page'] }} of {{ $pagination['total_pages'] }}
|
||||
<span class="ml-2 text-xs text-gray-500">({{ number_format($pagination['total_rows']) }} total releases)</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@php
|
||||
$prevPage = max($pagination['current_page'] - 1, 1);
|
||||
$nextPage = min($pagination['current_page'] + 1, $pagination['total_pages']);
|
||||
@endphp
|
||||
<a href="{{ $pagination['current_page'] > 1 ? request()->fullUrlWithQuery(['page' => $prevPage]) : '#' }}"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium {{ $pagination['current_page'] > 1 ? 'bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700' : 'bg-gray-200 dark:bg-gray-700 text-gray-400 cursor-not-allowed' }}">
|
||||
<i class="fas fa-arrow-left mr-2"></i>Previous
|
||||
</a>
|
||||
<a href="{{ $pagination['current_page'] < $pagination['total_pages'] ? request()->fullUrlWithQuery(['page' => $nextPage]) : '#' }}"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium {{ $pagination['current_page'] < $pagination['total_pages'] ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-200 dark:bg-gray-700 text-gray-400 cursor-not-allowed' }}">
|
||||
Next<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -360,4 +271,3 @@
|
||||
@push('scripts')
|
||||
@include('partials.cart-script')
|
||||
@endpush
|
||||
|
||||
|
||||
Reference in New Issue
Block a user