diff --git a/CHANGELOG.md b/CHANGELOG.md index d4be98a0e..8d1d1833f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ release channel, you can take advantage of these new features and fixes. ## 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 ## Bug Fixes diff --git a/composer.json b/composer.json index 833ce59c5..779441419 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,7 @@ "league/mime-type-detection": "^1.7", "league/oauth2-client": "^2.6", "league/plates": "^3.1", + "loilo/fuse": "^7.0", "lstrojny/fxmlrpc": "dev-master", "marcw/rss-writer": "^0.4.0", "matomo/device-detector": "^6", diff --git a/composer.lock b/composer.lock index 27fe31d0f..4d88203e9 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": "5bca0c641ba21645d05ab830394898ae", + "content-hash": "1a41be64f51e4bc965bcf2f53e06bc11", "packages": [ { "name": "aws/aws-crt-php", @@ -3426,6 +3426,69 @@ }, "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", "version": "dev-master", diff --git a/src/Controller/Api/Stations/Files/ListAction.php b/src/Controller/Api/Stations/Files/ListAction.php index 4464bf304..cf1c910eb 100644 --- a/src/Controller/Api/Stations/Files/ListAction.php +++ b/src/Controller/Api/Stations/Files/ListAction.php @@ -19,6 +19,7 @@ use App\Http\RouterInterface; use App\Http\ServerRequest; use App\Media\MimeType; use App\Paginator; +use App\Service\FuseSearch; use App\Utilities\Strings; use App\Utilities\Types; use Doctrine\Common\Collections\Order; @@ -36,7 +37,8 @@ final class ListAction implements SingleActionInterface public function __construct( 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) { - $cacheKeyParts[] = 'search_' . rawurlencode($searchPhraseFull); + if ($playlist instanceof StationPlaylist) { + $cacheKeyParts[] = 'search_playlist_' . $playlist->getIdRequired(); + } + if (!empty($special)) { + $cacheKeyParts[] = 'search_special_' . rawurlencode($special); + } } $cacheKey = implode('.', $cacheKeyParts); @@ -113,7 +120,7 @@ final class ListAction implements SingleActionInterface )->setParameter('storageLocation', $storageLocation) ->setParameter('path', $pathLike); - // Apply searching + // Apply special searches (string searches happen below). if ($isSearch) { if ('unprocessable' === $special) { $mediaQueryBuilder = null; @@ -147,12 +154,6 @@ final class ListAction implements SingleActionInterface )->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 = []; } @@ -265,11 +266,41 @@ final class ListAction implements SingleActionInterface $this->cache->set($cacheKey, $result, 300); } - // Apply sorting - [$sort, $sortOrder] = $this->getSortFromRequest($request); + // Apply searching + 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(); + // Apply sorting + [$sort, $sortOrder] = $this->getSortFromRequest($request); + usort( $result, static fn(FileList $a, FileList $b) => self::sortRows( diff --git a/src/Controller/Api/Traits/HasMediaSearch.php b/src/Controller/Api/Traits/HasMediaSearch.php index f5d38e373..f852b710e 100644 --- a/src/Controller/Api/Traits/HasMediaSearch.php +++ b/src/Controller/Api/Traits/HasMediaSearch.php @@ -12,6 +12,15 @@ trait HasMediaSearch { use EntityManagerAwareTrait; + /** + * @param Station $station + * @param string $query + * @return array{ + * string, + * StationPlaylist|null, + * string|null + * } + */ private function parseSearchQuery( Station $station, string $query diff --git a/src/Service/FuseSearch.php b/src/Service/FuseSearch.php new file mode 100644 index 000000000..240437335 --- /dev/null +++ b/src/Service/FuseSearch.php @@ -0,0 +1,65 @@ +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); + } +}