Add support for rich searches with FusePHP (no separate search index required).
This commit is contained in:
parent
6632b515c6
commit
28e5faa7e4
|
@ -5,6 +5,10 @@ release channel, you can take advantage of these new features and fixes.
|
||||||
|
|
||||||
## New Features/Changes
|
## New Features/Changes
|
||||||
|
|
||||||
|
- **Improved Media Search**: We've overhauled our media search functions to add support
|
||||||
|
for [extended search](https://www.fusejs.io/examples.html#extended-search) queries. Searches now also include values
|
||||||
|
stored in custom fields.
|
||||||
|
|
||||||
## Code Quality/Technical Changes
|
## Code Quality/Technical Changes
|
||||||
|
|
||||||
## Bug Fixes
|
## Bug Fixes
|
||||||
|
|
|
@ -51,6 +51,7 @@
|
||||||
"league/mime-type-detection": "^1.7",
|
"league/mime-type-detection": "^1.7",
|
||||||
"league/oauth2-client": "^2.6",
|
"league/oauth2-client": "^2.6",
|
||||||
"league/plates": "^3.1",
|
"league/plates": "^3.1",
|
||||||
|
"loilo/fuse": "^7.0",
|
||||||
"lstrojny/fxmlrpc": "dev-master",
|
"lstrojny/fxmlrpc": "dev-master",
|
||||||
"marcw/rss-writer": "^0.4.0",
|
"marcw/rss-writer": "^0.4.0",
|
||||||
"matomo/device-detector": "^6",
|
"matomo/device-detector": "^6",
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "5bca0c641ba21645d05ab830394898ae",
|
"content-hash": "1a41be64f51e4bc965bcf2f53e06bc11",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "aws/aws-crt-php",
|
"name": "aws/aws-crt-php",
|
||||||
|
@ -3426,6 +3426,69 @@
|
||||||
},
|
},
|
||||||
"time": "2023-01-16T20:25:45+00:00"
|
"time": "2023-01-16T20:25:45+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "loilo/fuse",
|
||||||
|
"version": "7.0.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/loilo/Fuse.git",
|
||||||
|
"reference": "3304788d2f30d677faea700e0e466006f1d70015"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/loilo/Fuse/zipball/3304788d2f30d677faea700e0e466006f1d70015",
|
||||||
|
"reference": "3304788d2f30d677faea700e0e466006f1d70015",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dms/phpunit-arraysubset-asserts": "^0.3.0 || ^0.4.0 || ^0.5.0",
|
||||||
|
"phpunit/phpunit": "^8.0 || ^9.0",
|
||||||
|
"psalm/plugin-phpunit": "^0.16.1 || ^0.17.0 || ^0.18.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.6",
|
||||||
|
"vimeo/psalm": "^4.9 || ^5.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/Core/computeScore.php",
|
||||||
|
"src/Core/config.php",
|
||||||
|
"src/Core/format.php",
|
||||||
|
"src/Core/parse.php",
|
||||||
|
"src/Helpers/get.php",
|
||||||
|
"src/Helpers/sort.php",
|
||||||
|
"src/Helpers/types.php",
|
||||||
|
"src/Search/Bitap/computeScore.php",
|
||||||
|
"src/Search/Bitap/convertMaskToIndices.php",
|
||||||
|
"src/Search/Bitap/createPatternAlphabet.php",
|
||||||
|
"src/Search/Bitap/search.php",
|
||||||
|
"src/Search/Extended/parseQuery.php",
|
||||||
|
"src/Transform/transformMatches.php",
|
||||||
|
"src/Transform/transformScore.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Fuse\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"Apache-2.0"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Florian Reuschel",
|
||||||
|
"email": "florian@loilo.de"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Fuzzy search for PHP based on Bitap algorithm",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/loilo/Fuse/issues",
|
||||||
|
"source": "https://github.com/loilo/Fuse/tree/v7.0.1"
|
||||||
|
},
|
||||||
|
"time": "2023-11-27T17:21:41+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "lstrojny/fxmlrpc",
|
"name": "lstrojny/fxmlrpc",
|
||||||
"version": "dev-master",
|
"version": "dev-master",
|
||||||
|
|
|
@ -19,6 +19,7 @@ use App\Http\RouterInterface;
|
||||||
use App\Http\ServerRequest;
|
use App\Http\ServerRequest;
|
||||||
use App\Media\MimeType;
|
use App\Media\MimeType;
|
||||||
use App\Paginator;
|
use App\Paginator;
|
||||||
|
use App\Service\FuseSearch;
|
||||||
use App\Utilities\Strings;
|
use App\Utilities\Strings;
|
||||||
use App\Utilities\Types;
|
use App\Utilities\Types;
|
||||||
use Doctrine\Common\Collections\Order;
|
use Doctrine\Common\Collections\Order;
|
||||||
|
@ -36,7 +37,8 @@ final class ListAction implements SingleActionInterface
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
private readonly StationFilesystems $stationFilesystems
|
private readonly StationFilesystems $stationFilesystems,
|
||||||
|
private readonly FuseSearch $fuseSearch
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,7 +71,12 @@ final class ListAction implements SingleActionInterface
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($isSearch) {
|
if ($isSearch) {
|
||||||
$cacheKeyParts[] = 'search_' . rawurlencode($searchPhraseFull);
|
if ($playlist instanceof StationPlaylist) {
|
||||||
|
$cacheKeyParts[] = 'search_playlist_' . $playlist->getIdRequired();
|
||||||
|
}
|
||||||
|
if (!empty($special)) {
|
||||||
|
$cacheKeyParts[] = 'search_special_' . rawurlencode($special);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheKey = implode('.', $cacheKeyParts);
|
$cacheKey = implode('.', $cacheKeyParts);
|
||||||
|
@ -113,7 +120,7 @@ final class ListAction implements SingleActionInterface
|
||||||
)->setParameter('storageLocation', $storageLocation)
|
)->setParameter('storageLocation', $storageLocation)
|
||||||
->setParameter('path', $pathLike);
|
->setParameter('path', $pathLike);
|
||||||
|
|
||||||
// Apply searching
|
// Apply special searches (string searches happen below).
|
||||||
if ($isSearch) {
|
if ($isSearch) {
|
||||||
if ('unprocessable' === $special) {
|
if ('unprocessable' === $special) {
|
||||||
$mediaQueryBuilder = null;
|
$mediaQueryBuilder = null;
|
||||||
|
@ -147,12 +154,6 @@ final class ListAction implements SingleActionInterface
|
||||||
)->setParameter('playlist', $playlist);
|
)->setParameter('playlist', $playlist);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($searchPhrase)) {
|
|
||||||
$mediaQueryBuilder->andWhere(
|
|
||||||
'(sm.title LIKE :query OR sm.artist LIKE :query OR sm.path LIKE :query)'
|
|
||||||
)->setParameter('query', '%' . $searchPhrase . '%');
|
|
||||||
}
|
|
||||||
|
|
||||||
$unprocessableMediaRaw = [];
|
$unprocessableMediaRaw = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,11 +266,41 @@ final class ListAction implements SingleActionInterface
|
||||||
$this->cache->set($cacheKey, $result, 300);
|
$this->cache->set($cacheKey, $result, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply sorting
|
// Apply searching
|
||||||
[$sort, $sortOrder] = $this->getSortFromRequest($request);
|
if (!empty($searchPhrase)) {
|
||||||
|
$result = $this->fuseSearch->search(
|
||||||
|
$searchPhrase,
|
||||||
|
$result,
|
||||||
|
[
|
||||||
|
'keys' => [
|
||||||
|
'text',
|
||||||
|
'path',
|
||||||
|
'album',
|
||||||
|
'genre',
|
||||||
|
'custom_fields',
|
||||||
|
],
|
||||||
|
'threshold' => 0.2,
|
||||||
|
'useExtendedSearch' => true,
|
||||||
|
'getFn' => fn(FileList $record, array $key) => match ($key[0] ?? null) {
|
||||||
|
'text' => $record->media->text,
|
||||||
|
'path' => $record->path,
|
||||||
|
'album' => $record->media->album,
|
||||||
|
'genre' => $record->media->genre,
|
||||||
|
'custom_fields' => implode(' ', $record->media->custom_fields),
|
||||||
|
default => null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
$cacheKey,
|
||||||
|
300,
|
||||||
|
$flushCache
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$propertyAccessor = self::getPropertyAccessor();
|
$propertyAccessor = self::getPropertyAccessor();
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
[$sort, $sortOrder] = $this->getSortFromRequest($request);
|
||||||
|
|
||||||
usort(
|
usort(
|
||||||
$result,
|
$result,
|
||||||
static fn(FileList $a, FileList $b) => self::sortRows(
|
static fn(FileList $a, FileList $b) => self::sortRows(
|
||||||
|
|
|
@ -12,6 +12,15 @@ trait HasMediaSearch
|
||||||
{
|
{
|
||||||
use EntityManagerAwareTrait;
|
use EntityManagerAwareTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Station $station
|
||||||
|
* @param string $query
|
||||||
|
* @return array{
|
||||||
|
* string,
|
||||||
|
* StationPlaylist|null,
|
||||||
|
* string|null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
private function parseSearchQuery(
|
private function parseSearchQuery(
|
||||||
Station $station,
|
Station $station,
|
||||||
string $query
|
string $query
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Fuse\Fuse;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Psr\SimpleCache\CacheInterface;
|
||||||
|
|
||||||
|
final class FuseSearch
|
||||||
|
{
|
||||||
|
public const int DEFAULT_TTL = 60;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly CacheInterface $cache,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(
|
||||||
|
?string $searchPhrase,
|
||||||
|
array $records,
|
||||||
|
array $options,
|
||||||
|
string $cacheKey,
|
||||||
|
int $cacheTtl = self::DEFAULT_TTL,
|
||||||
|
bool $flushCache = false
|
||||||
|
): array {
|
||||||
|
$fuse = $this->buildSearch($records, $options, $cacheKey, $cacheTtl, $flushCache);
|
||||||
|
return array_column($fuse->search($searchPhrase), 'item');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildSearch(
|
||||||
|
array $records,
|
||||||
|
array $options,
|
||||||
|
string $cacheKey,
|
||||||
|
int $cacheTtl = self::DEFAULT_TTL,
|
||||||
|
bool $flushCache = false
|
||||||
|
): Fuse {
|
||||||
|
$keys = $options['keys'] ?? null;
|
||||||
|
if (null === $keys) {
|
||||||
|
throw new InvalidArgumentException('No keys provided for search.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$keysHash = substr(md5(json_encode($keys, JSON_THROW_ON_ERROR)), 0, 6);
|
||||||
|
$cacheKey .= '_fuse_' . $keysHash;
|
||||||
|
|
||||||
|
if (!$flushCache && $this->cache->has($cacheKey)) {
|
||||||
|
$indexData = $this->cache->get($cacheKey);
|
||||||
|
$index = Fuse::parseIndex(
|
||||||
|
$indexData,
|
||||||
|
$options
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$index = Fuse::createIndex($keys, $records, $options);
|
||||||
|
|
||||||
|
$this->cache->set(
|
||||||
|
$cacheKey,
|
||||||
|
$index->jsonSerialize(),
|
||||||
|
$cacheTtl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Fuse($records, $options, $index);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue