Add missing files

This commit is contained in:
DariusIII
2026-07-11 12:05:47 +02:00
parent 254faf3050
commit a3a6fd8e4a
6 changed files with 1233 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Category;
use App\Models\Release;
use App\Services\Releases\ReleaseBrowseService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
class SeriesReleaseService
{
private const FALLBACK_SCAN_LIMIT = 500;
public function __construct(
private readonly EpisodeHydrationService $episodeHydrationService,
private readonly ReleaseBrowseService $releaseBrowseService
) {}
/**
* @param array<int, int> $categories
* @return array<int, int>
*/
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<int, int> $categories
* @return array{releases:EloquentCollection<int, Release>, 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<int, int> $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<int, int> $categories
* @return Builder<Release>
*/
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<int, int> $categories
* @return Builder<Release>
*/
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<int, int> $categories
* @return array<int, int>
*/
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<int, int> $categories
* @return EloquentCollection<int, Release>
*/
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<int, int> $categories
* @return Collection<int, Release>
*/
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<int, int|string> $categoryInput
* @return array<int, int>
*/
public function categoryIds(array $categoryInput): array
{
$categories = Category::getCategorySearch($categoryInput, 'tv', true);
return $categories === null ? [] : array_values(array_map('intval', $categories));
}
}
@@ -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);
},
}));
@@ -0,0 +1,76 @@
<div class="p-4" data-series-season-content>
@forelse($seasons as $seasonNumber => $episodes)
<div class="season-content" data-season="{{ $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>
@endforeach
</div>
</div>
@endforeach
</div>
@empty
<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 releases found on this page for the selected season.
</div>
@endforelse
</div>
@@ -0,0 +1,29 @@
@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" data-series-pagination>
<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']);
$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
<a href="{{ $prevUrl }}"
@if($pagination['current_page'] > 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' }}">
<i class="fas fa-arrow-left mr-2"></i>Previous
</a>
<a href="{{ $nextUrl }}"
@if($pagination['current_page'] < $pagination['total_pages']) data-series-pagination-link @endif
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
+496
View File
@@ -0,0 +1,496 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Http\Middleware\ClearanceMiddleware;
use App\Http\Middleware\Google2FAMiddleware;
use App\Http\Middleware\TrustedDevice2FAMiddleware;
use App\Models\Category;
use App\Models\User;
use App\View\Composers\GlobalDataComposer;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use PDO;
use ReflectionClass;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
class SeriesControllerTest extends TestCase
{
private string $databasePath;
/**
* @var array<string, string|false>
*/
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&amp;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('<!DOCTYPE html>', $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;
}
}
+255
View File
@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Models\Category;
use App\Services\SeriesReleaseService;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use PDO;
use Tests\TestCase;
class SeriesReleaseServiceTest extends TestCase
{
private string $databasePath;
/**
* @var array<string, string|false>
*/
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;
}
}