add: use meilisearch to search torrents

This commit is contained in:
Roardom
2024-08-01 15:29:41 +00:00
parent 4d4a2f73b1
commit fa35e4e5c0
24 changed files with 1512 additions and 174 deletions

View File

@@ -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=

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Roardom <roardom@protonmail.com>
* @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.');
}
}

View File

@@ -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();

View File

@@ -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<string|list<string>>
*/
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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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'));
}

View File

@@ -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,

View File

@@ -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!');
}

View File

@@ -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', [

View File

@@ -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<Torrent>): Builder<Torrent>
*/
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);

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -139,4 +139,12 @@ class Movie extends Model
$q->where('movie_meta', '-', true);
});
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany<Wish, $this>
*/
public function wishes(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Wish::class);
}
}

View File

@@ -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<string, mixed>
*/
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<self> $query
* @return Builder<self>
*/
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
");
}
}

View File

@@ -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<Wish, $this>
*/
public function wishes(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Wish::class);
}
}

View File

@@ -25,19 +25,30 @@ use ReflectionException;
trait TorrentMeta
{
/**
* @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Torrent>|\Illuminate\Pagination\CursorPaginator<\App\Models\Torrent>|\Illuminate\Pagination\LengthAwarePaginator<\App\Models\Torrent> $torrents
* @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Torrent>|\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<int, \App\Models\Torrent> ? \Illuminate\Support\Collection<int, \App\Models\Torrent> : ($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<int, \App\Models\Torrent> ? \Illuminate\Support\Collection<int, \App\Models\Torrent>
* : ($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);
}
}

View File

@@ -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",

226
composer.lock generated
View File

@@ -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",

288
config/scout.php Normal file
View File

@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Default Search Engine
|--------------------------------------------------------------------------
|
| This option controls the default search connection that gets used while
| using Laravel Scout. This connection is used when syncing all models
| to the search service. You should adjust this based on your needs.
|
| Supported: "algolia", "meilisearch", "typesense",
| "database", "collection", "null"
|
*/
'driver' => 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'
// ],
// ],
],
],
];

View File

@@ -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

View File

@@ -22,3 +22,13 @@ parameters:
- bootstrap/cache
level: 7
checkOctaneCompatibility: true
ignoreErrors:
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Builder\<App\\Models\\Torrent\>\:\:searchable\(\)\.$#'
identifier: method.notFound
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany\<App\\Models\\Torrent, [a-zA-Z0-9\\_]+\>\:\:searchable\(\)\.$#'
identifier: method.notFound
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Relations\\BelongsTo\<App\\Models\\Torrent, [a-zA-Z0-9\\_]+\>\:\:searchable\(\)\.$#'
identifier: method.notFound

View File

@@ -20,6 +20,7 @@
<env name="REDIS_PORT" value="6379" />
-->
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="SCOUT_DRIVER" value="null"/>
</php>
<coverage/>
<source>

View File

@@ -59,7 +59,7 @@
/>
@if (strlen($quicksearchText) > 0)
<div class="quick-search__results" x-ref="searchResults">
@forelse ($search_results as $search_result)
@forelse ($search_results['hits'] ?? $search_results as $search_result)
<article
class="quick-search__result"
x-on:keydown.down.prevent="quickSearchArrowDown($el)"
@@ -69,21 +69,21 @@
@case('movies')
<a
class="quick-search__result-link"
href="{{ route('torrents.similar', ['category_id' => $search_result['category_id'], 'tmdb' => $search_result['id']]) }}"
href="{{ route('torrents.similar', ['category_id' => $search_result['category']['id'], 'tmdb' => $search_result['tmdb']]) }}"
>
<img
class="quick-search__image"
src="{{ isset($search_result['poster']) ? \tmdb_image('poster_small', $search_result['poster']) : 'https://via.placeholder.com/90x135' }}"
src="{{ isset($search_result['movie']['poster']) ? \tmdb_image('poster_small', $search_result['movie']['poster']) : 'https://via.placeholder.com/90x135' }}"
alt=""
/>
<h2 class="quick-search__result-text">
{{ $search_result['title'] }}
{{ $search_result['movie']['name'] }}
<time
class="quick-search__result-year"
datetime="{{ $search_result['release_date'] }}"
title="{{ $search_result['release_date'] }}"
datetime="{{ $search_result['movie']['year'] }}"
title="{{ $search_result['movie']['year'] }}"
>
{{ substr($search_result['release_date'], 0, 4) }}
{{ substr($search_result['movie']['year'], 0, 4) }}
</time>
</h2>
</a>
@@ -92,21 +92,21 @@
@case('series')
<a
class="quick-search__result-link"
href="{{ route('torrents.similar', ['category_id' => $search_result['category_id'], 'tmdb' => $search_result['id']]) }}"
href="{{ route('torrents.similar', ['category_id' => $search_result['category']['id'], 'tmdb' => $search_result['tmdb']]) }}"
>
<img
class="quick-search__image"
src="{{ isset($search_result['poster']) ? \tmdb_image('poster_small', $search_result['poster']) : 'https://via.placeholder.com/90x135' }}"
src="{{ isset($search_result['tv']['poster']) ? \tmdb_image('poster_small', $search_result['tv']['poster']) : 'https://via.placeholder.com/90x135' }}"
alt=""
/>
<h2 class="quick-search__result-text">
{{ $search_result['name'] }}
{{ $search_result['tv']['name'] }}
<time
class="quick-search__result-year"
datetime="{{ $search_result['first_air_date'] }}"
title="{{ $search_result['first_air_date'] }}"
datetime="{{ $search_result['tv']['year'] }}"
title="{{ $search_result['tv']['year'] }}"
>
{{ substr($search_result['first_air_date'], 0, 4) }}
{{ $search_result['tv']['year'] }}
</time>
</h2>
</a>