diff --git a/.env.example b/.env.example index 5721015bc..a463709bb 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,6 @@ DEFAULT_OWNER_PASSWORD=UNIT3D TMDB_API_KEY= TWITCH_CLIENT_ID= TWITCH_CLIENT_SECRET= + +MEILISEARCH_HOST=http://127.0.0.1:7700 +MEILISEARCH_KEY= diff --git a/app/Console/Commands/AutoSyncTorrentsToMeilisearch.php b/app/Console/Commands/AutoSyncTorrentsToMeilisearch.php new file mode 100644 index 000000000..57070d0ad --- /dev/null +++ b/app/Console/Commands/AutoSyncTorrentsToMeilisearch.php @@ -0,0 +1,56 @@ + + * @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0 + */ + +namespace App\Console\Commands; + +use App\Models\Torrent; +use Exception; +use Illuminate\Console\Command; + +class AutoSyncTorrentsToMeilisearch extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'auto:sync_torrents_to_meilisearch {--wipe}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Syncs torrents and their relations to meilisearch'; + + /** + * Execute the console command. + * + * @throws Exception + */ + public function handle(): void + { + $start = now(); + + if ($this->option('wipe')) { + Torrent::removeAllFromSearch(); + } + + Torrent::query()->searchable(); + + $this->comment('Synced all torrents to Meilisearch in '.(now()->diffInMilliseconds($start) / 1000).' seconds.'); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 71135bef4..047b8cce3 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -64,6 +64,7 @@ class Kernel extends ConsoleKernel $schedule->command('auto:refund_download')->daily(); $schedule->command('auth:clear-resets')->daily(); $schedule->command('fetch:release_years')->everyTenMinutes(); + $schedule->command('auto:sync_torrents_to_meilisearch')->everyThirtyMinutes(); //$schedule->command('auto:ban_disposable_users')->weekends(); //$schedule->command('backup:clean')->daily(); //$schedule->command('backup:run --only-db')->daily(); diff --git a/app/DTO/TorrentSearchFiltersDTO.php b/app/DTO/TorrentSearchFiltersDTO.php index d24c456ef..ee68c4bd4 100644 --- a/app/DTO/TorrentSearchFiltersDTO.php +++ b/app/DTO/TorrentSearchFiltersDTO.php @@ -24,9 +24,10 @@ use Closure; readonly class TorrentSearchFiltersDTO { use TorrentFilter; + private ?User $user; public function __construct( - private ?User $user = null, + User $user = null, private string $name = '', private string $description = '', private string $mediainfo = '', @@ -84,6 +85,7 @@ readonly class TorrentSearchFiltersDTO private ?bool $userSeeder = null, private ?bool $userActive = null, ) { + $this->user = $user ?? auth()->user(); } /** @@ -91,8 +93,9 @@ readonly class TorrentSearchFiltersDTO */ final public function toSqlQueryBuilder(): Closure { - $user = $this->user ?? auth()->user(); - $isRegexAllowed = $user->group->is_modo || $user->group->is_editor; + $group = $this->user->group; + + $isRegexAllowed = $group->is_modo || $group->is_editor; $isRegex = fn ($field) => $isRegexAllowed && \strlen((string) $field) > 2 && $field[0] === '/' @@ -136,8 +139,8 @@ readonly class TorrentSearchFiltersDTO ->when($this->stream, fn ($query) => $query->streamOptimized()) ->when($this->sd, fn ($query) => $query->sd()) ->when($this->highspeed, fn ($query) => $query->highspeed()) - ->when($this->userBookmarked, fn ($query) => $query->bookmarkedBy($user)) - ->when($this->userWished, fn ($query) => $query->wishedBy($user)) + ->when($this->userBookmarked, fn ($query) => $query->bookmarkedBy($this->user)) + ->when($this->userWished, fn ($query) => $query->wishedBy($this->user)) ->when($this->internal, fn ($query) => $query->internal()) ->when($this->personalRelease, fn ($query) => $query->personalRelease()) ->when($this->trumpable, fn ($query) => $query->trumpable()) @@ -145,10 +148,236 @@ readonly class TorrentSearchFiltersDTO ->when($this->dying, fn ($query) => $query->dying()) ->when($this->dead, fn ($query) => $query->dead()) ->when($this->graveyard, fn ($query) => $query->graveyard()) - ->when($this->userDownloaded === false, fn ($query) => $query->notDownloadedBy($user)) - ->when($this->userDownloaded === true, fn ($query) => $query->downloadedBy($user)) - ->when($this->userSeeder === true && $this->userActive === true, fn ($query) => $query->seededBy($user)) - ->when($this->userSeeder === false && $this->userActive === true, fn ($query) => $query->leechedBy($user)) - ->when($this->userSeeder === false && $this->userActive === false, fn ($query) => $query->uncompletedBy($user)); + ->when($this->userDownloaded === false, fn ($query) => $query->notDownloadedBy($this->user)) + ->when($this->userDownloaded === true, fn ($query) => $query->downloadedBy($this->user)) + ->when($this->userSeeder === true && $this->userActive === true, fn ($query) => $query->seededBy($this->user)) + ->when($this->userSeeder === false && $this->userActive === true, fn ($query) => $query->leechedBy($this->user)) + ->when($this->userSeeder === false && $this->userActive === false, fn ($query) => $query->uncompletedBy($this->user)); + } + + /** + * @return list> + */ + final public function toMeilisearchFilter(): array + { + $group = $this->user->group; + + $filters = [ + 'deleted_at IS NULL', + 'status = 1', + ]; + + if ($this->uploader !== '') { + $filters[] = 'user.username = '.json_encode($this->uploader); + + if (!$group->is_modo) { + $filters[] = 'anon = 0'; + } + } + + if ($this->keywords !== []) { + $filters[] = 'keywords.name IN '.json_encode($this->keywords); + } + + if ($this->startYear !== null) { + $filters[] = [ + 'movie.year >= '.$this->startYear, + 'tv.year >= '.$this->startYear, + ]; + } + + if ($this->endYear !== null) { + $filters[] = [ + 'movie.year <= '.$this->startYear, + 'tv.year <= '.$this->startYear, + ]; + } + + if ($this->minSize !== null) { + $filters[] = 'size >= '.$this->minSize; + } + + if ($this->maxSize !== null) { + $filters[] = 'size <= '.$this->maxSize; + } + + if ($this->seasonNumber !== null) { + $filters[] = 'season_number = '.$this->seasonNumber; + } + + if ($this->episodeNumber !== null) { + $filters[] = 'episode_number = '.$this->episodeNumber; + } + + if ($this->categoryIds !== []) { + $filters[] = 'category.id IN '.json_encode(array_map('intval', $this->categoryIds)); + } + + if ($this->typeIds !== []) { + $filters[] = 'type.id IN '.json_encode(array_map('intval', $this->typeIds)); + } + + if ($this->resolutionIds !== []) { + $filters[] = 'resolution.id IN '.json_encode(array_map('intval', $this->resolutionIds)); + } + + if ($this->genreIds !== []) { + $filters[] = [ + 'movie.genres.id IN '.json_encode(array_map('intval', $this->genreIds)), + 'tv.genres.id IN '.json_encode(array_map('intval', $this->genreIds)), + ]; + } + + if ($this->regionIds !== []) { + $filters[] = 'region_id IN '.json_encode(array_map('intval', $this->regionIds)); + } + + if ($this->distributorIds !== []) { + $filters[] = 'distributor_id IN '.json_encode(array_map('intval', $this->distributorIds)); + } + + if ($this->adult !== null) { + $filters[] = 'movie.adult = '.($this->adult ? '1' : '0'); + } + + if ($this->tmdbId !== null) { + $filters[] = 'tmdb = '.$this->tmdbId; + } + + if ($this->imdbId !== null) { + $filters[] = 'imdb = '.$this->imdbId; + } + + if ($this->tvdbId !== null) { + $filters[] = 'tvdb = '.$this->tvdbId; + } + + if ($this->malId !== null) { + $filters[] = 'mal = '.$this->malId; + } + + if ($this->playlistId !== null) { + $filters[] = 'playlists.id = '.$this->playlistId; + } + + if ($this->collectionId !== null) { + $filters[] = 'movie.collection.id = '.$this->collectionId; + } + + if ($this->companyId !== null) { + $filters[] = [ + 'movie.companies.id = '.$this->companyId, + 'tv.companies.id = '.$this->companyId, + ]; + } + + if ($this->networkId !== null) { + $filters[] = 'tv.networks.id = '.$this->networkId; + } + + if ($this->primaryLanguageNames !== []) { + $filters[] = [ + 'movie.original_language IN '.json_encode(array_map('strval', $this->primaryLanguageNames)), + 'tv.original_language IN '.json_encode(array_map('strval', $this->primaryLanguageNames)), + ]; + } + + if ($this->free !== []) { + /** @phpstan-ignore booleanNot.alwaysTrue */ + if (!config('other.freeleech')) { + $filters[] = 'free IN '.json_encode(array_map('intval', $this->free)); + } + } + + if ($this->doubleup) { + $filters[] = 'doubleup = 1'; + } + + if ($this->featured) { + $filters[] = 'featured = 1'; + } + + if ($this->refundable) { + $filters[] = 'refundable = 1'; + } + + if ($this->stream) { + $filters[] = 'stream = 1'; + } + + if ($this->sd) { + $filters[] = 'sd = 1'; + } + + if ($this->highspeed) { + $filters[] = 'highspeed = 1'; + } + + if ($this->internal) { + $filters[] = 'internal = 1'; + } + + if ($this->personalRelease) { + $filters[] = 'personal_release = 1'; + } + + if ($this->alive) { + $filters[] = 'seeders > 0'; + } + + if ($this->dying) { + $filters[] = 'seeders = 1'; + $filters[] = 'times_completed >= 3'; + } + + if ($this->dead) { + $filters[] = 'seeders = 0'; + } + + if ($this->filename !== '') { + $filters[] = 'files.name = '.json_encode($this->filename); + } + + if ($this->graveyard) { + $filters[] = 'seeders = 0'; + $filters[] = 'created_at < '.now()->subDays(30)->timestamp; + } + + if ($this->userBookmarked) { + $filters[] = 'bookmarks.user_id = '.$this->user->id; + } + + if ($this->userWished) { + $filters[] = [ + 'movie.wishes.user_id = '.$this->user->id, + 'tv.wishes.user_id = '.$this->user->id, + ]; + } + + if ($this->userDownloaded === true) { + $filters[] = 'history_complete.user_id = '.$this->user->id; + } + + if ($this->userDownloaded === false) { + $filters[] = 'history_incomplete.user_id = '.$this->user->id; + } + + if ($this->userSeeder === false) { + $filters[] = 'history_leechers.user_id = '.$this->user->id; + } + + if ($this->userSeeder === true) { + $filters[] = 'history_seeders.user_id = '.$this->user->id; + } + + if ($this->userActive === true) { + $filters[] = 'history_active.user_id = '.$this->user->id; + } + + if ($this->userActive === false) { + $filters[] = 'history_inactive.user_id = '.$this->user->id; + } + + return $filters; } } diff --git a/app/Http/Controllers/API/BookmarkController.php b/app/Http/Controllers/API/BookmarkController.php index 2b8ae7a19..850599997 100644 --- a/app/Http/Controllers/API/BookmarkController.php +++ b/app/Http/Controllers/API/BookmarkController.php @@ -16,12 +16,16 @@ declare(strict_types=1); namespace App\Http\Controllers\API; +use App\Models\Torrent; + class BookmarkController extends BaseController { final public function store(int $torrentId): bool { auth()->user()->bookmarks()->attach($torrentId); + Torrent::query()->whereKey($torrentId)->searchable(); + return true; } @@ -29,6 +33,8 @@ class BookmarkController extends BaseController { auth()->user()->bookmarks()->detach($torrentId); + Torrent::query()->whereKey($torrentId)->searchable(); + return false; } } diff --git a/app/Http/Controllers/API/TorrentController.php b/app/Http/Controllers/API/TorrentController.php index 74e24b3a5..c0cb1bdf3 100644 --- a/app/Http/Controllers/API/TorrentController.php +++ b/app/Http/Controllers/API/TorrentController.php @@ -35,11 +35,13 @@ use App\Services\Tmdb\TMDBScraper; use App\Services\Unit3dAnnounce; use App\Traits\TorrentMeta; use Exception; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rule; use MarcReichel\IGDBLaravel\Models\Game; +use Meilisearch\Endpoints\Indexes; /** * @see \Tests\Todo\Feature\Http\Controllers\TorrentControllerTest @@ -490,6 +492,20 @@ class TorrentController extends BaseController { $user = auth()->user(); $isRegexAllowed = $user->group->is_modo; + $isSqlAllowed = $user->group->is_modo && $request->driver === 'sql'; + + $request->validate([ + 'sortField' => [ + 'nullable', + 'sometimes', + 'in:name,size,seeders,leechers,times_completed,created_at,bumped_at', + ], + 'sortDirection' => [ + 'nullable', + 'sometimes', + 'in:asc,desc' + ] + ]); // Caching $url = $request->url(); @@ -498,6 +514,7 @@ class TorrentController extends BaseController // Don't cache the api_token so that multiple users can share the cache unset($queryParams['api_token']); $queryParams['isRegexAllowed'] = $isRegexAllowed; + $queryParams['isSqlAllowed'] = $isSqlAllowed; // Sorting query params by key (acts by reference) ksort($queryParams); @@ -506,8 +523,9 @@ class TorrentController extends BaseController $queryString = http_build_query($queryParams); $cacheKey = $url.'?'.$queryString; - $torrents = cache()->remember($cacheKey, 300, function () use ($request) { - $torrents = Torrent::with(['user:id,username', 'category', 'type', 'resolution', 'distributor', 'region', 'files']) + $torrents = cache()->remember($cacheKey, 300, function () use ($request, $isSqlAllowed) { + $eagerLoads = fn (Builder $query) => $query + ->with(['user:id,username', 'category', 'type', 'resolution', 'distributor', 'region', 'files']) ->select('*') ->selectRaw(" CASE @@ -517,46 +535,68 @@ class TorrentController extends BaseController WHEN category_id IN (SELECT `id` from `categories` where `music_meta` = 1) THEN 'music' WHEN category_id IN (SELECT `id` from `categories` where `no_meta` = 1) THEN 'no' END as meta - ") - ->where((new TorrentSearchFiltersDTO( - name: $request->filled('name') ? $request->string('name')->toString() : '', - description: $request->filled('description') ? $request->string('description')->toString() : '', - mediainfo: $request->filled('mediainfo') ? $request->string('mediainfo')->toString() : '', - uploader: $request->filled('uploader') ? $request->string('uploader')->toString() : '', - keywords: $request->filled('keywords') ? array_map('trim', explode(',', $request->string('keywords')->toString())) : [], - startYear: $request->filled('startYear') ? $request->integer('startYear') : null, - endYear: $request->filled('endYear') ? $request->integer('endYear') : null, - categoryIds: $request->filled('categories') ? array_map('intval', $request->categories) : [], - typeIds: $request->filled('types') ? array_map('intval', $request->types) : [], - resolutionIds: $request->filled('resolutions') ? array_map('intval', $request->resolutions) : [], - genreIds: $request->filled('genres') ? array_map('intval', $request->genres) : [], - tmdbId: $request->filled('tmdbId') ? $request->integer('tmdbId') : null, - imdbId: $request->filled('imdbId') ? $request->integer('imdbId') : null, - tvdbId: $request->filled('tvdbId') ? $request->integer('tvdbId') : null, - malId: $request->filled('malId') ? $request->integer('malId') : null, - playlistId: $request->filled('playlistId') ? $request->integer('playlistId') : null, - collectionId: $request->filled('collectionId') ? $request->integer('collectionId') : null, - primaryLanguageNames: $request->filled('primaryLanguages') ? array_map('str', $request->primaryLanguages) : [], - adult: $request->filled('adult') ? $request->boolean('adult') : null, - free: $request->filled('free') ? array_map('intval', (array) $request->free) : [], - doubleup: $request->filled('doubleup'), - refundable: $request->filled('refundable'), - featured: $request->filled('featured'), - stream: $request->filled('stream'), - sd: $request->filled('sd'), - highspeed: $request->filled('highspeed'), - internal: $request->filled('internal'), - personalRelease: $request->filled('personalRelease'), - alive: $request->filled('alive'), - dying: $request->filled('dying'), - dead: $request->filled('dead'), - filename: $request->filled('file_name') ? $request->string('file_name')->toString() : '', - seasonNumber: $request->filled('seasonNumber') ? $request->integer('seasonNumber') : null, - episodeNumber: $request->filled('episodeNumber') ? $request->integer('episodeNumber') : null, - ))->toSqlQueryBuilder()) - ->latest('sticky') - ->orderBy($request->input('sortField') ?? $this->sortField, $request->input('sortDirection') ?? $this->sortDirection) - ->cursorPaginate(min($request->input('perPage') ?? $this->perPage, 100)); + "); + + $filters = new TorrentSearchFiltersDTO( + name: $request->filled('name') ? $request->string('name')->toString() : '', + description: $request->filled('description') ? $request->string('description')->toString() : '', + mediainfo: $request->filled('mediainfo') ? $request->string('mediainfo')->toString() : '', + uploader: $request->filled('uploader') ? $request->string('uploader')->toString() : '', + keywords: $request->filled('keywords') ? array_map('trim', explode(',', $request->string('keywords')->toString())) : [], + startYear: $request->filled('startYear') ? $request->integer('startYear') : null, + endYear: $request->filled('endYear') ? $request->integer('endYear') : null, + categoryIds: $request->filled('categories') ? array_map('intval', $request->categories) : [], + typeIds: $request->filled('types') ? array_map('intval', $request->types) : [], + resolutionIds: $request->filled('resolutions') ? array_map('intval', $request->resolutions) : [], + genreIds: $request->filled('genres') ? array_map('intval', $request->genres) : [], + tmdbId: $request->filled('tmdbId') ? $request->integer('tmdbId') : null, + imdbId: $request->filled('imdbId') ? $request->integer('imdbId') : null, + tvdbId: $request->filled('tvdbId') ? $request->integer('tvdbId') : null, + malId: $request->filled('malId') ? $request->integer('malId') : null, + playlistId: $request->filled('playlistId') ? $request->integer('playlistId') : null, + collectionId: $request->filled('collectionId') ? $request->integer('collectionId') : null, + primaryLanguageNames: $request->filled('primaryLanguages') ? array_map('str', $request->primaryLanguages) : [], + adult: $request->filled('adult') ? $request->boolean('adult') : null, + free: $request->filled('free') ? array_map('intval', (array) $request->free) : [], + doubleup: $request->filled('doubleup'), + refundable: $request->filled('refundable'), + featured: $request->filled('featured'), + stream: $request->filled('stream'), + sd: $request->filled('sd'), + highspeed: $request->filled('highspeed'), + internal: $request->filled('internal'), + personalRelease: $request->filled('personalRelease'), + alive: $request->filled('alive'), + dying: $request->filled('dying'), + dead: $request->filled('dead'), + filename: $request->filled('file_name') ? $request->string('file_name')->toString() : '', + seasonNumber: $request->filled('seasonNumber') ? $request->integer('seasonNumber') : null, + episodeNumber: $request->filled('episodeNumber') ? $request->integer('episodeNumber') : null, + ); + + if ($isSqlAllowed) { + $torrents = Torrent::query() + ->where($filters->toSqlQueryBuilder()) + ->latest('sticky') + ->orderBy($request->input('sortField') ?? $this->sortField, $request->input('sortDirection') ?? $this->sortDirection) + ->cursorPaginate(min($request->input('perPage') ?? $this->perPage, 100)); + } else { + $torrents = Torrent::search( + $request->filled('name') ? $request->string('name')->toString() : '', + function (Indexes $meilisearch, string $query, array $options) use ($request, $filters) { + $options['sort'] = [ + ($request->input('sortField') ?: $this->sortField).':'.($request->input('sortDirection') ?? $this->sortDirection), + ]; + $options['filter'] = $filters->toMeilisearchFilter(); + + $results = $meilisearch->search($query, $options); + + return $results; + } + ) + ->query($eagerLoads) + ->paginate(min($request->input('perPage') ?? $this->perPage, 100)); + } // See app/Traits/TorrentMeta.php $this->scopeMeta($torrents); diff --git a/app/Http/Controllers/PlaylistTorrentController.php b/app/Http/Controllers/PlaylistTorrentController.php index 66d84ca55..408efc833 100644 --- a/app/Http/Controllers/PlaylistTorrentController.php +++ b/app/Http/Controllers/PlaylistTorrentController.php @@ -39,7 +39,9 @@ class PlaylistTorrentController extends Controller abort_unless($request->user()->id === $playlist->user_id, 403); - PlaylistTorrent::create($request->validated()); + $playlistTorrent = PlaylistTorrent::create($request->validated()); + + $playlistTorrent->torrent()->searchable(); return to_route('playlists.show', ['playlist' => $playlist]) ->withSuccess(trans('playlist.attached-success')); @@ -69,6 +71,8 @@ class PlaylistTorrentController extends Controller PlaylistTorrent::upsert($playlistTorrents, ['playlist_id', 'torrent_id', 'tmdb_id']); + $playlist->torrents()->searchable(); + return to_route('playlists.show', ['playlist' => $playlist]) ->withSuccess(trans('playlist.attached-success')); } @@ -84,6 +88,8 @@ class PlaylistTorrentController extends Controller $playlistTorrent->delete(); + $playlistTorrent->torrent()->searchable(); + return to_route('playlists.show', ['playlist' => $playlistTorrent->playlist]) ->withSuccess(trans('playlist.detached-success')); } diff --git a/app/Http/Controllers/RssController.php b/app/Http/Controllers/RssController.php index 59cd62c17..4bcf922f3 100644 --- a/app/Http/Controllers/RssController.php +++ b/app/Http/Controllers/RssController.php @@ -27,6 +27,8 @@ use App\Models\Type; use App\Models\User; use Illuminate\Http\Request; use Exception; +use Illuminate\Contracts\Database\Eloquent\Builder; +use Meilisearch\Endpoints\Indexes; /** * @see \Tests\Todo\Feature\Http\Controllers\RssControllerTest @@ -152,33 +154,8 @@ class RssController extends Controller if (\is_object($search)) { $cacheKey = 'rss:'.$rss->id; - $torrents = cache()->remember($cacheKey, 300, fn () => Torrent::query() - ->select([ - 'name', - 'id', - 'category_id', - 'type_id', - 'resolution_id', - 'size', - 'created_at', - 'seeders', - 'leechers', - 'times_completed', - 'user_id', - 'anon', - 'imdb', - 'tmdb', - 'tvdb', - 'mal', - 'internal', - ]) - ->with([ - 'user:id,username,rsskey', - 'category:id,name,movie_meta,tv_meta', - 'type:id,name', - 'resolution:id,name' - ]) - ->where((new TorrentSearchFiltersDTO( + $torrents = cache()->remember($cacheKey, 300, function () use ($search, $user) { + $filters = new TorrentSearchFiltersDTO( user: $user, name: $search->search ?? '', description: $search->description ?? '', @@ -203,10 +180,53 @@ class RssController extends Controller alive: (bool) ($search->alive ?? false), dying: (bool) ($search->dying ?? false), dead: (bool) ($search->dead ?? false), - ))->toSqlQueryBuilder()) - ->orderByDesc('bumped_at') - ->take(50) - ->get()); + ); + + $torrents = Torrent::search( + '', + function (Indexes $meilisearch, string $query, array $options) use ($filters) { + $options['sort'] = [ + 'bumped_at:desc', + ]; + $options['filter'] = $filters->toMeilisearchFilter(); + + $results = $meilisearch->search($query, $options); + + return $results; + } + ) + ->query( + fn (Builder $query) => $query-> + select([ + 'name', + 'id', + 'category_id', + 'type_id', + 'resolution_id', + 'size', + 'created_at', + 'seeders', + 'leechers', + 'times_completed', + 'user_id', + 'anon', + 'imdb', + 'tmdb', + 'tvdb', + 'mal', + 'internal', + ]) + ->with([ + 'user:id,username,rsskey', + 'category:id,name,movie_meta,tv_meta', + 'type:id,name', + 'resolution:id,name' + ]) + ) + ->paginate(50); + + return $torrents; + }); return response()->view('rss.show', [ 'torrents' => $torrents, diff --git a/app/Http/Controllers/TorrentBuffController.php b/app/Http/Controllers/TorrentBuffController.php index e7d78c776..5c4948a96 100644 --- a/app/Http/Controllers/TorrentBuffController.php +++ b/app/Http/Controllers/TorrentBuffController.php @@ -264,6 +264,8 @@ class TorrentBuffController extends Controller cache()->put('freeleech_token:'.$user->id.':'.$torrent->id, true); + $torrent->searchable(); + return to_route('torrents.show', ['id' => $torrent->id]) ->withSuccess('You Have Successfully Activated A Freeleech Token For This Torrent!'); } diff --git a/app/Http/Livewire/QuickSearchDropdown.php b/app/Http/Livewire/QuickSearchDropdown.php index 0c6d8cfe5..f0d48f720 100755 --- a/app/Http/Livewire/QuickSearchDropdown.php +++ b/app/Http/Livewire/QuickSearchDropdown.php @@ -16,11 +16,10 @@ declare(strict_types=1); namespace App\Http\Livewire; -use App\Models\Movie; use App\Models\Person; use App\Models\Torrent; -use App\Models\Tv; use Livewire\Component; +use Meilisearch\Endpoints\Indexes; class QuickSearchDropdown extends Component { @@ -32,79 +31,62 @@ class QuickSearchDropdown extends Component { $search = '%'.str_replace(' ', '%', $this->quicksearchText).'%'; + $filters = [ + 'deleted_at IS NULL', + 'status = 1', + ]; + + if (preg_match('/^(\d+)$/', $this->quicksearchText, $matches)) { + $filters[] = 'tmdb = '.$matches[1]; + } + + if (preg_match('/tt0*(?=(\d{7,}))/', $this->quicksearchText, $matches)) { + $filters[] = 'imdb = '.$matches[1]; + } + $searchResults = []; switch ($this->quicksearchRadio) { case 'movies': - $query = Movie::query() - ->select(['id', 'poster', 'title', 'release_date']) - ->selectSub( - Torrent::query() - ->select('category_id') - ->whereColumn('torrents.tmdb', '=', 'movies.id') - ->whereRelation('category', 'movie_meta', '=', true) - ->limit(1), - 'category_id' - ) - ->selectRaw("concat(title, ' ', release_date) as title_and_year") - ->when( - preg_match('/^\d+$/', $this->quicksearchText), - fn ($query) => $query->where('id', '=', $this->quicksearchText), - fn ($query) => $query - ->when( - preg_match('/tt0*(?=(\d{7,}))/', $this->quicksearchText, $matches), - fn ($query) => $query->where('imdb_id', '=', $matches[1]), - fn ($query) => $query->having('title_and_year', 'LIKE', $search), - ) - ) - ->havingNotNull('category_id') - ->oldest('title') - ->take(10); + $filters[] = 'category.movie_meta = 1'; + $filters[] = 'movie.name IS NOT NULL'; + + $searchResults = Torrent::search( + $this->quicksearchText, + function (Indexes $meilisearch, string $query, array $options) use ($filters) { + $options['filter'] = $filters; + $options['distinct'] = 'movie.id'; + + return $meilisearch->search($query, $options); + } + ) + ->simplePaginateRaw(20); break; case 'series': - $query = Tv::query() - ->select(['id', 'poster', 'name', 'first_air_date']) - ->selectSub( - Torrent::query() - ->select('category_id') - ->whereColumn('torrents.tmdb', '=', 'tv.id') - ->whereRelation('category', 'tv_meta', '=', true) - ->limit(1), - 'category_id' - ) - ->selectRaw("concat(name, ' ', first_air_date) as title_and_year") - ->when( - preg_match('/^\d+$/', $this->quicksearchText), - fn ($query) => $query->where('id', '=', $this->quicksearchText), - fn ($query) => $query - ->when( - preg_match('/tt0*(?=(\d{7,}))/', $this->quicksearchText, $matches), - fn ($query) => $query->where('imdb_id', '=', $matches[1]), - fn ($query) => $query->having('title_and_year', 'LIKE', $search), - ) - ) - ->havingNotNull('category_id') - ->oldest('name') - ->take(10); + $filters[] = 'category.tv_meta = 1'; + $filters[] = 'tv.name IS NOT NULL'; + + $searchResults = Torrent::search( + $this->quicksearchText, + function (Indexes $meilisearch, string $query, array $options) use ($filters) { + $options['filter'] = $filters; + $options['distinct'] = 'tv.id'; + + return $meilisearch->search($query, $options); + } + ) + ->simplePaginateRaw(20); break; case 'persons': - $query = Person::query() + $searchResults = Person::query() ->select(['id', 'still', 'name']) ->where('name', 'LIKE', $search) ->oldest('name') - ->take(10); - } - - if (isset($query)) { - // 56 characters whitelisted, 3 characters long, 3 search categories, ~3000 byte response each - // Cache should fill 56 ^ 3 * 3000 = ~526 MB - if (preg_match("/^[a-zA-Z0-9-_ .'@:\\[\\]+&\\/,!#()?\"]{0,3}$/", $this->quicksearchText)) { - $searchResults = cache()->remember('quicksearch:'.$this->quicksearchRadio.':'.strtolower($search), 3600 * 24, fn () => $query->get()->toArray()); - } else { - $searchResults = $query->get()->toArray(); - } + ->take(10) + ->get() + ->toArray(); } return view('livewire.quick-search-dropdown', [ diff --git a/app/Http/Livewire/TorrentSearch.php b/app/Http/Livewire/TorrentSearch.php index 26fc66005..6a6e3bfc7 100644 --- a/app/Http/Livewire/TorrentSearch.php +++ b/app/Http/Livewire/TorrentSearch.php @@ -35,6 +35,7 @@ use Livewire\Attributes\Computed; use Livewire\Attributes\Url; use Livewire\Component; use Livewire\WithPagination; +use Meilisearch\Endpoints\Indexes; use Closure; class TorrentSearch extends Component @@ -220,6 +221,9 @@ class TorrentSearch extends Component #[Url(history: true)] public bool $incomplete = false; + #[Url(history: true, except: 'meilisearch')] + public ?string $driver = 'meilisearch'; + #[Url(history: true)] public int $perPage = 25; @@ -339,7 +343,7 @@ class TorrentSearch extends Component /** * @return Closure(Builder): Builder */ - final public function filters(): Closure + final public function filters(): TorrentSearchFiltersDTO { return (new TorrentSearchFiltersDTO( name: $this->name, @@ -404,7 +408,7 @@ class TorrentSearch extends Component $this->leeching => true, default => null, }, - ))->toSqlQueryBuilder(); + )); } /** @@ -428,7 +432,13 @@ class TorrentSearch extends Component $this->reset('sortField'); } - $torrents = Torrent::with(['user:id,username,group_id', 'user.group', 'category', 'type', 'resolution']) + // Only allow sql for now to prevent user complaints of limiting page count to 1000 results (meilisearch limitation). + // However, eventually we want to switch to meilisearch only to reduce server load. + // $isSqlAllowed = $user->group->is_modo && $this->driver === 'sql'; + $isSqlAllowed = $this->driver !== 'sql'; + + $eagerLoads = fn (Builder $query) => $query + ->with(['user:id,username,group_id', 'user.group', 'category', 'type', 'resolution']) ->withCount([ 'thanks', 'comments', @@ -465,11 +475,34 @@ class TorrentSearch extends Component WHEN category_id IN (SELECT `id` from `categories` where `music_meta` = 1) THEN 'music' WHEN category_id IN (SELECT `id` from `categories` where `no_meta` = 1) THEN 'no' END as meta - ") - ->where($this->filters()) - ->latest('sticky') - ->orderBy($this->sortField, $this->sortDirection) - ->paginate(min($this->perPage, 100)); + "); + + if ($isSqlAllowed) { + $torrents = Torrent::query() + ->where($this->filters()->toSqlQueryBuilder()) + ->latest('sticky') + ->orderBy($this->sortField, $this->sortDirection); + + $eagerLoads($torrents); + $torrents = $torrents->paginate(min($this->perPage, 100)); + } else { + $torrents = Torrent::search( + $this->name, + function (Indexes $meilisearch, string $query, array $options) { + $options['sort'] = [ + 'sticky:desc', + $this->sortField.':'.$this->sortDirection, + ]; + $options['filter'] = $this->filters()->toMeilisearchFilter(); + + $results = $meilisearch->search($query, $options); + + return $results; + } + ) + ->query($eagerLoads) + ->paginate(min($this->perPage, 100)); + } // See app/Traits/TorrentMeta.php $this->scopeMeta($torrents); diff --git a/app/Jobs/ProcessMovieJob.php b/app/Jobs/ProcessMovieJob.php index e036b29da..32a623fb3 100644 --- a/app/Jobs/ProcessMovieJob.php +++ b/app/Jobs/ProcessMovieJob.php @@ -23,6 +23,7 @@ use App\Models\Genre; use App\Models\Movie; use App\Models\Person; use App\Models\Recommendation; +use App\Models\Torrent; use App\Services\Tmdb\Client; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -93,5 +94,10 @@ class ProcessMovieJob implements ShouldQueue // Recommendations Recommendation::upsert($movieScraper->getRecommendations(), ['recommendation_movie_id', 'movie_id']); + + Torrent::query() + ->where('tmdb', '=', $this->id) + ->whereRelation('category', 'movie_meta', '=', true) + ->searchable(); } } diff --git a/app/Jobs/ProcessTvJob.php b/app/Jobs/ProcessTvJob.php index 467ccf3e4..603752b59 100644 --- a/app/Jobs/ProcessTvJob.php +++ b/app/Jobs/ProcessTvJob.php @@ -24,6 +24,7 @@ use App\Models\Network; use App\Models\Person; use App\Models\Recommendation; use App\Models\Season; +use App\Models\Torrent; use App\Models\Tv; use App\Services\Tmdb\Client; use Illuminate\Bus\Queueable; @@ -112,5 +113,10 @@ class ProcessTvJob implements ShouldQueue // Recommendations Recommendation::upsert($tvScraper->getRecommendations(), ['recommendation_tv_id', 'tv_id']); + + Torrent::query() + ->where('tmdb', '=', $this->id) + ->whereRelation('category', 'tv_meta', '=', true) + ->searchable(); } } diff --git a/app/Models/Movie.php b/app/Models/Movie.php index e056a6d44..257235c80 100644 --- a/app/Models/Movie.php +++ b/app/Models/Movie.php @@ -139,4 +139,12 @@ class Movie extends Model $q->where('movie_meta', '-', true); }); } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function wishes(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Wish::class); + } } diff --git a/app/Models/Torrent.php b/app/Models/Torrent.php index d6f14b47c..5487b408d 100644 --- a/app/Models/Torrent.php +++ b/app/Models/Torrent.php @@ -26,9 +26,11 @@ use App\Notifications\NewThank; use App\Traits\Auditable; use App\Traits\GroupedLastScope; use App\Traits\TorrentFilter; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Laravel\Scout\Searchable; use voku\helper\AntiXSS; /** @@ -92,6 +94,7 @@ class Torrent extends Model /** @use HasFactory<\Database\Factories\TorrentFactory> */ use HasFactory; + use Searchable; use SoftDeletes; use TorrentFilter; @@ -481,4 +484,373 @@ class Torrent extends Model return $this->free || config('other.freeleech') || $pfree; } + + /** + * Get the indexable data array for the model. + * + * @return array + */ + public function toSearchableArray(): array + { + return [ + 'id' => (string) $this->id, + 'name' => $this->name, + 'num_file' => $this->num_file, + 'folder' => $this->folder, + 'size' => $this->size, + 'leechers' => $this->leechers, + 'seeders' => $this->seeders, + 'times_completed' => $this->times_completed, + 'created_at' => $this->created_at?->timestamp, + 'bumped_at' => $this->bumped_at?->timestamp, + 'fl_until' => $this->fl_until?->timestamp, + 'du_until' => $this->du_until?->timestamp, + 'user_id' => $this->user_id, + 'imdb' => $this->imdb, + 'tvdb' => $this->tvdb, + 'tmdb' => $this->tmdb, + 'mal' => $this->mal, + 'igdb' => $this->igdb, + 'season_number' => $this->season_number, + 'episode_number' => $this->episode_number, + 'stream' => $this->stream, + 'free' => $this->free, + 'doubleup' => $this->doubleup, + 'refundable' => $this->refundable, + 'highspeed' => $this->highspeed, + 'featured' => $this->featured, + 'status' => $this->status, + 'anon' => $this->anon, + 'sticky' => $this->sticky, + 'sd' => $this->sd, + 'internal' => $this->internal, + 'release_year' => $this->release_year, + 'deleted_at' => $this->deleted_at?->timestamp, + 'distributor_id' => $this->distributor_id, + 'region_id' => $this->region_id, + 'personal_release' => $this->personal_release, + 'info_hash' => bin2hex($this->info_hash), + 'user' => json_decode($this->json_user ?? 'null'), + 'type' => json_decode($this->json_type ?? 'null'), + 'category' => json_decode($this->json_category ?? 'null'), + 'resolution' => json_decode($this->json_resolution ?? 'null'), + 'movie' => json_decode($this->json_movie ?? 'null'), + 'tv' => json_decode($this->json_tv ?? 'null'), + 'playlists' => json_decode($this->json_playlists ?? []), + 'freeleech_tokens' => json_decode($this->json_freeleech_tokens ?? []), + 'bookmarks' => json_decode($this->json_bookmarks ?? []), + 'files' => json_decode($this->json_files ?? []), + 'keywords' => json_decode($this->json_keywords ?? []), + 'history_seeders' => json_decode($this->json_history_seeders ?? []), + 'history_leechers' => json_decode($this->json_history_leechers ?? []), + 'history_active' => json_decode($this->json_history_active ?? []), + 'history_inactive' => json_decode($this->json_history_inactive ?? []), + 'history_complete' => json_decode($this->json_history_complete ?? []), + 'history_incomplete' => json_decode($this->json_history_incomplete ?? []), + ]; + } + + /** + * Modify the query used to retrieve models when making all of the models searchable. + * + * @param Builder $query + * @return Builder + */ + protected function makeAllSearchableUsing(Builder $query): Builder + { + return $query->selectRaw(" + torrents.id, + torrents.name, + torrents.description, + torrents.mediainfo, + torrents.bdinfo, + torrents.num_file, + torrents.folder, + torrents.size, + torrents.leechers, + torrents.seeders, + torrents.times_completed, + UNIX_TIMESTAMP(torrents.created_at) AS created_at, + UNIX_TIMESTAMP(torrents.bumped_at) AS bumped_at, + UNIX_TIMESTAMP(torrents.fl_until) AS fl_until, + UNIX_TIMESTAMP(torrents.du_until) AS du_until, + torrents.user_id, + torrents.imdb, + torrents.tvdb, + torrents.tmdb, + torrents.mal, + torrents.igdb, + torrents.season_number, + torrents.episode_number, + torrents.stream, + torrents.free, + torrents.doubleup, + torrents.refundable, + torrents.highspeed, + torrents.featured, + torrents.status, + torrents.anon, + torrents.sticky, + torrents.sd, + torrents.internal, + torrents.release_year, + UNIX_TIMESTAMP(torrents.deleted_at) AS deleted_at, + torrents.distributor_id, + torrents.region_id, + torrents.personal_release, + LOWER(HEX(torrents.info_hash)) AS info_hash, + ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'user_id', history.user_id + )), JSON_ARRAY()) + FROM history + WHERE torrents.id = history.torrent_id + AND seeder = 1 + ) AS json_history_seeders, + ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'user_id', history.user_id + )), JSON_ARRAY()) + FROM history + WHERE torrents.id = history.torrent_id + AND seeder = 0 + ) AS json_history_leechers, + ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'user_id', history.user_id + )), JSON_ARRAY()) + FROM history + WHERE torrents.id = history.torrent_id + AND active = 1 + ) AS json_history_active, + ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'user_id', history.user_id + )), JSON_ARRAY()) + FROM history + WHERE torrents.id = history.torrent_id + AND active = 0 + ) AS json_history_inactive, + ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'user_id', history.user_id + )), JSON_ARRAY()) + FROM history + WHERE torrents.id = history.torrent_id + AND completed_at IS NOT NULL + ) AS json_history_complete, + ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'user_id', history.user_id + )), JSON_ARRAY()) + FROM history + WHERE torrents.id = history.torrent_id + AND completed_at IS NULL + ) AS json_history_incomplete, + ( + SELECT JSON_OBJECT( + 'id', users.id, + 'username', users.username, + 'group', ( + SELECT JSON_OBJECT( + 'name', `groups`.name, + 'color', `groups`.color, + 'icon', `groups`.icon, + 'effect', `groups`.effect + ) + FROM `groups` + WHERE `groups`.id = users.group_id + LIMIT 1 + ) + ) + FROM users + WHERE torrents.user_id = users.id + LIMIT 1 + ) AS json_user, + ( + SELECT JSON_OBJECT( + 'id', categories.id, + 'name', categories.name, + 'image', categories.image, + 'icon', categories.icon, + 'no_meta', categories.no_meta, + 'music_meta', categories.music_meta, + 'game_meta', categories.game_meta, + 'tv_meta', categories.tv_meta, + 'movie_meta', categories.movie_meta + ) + FROM categories + WHERE torrents.category_id = categories.id + LIMIT 1 + ) AS json_category, + ( + SELECT JSON_OBJECT( + 'id', types.id, + 'name', types.name + ) + FROM types + WHERE torrents.type_id = types.id + LIMIT 1 + ) AS json_type, + ( + SELECT JSON_OBJECT( + 'id', resolutions.id, + 'name', resolutions.name + ) + FROM resolutions + WHERE torrents.resolution_id = resolutions.id + LIMIT 1 + ) AS json_resolution, + ( + SELECT JSON_OBJECT( + 'id', movies.id, + 'name', movies.title, + 'year', YEAR(movies.release_date), + 'poster', movies.poster, + 'original_language', movies.original_language, + 'adult', movies.adult, + 'companies', ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'id', companies.id, + 'name', companies.name + )), JSON_ARRAY()) + FROM companies + WHERE companies.id IN ( + SELECT company_id + FROM company_movie + WHERE company_movie.movie_id = torrents.tmdb + ) + ), + 'genres', ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'id', genres.id, + 'name', genres.name + )), JSON_ARRAY()) + FROM genres + WHERE genres.id IN ( + SELECT genre_id + FROM genre_movie + WHERE genre_movie.movie_id = torrents.tmdb + ) + ), + 'collection_id', ( + SELECT collection_movie.collection_id + FROM collection_movie + WHERE movies.id = collection_movie.movie_id + LIMIT 1 + ), + 'wishes', ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'user_id', wishes.user_id + )), JSON_ARRAY()) + FROM wishes + WHERE wishes.movie_id = movies.id + ) + ) + FROM movies + WHERE torrents.tmdb = movies.id + AND torrents.category_id in ( + SELECT id + FROM categories + WHERE movie_meta = 1 + ) + LIMIT 1 + ) AS json_movie, + ( + SELECT JSON_OBJECT( + 'id', tv.id, + 'name', tv.name, + 'year', YEAR(tv.first_air_date), + 'poster', tv.poster, + 'original_language', tv.original_language, + 'companies', ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'id', companies.id, + 'name', companies.name + )), JSON_ARRAY()) + FROM companies + WHERE companies.id IN ( + SELECT company_id + FROM company_tv + WHERE company_tv.tv_id = torrents.id + ) + ), + 'genres', ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'id', genres.id, + 'name', genres.name + )), JSON_ARRAY()) + FROM genres + WHERE genres.id IN ( + SELECT genre_id + FROM genre_tv + WHERE genre_tv.tv_id = torrents.tmdb + ) + ), + 'networks', ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'id', networks.id, + 'name', networks.name + )), JSON_ARRAY()) + FROM networks + WHERE networks.id IN ( + SELECT network_id + FROM network_tv + WHERE network_tv.tv_id = torrents.id + ) + ), + 'wishes', ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'user_id', wishes.user_id + )), JSON_ARRAY()) + FROM wishes + WHERE wishes.tv_id = tv.id + ) + ) + FROM tv + WHERE torrents.tmdb = tv.id + AND torrents.category_id in ( + SELECT id + FROM categories + WHERE tv_meta = 1 + ) + LIMIT 1 + ) AS json_tv, + ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'id', playlist_torrents.id + )), JSON_ARRAY()) + FROM playlist_torrents + WHERE torrents.id = playlist_torrents.playlist_id + ) AS json_playlists, + ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'user_id', freeleech_tokens.user_id + )), JSON_ARRAY()) + FROM freeleech_tokens + WHERE torrents.id = freeleech_tokens.torrent_id + ) AS json_freeleech_tokens, + ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'user_id', bookmarks.user_id + )), JSON_ARRAY()) + FROM bookmarks + WHERE torrents.id = bookmarks.torrent_id + ) AS json_bookmarks, + ( + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT( + 'id', files.id, + 'name', files.name, + 'size', files.size + )), JSON_ARRAY()) + FROM files + WHERE torrents.id = files.torrent_id + ) AS json_files, + ( + SELECT COALESCE(JSON_ARRAYAGG(keywords.name), JSON_ARRAY()) + FROM keywords + WHERE torrents.id = keywords.torrent_id + ) AS json_keywords + "); + } } diff --git a/app/Models/Tv.php b/app/Models/Tv.php index 1431dfc28..a9b1f53e5 100644 --- a/app/Models/Tv.php +++ b/app/Models/Tv.php @@ -150,4 +150,12 @@ class Tv extends Model { return $this->belongsToMany(__CLASS__, Recommendation::class, 'tv_id', 'recommendation_tv_id', 'id', 'id'); } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function wishes(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Wish::class); + } } diff --git a/app/Traits/TorrentMeta.php b/app/Traits/TorrentMeta.php index bf2310526..70a994ce8 100644 --- a/app/Traits/TorrentMeta.php +++ b/app/Traits/TorrentMeta.php @@ -25,19 +25,30 @@ use ReflectionException; trait TorrentMeta { /** - * @param \Illuminate\Database\Eloquent\Collection|\Illuminate\Pagination\CursorPaginator<\App\Models\Torrent>|\Illuminate\Pagination\LengthAwarePaginator<\App\Models\Torrent> $torrents + * @param \Illuminate\Database\Eloquent\Collection|\Illuminate\Pagination\CursorPaginator<\App\Models\Torrent>|\Illuminate\Pagination\LengthAwarePaginator<\App\Models\Torrent>|\Illuminate\Contracts\Pagination\LengthAwarePaginator<\App\Models\Torrent> $torrents * * @throws \MarcReichel\IGDBLaravel\Exceptions\MissingEndpointException * @throws \MarcReichel\IGDBLaravel\Exceptions\InvalidParamsException * @throws ReflectionException * @throws JsonException - * @return ($torrents is \Illuminate\Database\Eloquent\Collection ? \Illuminate\Support\Collection : ($torrents is \Illuminate\Pagination\CursorPaginator<\App\Models\Torrent> ? \Illuminate\Pagination\CursorPaginator<\App\Models\Torrent> : \Illuminate\Pagination\LengthAwarePaginator<\App\Models\Torrent>)) + * @return ( + * $torrents is \Illuminate\Database\Eloquent\Collection ? \Illuminate\Support\Collection + * : ($torrents is \Illuminate\Pagination\CursorPaginator<\App\Models\Torrent> ? \Illuminate\Pagination\CursorPaginator<\App\Models\Torrent> + * : ($torrents is \Illuminate\Pagination\LengthAwarePaginator<\App\Models\Torrent> ? \Illuminate\Pagination\LengthAwarePaginator<\App\Models\Torrent> + * : \Illuminate\Contracts\Pagination\LengthAwarePaginator<\App\Models\Torrent> + * ))) */ - public function scopeMeta(\Illuminate\Database\Eloquent\Collection|\Illuminate\Pagination\CursorPaginator|\Illuminate\Pagination\LengthAwarePaginator $torrents): \Illuminate\Support\Collection|\Illuminate\Pagination\CursorPaginator|\Illuminate\Pagination\LengthAwarePaginator + public function scopeMeta(\Illuminate\Database\Eloquent\Collection|\Illuminate\Pagination\CursorPaginator|\Illuminate\Pagination\LengthAwarePaginator|\Illuminate\Contracts\Pagination\LengthAwarePaginator $torrents): \Illuminate\Support\Collection|\Illuminate\Pagination\CursorPaginator|\Illuminate\Pagination\LengthAwarePaginator|\Illuminate\Contracts\Pagination\LengthAwarePaginator { - $movieIds = $torrents->where('meta', '=', 'movie')->pluck('tmdb'); - $tvIds = $torrents->where('meta', '=', 'tv')->pluck('tmdb'); - $gameIds = $torrents->where('meta', '=', 'game')->pluck('igdb'); + if ($torrents instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator || $torrents instanceof \Illuminate\Contracts\Pagination\CursorPaginator) { + $movieIds = collect($torrents->items())->where('meta', '=', 'movie')->pluck('tmdb'); + $tvIds = collect($torrents->items())->where('meta', '=', 'tv')->pluck('tmdb'); + $gameIds = collect($torrents->items())->where('meta', '=', 'game')->pluck('igdb'); + } else { + $movieIds = $torrents->where('meta', '=', 'movie')->pluck('tmdb'); + $tvIds = $torrents->where('meta', '=', 'tv')->pluck('tmdb'); + $gameIds = $torrents->where('meta', '=', 'game')->pluck('igdb'); + } $movies = Movie::with('genres')->whereIntegerInRaw('id', $movieIds)->get()->keyBy('id'); $tv = Tv::with('genres')->whereIntegerInRaw('id', $tvIds)->get()->keyBy('id'); @@ -65,6 +76,14 @@ trait TorrentMeta return $torrents->map($setRelation); } + /** + * Laravel's \Illuminate\Contracts\Pagination\LengthAwarePaginator does not have a through method + * but we are passed a \Illuminate\Pagination\LengthAwarePaginator which does have such a method. + * Seems to be caused by some Laravel type error that's returning an interface instead of the type + * itself, or that the interface is missing the method. + * + * @phpstan-ignore method.notFound + */ return $torrents->through($setRelation); } } diff --git a/composer.json b/composer.json index 0f7f32e7c..cd96c417f 100644 --- a/composer.json +++ b/composer.json @@ -27,9 +27,11 @@ "laravel/fortify": "1.20.0", "laravel/framework": "^11.19.0", "laravel/octane": "^2.5.2", + "laravel/scout": "^10.11", "laravel/tinker": "^2.9.0", "livewire/livewire": "^3.5.4", "marcreichel/igdb-laravel": "^4.3.0", + "meilisearch/meilisearch-php": "^1.9", "nesbot/carbon": "2.72.3", "paragonie/constant_time_encoding": "^2.7.0", "spatie/laravel-backup": "^8.8.1", diff --git a/composer.lock b/composer.lock index 535ce0b78..28a191ba0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6df7a44ea0115c5842c2d7e199210b81", + "content-hash": "8a579c31f1ba56bdc01cf189e5b971d8", "packages": [ { "name": "assada/laravel-achievements", @@ -2617,6 +2617,84 @@ }, "time": "2024-06-17T13:58:22+00:00" }, + { + "name": "laravel/scout", + "version": "v10.11.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/scout.git", + "reference": "42482b9fb8d3f82bdb52307a990107231c50b882" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/scout/zipball/42482b9fb8d3f82bdb52307a990107231c50b882", + "reference": "42482b9fb8d3f82bdb52307a990107231c50b882", + "shasum": "" + }, + "require": { + "illuminate/bus": "^9.0|^10.0|^11.0", + "illuminate/contracts": "^9.0|^10.0|^11.0", + "illuminate/database": "^9.0|^10.0|^11.0", + "illuminate/http": "^9.0|^10.0|^11.0", + "illuminate/pagination": "^9.0|^10.0|^11.0", + "illuminate/queue": "^9.0|^10.0|^11.0", + "illuminate/support": "^9.0|^10.0|^11.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0" + }, + "require-dev": { + "algolia/algoliasearch-client-php": "^3.2", + "meilisearch/meilisearch-php": "^1.0", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.31|^8.11|^9.0", + "php-http/guzzle7-adapter": "^1.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.3|^10.4", + "typesense/typesense-php": "^4.9.3" + }, + "suggest": { + "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", + "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).", + "typesense/typesense-php": "Required to use the Typesense engine (^4.9)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + }, + "laravel": { + "providers": [ + "Laravel\\Scout\\ScoutServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Scout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Scout provides a driver based solution to searching your Eloquent models.", + "keywords": [ + "algolia", + "laravel", + "search" + ], + "support": { + "issues": "https://github.com/laravel/scout/issues", + "source": "https://github.com/laravel/scout" + }, + "time": "2024-07-30T15:28:14+00:00" + }, { "name": "laravel/serializable-closure", "version": "v1.3.3", @@ -3331,6 +3409,73 @@ }, "time": "2024-03-31T07:05:07+00:00" }, + { + "name": "meilisearch/meilisearch-php", + "version": "v1.9.1", + "source": { + "type": "git", + "url": "https://github.com/meilisearch/meilisearch-php.git", + "reference": "c4eb8ecd08799abd65d00dc74f4372b61af1fc37" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/meilisearch/meilisearch-php/zipball/c4eb8ecd08799abd65d00dc74f4372b61af1fc37", + "reference": "c4eb8ecd08799abd65d00dc74f4372b61af1fc37", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "php-http/discovery": "^1.7", + "psr/http-client": "^1.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.8.1", + "http-interop/http-factory-guzzle": "^1.2.0", + "php-cs-fixer/shim": "^3.59.3", + "phpstan/extension-installer": "^1.4.1", + "phpstan/phpstan": "^1.11.5", + "phpstan/phpstan-deprecation-rules": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.0", + "phpunit/phpunit": "^9.5 || ^10.5" + }, + "suggest": { + "guzzlehttp/guzzle": "Use Guzzle ^7 as HTTP client", + "http-interop/http-factory-guzzle": "Factory for guzzlehttp/guzzle" + }, + "type": "library", + "autoload": { + "psr-4": { + "MeiliSearch\\": "src/", + "Meilisearch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Clementine", + "email": "clementine@meilisearch.com" + } + ], + "description": "PHP wrapper for the Meilisearch API", + "keywords": [ + "api", + "client", + "instant", + "meilisearch", + "php", + "search" + ], + "support": { + "issues": "https://github.com/meilisearch/meilisearch-php/issues", + "source": "https://github.com/meilisearch/meilisearch-php/tree/v1.9.1" + }, + "time": "2024-07-25T15:54:07+00:00" + }, { "name": "monolog/monolog", "version": "3.7.0", @@ -3900,6 +4045,85 @@ }, "time": "2024-05-08T12:18:48+00:00" }, + { + "name": "php-http/discovery", + "version": "1.19.4", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "0700efda8d7526335132360167315fdab3aeb599" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/0700efda8d7526335132360167315fdab3aeb599", + "reference": "0700efda8d7526335132360167315fdab3aeb599", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.19.4" + }, + "time": "2024-03-29T13:00:05+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.3", diff --git a/config/scout.php b/config/scout.php new file mode 100644 index 000000000..f898968d9 --- /dev/null +++ b/config/scout.php @@ -0,0 +1,288 @@ + env('SCOUT_DRIVER', 'meilisearch'), + + /* + |-------------------------------------------------------------------------- + | Index Prefix + |-------------------------------------------------------------------------- + | + | Here you may specify a prefix that will be applied to all search index + | names used by Scout. This prefix may be useful if you have multiple + | "tenants" or applications sharing the same search infrastructure. + | + */ + + 'prefix' => env('SCOUT_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Queue Data Syncing + |-------------------------------------------------------------------------- + | + | This option allows you to control if the operations that sync your data + | with your search engines are queued. When this is set to "true" then + | all automatic data syncing will get queued for better performance. + | + */ + + 'queue' => env('SCOUT_QUEUE', true), + + /* + |-------------------------------------------------------------------------- + | Database Transactions + |-------------------------------------------------------------------------- + | + | This configuration option determines if your data will only be synced + | with your search indexes after every open database transaction has + | been committed, thus preventing any discarded data from syncing. + | + */ + + 'after_commit' => false, + + /* + |-------------------------------------------------------------------------- + | Chunk Sizes + |-------------------------------------------------------------------------- + | + | These options allow you to control the maximum chunk size when you are + | mass importing data into the search engine. This allows you to fine + | tune each of these chunk sizes based on the power of the servers. + | + */ + + 'chunk' => [ + 'searchable' => 500, + 'unsearchable' => 500, + ], + + /* + |-------------------------------------------------------------------------- + | Soft Deletes + |-------------------------------------------------------------------------- + | + | This option allows to control whether to keep soft deleted records in + | the search indexes. Maintaining soft deleted records can be useful + | if your application still needs to search for the records later. + | + */ + + 'soft_delete' => false, + + /* + |-------------------------------------------------------------------------- + | Identify User + |-------------------------------------------------------------------------- + | + | This option allows you to control whether to notify the search engine + | of the user performing the search. This is sometimes useful if the + | engine supports any analytics based on this application's users. + | + | Supported engines: "algolia" + | + */ + + 'identify' => env('SCOUT_IDENTIFY', false), + + /* + |-------------------------------------------------------------------------- + | Algolia Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Algolia settings. Algolia is a cloud hosted + | search engine which works great with Scout out of the box. Just plug + | in your application ID and admin API key to get started searching. + | + */ + + 'algolia' => [ + 'id' => env('ALGOLIA_APP_ID', ''), + 'secret' => env('ALGOLIA_SECRET', ''), + ], + + /* + |-------------------------------------------------------------------------- + | Meilisearch Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Meilisearch settings. Meilisearch is an open + | source search engine with minimal configuration. Below, you can state + | the host and key information for your own Meilisearch installation. + | + | See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options + | + */ + + 'meilisearch' => [ + 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), + 'key' => env('MEILISEARCH_KEY'), + 'index-settings' => [ + App\Models\Torrent::class => [ + 'searchableAttributes' => [ + 'name', + 'movie.name', + 'tv.name', + 'movie.year', + 'tv.year', + 'type.name', + 'resolution.name', + ], + 'filterableAttributes' => [ + 'id', + 'name', + 'folder', + 'size', + 'leechers', + 'seeders', + 'times_completed', + 'created_at', + 'bumped_at', + 'fl_until', + 'du_until', + 'user_id', + 'imdb', + 'tvdb', + 'tmdb', + 'mal', + 'igdb', + 'season_number', + 'episode_number', + 'stream', + 'free', + 'doubleup', + 'refundable', + 'highspeed', + 'featured', + 'status', + 'anon', + 'sticky', + 'sd', + 'internal', + 'release_year', + 'deleted_at', + 'personal_release', + 'info_hash', + 'history_seeders.user_id', + 'history_leechers.user_id', + 'history_active.user_id', + 'history_inactive.user_id', + 'history_complete.user_id', + 'history_incomplete.user_id', + 'user.username', + 'category.id', + 'category.movie_meta', + 'category.tv_meta', + 'type.id', + 'resolution.id', + 'movie.id', + 'movie.name', + 'movie.original_language', + 'movie.adult', + 'movie.genres.id', + 'movie.collection.id', + 'movie.companies.id', + 'tv.id', + 'tv.name', + 'tv.original_language', + 'tv.genres.id', + 'tv.networks.id', + 'tv.companies.id', + 'playlists.id', + 'bookmarks.user_id', + 'freeleech_tokens.user_id', + 'files.name', + 'keywords', + 'distributor_id', + 'region_id', + ], + 'sortableAttributes' => [ + 'name', + 'size', + 'seeders', + 'leechers', + 'times_completed', + 'created_at', + 'bumped_at', + 'sticky', + ], + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Typesense Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Typesense settings. Typesense is an open + | source search engine using minimal configuration. Below, you will + | state the host, key, and schema configuration for the instance. + | + */ + + 'typesense' => [ + 'client-settings' => [ + 'api_key' => env('TYPESENSE_API_KEY', 'xyz'), + 'nodes' => [ + [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + ], + 'nearest_node' => [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + 'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2), + 'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30), + 'num_retries' => env('TYPESENSE_NUM_RETRIES', 3), + 'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1), + ], + 'model-settings' => [ + // User::class => [ + // 'collection-schema' => [ + // 'fields' => [ + // [ + // 'name' => 'id', + // 'type' => 'string', + // ], + // [ + // 'name' => 'name', + // 'type' => 'string', + // ], + // [ + // 'name' => 'created_at', + // 'type' => 'int64', + // ], + // ], + // 'default_sorting_field' => 'created_at', + // ], + // 'search-parameters' => [ + // 'query_by' => 'name' + // ], + // ], + ], + ], +]; diff --git a/docker-compose.yml b/docker-compose.yml index 92f518a86..05cabdbca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,6 +89,20 @@ services: - '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025' networks: - sail + meilisearch: + image: 'getmeili/meilisearch:latest' + ports: + - '${FORWARD_MEILISEARCH_PORT:-7700}:7700' + environment: + MEILI_NO_ANALYTICS: '${MEILISEARCH_NO_ANALYTICS:-false}' + volumes: + - 'sail-meilisearch:/meili_data' + networks: + - sail + healthcheck: + test: [ "CMD", "wget", "--no-verbose", "--spider", "http://localhost:7700/health" ] + retries: 3 + timeout: 5s networks: sail: driver: bridge @@ -99,3 +113,5 @@ volumes: driver: local sail-redis: driver: local + sail-meilisearch: + driver: local diff --git a/phpstan.neon b/phpstan.neon index 0f0a4e90e..e013932b0 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -22,3 +22,13 @@ parameters: - bootstrap/cache level: 7 checkOctaneCompatibility: true + ignoreErrors: + - + message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Builder\\:\:searchable\(\)\.$#' + identifier: method.notFound + - + message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany\\:\:searchable\(\)\.$#' + identifier: method.notFound + - + message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Relations\\BelongsTo\\:\:searchable\(\)\.$#' + identifier: method.notFound diff --git a/phpunit.xml b/phpunit.xml index 8e14ae3c4..4aaff1b7b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -20,6 +20,7 @@ --> + diff --git a/resources/views/livewire/quick-search-dropdown.blade.php b/resources/views/livewire/quick-search-dropdown.blade.php index c030c56d1..a88a8c5b7 100755 --- a/resources/views/livewire/quick-search-dropdown.blade.php +++ b/resources/views/livewire/quick-search-dropdown.blade.php @@ -59,7 +59,7 @@ /> @if (strlen($quicksearchText) > 0)
- @forelse ($search_results as $search_result) + @forelse ($search_results['hits'] ?? $search_results as $search_result)