From e1d1c3cee71e7a6f3caa75891d49399f76acec55 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Fri, 7 Aug 2026 16:01:41 +0200 Subject: [PATCH] Use manticore for mediainfo data --- .../Commands/NntmuxCreateESIndexes.php | 13 ++ app/Console/Commands/NntmuxOffsetPopulate.php | 13 ++ .../Commands/NntmuxPopulateSearchIndexes.php | 36 +--- app/Console/Commands/NntmuxResetDb.php | 13 ++ .../Commands/UpdateReleasesIndexSchema.php | 13 ++ .../Commands/UpdateReleasesIndexSchemaES.php | 23 ++- app/Models/MediaInfo.php | 2 +- app/Services/ReleaseExtraService.php | 3 + .../Contracts/SearchServiceInterface.php | 3 + .../Search/DTO/ReleaseSearchQuery.php | 31 ++++ .../Search/Drivers/ElasticSearchDriver.php | 154 +++++++++--------- .../Search/Drivers/ManticoreSearchDriver.php | 79 ++++++++- .../Search/Support/ManticoreIndexRegistry.php | 7 + .../Search/Support/ReleaseIndexProjection.php | 41 +++++ app/Support/ReleaseSearchIndexDocument.php | 18 ++ tests/Unit/ElasticSearchQueryTest.php | 38 +++++ tests/Unit/ManticoreIndexRegistryTest.php | 5 + tests/Unit/ManticoreSearchQueryTest.php | 41 +++++ tests/Unit/ReleaseExtraServiceTest.php | 58 +++++++ tests/Unit/ReleaseSearchIndexDocumentTest.php | 10 ++ .../Search/ReleaseIndexProjectionTest.php | 65 ++++++++ 21 files changed, 546 insertions(+), 120 deletions(-) create mode 100644 tests/Unit/ReleaseExtraServiceTest.php diff --git a/app/Console/Commands/NntmuxCreateESIndexes.php b/app/Console/Commands/NntmuxCreateESIndexes.php index 60e3c35a0..a5cd0b8ca 100644 --- a/app/Console/Commands/NntmuxCreateESIndexes.php +++ b/app/Console/Commands/NntmuxCreateESIndexes.php @@ -133,6 +133,19 @@ class NntmuxCreateESIndexes extends Command 'tvrage' => ['type' => 'integer'], 'videos_id' => ['type' => 'integer'], 'movieinfo_id' => ['type' => 'integer'], + 'media_movie_name' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'media_file_name' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'media_unique_id' => ['type' => 'keyword', 'ignore_above' => 256], + 'media_container_format' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'media_video_format' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'media_video_codec' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'media_video_width' => ['type' => 'integer'], + 'media_video_height' => ['type' => 'integer'], + 'media_audio_format' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'media_audio_channels' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'media_audio_language' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'media_subtitle_language' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'has_media_info' => ['type' => 'integer'], ], ], ]); diff --git a/app/Console/Commands/NntmuxOffsetPopulate.php b/app/Console/Commands/NntmuxOffsetPopulate.php index 40f754c0d..1959aaa74 100644 --- a/app/Console/Commands/NntmuxOffsetPopulate.php +++ b/app/Console/Commands/NntmuxOffsetPopulate.php @@ -406,6 +406,19 @@ class NntmuxOffsetPopulate extends Command 'categories_id' => ['type' => 'integer'], 'filename' => ['type' => 'text', 'analyzer' => 'standard'], 'postdate' => ['type' => 'date'], + 'media_movie_name' => ['type' => 'text', 'analyzer' => 'standard'], + 'media_file_name' => ['type' => 'text', 'analyzer' => 'standard'], + 'media_unique_id' => ['type' => 'keyword'], + 'media_container_format' => ['type' => 'text', 'analyzer' => 'standard'], + 'media_video_format' => ['type' => 'text', 'analyzer' => 'standard'], + 'media_video_codec' => ['type' => 'text', 'analyzer' => 'standard'], + 'media_video_width' => ['type' => 'integer'], + 'media_video_height' => ['type' => 'integer'], + 'media_audio_format' => ['type' => 'text', 'analyzer' => 'standard'], + 'media_audio_channels' => ['type' => 'text', 'analyzer' => 'standard'], + 'media_audio_language' => ['type' => 'text', 'analyzer' => 'standard'], + 'media_subtitle_language' => ['type' => 'text', 'analyzer' => 'standard'], + 'has_media_info' => ['type' => 'integer'], ], ]; } else { diff --git a/app/Console/Commands/NntmuxPopulateSearchIndexes.php b/app/Console/Commands/NntmuxPopulateSearchIndexes.php index 9152c7def..3895f466c 100644 --- a/app/Console/Commands/NntmuxPopulateSearchIndexes.php +++ b/app/Console/Commands/NntmuxPopulateSearchIndexes.php @@ -760,45 +760,13 @@ class NntmuxPopulateSearchIndexes extends Command return Command::SUCCESS; } - $query = Release::query() - ->orderByDesc('releases.id') - ->leftJoin('release_files', 'releases.id', '=', 'release_files.releases_id') - ->select([ - 'releases.id', - 'releases.name', - 'releases.searchname', - 'releases.fromname', - 'releases.categories_id', - 'releases.postdate', - ]) - ->selectRaw('IFNULL(GROUP_CONCAT(release_files.name SEPARATOR " "),"") AS filename') - ->groupBy([ - 'releases.id', - 'releases.name', - 'releases.searchname', - 'releases.fromname', - 'releases.categories_id', - 'releases.postdate', - ]); + $query = ReleaseIndexProjection::query()->orderBy('r.id'); return $this->processElasticData( 'releases', $total, $query, - function ($item) { - $searchName = str_replace(['.', '-'], ' ', $item->searchname ?? ''); - - return [ - 'id' => $item->id, - 'name' => $item->name, - 'searchname' => $item->searchname, - 'plainsearchname' => $searchName, - 'fromname' => $item->fromname, - 'categories_id' => $item->categories_id, - 'filename' => $item->filename ?? '', - 'postdate' => $item->postdate, - ]; - } + fn ($item): array => ReleaseSearchIndexDocument::normalize((array) $item) ); } diff --git a/app/Console/Commands/NntmuxResetDb.php b/app/Console/Commands/NntmuxResetDb.php index 50c9f03fa..1156dd74c 100644 --- a/app/Console/Commands/NntmuxResetDb.php +++ b/app/Console/Commands/NntmuxResetDb.php @@ -136,6 +136,19 @@ class NntmuxResetDb extends Command 'filename' => ['type' => 'text'], 'add_date' => ['type' => 'date', 'format' => 'yyyy-MM-dd HH:mm:ss'], 'post_date' => ['type' => 'date', 'format' => 'yyyy-MM-dd HH:mm:ss'], + 'media_movie_name' => ['type' => 'text'], + 'media_file_name' => ['type' => 'text'], + 'media_unique_id' => ['type' => 'keyword'], + 'media_container_format' => ['type' => 'text'], + 'media_video_format' => ['type' => 'text'], + 'media_video_codec' => ['type' => 'text'], + 'media_video_width' => ['type' => 'integer'], + 'media_video_height' => ['type' => 'integer'], + 'media_audio_format' => ['type' => 'text'], + 'media_audio_channels' => ['type' => 'text'], + 'media_audio_language' => ['type' => 'text'], + 'media_subtitle_language' => ['type' => 'text'], + 'has_media_info' => ['type' => 'integer'], ], ], ], diff --git a/app/Console/Commands/UpdateReleasesIndexSchema.php b/app/Console/Commands/UpdateReleasesIndexSchema.php index 6033d2369..0729dfc83 100644 --- a/app/Console/Commands/UpdateReleasesIndexSchema.php +++ b/app/Console/Commands/UpdateReleasesIndexSchema.php @@ -56,6 +56,19 @@ class UpdateReleasesIndexSchema extends Command 'tvrage' => ['type' => 'int'], 'videos_id' => ['type' => 'int'], 'movieinfo_id' => ['type' => 'int'], + 'media_movie_name' => ['type' => 'text'], + 'media_file_name' => ['type' => 'text'], + 'media_unique_id' => ['type' => 'string'], + 'media_container_format' => ['type' => 'text'], + 'media_video_format' => ['type' => 'text'], + 'media_video_codec' => ['type' => 'text'], + 'media_video_width' => ['type' => 'int'], + 'media_video_height' => ['type' => 'int'], + 'media_audio_format' => ['type' => 'text'], + 'media_audio_channels' => ['type' => 'text'], + 'media_audio_language' => ['type' => 'text'], + 'media_subtitle_language' => ['type' => 'text'], + 'has_media_info' => ['type' => 'int'], ]; protected Client $client; diff --git a/app/Console/Commands/UpdateReleasesIndexSchemaES.php b/app/Console/Commands/UpdateReleasesIndexSchemaES.php index 520673d3f..d0aee32a5 100644 --- a/app/Console/Commands/UpdateReleasesIndexSchemaES.php +++ b/app/Console/Commands/UpdateReleasesIndexSchemaES.php @@ -50,6 +50,19 @@ class UpdateReleasesIndexSchemaES extends Command 'tvrage' => ['type' => 'integer'], 'videos_id' => ['type' => 'integer'], 'movieinfo_id' => ['type' => 'integer'], + 'media_movie_name' => ['type' => 'text', 'analyzer' => 'release_analyzer'], + 'media_file_name' => ['type' => 'text', 'analyzer' => 'release_analyzer'], + 'media_unique_id' => ['type' => 'keyword', 'ignore_above' => 256], + 'media_container_format' => ['type' => 'text', 'analyzer' => 'release_analyzer'], + 'media_video_format' => ['type' => 'text', 'analyzer' => 'release_analyzer'], + 'media_video_codec' => ['type' => 'text', 'analyzer' => 'release_analyzer'], + 'media_video_width' => ['type' => 'integer'], + 'media_video_height' => ['type' => 'integer'], + 'media_audio_format' => ['type' => 'text', 'analyzer' => 'release_analyzer'], + 'media_audio_channels' => ['type' => 'text', 'analyzer' => 'release_analyzer'], + 'media_audio_language' => ['type' => 'text', 'analyzer' => 'release_analyzer'], + 'media_subtitle_language' => ['type' => 'text', 'analyzer' => 'release_analyzer'], + 'has_media_info' => ['type' => 'integer'], ]; private ?ElasticsearchClient $client = null; @@ -383,15 +396,7 @@ class UpdateReleasesIndexSchemaES extends Command 'type' => 'date', 'format' => 'yyyy-MM-dd HH:mm:ss', ], - // New media-related fields - 'imdbid' => ['type' => 'keyword'], - 'tmdbid' => ['type' => 'integer'], - 'traktid' => ['type' => 'integer'], - 'tvdb' => ['type' => 'integer'], - 'tvmaze' => ['type' => 'integer'], - 'tvrage' => ['type' => 'integer'], - 'videos_id' => ['type' => 'integer'], - 'movieinfo_id' => ['type' => 'integer'], + ...$this->mediaFields, ], ], ], diff --git a/app/Models/MediaInfo.php b/app/Models/MediaInfo.php index 5fbc52f6d..0251f44ab 100644 --- a/app/Models/MediaInfo.php +++ b/app/Models/MediaInfo.php @@ -36,7 +36,7 @@ class MediaInfo extends Model 'releases_id' => $id, 'movie_name' => $mediainfoArray->get('movie_name') ?? null, 'file_name' => $mediainfoArray->get('file_name') ?? null, - 'unique_id' => $mediainfoArray->get('unique_id') ?? null, + 'unique_id' => $mediaUniqueId, 'created_at' => now(), 'updated_at' => now(), ]); diff --git a/app/Services/ReleaseExtraService.php b/app/Services/ReleaseExtraService.php index 232bfcf64..2c7cbe54e 100644 --- a/app/Services/ReleaseExtraService.php +++ b/app/Services/ReleaseExtraService.php @@ -7,6 +7,7 @@ namespace App\Services; use App\Models\AudioData; use App\Models\ReleaseSubtitle; use App\Models\VideoData; +use App\Support\ReleaseSearchIndexSync; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; @@ -250,6 +251,8 @@ class ReleaseExtraService } } } + + ReleaseSearchIndexSync::forIds([$releaseID]); } /** diff --git a/app/Services/Search/Contracts/SearchServiceInterface.php b/app/Services/Search/Contracts/SearchServiceInterface.php index 9b6a3a068..42f339bca 100644 --- a/app/Services/Search/Contracts/SearchServiceInterface.php +++ b/app/Services/Search/Contracts/SearchServiceInterface.php @@ -356,6 +356,9 @@ interface SearchServiceInterface * @param array $criteria Keys: phrases (string|array|null), category_ids (list|null), * excluded_category_ids (list), min_size (int), max_age_days (int), * groups_id (int|null), password_allow_rar (bool), password_status_min (int|null), + * has_media_info (bool|null), media_unique_id (string|null), + * min_video_width (int), max_video_width (int), + * min_video_height (int), max_video_height (int), * sort_field (string), sort_dir (string), try_fuzzy (bool), release_ids (list|null) * @return array{ids: list, total: int, fuzzy: bool} */ diff --git a/app/Services/Search/DTO/ReleaseSearchQuery.php b/app/Services/Search/DTO/ReleaseSearchQuery.php index 8806bff55..475e35dfe 100644 --- a/app/Services/Search/DTO/ReleaseSearchQuery.php +++ b/app/Services/Search/DTO/ReleaseSearchQuery.php @@ -25,6 +25,12 @@ final readonly class ReleaseSearchQuery public ?int $groupId = null, public bool $passwordAllowRar = false, public ?int $passwordStatusMin = null, + public ?bool $hasMediaInfo = null, + public ?string $mediaUniqueId = null, + public int $minVideoWidth = 0, + public int $maxVideoWidth = 0, + public int $minVideoHeight = 0, + public int $maxVideoHeight = 0, public string $sortField = 'postdate_ts', public string $sortDirection = 'desc', public bool $tryFuzzy = true, @@ -51,6 +57,14 @@ final readonly class ReleaseSearchQuery groupId: isset($criteria['groups_id']) ? (int) $criteria['groups_id'] : null, passwordAllowRar: (bool) ($criteria['password_allow_rar'] ?? false), passwordStatusMin: isset($criteria['password_status_min']) ? (int) $criteria['password_status_min'] : null, + hasMediaInfo: array_key_exists('has_media_info', $criteria) && $criteria['has_media_info'] !== null + ? (bool) $criteria['has_media_info'] + : null, + mediaUniqueId: self::normalizeMediaUniqueId($criteria['media_unique_id'] ?? null), + minVideoWidth: (int) ($criteria['min_video_width'] ?? 0), + maxVideoWidth: (int) ($criteria['max_video_width'] ?? 0), + minVideoHeight: (int) ($criteria['min_video_height'] ?? 0), + maxVideoHeight: (int) ($criteria['max_video_height'] ?? 0), sortField: (string) ($criteria['sort_field'] ?? 'postdate_ts'), sortDirection: (string) ($criteria['sort_dir'] ?? 'desc'), tryFuzzy: (bool) ($criteria['try_fuzzy'] ?? true), @@ -78,6 +92,12 @@ final readonly class ReleaseSearchQuery 'groups_id' => $this->groupId, 'password_allow_rar' => $this->passwordAllowRar, 'password_status_min' => $this->passwordStatusMin, + 'has_media_info' => $this->hasMediaInfo, + 'media_unique_id' => $this->mediaUniqueId, + 'min_video_width' => max(0, $this->minVideoWidth), + 'max_video_width' => max(0, $this->maxVideoWidth), + 'min_video_height' => max(0, $this->minVideoHeight), + 'max_video_height' => max(0, $this->maxVideoHeight), 'sort_field' => $this->normalizedSortField(), 'sort_dir' => $this->normalizedSortDirection(), 'try_fuzzy' => $this->tryFuzzy, @@ -98,4 +118,15 @@ final readonly class ReleaseSearchQuery { return strtolower($this->sortDirection) === 'asc' ? 'asc' : 'desc'; } + + public static function normalizeMediaUniqueId(mixed $value): ?string + { + if (! is_scalar($value)) { + return null; + } + + $mediaUniqueId = trim((string) $value); + + return $mediaUniqueId === '' || $mediaUniqueId === '-1' ? null : $mediaUniqueId; + } } diff --git a/app/Services/Search/Drivers/ElasticSearchDriver.php b/app/Services/Search/Drivers/ElasticSearchDriver.php index 74fa7b5c0..83ad4bf3c 100644 --- a/app/Services/Search/Drivers/ElasticSearchDriver.php +++ b/app/Services/Search/Drivers/ElasticSearchDriver.php @@ -10,7 +10,6 @@ use App\Models\ConsoleInfo; use App\Models\GamesInfo; use App\Models\MovieInfo; use App\Models\MusicInfo; -use App\Models\Release; use App\Models\SteamApp; use App\Models\Video; use App\Services\Search\Contracts\SearchDriverInterface; @@ -18,12 +17,13 @@ use App\Services\Search\DTO\ReleaseSearchQuery; use App\Services\Search\DTO\SearchPage; use App\Services\Search\Support\ElasticsearchClientFactory; use App\Services\Search\Support\ElasticsearchResponseHelper; +use App\Services\Search\Support\ReleaseIndexProjection; +use App\Support\ReleaseSearchIndexDocument; use App\Support\SecondaryIndexDocuments; use Elastic\Elasticsearch\Client; use Elastic\Elasticsearch\Exception\ElasticsearchException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use RuntimeException; @@ -60,6 +60,19 @@ class ElasticSearchDriver implements SearchDriverInterface */ private const RELEASE_TEXT_FIELDS = ['searchname^3', 'plainsearchname^2']; + /** @var list */ + private const RELEASE_MEDIA_INFO_TEXT_FIELDS = [ + 'media_movie_name', + 'media_file_name', + 'media_container_format', + 'media_video_format', + 'media_video_codec', + 'media_audio_format', + 'media_audio_channels', + 'media_audio_language', + 'media_subtitle_language', + ]; + private static ?Client $client = null; private static ?bool $availabilityCache = null; @@ -1084,61 +1097,7 @@ class ElasticSearchDriver implements SearchDriverInterface } try { - $release = Release::query() - ->where('releases.id', $releaseID) - ->leftJoin('release_files as rf', 'releases.id', '=', 'rf.releases_id') - ->leftJoin('movieinfo as mi', 'releases.movieinfo_id', '=', 'mi.id') - ->leftJoin('videos as v', 'releases.videos_id', '=', 'v.id') - ->select([ - 'releases.id', - 'releases.name', - 'releases.searchname', - 'releases.fromname', - 'releases.categories_id', - 'releases.size', - 'releases.postdate', - 'releases.adddate', - 'releases.totalpart', - 'releases.grabs', - 'releases.passwordstatus', - 'releases.groups_id', - 'releases.nzbstatus', - 'releases.haspreview', - 'releases.imdbid', - 'releases.videos_id', - 'releases.movieinfo_id', - DB::raw('IFNULL(mi.tmdbid, 0) AS tmdbid'), - DB::raw('IFNULL(mi.traktid, 0) AS traktid'), - DB::raw('IFNULL(v.tvdb, 0) AS tvdb'), - DB::raw('IFNULL(v.tvmaze, 0) AS tvmaze'), - DB::raw('IFNULL(v.tvrage, 0) AS tvrage'), - DB::raw('IFNULL(GROUP_CONCAT(rf.name SEPARATOR " "),"") filename'), - ]) - ->groupBy([ - 'releases.id', - 'releases.name', - 'releases.searchname', - 'releases.fromname', - 'releases.categories_id', - 'releases.size', - 'releases.postdate', - 'releases.adddate', - 'releases.totalpart', - 'releases.grabs', - 'releases.passwordstatus', - 'releases.groups_id', - 'releases.nzbstatus', - 'releases.haspreview', - 'releases.imdbid', - 'releases.videos_id', - 'releases.movieinfo_id', - 'mi.tmdbid', - 'mi.traktid', - 'v.tvdb', - 'v.tvmaze', - 'v.tvrage', - ]) - ->first(); + $release = ReleaseIndexProjection::forId((int) $releaseID); if ($release === null) { Log::warning('ElasticSearch: Release not found for update, removing from index', ['id' => $releaseID]); @@ -1147,8 +1106,7 @@ class ElasticSearchDriver implements SearchDriverInterface return; } - $client = $this->getClient(); - $client->index($this->buildReleaseDocument($release->toArray())); + $this->insertRelease($release); } catch (ElasticsearchException $e) { Log::error('ElasticSearch updateRelease error: '.$e->getMessage(), [ @@ -1747,23 +1705,12 @@ class ElasticSearchDriver implements SearchDriverInterface */ private function buildReleaseDocument(array $parameters): array { - $searchNameDotless = $this->createPlainSearchName($parameters['searchname'] ?? ''); + $document = ReleaseSearchIndexDocument::normalizeForBulk($parameters); return [ - 'body' => [ - 'id' => $parameters['id'], - 'name' => $parameters['name'] ?? '', - 'searchname' => $parameters['searchname'] ?? '', - 'plainsearchname' => $searchNameDotless, - 'fromname' => $parameters['fromname'] ?? '', - 'categories_id' => $parameters['categories_id'] ?? 0, - 'imdbid' => (string) ($parameters['imdbid'] ?? ''), - 'filename' => $parameters['filename'] ?? '', - 'add_date' => now()->format('Y-m-d H:i:s'), - 'post_date' => $parameters['postdate'] ?? now()->format('Y-m-d H:i:s'), - ], + 'body' => $document, 'index' => $this->getReleasesIndex(), - 'id' => $parameters['id'], + 'id' => $document['id'], ]; } @@ -2895,7 +2842,7 @@ class ElasticSearchDriver implements SearchDriverInterface $phrases = $criteria['phrases'] ?? null; $hasText = $phrases !== null && $phrases !== '' && $phrases !== -1; - $tryFuzzy = (bool) ($criteria['try_fuzzy'] ?? true); + $tryFuzzy = (bool) ($criteria['try_fuzzy'] ?? true) && ! self::hasMediaInfoFieldQuery($phrases); $run = function (bool $useFuzzy) use ($criteria, $limit, $offset, $hasText, $phrases): array { $filter = $this->buildElasticsearchReleaseFilters($criteria); @@ -3036,6 +2983,15 @@ class ElasticSearchDriver implements SearchDriverInterface 'name' => ['name'], 'fromname' => ['fromname'], 'filename' => ['filename'], + 'media_movie_name' => ['media_movie_name'], + 'media_file_name' => ['media_file_name'], + 'media_container_format' => ['media_container_format'], + 'media_video_format' => ['media_video_format'], + 'media_video_codec' => ['media_video_codec'], + 'media_audio_format' => ['media_audio_format'], + 'media_audio_channels' => ['media_audio_channels'], + 'media_audio_language' => ['media_audio_language'], + 'media_subtitle_language' => ['media_subtitle_language'], ]; $must = []; @@ -3079,6 +3035,21 @@ class ElasticSearchDriver implements SearchDriverInterface return $must; } + private static function hasMediaInfoFieldQuery(mixed $phrases): bool + { + if (! is_array($phrases)) { + return false; + } + + foreach (self::RELEASE_MEDIA_INFO_TEXT_FIELDS as $field) { + if (isset($phrases[$field]) && $phrases[$field] !== '' && $phrases[$field] !== -1) { + return true; + } + } + + return false; + } + /** * @param array $phrases */ @@ -3162,6 +3133,41 @@ class ElasticSearchDriver implements SearchDriverInterface $filter[] = ['range' => ['passwordstatus' => ['gte' => (int) $criteria['password_status_min']]]]; } + if (array_key_exists('has_media_info', $criteria) && $criteria['has_media_info'] !== null) { + $filter[] = ['term' => ['has_media_info' => (bool) $criteria['has_media_info'] ? 1 : 0]]; + } + + $mediaUniqueId = ReleaseSearchQuery::normalizeMediaUniqueId($criteria['media_unique_id'] ?? null); + if ($mediaUniqueId !== null) { + $filter[] = ['term' => ['media_unique_id' => $mediaUniqueId]]; + } + + $minVideoWidth = (int) ($criteria['min_video_width'] ?? 0); + $maxVideoWidth = (int) ($criteria['max_video_width'] ?? 0); + if ($minVideoWidth > 0 || $maxVideoWidth > 0) { + $range = []; + if ($minVideoWidth > 0) { + $range['gte'] = $minVideoWidth; + } + if ($maxVideoWidth > 0) { + $range['lte'] = $maxVideoWidth; + } + $filter[] = ['range' => ['media_video_width' => $range]]; + } + + $minVideoHeight = (int) ($criteria['min_video_height'] ?? 0); + $maxVideoHeight = (int) ($criteria['max_video_height'] ?? 0); + if ($minVideoHeight > 0 || $maxVideoHeight > 0) { + $range = []; + if ($minVideoHeight > 0) { + $range['gte'] = $minVideoHeight; + } + if ($maxVideoHeight > 0) { + $range['lte'] = $maxVideoHeight; + } + $filter[] = ['range' => ['media_video_height' => $range]]; + } + return $filter; } diff --git a/app/Services/Search/Drivers/ManticoreSearchDriver.php b/app/Services/Search/Drivers/ManticoreSearchDriver.php index c073c4280..0a3f00353 100644 --- a/app/Services/Search/Drivers/ManticoreSearchDriver.php +++ b/app/Services/Search/Drivers/ManticoreSearchDriver.php @@ -37,6 +37,37 @@ class ManticoreSearchDriver implements SearchDriverInterface { private const AVAILABILITY_CHECK_CACHE_TTL = 30; + /** @var list */ + private const RELEASE_FIELD_SEARCH_FIELDS = [ + 'searchname', + 'plainsearchname', + 'name', + 'fromname', + 'filename', + 'media_movie_name', + 'media_file_name', + 'media_container_format', + 'media_video_format', + 'media_video_codec', + 'media_audio_format', + 'media_audio_channels', + 'media_audio_language', + 'media_subtitle_language', + ]; + + /** @var list */ + private const RELEASE_MEDIA_INFO_TEXT_FIELDS = [ + 'media_movie_name', + 'media_file_name', + 'media_container_format', + 'media_video_format', + 'media_video_codec', + 'media_audio_format', + 'media_audio_channels', + 'media_audio_language', + 'media_subtitle_language', + ]; + private static ?bool $availabilityCache = null; private static ?int $availabilityCacheTime = null; @@ -2591,7 +2622,7 @@ class ManticoreSearchDriver implements SearchDriverInterface $phrases = $criteria['phrases'] ?? null; $hasText = $phrases !== null && $phrases !== '' && $phrases !== -1; - $tryFuzzy = (bool) ($criteria['try_fuzzy'] ?? true); + $tryFuzzy = (bool) ($criteria['try_fuzzy'] ?? true) && ! self::hasMediaInfoFieldQuery($phrases); $execute = function (bool $useFuzzy) use ($criteria, $limit, $offset, $hasText, $phrases): array { $query = (new Search($this->manticoreSearch)) @@ -2620,7 +2651,7 @@ class ManticoreSearchDriver implements SearchDriverInterface $searchArray = $this->phrasesToSearchArray($phrases); $terms = []; foreach ($searchArray as $key => $value) { - if ($value === '' || $value === null) { + if (! is_string($key) || ! in_array($key, self::RELEASE_FIELD_SEARCH_FIELDS, true) || $value === '' || $value === null) { continue; } $prepared = self::prepareUserSearchQuery((string) $value); @@ -2744,6 +2775,21 @@ class ManticoreSearchDriver implements SearchDriverInterface return implode(' ', $searchTerms); } + private static function hasMediaInfoFieldQuery(mixed $phrases): bool + { + if (! is_array($phrases)) { + return false; + } + + foreach (self::RELEASE_MEDIA_INFO_TEXT_FIELDS as $field) { + if (isset($phrases[$field]) && $phrases[$field] !== '' && $phrases[$field] !== -1) { + return true; + } + } + + return false; + } + /** * @param array $criteria */ @@ -2818,6 +2864,35 @@ class ManticoreSearchDriver implements SearchDriverInterface if (array_key_exists('password_status_min', $criteria) && $criteria['password_status_min'] !== null) { $query->filter('passwordstatus', 'gte', (int) $criteria['password_status_min']); } + + if (array_key_exists('has_media_info', $criteria) && $criteria['has_media_info'] !== null) { + $query->filter('has_media_info', '=', (bool) $criteria['has_media_info'] ? 1 : 0); + } + + $mediaUniqueId = ReleaseSearchQuery::normalizeMediaUniqueId($criteria['media_unique_id'] ?? null); + if ($mediaUniqueId !== null) { + $query->filter('media_unique_id', '=', $mediaUniqueId); + } + + $minVideoWidth = (int) ($criteria['min_video_width'] ?? 0); + if ($minVideoWidth > 0) { + $query->filter('media_video_width', 'gte', $minVideoWidth); + } + + $maxVideoWidth = (int) ($criteria['max_video_width'] ?? 0); + if ($maxVideoWidth > 0) { + $query->filter('media_video_width', 'lte', $maxVideoWidth); + } + + $minVideoHeight = (int) ($criteria['min_video_height'] ?? 0); + if ($minVideoHeight > 0) { + $query->filter('media_video_height', 'gte', $minVideoHeight); + } + + $maxVideoHeight = (int) ($criteria['max_video_height'] ?? 0); + if ($maxVideoHeight > 0) { + $query->filter('media_video_height', 'lte', $maxVideoHeight); + } } public function getSecondaryIndexName(SecondarySearchIndex $index): string diff --git a/app/Services/Search/Support/ManticoreIndexRegistry.php b/app/Services/Search/Support/ManticoreIndexRegistry.php index dc84fb873..5086e2457 100644 --- a/app/Services/Search/Support/ManticoreIndexRegistry.php +++ b/app/Services/Search/Support/ManticoreIndexRegistry.php @@ -40,6 +40,13 @@ final class ManticoreIndexRegistry 'reid' => ['type' => 'integer'], 'passwordstatus' => ['type' => 'bigint'], 'groups_id' => ['type' => 'integer'], 'nzbstatus' => ['type' => 'integer'], 'haspreview' => ['type' => 'bigint'], + 'media_movie_name' => ['type' => 'text'], 'media_file_name' => ['type' => 'text'], + 'media_unique_id' => ['type' => 'string'], 'media_container_format' => ['type' => 'text'], + 'media_video_format' => ['type' => 'text'], 'media_video_codec' => ['type' => 'text'], + 'media_video_width' => ['type' => 'integer'], 'media_video_height' => ['type' => 'integer'], + 'media_audio_format' => ['type' => 'text'], 'media_audio_channels' => ['type' => 'text'], + 'media_audio_language' => ['type' => 'text'], 'media_subtitle_language' => ['type' => 'text'], + 'has_media_info' => ['type' => 'integer'], ]], 'predb' => ['settings' => $settings, 'columns' => [ 'title' => ['type' => 'text', 'attribute' => true], diff --git a/app/Services/Search/Support/ReleaseIndexProjection.php b/app/Services/Search/Support/ReleaseIndexProjection.php index 08430e816..6b0395057 100644 --- a/app/Services/Search/Support/ReleaseIndexProjection.php +++ b/app/Services/Search/Support/ReleaseIndexProjection.php @@ -16,9 +16,22 @@ final class ReleaseIndexProjection public static function query(): Builder { $isSqlite = DB::connection()->getDriverName() === 'sqlite'; + $mediaInfo = self::mediaInfoQuery(); $filename = $isSqlite ? "COALESCE((SELECT GROUP_CONCAT(rf.name, ' ') FROM release_files rf WHERE rf.releases_id = r.id), '')" : "COALESCE((SELECT GROUP_CONCAT(rf.name SEPARATOR ' ') FROM release_files rf WHERE rf.releases_id = r.id), '')"; + $audioFormat = $isSqlite + ? "COALESCE((SELECT GROUP_CONCAT(ad.audioformat, ' ') FROM audio_data ad WHERE ad.releases_id = r.id), '')" + : "COALESCE((SELECT GROUP_CONCAT(ad.audioformat SEPARATOR ' ') FROM audio_data ad WHERE ad.releases_id = r.id), '')"; + $audioChannels = $isSqlite + ? "COALESCE((SELECT GROUP_CONCAT(ad.audiochannels, ' ') FROM audio_data ad WHERE ad.releases_id = r.id), '')" + : "COALESCE((SELECT GROUP_CONCAT(ad.audiochannels SEPARATOR ' ') FROM audio_data ad WHERE ad.releases_id = r.id), '')"; + $audioLanguage = $isSqlite + ? "COALESCE((SELECT GROUP_CONCAT(ad.audiolanguage, ' ') FROM audio_data ad WHERE ad.releases_id = r.id), '')" + : "COALESCE((SELECT GROUP_CONCAT(ad.audiolanguage SEPARATOR ' ') FROM audio_data ad WHERE ad.releases_id = r.id), '')"; + $subtitleLanguage = $isSqlite + ? "COALESCE((SELECT GROUP_CONCAT(rs.subslanguage, ' ') FROM release_subtitles rs WHERE rs.releases_id = r.id), '')" + : "COALESCE((SELECT GROUP_CONCAT(rs.subslanguage SEPARATOR ' ') FROM release_subtitles rs WHERE rs.releases_id = r.id), '')"; $categoryName = $isSqlite ? "cp.title || ' > ' || c.title" : "CONCAT(cp.title, ' > ', c.title)"; @@ -41,6 +54,7 @@ final class ReleaseIndexProjection }) ->leftJoin('release_nfos as rn', 'rn.releases_id', '=', 'r.id') ->leftJoin('video_data as vd', 'vd.releases_id', '=', 'r.id') + ->leftJoinSub($mediaInfo, 'mdi', 'mdi.releases_id', '=', 'r.id') ->select([ 'r.id', 'r.guid', 'r.name', 'r.searchname', 'r.fromname', 'r.categories_id', 'r.groups_id', 'r.size', 'r.postdate', 'r.adddate', 'r.totalpart', 'r.grabs', @@ -61,9 +75,36 @@ final class ReleaseIndexProjection DB::raw('COALESCE(v.tmdb, 0) AS tmdb'), DB::raw('COALESCE(rn.releases_id, 0) AS nfoid'), DB::raw('COALESCE(vd.releases_id, 0) AS reid'), + DB::raw("COALESCE(mdi.movie_name, '') AS media_movie_name"), + DB::raw("COALESCE(mdi.file_name, '') AS media_file_name"), + DB::raw("COALESCE(mdi.unique_id, '') AS media_unique_id"), + DB::raw("COALESCE(vd.containerformat, '') AS media_container_format"), + DB::raw("COALESCE(vd.videoformat, '') AS media_video_format"), + DB::raw("COALESCE(vd.videocodec, '') AS media_video_codec"), + DB::raw('COALESCE(vd.videowidth, 0) AS media_video_width'), + DB::raw('COALESCE(vd.videoheight, 0) AS media_video_height'), + DB::raw("{$audioFormat} AS media_audio_format"), + DB::raw("{$audioChannels} AS media_audio_channels"), + DB::raw("{$audioLanguage} AS media_audio_language"), + DB::raw("{$subtitleLanguage} AS media_subtitle_language"), + DB::raw('CASE WHEN mdi.releases_id IS NOT NULL OR vd.releases_id IS NOT NULL OR EXISTS (SELECT 1 FROM audio_data ad WHERE ad.releases_id = r.id) OR EXISTS (SELECT 1 FROM release_subtitles rs WHERE rs.releases_id = r.id) THEN 1 ELSE 0 END AS has_media_info'), ]); } + private static function mediaInfoQuery(): Builder + { + return DB::table('media_infos as selected_media_info') + ->select([ + 'selected_media_info.releases_id', + 'selected_media_info.movie_name', + 'selected_media_info.file_name', + 'selected_media_info.unique_id', + ]) + ->whereRaw( + 'selected_media_info.id = (SELECT MIN(candidate_media_info.id) FROM media_infos candidate_media_info WHERE candidate_media_info.releases_id = selected_media_info.releases_id)' + ); + } + /** * @return array|null */ diff --git a/app/Support/ReleaseSearchIndexDocument.php b/app/Support/ReleaseSearchIndexDocument.php index 396f22f19..9d8999793 100644 --- a/app/Support/ReleaseSearchIndexDocument.php +++ b/app/Support/ReleaseSearchIndexDocument.php @@ -26,6 +26,11 @@ final class ReleaseSearchIndexDocument 'size', 'postdate_ts', 'adddate_ts', 'totalpart', 'grabs', 'comments', 'passwordstatus', 'nzbstatus', 'nfostatus', 'haspreview', 'jpgstatus', 'nfoid', 'reid', + 'media_movie_name', 'media_file_name', 'media_unique_id', + 'media_container_format', 'media_video_format', 'media_video_codec', + 'media_video_width', 'media_video_height', 'media_audio_format', + 'media_audio_channels', 'media_audio_language', 'media_subtitle_language', + 'has_media_info', ]; } @@ -103,6 +108,19 @@ final class ReleaseSearchIndexDocument 'jpgstatus' => (int) ($row['jpgstatus'] ?? 0), 'nfoid' => (int) ($row['nfoid'] ?? 0), 'reid' => (int) ($row['reid'] ?? 0), + 'media_movie_name' => (string) ($row['media_movie_name'] ?? ''), + 'media_file_name' => (string) ($row['media_file_name'] ?? ''), + 'media_unique_id' => (string) ($row['media_unique_id'] ?? ''), + 'media_container_format' => (string) ($row['media_container_format'] ?? ''), + 'media_video_format' => (string) ($row['media_video_format'] ?? ''), + 'media_video_codec' => (string) ($row['media_video_codec'] ?? ''), + 'media_video_width' => (int) ($row['media_video_width'] ?? 0), + 'media_video_height' => (int) ($row['media_video_height'] ?? 0), + 'media_audio_format' => (string) ($row['media_audio_format'] ?? ''), + 'media_audio_channels' => (string) ($row['media_audio_channels'] ?? ''), + 'media_audio_language' => (string) ($row['media_audio_language'] ?? ''), + 'media_subtitle_language' => (string) ($row['media_subtitle_language'] ?? ''), + 'has_media_info' => (int) ($row['has_media_info'] ?? 0), ]; } diff --git a/tests/Unit/ElasticSearchQueryTest.php b/tests/Unit/ElasticSearchQueryTest.php index 03bde64fe..e64780af6 100644 --- a/tests/Unit/ElasticSearchQueryTest.php +++ b/tests/Unit/ElasticSearchQueryTest.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace Tests\Unit; +use App\Services\Search\Drivers\ElasticSearchDriver; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use ReflectionClass; final class ElasticSearchQueryTest extends TestCase { @@ -67,4 +69,40 @@ final class ElasticSearchQueryTest extends TestCase $this->assertStringContainsString("'_source' => false", $methodSource); $this->assertStringContainsString("'track_total_hits' => (bool)", $methodSource); } + + #[Test] + public function filtered_release_search_supports_media_info_text_and_numeric_filters(): void + { + $driverSource = file_get_contents(__DIR__.'/../../app/Services/Search/Drivers/ElasticSearchDriver.php'); + + $this->assertIsString($driverSource); + $this->assertStringContainsString("'media_video_codec' => ['media_video_codec']", $driverSource); + $this->assertStringContainsString("'term' => ['media_unique_id' => \$mediaUniqueId]", $driverSource); + $this->assertStringContainsString("'term' => ['has_media_info' =>", $driverSource); + $this->assertStringContainsString("'range' => ['media_video_width' => \$range]", $driverSource); + $this->assertStringContainsString("'range' => ['media_video_height' => \$range]", $driverSource); + } + + #[Test] + public function media_info_unique_id_uses_an_exact_keyword_filter(): void + { + $reflection = new ReflectionClass(ElasticSearchDriver::class); + $driver = $reflection->newInstanceWithoutConstructor(); + $method = $reflection->getMethod('buildElasticsearchReleaseFilters'); + + $filters = $method->invoke($driver, ['media_unique_id' => ' 0x123ABC ']); + + $this->assertContains(['term' => ['media_unique_id' => '0x123ABC']], $filters); + } + + #[Test] + public function incremental_release_updates_use_the_canonical_projection_and_document(): void + { + $driverSource = file_get_contents(__DIR__.'/../../app/Services/Search/Drivers/ElasticSearchDriver.php'); + + $this->assertIsString($driverSource); + $this->assertStringContainsString('ReleaseIndexProjection::forId((int) $releaseID)', $driverSource); + $this->assertStringContainsString('ReleaseSearchIndexDocument::normalizeForBulk($parameters)', $driverSource); + $this->assertStringContainsString("'body' => \$document", $driverSource); + } } diff --git a/tests/Unit/ManticoreIndexRegistryTest.php b/tests/Unit/ManticoreIndexRegistryTest.php index 8440a71ff..a127d760f 100644 --- a/tests/Unit/ManticoreIndexRegistryTest.php +++ b/tests/Unit/ManticoreIndexRegistryTest.php @@ -29,6 +29,11 @@ final class ManticoreIndexRegistryTest extends TestCase self::assertSame('bigint', $definitions['releases']['columns']['passwordstatus']['type']); self::assertSame('bigint', $definitions['releases']['columns']['haspreview']['type']); + self::assertSame('text', $definitions['releases']['columns']['media_movie_name']['type']); + self::assertSame('string', $definitions['releases']['columns']['media_unique_id']['type']); + self::assertSame('integer', $definitions['releases']['columns']['media_video_width']['type']); + self::assertSame('text', $definitions['releases']['columns']['media_audio_language']['type']); + self::assertSame('integer', $definitions['releases']['columns']['has_media_info']['type']); } #[Test] diff --git a/tests/Unit/ManticoreSearchQueryTest.php b/tests/Unit/ManticoreSearchQueryTest.php index d854f791a..7aed56e70 100644 --- a/tests/Unit/ManticoreSearchQueryTest.php +++ b/tests/Unit/ManticoreSearchQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit; use App\Services\Search\Drivers\ManticoreSearchDriver; +use App\Services\Search\DTO\ReleaseSearchQuery; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -263,6 +264,46 @@ class ManticoreSearchQueryTest extends TestCase $this->assertStringContainsString("\$query->sort('id', \$order);", $driverSource); } + #[Test] + public function release_search_query_normalizes_media_info_filters(): void + { + $criteria = ReleaseSearchQuery::fromCriteria([ + 'has_media_info' => true, + 'media_unique_id' => ' 0x123ABC ', + 'min_video_width' => -1, + 'max_video_width' => 3840, + 'min_video_height' => 720, + 'max_video_height' => 2160, + ], 25)->criteria(); + + $this->assertTrue($criteria['has_media_info']); + $this->assertSame('0x123ABC', $criteria['media_unique_id']); + $this->assertSame(0, $criteria['min_video_width']); + $this->assertSame(3840, $criteria['max_video_width']); + $this->assertSame(720, $criteria['min_video_height']); + $this->assertSame(2160, $criteria['max_video_height']); + + $invalidCriteria = ReleaseSearchQuery::fromCriteria(['media_unique_id' => '-1'], 25)->criteria(); + $this->assertNull($invalidCriteria['media_unique_id']); + } + + #[Test] + public function filtered_release_search_allowlists_media_info_fields_and_filters(): void + { + $driverSource = file_get_contents(__DIR__.'/../../app/Services/Search/Drivers/ManticoreSearchDriver.php'); + + $this->assertIsString($driverSource); + $this->assertStringContainsString("'media_video_codec',", $driverSource); + $this->assertStringContainsString('in_array($key, self::RELEASE_FIELD_SEARCH_FIELDS, true)', $driverSource); + $this->assertStringContainsString("\$query->filter('has_media_info', '='", $driverSource); + $this->assertStringContainsString("\$query->filter('media_unique_id', '='", $driverSource); + $this->assertStringContainsString("\$query->filter('media_video_width', 'gte'", $driverSource); + $this->assertStringContainsString("\$query->filter('media_video_height', 'lte'", $driverSource); + + $reflection = new ReflectionClass(ManticoreSearchDriver::class); + $this->assertNotContains('media_unique_id', $reflection->getConstant('RELEASE_FIELD_SEARCH_FIELDS')); + } + #[Test] public function search_releases_filtered_scopes_field_queries_with_grouping_parentheses(): void { diff --git a/tests/Unit/ReleaseExtraServiceTest.php b/tests/Unit/ReleaseExtraServiceTest.php new file mode 100644 index 000000000..8c072d3fe --- /dev/null +++ b/tests/Unit/ReleaseExtraServiceTest.php @@ -0,0 +1,58 @@ +once() + ->with(42); + + (new ReleaseExtraService)->addFromXml(42, new MediaInfoContainer); + } + + #[Test] + public function it_does_not_persist_invalid_media_unique_id_sentinels(): void + { + Schema::create('media_infos', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('releases_id'); + $table->string('movie_name')->nullable(); + $table->string('file_name')->nullable(); + $table->string('unique_id')->nullable(); + $table->timestamps(); + }); + + $general = new General; + $general->set('movie_name', 'Example Movie'); + $general->set('unique_id', '0x0'); + + $mediaInfo = new MediaInfoContainer; + $mediaInfo->setGeneral($general); + + try { + MediaInfoRecord::addData(43, $mediaInfo); + $record = MediaInfoRecord::query()->where('releases_id', 43)->firstOrFail(); + } finally { + Schema::drop('media_infos'); + } + + $this->assertSame('Example Movie', $record->movie_name); + $this->assertNull($record->unique_id); + } +} diff --git a/tests/Unit/ReleaseSearchIndexDocumentTest.php b/tests/Unit/ReleaseSearchIndexDocumentTest.php index 1330bce37..a84edcb82 100644 --- a/tests/Unit/ReleaseSearchIndexDocumentTest.php +++ b/tests/Unit/ReleaseSearchIndexDocumentTest.php @@ -30,6 +30,11 @@ final class ReleaseSearchIndexDocumentTest extends TestCase 'tv_episodes_id' => '456', 'passwordstatus' => '-1', 'comments' => '3', + 'media_movie_name' => 'Example Movie', + 'media_video_width' => '1920', + 'media_video_height' => '1080', + 'media_audio_language' => 'English French', + 'has_media_info' => '1', 'postdate' => '2026-01-02 03:04:05', 'adddate' => '2026-01-03 04:05:06', 'nzb_password' => 'must-not-be-indexed', @@ -40,6 +45,11 @@ final class ReleaseSearchIndexDocumentTest extends TestCase self::assertSame(123, $document['tmdbid']); self::assertSame(456, $document['tv_episodes_id']); self::assertSame(-1, $document['passwordstatus']); + self::assertSame('Example Movie', $document['media_movie_name']); + self::assertSame(1920, $document['media_video_width']); + self::assertSame(1080, $document['media_video_height']); + self::assertSame('English French', $document['media_audio_language']); + self::assertSame(1, $document['has_media_info']); self::assertGreaterThan(0, $document['postdate_ts']); self::assertGreaterThan(0, $document['adddate_ts']); self::assertArrayNotHasKey('nzb_password', $document); diff --git a/tests/Unit/Services/Search/ReleaseIndexProjectionTest.php b/tests/Unit/Services/Search/ReleaseIndexProjectionTest.php index 187f80818..c3b57a1b2 100644 --- a/tests/Unit/Services/Search/ReleaseIndexProjectionTest.php +++ b/tests/Unit/Services/Search/ReleaseIndexProjectionTest.php @@ -5,7 +5,12 @@ declare(strict_types=1); namespace Tests\Unit\Services\Search; use App\Services\Search\Support\ReleaseIndexProjection; +use Illuminate\Database\Query\Builder; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use PHPUnit\Framework\Attributes\Test; +use ReflectionClass; use Tests\TestCase; final class ReleaseIndexProjectionTest extends TestCase @@ -17,8 +22,68 @@ final class ReleaseIndexProjectionTest extends TestCase self::assertStringContainsString('select group_concat(rf.name', $sql); self::assertStringContainsString('where rf.releases_id = r.id', $sql); + self::assertStringContainsString('select group_concat(ad.audioformat', $sql); + self::assertStringContainsString('from release_subtitles rs', $sql); + self::assertStringContainsString('left join (select "selected_media_info"."releases_id"', $sql); + self::assertStringContainsString('select min(candidate_media_info.id)', $sql); + self::assertStringContainsString('left join "video_data" as "vd"', $sql); self::assertStringNotContainsString('join "release_files"', $sql); + self::assertStringNotContainsString('join "audio_data"', $sql); + self::assertStringNotContainsString('join "release_subtitles"', $sql); self::assertStringNotContainsString(' group by ', $sql); self::assertStringContainsString('order by "r"."id" asc limit 5000', $sql); } + + #[Test] + public function media_info_projection_selects_one_deterministic_row_per_release(): void + { + Schema::create('media_infos', static function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('releases_id'); + $table->string('movie_name')->nullable(); + $table->string('file_name')->nullable(); + $table->string('unique_id')->nullable(); + }); + + try { + DB::table('media_infos')->insert([ + [ + 'id' => 10, + 'releases_id' => 1, + 'movie_name' => 'First movie', + 'file_name' => 'first.mkv', + 'unique_id' => 'first-id', + ], + [ + 'id' => 11, + 'releases_id' => 1, + 'movie_name' => 'Duplicate movie', + 'file_name' => 'duplicate.mkv', + 'unique_id' => 'duplicate-id', + ], + ]); + + $method = (new ReflectionClass(ReleaseIndexProjection::class))->getMethod('mediaInfoQuery'); + $query = $method->invoke(null); + self::assertInstanceOf(Builder::class, $query); + $rows = $query->get(); + $row = (array) $rows->first(); + + self::assertCount(1, $rows); + self::assertSame('First movie', $row['movie_name']); + self::assertSame('first-id', $row['unique_id']); + } finally { + Schema::drop('media_infos'); + } + } + + #[Test] + public function both_search_engines_bulk_populate_from_the_canonical_projection(): void + { + $commandSource = file_get_contents(__DIR__.'/../../../../app/Console/Commands/NntmuxPopulateSearchIndexes.php'); + + self::assertIsString($commandSource); + self::assertGreaterThanOrEqual(2, substr_count($commandSource, 'ReleaseIndexProjection::query()->orderBy(\'r.id\')')); + self::assertGreaterThanOrEqual(2, substr_count($commandSource, 'ReleaseSearchIndexDocument::normalize((array) $item)')); + } }