diff --git a/app/Services/SeriesReleaseService.php b/app/Services/SeriesReleaseService.php new file mode 100644 index 000000000..8ed39d046 --- /dev/null +++ b/app/Services/SeriesReleaseService.php @@ -0,0 +1,232 @@ + $categories + * @return array + */ + public function seasonCounts(int $videoId, array $categories): array + { + $counts = $this->baseReleaseQuery($videoId, $categories) + ->join('tv_episodes as tve', 'r.tv_episodes_id', '=', 'tve.id') + ->where('tve.videos_id', $videoId) + ->selectRaw('tve.series as season, COUNT(*) as release_count') + ->groupBy('tve.series') + ->orderBy('tve.series') + ->pluck('release_count', 'season') + ->mapWithKeys(static fn ($count, $season): array => [(int) $season => (int) $count]) + ->all(); + + foreach ($this->fallbackSeasonCounts($videoId, $categories) as $season => $count) { + $counts[$season] = ($counts[$season] ?? 0) + $count; + } + + ksort($counts); + + return $counts; + } + + /** + * @param array $categories + * @return array{releases:EloquentCollection, total:int} + */ + public function releasesForSeason(int $videoId, int $season, int $offset, int $limit, array $categories): array + { + $matchedCount = $this->matchedSeasonCount($videoId, $season, $categories); + $fallback = $this->fallbackReleasesForSeason($videoId, $season, $categories); + $fallbackCount = $fallback->count(); + + $matchedLimit = $limit; + $matchedOffset = $offset; + $fallbackSlice = new EloquentCollection; + + if ($limit > 0) { + if ($offset >= $matchedCount) { + $matchedLimit = 0; + $matchedOffset = 0; + $fallbackSlice = new EloquentCollection( + $fallback->slice($offset - $matchedCount, $limit)->values()->all() + ); + } elseif (($offset + $limit) > $matchedCount) { + $matchedLimit = $matchedCount - $offset; + $fallbackLimit = $limit - $matchedLimit; + $fallbackSlice = new EloquentCollection($fallback->take($fallbackLimit)->values()->all()); + } + } + + $matched = $matchedLimit > 0 + ? $this->matchedSeasonReleaseQuery($videoId, $season, $categories) + ->orderByDesc('r.postdate') + ->limit($matchedLimit) + ->offset($matchedOffset) + ->get() + : new EloquentCollection; + + $releases = new EloquentCollection($matched->concat($fallbackSlice)->values()->all()); + if ($releases->isNotEmpty()) { + $releases->loadCount('failed'); + } + + return [ + 'releases' => $releases, + 'total' => $matchedCount + $fallbackCount, + ]; + } + + /** + * @param array $categories + */ + private function matchedSeasonCount(int $videoId, int $season, array $categories): int + { + return $this->baseReleaseQuery($videoId, $categories) + ->join('tv_episodes as tve', 'r.tv_episodes_id', '=', 'tve.id') + ->where('tve.videos_id', $videoId) + ->where('tve.series', $season) + ->count(); + } + + /** + * @param array $categories + * @return Builder + */ + private function matchedSeasonReleaseQuery(int $videoId, int $season, array $categories): Builder + { + return $this->baseReleaseQuery($videoId, $categories) + ->join('tv_episodes as tve', 'r.tv_episodes_id', '=', 'tve.id') + ->where('tve.videos_id', $videoId) + ->where('tve.series', $season) + ->select([ + 'r.id', + 'r.searchname', + 'r.guid', + 'r.postdate', + 'r.groups_id', + 'r.categories_id', + 'r.size', + 'r.totalpart', + 'r.fromname', + 'r.passwordstatus', + 'r.grabs', + 'r.comments', + 'r.adddate', + 'r.videos_id', + 'r.tv_episodes_id', + 'tve.series', + 'tve.episode', + 'tve.firstaired', + ]); + } + + /** + * @param array $categories + * @return Builder + */ + private function baseReleaseQuery(int $videoId, array $categories): Builder + { + $query = Release::query() + ->from('releases as r') + ->where('r.videos_id', $videoId) + ->whereRaw('r.passwordstatus '.$this->releaseBrowseService->showPasswords()); + + if ($categories !== []) { + $query->whereIn('r.categories_id', $categories); + } + + return $query; + } + + /** + * @param array $categories + * @return array + */ + private function fallbackSeasonCounts(int $videoId, array $categories): array + { + return $this->fallbackCandidates($videoId, $categories) + ->groupBy(static fn (Release $release): int => (int) $release->getAttribute('series')) + ->map(static fn (Collection $releases): int => $releases->count()) + ->all(); + } + + /** + * @param array $categories + * @return EloquentCollection + */ + private function fallbackReleasesForSeason(int $videoId, int $season, array $categories): EloquentCollection + { + return new EloquentCollection( + $this->fallbackCandidates($videoId, $categories) + ->filter(static fn (Release $release): bool => (int) $release->getAttribute('series') === $season) + ->values() + ->all() + ); + } + + /** + * @param array $categories + * @return Collection + */ + private function fallbackCandidates(int $videoId, array $categories): Collection + { + $candidates = $this->baseReleaseQuery($videoId, $categories) + ->where(static function (Builder $query): void { + $query->whereNull('r.tv_episodes_id') + ->orWhere('r.tv_episodes_id', '<=', 0); + }) + ->select([ + 'r.id', + 'r.searchname', + 'r.guid', + 'r.postdate', + 'r.groups_id', + 'r.categories_id', + 'r.size', + 'r.totalpart', + 'r.fromname', + 'r.passwordstatus', + 'r.grabs', + 'r.comments', + 'r.adddate', + 'r.videos_id', + 'r.tv_episodes_id', + ]) + ->orderByDesc('r.postdate') + ->limit(self::FALLBACK_SCAN_LIMIT) + ->get(); + + $this->episodeHydrationService->hydrateEpisodeMetadata($candidates); + + return $candidates + ->filter(static fn (Release $release): bool => $release->getAttribute('series') !== null && $release->getAttribute('series') !== '' && ! empty($release->getAttribute('episode'))) + ->values(); + } + + /** + * @param array $categoryInput + * @return array + */ + public function categoryIds(array $categoryInput): array + { + $categories = Category::getCategorySearch($categoryInput, 'tv', true); + + return $categories === null ? [] : array_values(array_map('intval', $categories)); + } +} diff --git a/resources/js/alpine/components/series-season-loader.js b/resources/js/alpine/components/series-season-loader.js new file mode 100644 index 000000000..6115a07b4 --- /dev/null +++ b/resources/js/alpine/components/series-season-loader.js @@ -0,0 +1,145 @@ +import Alpine from '@alpinejs/csp'; + +Alpine.data('seriesSeasonLoader', () => ({ + loading: false, + _abortController: null, + + init() { + this.$el.addEventListener('click', event => { + const link = event.target instanceof Element + ? event.target.closest('[data-series-season-link], [data-series-pagination-link]') + : null; + if (!link || !this.$el.contains(link) || link.getAttribute('href') === '#') { + return; + } + + event.preventDefault(); + this.load(link); + }); + + window.addEventListener('popstate', () => { + this.loadUrl(window.location.href, false); + }); + }, + + load(link) { + this.loadUrl(link.href, true, link); + }, + + loadUrl(href, pushState = true, link = null) { + if (this.loading && this._abortController) { + this._abortController.abort(); + } + + const visibleUrl = new URL(href, window.location.origin); + const requestUrl = new URL(visibleUrl.toString()); + requestUrl.hash = ''; + requestUrl.searchParams.set('_fragment', 'season'); + + this.loading = true; + this.$el.setAttribute('aria-busy', 'true'); + this._setPanelLoading(true); + this._abortController = new AbortController(); + + fetch(requestUrl.toString(), { + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + signal: this._abortController.signal, + }) + .then(response => { + if (!response.ok) { + throw new Error(`Season request failed: ${response.status}`); + } + + return response.json(); + }) + .then(payload => { + this._replaceHtml('[data-series-season-content]', payload.contentHtml); + const pagination = this.$el.querySelector('[data-series-pagination-container]'); + if (pagination) { + pagination.innerHTML = payload.paginationHtml || ''; + } + + this._setActiveSeason(String(payload.selectedSeason)); + this._resetSelection(); + + if (pushState) { + window.history.pushState({}, '', payload.url || visibleUrl.toString()); + } + }) + .catch(error => { + if (error.name !== 'AbortError') { + window.location.href = href; + } + }) + .finally(() => { + this.loading = false; + this.$el.removeAttribute('aria-busy'); + this._setPanelLoading(false); + this._abortController = null; + }); + }, + + _replaceHtml(selector, html) { + const current = this.$el.querySelector(selector); + if (!current || typeof html !== 'string') { + return; + } + + current.outerHTML = html; + + const replacement = this.$el.querySelector(selector); + if (replacement) { + Alpine.initTree(replacement); + } + }, + + _setActiveSeason(season) { + const activeClasses = ['border-blue-500', 'text-blue-600']; + const inactiveClasses = ['border-transparent', 'text-gray-500', 'hover:text-gray-700', 'dark:text-gray-300', 'hover:border-gray-300']; + const activeBadgeClasses = ['bg-blue-100', 'text-blue-800']; + const inactiveBadgeClasses = ['bg-gray-100', 'dark:bg-gray-800', 'text-gray-600']; + + this.$el.querySelectorAll('[data-series-season-link]').forEach(tab => { + const isActive = tab.dataset.season === season; + tab.classList.remove(...activeClasses, ...inactiveClasses); + tab.classList.add(...(isActive ? activeClasses : inactiveClasses)); + + if (isActive) { + tab.setAttribute('aria-current', 'page'); + } else { + tab.removeAttribute('aria-current'); + } + + const badge = tab.querySelector('span'); + if (badge) { + badge.classList.remove(...activeBadgeClasses, ...inactiveBadgeClasses); + badge.classList.add(...(isActive ? activeBadgeClasses : inactiveBadgeClasses)); + } + }); + }, + + _resetSelection() { + this.$el.querySelectorAll('.chkRelease').forEach(checkbox => { + checkbox.checked = false; + }); + + const selectAll = this.$el.querySelector('#chkSelectAll'); + if (selectAll) { + selectAll.checked = false; + selectAll.dispatchEvent(new Event('change', { bubbles: true })); + } + }, + + _setPanelLoading(isLoading) { + const content = this.$el.querySelector('[data-series-season-content]'); + if (!content) { + return; + } + + content.classList.toggle('opacity-60', isLoading); + content.classList.toggle('pointer-events-none', isLoading); + }, +})); diff --git a/resources/views/series/partials/season-content.blade.php b/resources/views/series/partials/season-content.blade.php new file mode 100644 index 000000000..d21a2705d --- /dev/null +++ b/resources/views/series/partials/season-content.blade.php @@ -0,0 +1,76 @@ +
+ @forelse($seasons as $seasonNumber => $episodes) +
+ @foreach($episodes as $episodeNumber => $releases) +
+
+ Episode {{ $episodeNumber }} +
+
+ @foreach($releases as $release) +
+
+ +
+
+
+ + {{ $release->searchname }} + + @if(($release->failed_count ?? 0) > 0) + + Failed ({{ $release->failed_count }}) + + @endif +
+
+ + {{ formatBytes($release->size) }} + + + Added: {{ userDateDiffForHumans($release->adddate) }} + + @if(!empty($release->postdate)) + + Posted: {{ userDate($release->postdate, 'M d, Y H:i') }} + + @endif + @if(!empty($release->fromname)) + + {{ $release->fromname }} + + @endif +
+
+ +
+ @endforeach +
+
+ @endforeach +
+ @empty +
+ + No releases found on this page for the selected season. +
+ @endforelse +
diff --git a/resources/views/series/partials/season-pagination.blade.php b/resources/views/series/partials/season-pagination.blade.php new file mode 100644 index 000000000..aa2ea0067 --- /dev/null +++ b/resources/views/series/partials/season-pagination.blade.php @@ -0,0 +1,29 @@ +@if(!empty($pagination['per_page']) && $pagination['per_page'] > 0 && ($pagination['total_pages'] ?? 1) > 1) +
+
+ Page {{ $pagination['current_page'] }} of {{ $pagination['total_pages'] }} + ({{ number_format($pagination['total_rows']) }} total releases) +
+
+ @php + $prevPage = max($pagination['current_page'] - 1, 1); + $nextPage = min($pagination['current_page'] + 1, $pagination['total_pages']); + $paginationQuery = request()->query(); + unset($paginationQuery['_fragment']); + $paginationBaseUrl = url()->current(); + $prevUrl = $pagination['current_page'] > 1 ? $paginationBaseUrl . '?' . http_build_query(array_merge($paginationQuery, ['page' => $prevPage])) . '#series-episodes' : '#'; + $nextUrl = $pagination['current_page'] < $pagination['total_pages'] ? $paginationBaseUrl . '?' . http_build_query(array_merge($paginationQuery, ['page' => $nextPage])) . '#series-episodes' : '#'; + @endphp + 1) data-series-pagination-link @endif + 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' }}"> + Previous + + + Next + +
+
+@endif diff --git a/tests/Feature/SeriesControllerTest.php b/tests/Feature/SeriesControllerTest.php new file mode 100644 index 000000000..7db0896b2 --- /dev/null +++ b/tests/Feature/SeriesControllerTest.php @@ -0,0 +1,496 @@ + + */ + private array $originalEnvironment = []; + + public function createApplication() + { + $this->databasePath = sys_get_temp_dir().'/nntmux-series-controller-test.sqlite'; + + $this->originalEnvironment = [ + 'APP_ENV' => getenv('APP_ENV'), + 'DB_CONNECTION' => getenv('DB_CONNECTION'), + 'DB_DATABASE' => getenv('DB_DATABASE'), + ]; + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + $pdo = new PDO('sqlite:'.$this->databasePath); + $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); + $pdo->exec("INSERT INTO settings (name, value) VALUES + ('showpasswordedrelease', '0'), + ('categorizeforeign', '0'), + ('catwebdl', '0'), + ('title', 'NNTmux Test'), + ('home_link', '/')"); + + $this->setEnvironmentValue('APP_ENV', 'testing'); + $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); + $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); + + $app = require __DIR__.'/../../bootstrap/app.php'; + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + + protected function setUp(): void + { + parent::setUp(); + + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => $this->databasePath, + 'mail.from.address' => 'noreply@example.test', + 'mail.from.name' => 'NNTmux Tests', + 'app.key' => 'base64:'.base64_encode(random_bytes(32)), + 'nntmux.series_view_limit' => 20, + ]); + + DB::purge(); + DB::reconnect(); + Cache::flush(); + + $this->createSchema(); + $this->seedBaseData(); + $this->resetGlobalComposerState(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + $this->withoutMiddleware([ + ClearanceMiddleware::class, + Google2FAMiddleware::class, + TrustedDevice2FAMiddleware::class, + ]); + } + + protected function tearDown(): void + { + if ($this->databasePath !== '' && file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + parent::tearDown(); + + foreach ($this->originalEnvironment as $key => $value) { + $this->setEnvironmentValue($key, $value === false ? null : $value); + } + } + + public function test_selected_season_renders_only_that_season_releases(): void + { + $user = $this->createUser(); + $videoId = $this->createShow(); + $this->createMatchedRelease($videoId, 1, 1, 'Only.Season.One.S01E01.720p-GROUP'); + $this->createMatchedRelease($videoId, 2, 1, 'Only.Season.Two.S02E01.720p-GROUP'); + + $seasonOne = $this->actingAs($user)->get(route('series', ['id' => $videoId, 'season' => 1])); + $seasonOne->assertOk(); + $seasonOne->assertSee('Only.Season.One.S01E01.720p-GROUP'); + $seasonOne->assertDontSee('Only.Season.Two.S02E01.720p-GROUP'); + + $seasonTwo = $this->actingAs($user)->get(route('series', ['id' => $videoId, 'season' => 2])); + $seasonTwo->assertOk(); + $seasonTwo->assertSee('Only.Season.Two.S02E01.720p-GROUP'); + $seasonTwo->assertDontSee('Only.Season.One.S01E01.720p-GROUP'); + } + + public function test_season_links_preserve_category_and_reset_page(): void + { + $user = $this->createUser(); + $videoId = $this->createShow(); + $this->createMatchedRelease($videoId, 1, 1, 'Link.Test.S01E01.720p-GROUP'); + $this->createMatchedRelease($videoId, 2, 1, 'Link.Test.S02E01.720p-GROUP'); + + $response = $this->actingAs($user)->get(route('series', [ + 'id' => $videoId, + 'season' => 1, + 'page' => 3, + 't' => Category::TV_SD, + ])); + + $response->assertOk(); + $html = $response->getContent(); + + $this->assertStringContainsString('season=2', $html); + $this->assertStringContainsString('page=1', $html); + $this->assertStringContainsString('t=5030', $html); + $this->assertStringContainsString('#series-episodes', $html); + $this->assertStringContainsString('x-data="seriesSeasonLoader"', $html); + $this->assertStringContainsString('data-series-season-link', $html); + $this->assertStringNotContainsString('season=2&page=3', $html); + } + + public function test_lazy_season_fragment_returns_only_selected_season_html(): void + { + $user = $this->createUser(); + $videoId = $this->createShow(); + $this->createMatchedRelease($videoId, 1, 1, 'Lazy.Test.S01E01.720p-GROUP'); + $this->createMatchedRelease($videoId, 2, 1, 'Lazy.Test.S02E01.720p-GROUP'); + + $response = $this->actingAs($user)->getJson(route('series', [ + 'id' => $videoId, + 'season' => 2, + '_fragment' => 'season', + ])); + + $response->assertOk(); + $response->assertJsonPath('selectedSeason', 2); + + $contentHtml = $response->json('contentHtml'); + $this->assertIsString($contentHtml); + $this->assertStringContainsString('data-series-season-content', $contentHtml); + $this->assertStringContainsString('Lazy.Test.S02E01.720p-GROUP', $contentHtml); + $this->assertStringNotContainsString('Lazy.Test.S01E01.720p-GROUP', $contentHtml); + $this->assertStringNotContainsString('', $contentHtml); + $this->assertStringNotContainsString('_fragment=season', (string) $response->json('url')); + } + + public function test_many_releases_only_render_selected_season_page(): void + { + $user = $this->createUser(); + $videoId = $this->createShow(); + + for ($episode = 1; $episode <= 30; $episode++) { + $this->createMatchedRelease($videoId, 1, $episode, sprintf('Paged.Show.S01E%02d.720p-GROUP', $episode)); + $this->createMatchedRelease($videoId, 2, $episode, sprintf('Paged.Show.S02E%02d.720p-GROUP', $episode)); + } + + $response = $this->actingAs($user)->get(route('series', ['id' => $videoId, 'season' => 1])); + + $response->assertOk(); + $response->assertSee('Paged.Show.S01E01.720p-GROUP'); + $response->assertDontSee('Paged.Show.S02E01.720p-GROUP'); + $this->assertSame(20, substr_count($response->getContent(), 'series-episode-card')); + } + + private function createSchema(): void + { + if (! Schema::hasTable('settings')) { + Schema::create('settings', function (Blueprint $table): void { + $table->string('name')->primary(); + $table->text('value')->nullable(); + }); + } + + Schema::create('roles', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name')->default('web'); + $table->timestamps(); + }); + + Schema::create('permissions', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name')->default('web'); + $table->timestamps(); + }); + + Schema::create('model_has_roles', function (Blueprint $table): void { + $table->unsignedInteger('role_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['role_id', 'model_id', 'model_type']); + }); + + Schema::create('model_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['permission_id', 'model_id', 'model_type']); + }); + + Schema::create('role_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->unsignedInteger('role_id'); + $table->primary(['permission_id', 'role_id']); + }); + + Schema::create('users', function (Blueprint $table): void { + $table->increments('id'); + $table->string('username'); + $table->string('email')->unique(); + $table->string('password'); + $table->unsignedInteger('roles_id')->default(1); + $table->integer('rate_limit')->default(60); + $table->string('api_token')->nullable(); + $table->boolean('verified')->default(true); + $table->boolean('can_post')->default(true); + $table->string('theme_preference', 10)->default('light'); + $table->string('session_token')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamp('lastlogin')->nullable(); + $table->rememberToken(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('user_excluded_categories', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->unsignedInteger('categories_id'); + }); + + Schema::create('content', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->string('url')->nullable(); + $table->text('body')->nullable(); + $table->text('metadescription')->nullable(); + $table->text('metakeywords')->nullable(); + $table->integer('contenttype')->default(1); + $table->integer('status')->default(1); + $table->integer('ordinal')->nullable(); + $table->integer('role')->default(0); + }); + + Schema::create('root_categories', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->integer('status')->default(1); + }); + + Schema::create('categories', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->unsignedInteger('root_categories_id')->nullable(); + $table->integer('status')->default(1); + $table->text('description')->nullable(); + }); + + Schema::create('videos', function (Blueprint $table): void { + $table->increments('id'); + $table->integer('type')->default(0); + $table->string('title')->default(''); + $table->string('countries_id', 2)->nullable(); + $table->string('started')->nullable(); + $table->integer('anidb')->default(0); + $table->string('imdb')->nullable(); + $table->integer('tmdb')->default(0); + $table->integer('trakt')->default(0); + $table->integer('tvdb')->default(0); + $table->integer('tvmaze')->default(0); + $table->integer('tvrage')->default(0); + $table->integer('source')->default(0); + }); + + Schema::create('tv_info', function (Blueprint $table): void { + $table->unsignedInteger('videos_id')->primary(); + $table->text('summary')->nullable(); + $table->string('publisher')->nullable(); + $table->boolean('image')->default(false); + }); + + Schema::create('tv_episodes', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('videos_id'); + $table->integer('series')->default(0); + $table->integer('episode')->default(0); + $table->string('se_complete')->default(''); + $table->string('title')->default(''); + $table->string('firstaired')->nullable(); + $table->text('summary')->nullable(); + }); + + Schema::create('releases', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('searchname')->default(''); + $table->string('fromname')->nullable(); + $table->dateTime('postdate')->nullable(); + $table->dateTime('adddate')->nullable(); + $table->string('guid')->nullable(); + $table->unsignedInteger('categories_id')->default(Category::TV_SD); + $table->unsignedInteger('groups_id')->nullable(); + $table->unsignedBigInteger('size')->default(0); + $table->integer('totalpart')->default(0); + $table->integer('passwordstatus')->default(0); + $table->integer('grabs')->default(0); + $table->integer('comments')->default(0); + $table->unsignedInteger('videos_id')->nullable(); + $table->integer('tv_episodes_id')->nullable(); + }); + + Schema::create('dnzb_failures', function (Blueprint $table): void { + $table->unsignedInteger('release_id'); + $table->unsignedInteger('users_id'); + $table->integer('failed')->default(0); + $table->primary(['release_id', 'users_id']); + }); + + Schema::create('user_series', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->unsignedInteger('videos_id'); + }); + } + + private function seedBaseData(): void + { + DB::table('root_categories')->insert([ + 'id' => Category::TV_ROOT, + 'title' => 'TV', + 'status' => 1, + ]); + + DB::table('categories')->insert([ + 'id' => Category::TV_SD, + 'title' => 'SD', + 'root_categories_id' => Category::TV_ROOT, + 'status' => 1, + 'description' => 'TV SD', + ]); + + DB::table('roles')->insert([ + 'id' => 1, + 'name' => 'User', + 'guard_name' => 'web', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('permissions')->insert([ + 'id' => 1, + 'name' => 'view tv', + 'guard_name' => 'web', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('role_has_permissions')->insert([ + 'permission_id' => 1, + 'role_id' => 1, + ]); + } + + private function createUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'username' => 'series-user', + 'email' => 'series@example.test', + 'password' => bcrypt('secret'), + 'roles_id' => 1, + 'api_token' => 'series-token', + 'verified' => true, + 'can_post' => true, + 'theme_preference' => 'light', + 'email_verified_at' => now(), + 'lastlogin' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $userModelClass = User::class; + DB::table('model_has_roles')->insert([ + 'role_id' => 1, + 'model_type' => $userModelClass, + 'model_id' => $userId, + ]); + DB::table('model_has_permissions')->insert([ + 'permission_id' => 1, + 'model_type' => $userModelClass, + 'model_id' => $userId, + ]); + + return User::query()->findOrFail($userId); + } + + private function createShow(): int + { + $videoId = DB::table('videos')->insertGetId([ + 'type' => 0, + 'title' => 'Paged Test Show', + 'started' => '2024-01-01', + 'countries_id' => 'US', + ]); + + DB::table('tv_info')->insert([ + 'videos_id' => $videoId, + 'summary' => 'A test show.', + 'publisher' => 'Test Network', + 'image' => 0, + ]); + + return (int) $videoId; + } + + private function createMatchedRelease(int $videoId, int $season, int $episode, string $searchName): void + { + $episodeId = DB::table('tv_episodes')->insertGetId([ + 'videos_id' => $videoId, + 'series' => $season, + 'episode' => $episode, + 'se_complete' => sprintf('S%02dE%02d', $season, $episode), + 'title' => 'Episode '.$episode, + 'firstaired' => now()->subDays($episode)->toDateString(), + 'summary' => 'Episode summary.', + ]); + + DB::table('releases')->insert([ + 'name' => $searchName, + 'searchname' => $searchName, + 'fromname' => 'poster@example.test', + 'postdate' => now()->subMinutes($episode), + 'adddate' => now()->subMinutes($episode), + 'guid' => sha1($searchName), + 'categories_id' => Category::TV_SD, + 'groups_id' => null, + 'size' => 1024, + 'totalpart' => 1, + 'passwordstatus' => 0, + 'grabs' => 0, + 'comments' => 0, + 'videos_id' => $videoId, + 'tv_episodes_id' => $episodeId, + ]); + } + + private function resetGlobalComposerState(): void + { + $reflection = new ReflectionClass(GlobalDataComposer::class); + $property = $reflection->getProperty('resolvedData'); + $property->setAccessible(true); + $property->setValue(null, null); + } + + private function setEnvironmentValue(string $key, ?string $value): void + { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + + return; + } + + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } +} diff --git a/tests/Unit/SeriesReleaseServiceTest.php b/tests/Unit/SeriesReleaseServiceTest.php new file mode 100644 index 000000000..52860490f --- /dev/null +++ b/tests/Unit/SeriesReleaseServiceTest.php @@ -0,0 +1,255 @@ + + */ + private array $originalEnvironment = []; + + public function createApplication() + { + $this->databasePath = sys_get_temp_dir().'/nntmux-series-release-service-test.sqlite'; + + $this->originalEnvironment = [ + 'APP_ENV' => getenv('APP_ENV'), + 'DB_CONNECTION' => getenv('DB_CONNECTION'), + 'DB_DATABASE' => getenv('DB_DATABASE'), + ]; + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + $pdo = new PDO('sqlite:'.$this->databasePath); + $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); + $pdo->exec("INSERT INTO settings (name, value) VALUES ('showpasswordedrelease', '0'), ('categorizeforeign', '0'), ('catwebdl', '0')"); + + $this->setEnvironmentValue('APP_ENV', 'testing'); + $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); + $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); + + $app = require __DIR__.'/../../bootstrap/app.php'; + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + + protected function setUp(): void + { + parent::setUp(); + + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => $this->databasePath, + ]); + + DB::purge(); + DB::reconnect(); + Cache::flush(); + + $this->createSchema(); + $this->seedCategories(); + } + + protected function tearDown(): void + { + if ($this->databasePath !== '' && file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + parent::tearDown(); + + foreach ($this->originalEnvironment as $key => $value) { + $this->setEnvironmentValue($key, $value === false ? null : $value); + } + } + + public function test_fallback_releases_are_only_in_their_parsed_season(): void + { + $videoId = $this->createShow(); + + $this->createRelease($videoId, 'Test.Show.S01E01.720p-GROUP', null); + $this->createRelease($videoId, 'Test.Show.S02E03.720p-GROUP', null); + + /** @var SeriesReleaseService $service */ + $service = app(SeriesReleaseService::class); + $categories = $service->categoryIds([-1]); + + $seasonOne = $service->releasesForSeason($videoId, 1, 0, 20, $categories); + $seasonTwo = $service->releasesForSeason($videoId, 2, 0, 20, $categories); + + $this->assertSame(['Test.Show.S01E01.720p-GROUP'], $seasonOne['releases']->pluck('searchname')->all()); + $this->assertSame(['Test.Show.S02E03.720p-GROUP'], $seasonTwo['releases']->pluck('searchname')->all()); + } + + private function createSchema(): void + { + if (! Schema::hasTable('settings')) { + Schema::create('settings', function (Blueprint $table): void { + $table->string('name')->primary(); + $table->text('value')->nullable(); + }); + } + + Schema::create('root_categories', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->integer('status')->default(1); + }); + + Schema::create('categories', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->unsignedInteger('root_categories_id')->nullable(); + $table->integer('status')->default(1); + $table->text('description')->nullable(); + }); + + Schema::create('videos', function (Blueprint $table): void { + $table->increments('id'); + $table->integer('type')->default(0); + $table->string('title')->default(''); + $table->string('countries_id', 2)->nullable(); + $table->string('started')->nullable(); + $table->integer('anidb')->default(0); + $table->string('imdb')->nullable(); + $table->integer('tmdb')->default(0); + $table->integer('trakt')->default(0); + $table->integer('tvdb')->default(0); + $table->integer('tvmaze')->default(0); + $table->integer('tvrage')->default(0); + $table->integer('source')->default(0); + }); + + Schema::create('tv_info', function (Blueprint $table): void { + $table->unsignedInteger('videos_id')->primary(); + $table->text('summary')->nullable(); + $table->string('publisher')->nullable(); + $table->boolean('image')->default(false); + }); + + Schema::create('tv_episodes', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('videos_id'); + $table->integer('series')->default(0); + $table->integer('episode')->default(0); + $table->string('se_complete')->default(''); + $table->string('title')->default(''); + $table->string('firstaired')->nullable(); + $table->text('summary')->nullable(); + }); + + Schema::create('releases', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('searchname')->default(''); + $table->string('fromname')->nullable(); + $table->dateTime('postdate')->nullable(); + $table->dateTime('adddate')->nullable(); + $table->string('guid')->nullable(); + $table->unsignedInteger('categories_id')->default(Category::TV_SD); + $table->unsignedInteger('groups_id')->nullable(); + $table->unsignedBigInteger('size')->default(0); + $table->integer('totalpart')->default(0); + $table->integer('passwordstatus')->default(0); + $table->integer('grabs')->default(0); + $table->integer('comments')->default(0); + $table->unsignedInteger('videos_id')->nullable(); + $table->integer('tv_episodes_id')->nullable(); + }); + + Schema::create('dnzb_failures', function (Blueprint $table): void { + $table->unsignedInteger('release_id'); + $table->unsignedInteger('users_id'); + $table->integer('failed')->default(0); + $table->primary(['release_id', 'users_id']); + }); + } + + private function seedCategories(): void + { + DB::table('root_categories')->insert([ + 'id' => Category::TV_ROOT, + 'title' => 'TV', + 'status' => 1, + ]); + + DB::table('categories')->insert([ + 'id' => Category::TV_SD, + 'title' => 'SD', + 'root_categories_id' => Category::TV_ROOT, + 'status' => 1, + 'description' => 'TV SD', + ]); + } + + private function createShow(): int + { + $videoId = DB::table('videos')->insertGetId([ + 'type' => 0, + 'title' => 'Test Show', + 'started' => '2024-01-01', + ]); + + DB::table('tv_info')->insert([ + 'videos_id' => $videoId, + 'summary' => 'A test show.', + 'publisher' => 'Test Network', + 'image' => 0, + ]); + + return (int) $videoId; + } + + private function createRelease(int $videoId, string $searchName, ?int $episodeId): void + { + DB::table('releases')->insert([ + 'name' => $searchName, + 'searchname' => $searchName, + 'fromname' => 'poster@example.test', + 'postdate' => now(), + 'adddate' => now(), + 'guid' => sha1($searchName), + 'categories_id' => Category::TV_SD, + 'groups_id' => null, + 'size' => 1024, + 'totalpart' => 1, + 'passwordstatus' => 0, + 'grabs' => 0, + 'comments' => 0, + 'videos_id' => $videoId, + 'tv_episodes_id' => $episodeId, + ]); + } + + private function setEnvironmentValue(string $key, ?string $value): void + { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + + return; + } + + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } +}