From 99c5090e6a541e1b4b58309748fb5b41bc47f9b1 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Mon, 20 Apr 2026 20:47:12 +0200 Subject: [PATCH] Fix tvsearch --- .../Releases/ReleaseSearchService.php | 167 +++++-- tests/Feature/TvSearchApiTest.php | 408 ++++++++++++++++++ 2 files changed, 534 insertions(+), 41 deletions(-) create mode 100644 tests/Feature/TvSearchApiTest.php diff --git a/app/Services/Releases/ReleaseSearchService.php b/app/Services/Releases/ReleaseSearchService.php index 2ab0f6db6..5c6fee8cc 100644 --- a/app/Services/Releases/ReleaseSearchService.php +++ b/app/Services/Releases/ReleaseSearchService.php @@ -736,6 +736,14 @@ class ReleaseSearchService } } $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))); + + $episodePredicates = $this->buildEpisodeJoinPredicates($series, $episode, $airDate); + if ($episodePredicates !== []) { + foreach ($episodePredicates as $predicate) { + $conditions[] = $predicate; + } + $needsEpisodeJoin = true; + } } // Only do name-based search if we don't already have results from external IDs @@ -796,17 +804,7 @@ class ReleaseSearchService // This will filter results to only those with matching episode data in tv_episodes table // If this results in no matches, we'll fall back to results without episode conditions if (! $hasValidSiteIds && (! empty($series) || ! empty($airDate))) { - $episodeConditions = []; - if (! empty($series) && (int) $series < 1900) { - $seriesNum = (int) preg_replace('/^s0*/i', '', $series); - $episodeConditions[] = sprintf('tve.series = %d', $seriesNum); - if (! empty($episode) && ! str_contains($episode, '/')) { - $episodeNum = (int) preg_replace('/^e0*/i', '', $episode); - $episodeConditions[] = sprintf('tve.episode = %d', $episodeNum); - } - } elseif (! empty($airDate)) { - $episodeConditions[] = sprintf('DATE(tve.firstaired) = %s', escapeString($airDate)); - } + $episodeConditions = $this->buildEpisodeJoinPredicates($series, $episode, $airDate); if (! empty($episodeConditions)) { // Check if any of the found releases have matching episode data @@ -953,39 +951,27 @@ class ReleaseSearchService } } - // Fall back to database lookup if index search didn't return results - $siteSQL = []; + // Resolve show / episode filters from videos + tv_episodes when the index missed, or + // when the index hit but the client narrowed by season/episode/airdate (index has no S/E). + $siteSQL = $this->buildApiTvSiteSqlArray($siteIdArr); $showSql = ''; - if (empty($indexSearchResult)) { - foreach ($siteIdArr as $column => $Id) { - if ($Id > 0) { - $siteSQL[] = sprintf('v.%s = %d', $column, $Id); - } - } - - if (\count($siteSQL) > 0) { - $showQry = sprintf( - "\n\t\t\t\tSELECT v.id AS video, GROUP_CONCAT(tve.id SEPARATOR ',') AS episodes FROM videos v LEFT JOIN tv_episodes tve ON v.id = tve.videos_id WHERE (%s) %s %s %s GROUP BY v.id LIMIT 1", - implode(' OR ', $siteSQL), - ($series !== '' ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''), - ($episode !== '' ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''), - ($airDate !== '' ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '') + if (\count($siteSQL) > 0) { + $shouldLookupShow = empty($indexSearchResult) + || $this->tvApiRequestTargetsEpisode($series, $episode, $airDate); + if ($shouldLookupShow) { + $strictEpisodeResolution = ! empty($indexSearchResult) + && $this->tvApiRequestTargetsEpisode($series, $episode, $airDate); + $fragment = $this->lookupApiTvShowSqlFragment( + $siteSQL, + $series, + $episode, + $airDate, + $strictEpisodeResolution ); - $show = Release::fromQuery($showQry); - if ($show->isNotEmpty()) { - if ((! empty($episode) && ! empty($series)) && $show[0]->episodes !== '') { - $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series; - } elseif (! empty($episode) && $show[0]->episodes !== '') { - $showSql = sprintf('AND r.tv_episodes_id IN (%s)', $show[0]->episodes); - } elseif (! empty($series) && empty($episode)) { - $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series; - } - if ((int) ($show[0]->video ?? 0) > 0) { - $showSql .= ' AND r.videos_id = '.$show[0]->video; - } - } else { - return []; + if ($fragment === null) { + return empty($indexSearchResult) ? [] : collect(); } + $showSql = $fragment; } } if (! empty($name) && $showSql === '' && empty($indexSearchResult)) { @@ -1428,6 +1414,105 @@ class ReleaseSearchService return []; } + /** + * SQL predicates on tv_episodes (alias tve) for season / episode / airdate, matching + * newznab-style tvsearch parameters. Empty array means no narrowing. + * + * @return list + */ + private function buildEpisodeJoinPredicates(string $series, string $episode, string $airDate): array + { + $predicates = []; + if (! empty($series) && (int) $series < 1900) { + $seriesNum = (int) preg_replace('/^s0*/i', '', $series); + $predicates[] = sprintf('tve.series = %d', $seriesNum); + if (! empty($episode) && ! str_contains($episode, '/')) { + $episodeNum = (int) preg_replace('/^e0*/i', '', $episode); + $predicates[] = sprintf('tve.episode = %d', $episodeNum); + } + } elseif (! empty($airDate)) { + $predicates[] = sprintf('DATE(tve.firstaired) = %s', escapeString($airDate)); + } + + return $predicates; + } + + /** + * @param array $siteIdArr + * @return list + */ + private function buildApiTvSiteSqlArray(array $siteIdArr): array + { + $siteSQL = []; + foreach ($siteIdArr as $column => $Id) { + if ($Id > 0) { + $siteSQL[] = sprintf('v.%s = %d', $column, $Id); + } + } + + return $siteSQL; + } + + private function tvApiRequestTargetsEpisode(string $series, string $episode, string $airDate): bool + { + if ($airDate !== '') { + return true; + } + if ($series !== '' && (int) $series < 1900) { + return true; + } + if ($episode !== '' && ! str_contains($episode, '/')) { + return true; + } + + return false; + } + + /** + * Build the extra WHERE fragment for API TV search (r.tv_episodes_id / r.videos_id / tve.series). + * + * @param list $siteSQL + */ + private function lookupApiTvShowSqlFragment( + array $siteSQL, + string $series, + string $episode, + string $airDate, + bool $strictEpisodeResolution + ): ?string { + $showQry = sprintf( + "\n\t\t\t\tSELECT v.id AS video, GROUP_CONCAT(tve.id SEPARATOR ',') AS episodes FROM videos v LEFT JOIN tv_episodes tve ON v.id = tve.videos_id WHERE (%s) %s %s %s GROUP BY v.id LIMIT 1", + implode(' OR ', $siteSQL), + ($series !== '' ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''), + ($episode !== '' ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''), + ($airDate !== '' ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '') + ); + $show = Release::fromQuery($showQry); + if ($show->isEmpty()) { + return null; + } + + $showSql = ''; + if ((! empty($episode) && ! empty($series)) && $show[0]->episodes !== '') { + $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series; + } elseif (! empty($episode) && $show[0]->episodes !== '') { + $showSql = sprintf('AND r.tv_episodes_id IN (%s)', $show[0]->episodes); + } elseif (! empty($series) && empty($episode)) { + $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series; + } + if ((int) ($show[0]->video ?? 0) > 0) { + $showSql .= ' AND r.videos_id = '.$show[0]->video; + } + + if ($strictEpisodeResolution + && $this->tvApiRequestTargetsEpisode($series, $episode, $airDate) + && ($show[0]->episodes === '' || $show[0]->episodes === null)) { + return null; + } + + return $showSql; + } + /** * Search-backed pages still apply SQL filters and ordering after the index lookup, * so fetch a buffered candidate set without handing MySQL thousands of IDs. diff --git a/tests/Feature/TvSearchApiTest.php b/tests/Feature/TvSearchApiTest.php new file mode 100644 index 000000000..cecfe9352 --- /dev/null +++ b/tests/Feature/TvSearchApiTest.php @@ -0,0 +1,408 @@ + 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + 'mail.from.address' => 'tvsearch@example.test', + 'app.key' => 'base64:'.base64_encode(random_bytes(32)), + ]); + + DB::purge(); + DB::reconnect(); + Cache::flush(); + + $this->registerSqliteConcatIfNeeded(); + + $this->createSchema(); + $this->seedData(); + } + + private function registerSqliteConcatIfNeeded(): void + { + if (DB::getDriverName() !== 'sqlite') { + return; + } + + $pdo = DB::connection()->getPdo(); + if ($pdo instanceof \PDO && method_exists($pdo, 'sqliteCreateFunction')) { + $pdo->sqliteCreateFunction('CONCAT', static fn (...$parts): string => implode('', $parts)); + } + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * When the search index returns release IDs by show-level external IDs only, season/episode + * must still be applied in SQL (v1 ApiController delegates to this service; successful v1 + * XML responses use echo+exit so they are not exercised via HTTP tests here). + */ + #[Test] + public function tv_search_applies_season_episode_when_search_index_returns_external_id_hits(): void + { + $this->bindSearchIndexMockReturningBothReleases(); + + $service = new ReleaseSearchService; + $results = $service->tvSearch( + ['tvdb' => 71663], + '6', + '24', + '', + 0, + 100, + '', + [5030], + -1, + 0, + [], + 'posted_desc' + ); + + $this->assertCount(1, $results); + $this->assertSame('Simpsons.S06E24.Test', $results[0]->searchname); + $this->assertSame(6, (int) $results[0]->series); + $this->assertSame(24, (int) $results[0]->episode); + } + + private function bindSearchIndexMockReturningBothReleases(): void + { + $mock = Mockery::mock(SearchService::class, [$this->app]); + $mock->shouldReceive('searchReleasesByExternalId')->andReturn([1, 2]); + $mock->shouldReceive('isAvailable')->andReturn(false); + + $this->app->instance(SearchService::class, $mock); + } + + private function createSchema(): void + { + Schema::create('roles', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name')->default('web'); + $table->integer('rate_limit')->default(60); + $table->integer('apirequests')->default(1000); + $table->integer('downloadrequests')->default(100); + $table->integer('addyears')->default(0); + $table->timestamps(); + }); + + Schema::create('users', function (Blueprint $table): void { + $table->increments('id'); + $table->string('username')->unique(); + $table->string('email')->unique(); + $table->string('password'); + $table->unsignedInteger('roles_id')->default(1); + $table->string('api_token')->nullable()->index(); + $table->string('host')->nullable(); + $table->timestamp('apiaccess')->nullable(); + $table->boolean('verified')->default(true); + $table->timestamp('email_verified_at')->nullable(); + $table->integer('rate_limit')->default(60); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('user_requests', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->text('request')->nullable(); + $table->timestamp('timestamp')->nullable(); + }); + + Schema::create('user_downloads', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->timestamp('timestamp')->nullable(); + }); + + 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('user_excluded_categories', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->unsignedInteger('categories_id'); + }); + + 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('usenet_groups', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->boolean('active')->default(true); + $table->string('description')->nullable(); + $table->timestamp('last_updated')->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_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->string('postdate')->nullable(); + $table->string('adddate')->nullable(); + $table->string('guid')->nullable(); + $table->unsignedInteger('categories_id')->default(5030); + $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->unsignedInteger('tv_episodes_id')->nullable(); + }); + + } + + private function seedData(): void + { + DB::table('settings')->insert([ + ['name' => 'showpasswordedrelease', 'value' => '0'], + ['name' => 'strapline', 'value' => 'Test'], + ['name' => 'metakeywords', 'value' => 'test'], + ['name' => 'registerstatus', 'value' => '0'], + ['name' => 'catwebdl', 'value' => '0'], + ['name' => 'title', 'value' => 'NNTmux Test'], + ['name' => 'home_link', 'value' => '/'], + ]); + + DB::table('roles')->insert([ + 'id' => 1, + 'name' => 'User', + 'guard_name' => 'web', + 'rate_limit' => 60, + 'apirequests' => 1000, + 'downloadrequests' => 100, + 'addyears' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('users')->insert([ + 'username' => 'tvsearch_user', + 'email' => 'tvsearch@example.test', + 'password' => bcrypt('secret'), + 'roles_id' => 1, + 'api_token' => Str::random(32), + 'verified' => 1, + 'email_verified_at' => now(), + 'rate_limit' => 60, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('permissions')->insert([ + 'id' => 1, + 'name' => 'view tv', + 'guard_name' => 'web', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $userModelClass = User::class; + DB::table('model_has_roles')->insert([ + 'role_id' => 1, + 'model_type' => $userModelClass, + 'model_id' => 1, + ]); + + DB::table('role_has_permissions')->insert([ + 'permission_id' => 1, + 'role_id' => 1, + ]); + + DB::table('model_has_permissions')->insert([ + 'permission_id' => 1, + 'model_type' => $userModelClass, + 'model_id' => 1, + ]); + + DB::table('root_categories')->insert([ + 'id' => 5000, + 'title' => 'TV', + 'status' => 1, + ]); + + DB::table('categories')->insert([ + 'id' => 5030, + 'title' => 'SD', + 'root_categories_id' => 5000, + 'status' => 1, + 'description' => 'TV SD', + ]); + + DB::table('usenet_groups')->insert([ + 'name' => 'alt.binaries.test', + 'active' => 1, + 'description' => 'Test', + 'last_updated' => now(), + ]); + + DB::table('videos')->insert([ + 'id' => 1, + 'type' => 0, + 'title' => 'The Simpsons', + 'tvdb' => 71663, + 'tvmaze' => 83, + 'tvrage' => 6190, + 'imdb' => '96697', + ]); + + DB::table('tv_episodes')->insert([ + [ + 'id' => 1, + 'videos_id' => 1, + 'series' => 6, + 'episode' => 24, + 'se_complete' => 'S06E24', + 'title' => 'Sideshow Bob Roberts', + 'firstaired' => '1994-10-09', + 'summary' => null, + ], + [ + 'id' => 2, + 'videos_id' => 1, + 'series' => 7, + 'episode' => 1, + 'se_complete' => 'S07E01', + 'title' => 'Who Shot Mr. Burns? (Part Two)', + 'firstaired' => '1995-09-17', + 'summary' => null, + ], + ]); + + $now = now()->toDateTimeString(); + + DB::table('releases')->insert([ + [ + 'id' => 1, + 'searchname' => 'Simpsons.S06E24.Test', + 'guid' => 'guid-s06e24', + 'postdate' => $now, + 'adddate' => $now, + 'categories_id' => 5030, + 'groups_id' => 1, + 'size' => 1000, + 'totalpart' => 1, + 'passwordstatus' => 0, + 'grabs' => 0, + 'comments' => 0, + 'videos_id' => 1, + 'tv_episodes_id' => 1, + ], + [ + 'id' => 2, + 'searchname' => 'Simpsons.S07E01.Test', + 'guid' => 'guid-s07e01', + 'postdate' => $now, + 'adddate' => $now, + 'categories_id' => 5030, + 'groups_id' => 1, + 'size' => 2000, + 'totalpart' => 1, + 'passwordstatus' => 0, + 'grabs' => 0, + 'comments' => 0, + 'videos_id' => 1, + 'tv_episodes_id' => 2, + ], + ]); + + Release::clearBootedModels(); + } +}