mirror of
https://github.com/AzuraCast/AzuraCast.git
synced 2024-06-14 05:06:37 +00:00
Merge commit 'b9fae077afbee0aa5a1ba30b757f22738c906290'
This commit is contained in:
commit
a10540d335
|
@ -5,6 +5,10 @@ release channel, you can take advantage of these new features and fixes.
|
||||||
|
|
||||||
## New Features/Changes
|
## New Features/Changes
|
||||||
|
|
||||||
|
- **Smarter, Faster Searches**: For searches in the Media Manager, as well as the public-facing Requests and On Demand
|
||||||
|
pages, we now use a new search tool called Meilisearch that allows for very fast, very accurate search results, as
|
||||||
|
well as more complex search queries (and other goodies, like typo correction).
|
||||||
|
|
||||||
## Code Quality/Technical Changes
|
## Code Quality/Technical Changes
|
||||||
|
|
||||||
## Bug Fixes
|
## Bug Fixes
|
||||||
|
|
|
@ -62,7 +62,7 @@ RUN bash /bd_build/redis/setup.sh \
|
||||||
|
|
||||||
RUN rm -rf /bd_build
|
RUN rm -rf /bd_build
|
||||||
|
|
||||||
VOLUME ["/var/azuracast/stations", "/var/azuracast/uploads", "/var/azuracast/backups", "/var/azuracast/sftpgo/persist", "/var/azuracast/servers/shoutcast2"]
|
VOLUME ["/var/azuracast/stations", "/var/azuracast/uploads", "/var/azuracast/backups", "/var/azuracast/sftpgo/persist", "/var/azuracast/servers/shoutcast2", "/var/azuracast/meilisearch/persist"]
|
||||||
|
|
||||||
#
|
#
|
||||||
# Final build (Just environment vars and squishing the FS)
|
# Final build (Just environment vars and squishing the FS)
|
||||||
|
@ -116,7 +116,8 @@ ENV TZ="UTC" \
|
||||||
PROFILING_EXTENSION_ALWAYS_ON=0 \
|
PROFILING_EXTENSION_ALWAYS_ON=0 \
|
||||||
PROFILING_EXTENSION_HTTP_KEY=dev \
|
PROFILING_EXTENSION_HTTP_KEY=dev \
|
||||||
PROFILING_EXTENSION_HTTP_IP_WHITELIST=* \
|
PROFILING_EXTENSION_HTTP_IP_WHITELIST=* \
|
||||||
ENABLE_WEB_UPDATER="true"
|
ENABLE_WEB_UPDATER="true" \
|
||||||
|
MEILI_MASTER_KEY="azur4c457"
|
||||||
|
|
||||||
# Entrypoint and default command
|
# Entrypoint and default command
|
||||||
ENTRYPOINT ["tini", "--", "/usr/local/bin/my_init"]
|
ENTRYPOINT ["tini", "--", "/usr/local/bin/my_init"]
|
||||||
|
|
|
@ -52,6 +52,7 @@
|
||||||
"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",
|
||||||
|
"meilisearch/meilisearch-php": "^0.27.0",
|
||||||
"mezzio/mezzio-session": "^1.3",
|
"mezzio/mezzio-session": "^1.3",
|
||||||
"mezzio/mezzio-session-cache": "^1.7",
|
"mezzio/mezzio-session-cache": "^1.7",
|
||||||
"monolog/monolog": "^3",
|
"monolog/monolog": "^3",
|
||||||
|
|
458
composer.lock
generated
458
composer.lock
generated
|
@ -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": "b1487b626a93972c92e12cb2130e0454",
|
"content-hash": "f92319538865587280bea86d366629b8",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "aws/aws-crt-php",
|
"name": "aws/aws-crt-php",
|
||||||
|
@ -434,6 +434,72 @@
|
||||||
],
|
],
|
||||||
"time": "2022-08-10T22:54:19+00:00"
|
"time": "2022-08-10T22:54:19+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "clue/stream-filter",
|
||||||
|
"version": "v1.6.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/clue/stream-filter.git",
|
||||||
|
"reference": "d6169430c7731d8509da7aecd0af756a5747b78e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/clue/stream-filter/zipball/d6169430c7731d8509da7aecd0af756a5747b78e",
|
||||||
|
"reference": "d6169430c7731d8509da7aecd0af756a5747b78e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=5.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.36"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/functions_include.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Clue\\StreamFilter\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Christian Lück",
|
||||||
|
"email": "christian@clue.engineering"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A simple and modern approach to stream filtering in PHP",
|
||||||
|
"homepage": "https://github.com/clue/php-stream-filter",
|
||||||
|
"keywords": [
|
||||||
|
"bucket brigade",
|
||||||
|
"callback",
|
||||||
|
"filter",
|
||||||
|
"php_user_filter",
|
||||||
|
"stream",
|
||||||
|
"stream_filter_append",
|
||||||
|
"stream_filter_register"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/clue/stream-filter/issues",
|
||||||
|
"source": "https://github.com/clue/stream-filter/tree/v1.6.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://clue.engineering/support",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/clue",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2022-02-21T13:15:14+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "composer/ca-bundle",
|
"name": "composer/ca-bundle",
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
|
@ -3742,6 +3808,74 @@
|
||||||
},
|
},
|
||||||
"time": "2023-01-11T09:41:57+00:00"
|
"time": "2023-01-11T09:41:57+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "meilisearch/meilisearch-php",
|
||||||
|
"version": "v0.27.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/meilisearch/meilisearch-php.git",
|
||||||
|
"reference": "e95db9ed85a45dcd831573979bf1d42cb99b9b3b"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/meilisearch/meilisearch-php/zipball/e95db9ed85a45dcd831573979bf1d42cb99b9b3b",
|
||||||
|
"reference": "e95db9ed85a45dcd831573979bf1d42cb99b9b3b",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"php": "^7.4 || ^8.0",
|
||||||
|
"php-http/client-common": "^2.0",
|
||||||
|
"php-http/discovery": "^1.7",
|
||||||
|
"php-http/httplug": "^2.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.0",
|
||||||
|
"guzzlehttp/guzzle": "^7.1",
|
||||||
|
"http-interop/http-factory-guzzle": "^1.0",
|
||||||
|
"phpstan/extension-installer": "^1.1",
|
||||||
|
"phpstan/phpstan": "1.9.3",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^1.0",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.0",
|
||||||
|
"phpstan/phpstan-strict-rules": "^1.1",
|
||||||
|
"phpunit/phpunit": "^9.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 Urquizar",
|
||||||
|
"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/v0.27.0"
|
||||||
|
},
|
||||||
|
"time": "2023-01-10T19:29:02+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "mezzio/mezzio-session",
|
"name": "mezzio/mezzio-session",
|
||||||
"version": "1.12.0",
|
"version": "1.12.0",
|
||||||
|
@ -4907,6 +5041,81 @@
|
||||||
},
|
},
|
||||||
"time": "2022-12-09T13:57:05+00:00"
|
"time": "2022-12-09T13:57:05+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "php-http/client-common",
|
||||||
|
"version": "2.6.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-http/client-common.git",
|
||||||
|
"reference": "45db684cd4e186dcdc2b9c06b22970fe123796c0"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-http/client-common/zipball/45db684cd4e186dcdc2b9c06b22970fe123796c0",
|
||||||
|
"reference": "45db684cd4e186dcdc2b9c06b22970fe123796c0",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0",
|
||||||
|
"php-http/httplug": "^2.0",
|
||||||
|
"php-http/message": "^1.6",
|
||||||
|
"php-http/message-factory": "^1.0",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"psr/http-factory": "^1.0",
|
||||||
|
"psr/http-message": "^1.0",
|
||||||
|
"symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0",
|
||||||
|
"symfony/polyfill-php80": "^1.17"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/instantiator": "^1.1",
|
||||||
|
"guzzlehttp/psr7": "^1.4",
|
||||||
|
"nyholm/psr7": "^1.2",
|
||||||
|
"phpspec/phpspec": "^5.1 || ^6.3 || ^7.1",
|
||||||
|
"phpspec/prophecy": "^1.10.2",
|
||||||
|
"phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.3"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-json": "To detect JSON responses with the ContentTypePlugin",
|
||||||
|
"ext-libxml": "To detect XML responses with the ContentTypePlugin",
|
||||||
|
"php-http/cache-plugin": "PSR-6 Cache plugin",
|
||||||
|
"php-http/logger-plugin": "PSR-3 Logger plugin",
|
||||||
|
"php-http/stopwatch-plugin": "Symfony Stopwatch plugin"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Http\\Client\\Common\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Márk Sági-Kazár",
|
||||||
|
"email": "mark.sagikazar@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Common HTTP Client implementations and tools for HTTPlug",
|
||||||
|
"homepage": "http://httplug.io",
|
||||||
|
"keywords": [
|
||||||
|
"client",
|
||||||
|
"common",
|
||||||
|
"http",
|
||||||
|
"httplug"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/php-http/client-common/issues",
|
||||||
|
"source": "https://github.com/php-http/client-common/tree/2.6.0"
|
||||||
|
},
|
||||||
|
"time": "2022-09-29T09:59:43+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "php-http/discovery",
|
"name": "php-http/discovery",
|
||||||
"version": "1.14.3",
|
"version": "1.14.3",
|
||||||
|
@ -4974,6 +5183,253 @@
|
||||||
},
|
},
|
||||||
"time": "2022-07-11T14:04:40+00:00"
|
"time": "2022-07-11T14:04:40+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "php-http/httplug",
|
||||||
|
"version": "2.3.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-http/httplug.git",
|
||||||
|
"reference": "f640739f80dfa1152533976e3c112477f69274eb"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-http/httplug/zipball/f640739f80dfa1152533976e3c112477f69274eb",
|
||||||
|
"reference": "f640739f80dfa1152533976e3c112477f69274eb",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0",
|
||||||
|
"php-http/promise": "^1.1",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"psr/http-message": "^1.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friends-of-phpspec/phpspec-code-coverage": "^4.1",
|
||||||
|
"phpspec/phpspec": "^5.1 || ^6.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Http\\Client\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Eric GELOEN",
|
||||||
|
"email": "geloen.eric@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Márk Sági-Kazár",
|
||||||
|
"email": "mark.sagikazar@gmail.com",
|
||||||
|
"homepage": "https://sagikazarmark.hu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "HTTPlug, the HTTP client abstraction for PHP",
|
||||||
|
"homepage": "http://httplug.io",
|
||||||
|
"keywords": [
|
||||||
|
"client",
|
||||||
|
"http"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/php-http/httplug/issues",
|
||||||
|
"source": "https://github.com/php-http/httplug/tree/2.3.0"
|
||||||
|
},
|
||||||
|
"time": "2022-02-21T09:52:22+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "php-http/message",
|
||||||
|
"version": "1.13.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-http/message.git",
|
||||||
|
"reference": "7886e647a30a966a1a8d1dad1845b71ca8678361"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-http/message/zipball/7886e647a30a966a1a8d1dad1845b71ca8678361",
|
||||||
|
"reference": "7886e647a30a966a1a8d1dad1845b71ca8678361",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"clue/stream-filter": "^1.5",
|
||||||
|
"php": "^7.1 || ^8.0",
|
||||||
|
"php-http/message-factory": "^1.0.2",
|
||||||
|
"psr/http-message": "^1.0"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"php-http/message-factory-implementation": "1.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ergebnis/composer-normalize": "^2.6",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"guzzlehttp/psr7": "^1.0",
|
||||||
|
"laminas/laminas-diactoros": "^2.0",
|
||||||
|
"phpspec/phpspec": "^5.1 || ^6.3 || ^7.1",
|
||||||
|
"slim/slim": "^3.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-zlib": "Used with compressor/decompressor streams",
|
||||||
|
"guzzlehttp/psr7": "Used with Guzzle PSR-7 Factories",
|
||||||
|
"laminas/laminas-diactoros": "Used with Diactoros Factories",
|
||||||
|
"slim/slim": "Used with Slim Framework PSR-7 implementation"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.10-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/filters.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Http\\Message\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Márk Sági-Kazár",
|
||||||
|
"email": "mark.sagikazar@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "HTTP Message related tools",
|
||||||
|
"homepage": "http://php-http.org",
|
||||||
|
"keywords": [
|
||||||
|
"http",
|
||||||
|
"message",
|
||||||
|
"psr-7"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/php-http/message/issues",
|
||||||
|
"source": "https://github.com/php-http/message/tree/1.13.0"
|
||||||
|
},
|
||||||
|
"time": "2022-02-11T13:41:14+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "php-http/message-factory",
|
||||||
|
"version": "v1.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-http/message-factory.git",
|
||||||
|
"reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-http/message-factory/zipball/a478cb11f66a6ac48d8954216cfed9aa06a501a1",
|
||||||
|
"reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=5.4",
|
||||||
|
"psr/http-message": "^1.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Http\\Message\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Márk Sági-Kazár",
|
||||||
|
"email": "mark.sagikazar@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Factory interfaces for PSR-7 HTTP Message",
|
||||||
|
"homepage": "http://php-http.org",
|
||||||
|
"keywords": [
|
||||||
|
"factory",
|
||||||
|
"http",
|
||||||
|
"message",
|
||||||
|
"stream",
|
||||||
|
"uri"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/php-http/message-factory/issues",
|
||||||
|
"source": "https://github.com/php-http/message-factory/tree/master"
|
||||||
|
},
|
||||||
|
"time": "2015-12-19T14:08:53+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "php-http/promise",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-http/promise.git",
|
||||||
|
"reference": "4c4c1f9b7289a2ec57cde7f1e9762a5789506f88"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-http/promise/zipball/4c4c1f9b7289a2ec57cde7f1e9762a5789506f88",
|
||||||
|
"reference": "4c4c1f9b7289a2ec57cde7f1e9762a5789506f88",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friends-of-phpspec/phpspec-code-coverage": "^4.3.2",
|
||||||
|
"phpspec/phpspec": "^5.1.2 || ^6.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.1-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Http\\Promise\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Joel Wurtz",
|
||||||
|
"email": "joel.wurtz@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Márk Sági-Kazár",
|
||||||
|
"email": "mark.sagikazar@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Promise used for asynchronous HTTP requests",
|
||||||
|
"homepage": "http://httplug.io",
|
||||||
|
"keywords": [
|
||||||
|
"promise"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/php-http/promise/issues",
|
||||||
|
"source": "https://github.com/php-http/promise/tree/1.1.0"
|
||||||
|
},
|
||||||
|
"time": "2020-07-07T09:29:14+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpmyadmin/motranslator",
|
"name": "phpmyadmin/motranslator",
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
|
|
|
@ -144,6 +144,7 @@ return static function (CallableEventDispatcherInterface $dispatcher) {
|
||||||
App\Sync\Task\RunBackupTask::class,
|
App\Sync\Task\RunBackupTask::class,
|
||||||
App\Sync\Task\SendTimeOnSocketTask::class,
|
App\Sync\Task\SendTimeOnSocketTask::class,
|
||||||
App\Sync\Task\UpdateGeoLiteTask::class,
|
App\Sync\Task\UpdateGeoLiteTask::class,
|
||||||
|
App\Sync\Task\UpdateMeilisearchIndex::class,
|
||||||
App\Sync\Task\UpdateStorageLocationSizesTask::class,
|
App\Sync\Task\UpdateStorageLocationSizesTask::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,5 +20,8 @@ return [
|
||||||
Message\DispatchWebhookMessage::class => App\Webhook\Dispatcher::class,
|
Message\DispatchWebhookMessage::class => App\Webhook\Dispatcher::class,
|
||||||
Message\TestWebhookMessage::class => App\Webhook\Dispatcher::class,
|
Message\TestWebhookMessage::class => App\Webhook\Dispatcher::class,
|
||||||
|
|
||||||
|
Message\Meilisearch\AddMediaMessage::class => App\Service\Meilisearch\MessageHandler::class,
|
||||||
|
Message\Meilisearch\UpdatePlaylistsMessage::class => App\Service\Meilisearch\MessageHandler::class,
|
||||||
|
|
||||||
Mailer\Messenger\SendEmailMessage::class => Mailer\Messenger\MessageHandler::class,
|
Mailer\Messenger\SendEmailMessage::class => Mailer\Messenger\MessageHandler::class,
|
||||||
];
|
];
|
||||||
|
|
|
@ -125,6 +125,7 @@ return [
|
||||||
// $config->setSQLLogger(new Doctrine\DBAL\Logging\EchoSQLLogger);
|
// $config->setSQLLogger(new Doctrine\DBAL\Logging\EchoSQLLogger);
|
||||||
|
|
||||||
$config->addCustomNumericFunction('RAND', DoctrineExtensions\Query\Mysql\Rand::class);
|
$config->addCustomNumericFunction('RAND', DoctrineExtensions\Query\Mysql\Rand::class);
|
||||||
|
$config->addCustomStringFunction('FIELD', DoctrineExtensions\Query\Mysql\Field::class);
|
||||||
|
|
||||||
if (!Doctrine\DBAL\Types\Type::hasType('carbon_immutable')) {
|
if (!Doctrine\DBAL\Types\Type::hasType('carbon_immutable')) {
|
||||||
Doctrine\DBAL\Types\Type::addType('carbon_immutable', Carbon\Doctrine\CarbonImmutableType::class);
|
Doctrine\DBAL\Types\Type::addType('carbon_immutable', Carbon\Doctrine\CarbonImmutableType::class);
|
||||||
|
|
|
@ -32,7 +32,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<data-table
|
<data-table
|
||||||
id="station_on_demand_table"
|
id="public_on_demand"
|
||||||
ref="datatable"
|
ref="datatable"
|
||||||
paginated
|
paginated
|
||||||
select-fields
|
select-fields
|
||||||
|
@ -58,9 +58,9 @@
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #cell(media_art)="row">
|
<template #cell(art)="row">
|
||||||
<a
|
<a
|
||||||
:href="row.item.media_art"
|
:href="row.item.media.art"
|
||||||
class="album-art"
|
class="album-art"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
data-fancybox="gallery"
|
data-fancybox="gallery"
|
||||||
|
@ -68,7 +68,7 @@
|
||||||
<img
|
<img
|
||||||
class="media_manager_album_art"
|
class="media_manager_album_art"
|
||||||
:alt="$gettext('Album Art')"
|
:alt="$gettext('Album Art')"
|
||||||
:src="row.item.media_art"
|
:src="row.item.media.art"
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
@ -93,6 +93,7 @@ import {forEach} from 'lodash';
|
||||||
import Icon from '~/components/Common/Icon';
|
import Icon from '~/components/Common/Icon';
|
||||||
import PlayButton from "~/components/Common/PlayButton";
|
import PlayButton from "~/components/Common/PlayButton";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
|
import formatFileSize from "../../functions/formatFileSize";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
listUrl: {
|
listUrl: {
|
||||||
|
@ -119,11 +120,29 @@ const {$gettext} = useTranslate();
|
||||||
|
|
||||||
let fields = [
|
let fields = [
|
||||||
{key: 'download_url', label: ' '},
|
{key: 'download_url', label: ' '},
|
||||||
{key: 'media_art', label: $gettext('Art')},
|
{key: 'art', label: $gettext('Art')},
|
||||||
{key: 'media_title', label: $gettext('Title'), sortable: true, selectable: true},
|
{
|
||||||
{key: 'media_artist', label: $gettext('Artist'), sortable: true, selectable: true},
|
key: 'title',
|
||||||
{key: 'media_album', label: $gettext('Album'), sortable: true, selectable: true, visible: false},
|
label: $gettext('Title'),
|
||||||
{key: 'playlist', label: $gettext('Playlist'), sortable: true, selectable: true, visible: false}
|
sortable: true,
|
||||||
|
selectable: true,
|
||||||
|
formatter: (value, key, item) => item.media.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'artist',
|
||||||
|
label: $gettext('Artist'),
|
||||||
|
sortable: true,
|
||||||
|
selectable: true,
|
||||||
|
formatter: (value, key, item) => item.media.artist,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'album',
|
||||||
|
label: $gettext('Album'),
|
||||||
|
sortable: true,
|
||||||
|
selectable: true,
|
||||||
|
visible: false,
|
||||||
|
formatter: (value, key, item) => item.media.album
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
forEach(props.customFields.slice(), (field) => {
|
forEach(props.customFields.slice(), (field) => {
|
||||||
|
@ -132,7 +151,8 @@ forEach(props.customFields.slice(), (field) => {
|
||||||
label: field.label,
|
label: field.label,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
visible: false
|
visible: false,
|
||||||
|
formatter: (value, key, item) => item.media.custom_fields[field.key]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div style="overflow-x: hidden">
|
<div style="overflow-x: hidden">
|
||||||
<data-table
|
<data-table
|
||||||
id="song_requests"
|
id="public_requests"
|
||||||
ref="datatable"
|
ref="datatable"
|
||||||
paginated
|
paginated
|
||||||
select-fields
|
select-fields
|
||||||
|
@ -74,46 +74,51 @@ const fields = computed(() => {
|
||||||
key: 'name',
|
key: 'name',
|
||||||
isRowHeader: true,
|
isRowHeader: true,
|
||||||
label: $gettext('Name'),
|
label: $gettext('Name'),
|
||||||
sortable: true,
|
sortable: false,
|
||||||
selectable: true
|
selectable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'song.title',
|
key: 'title',
|
||||||
label: $gettext('Title'),
|
label: $gettext('Title'),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
visible: false,
|
visible: false,
|
||||||
|
formatter: (value, key, item) => item.song.title
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'song.artist',
|
key: 'artist',
|
||||||
label: $gettext('Artist'),
|
label: $gettext('Artist'),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
visible: false,
|
visible: false,
|
||||||
|
formatter: (value, key, item) => item.song.artist
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'song.album',
|
key: 'album',
|
||||||
label: $gettext('Album'),
|
label: $gettext('Album'),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
visible: false
|
visible: false,
|
||||||
|
formatter: (value, key, item) => item.song.album
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'song.genre',
|
key: 'genre',
|
||||||
label: $gettext('Genre'),
|
label: $gettext('Genre'),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
visible: false
|
visible: false,
|
||||||
|
formatter: (value, key, item) => item.song.genre
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
forEach({...props.customFields}, (field) => {
|
forEach({...props.customFields}, (field) => {
|
||||||
fields.push({
|
fields.push({
|
||||||
key: 'song.custom_fields.' + field.short_name,
|
key: 'custom_field_' + field.id,
|
||||||
label: field.name,
|
label: field.name,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
visible: false
|
visible: false,
|
||||||
|
formatter: (value, key, item) => item.song.custom_fields[field.short_name]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ namespace App\Controller\Api\Stations\Files;
|
||||||
use App\Doctrine\ReloadableEntityManagerInterface;
|
use App\Doctrine\ReloadableEntityManagerInterface;
|
||||||
use App\Entity;
|
use App\Entity;
|
||||||
use App\Event\Radio\AnnotateNextSong;
|
use App\Event\Radio\AnnotateNextSong;
|
||||||
|
use App\Flysystem\ExtendedFilesystemInterface;
|
||||||
use App\Flysystem\StationFilesystems;
|
use App\Flysystem\StationFilesystems;
|
||||||
use App\Http\Response;
|
use App\Http\Response;
|
||||||
use App\Http\ServerRequest;
|
use App\Http\ServerRequest;
|
||||||
|
@ -18,7 +19,6 @@ use App\Radio\Backend\Liquidsoap;
|
||||||
use App\Radio\Enums\BackendAdapters;
|
use App\Radio\Enums\BackendAdapters;
|
||||||
use App\Radio\Enums\LiquidsoapQueues;
|
use App\Radio\Enums\LiquidsoapQueues;
|
||||||
use App\Utilities\File;
|
use App\Utilities\File;
|
||||||
use App\Flysystem\ExtendedFilesystemInterface;
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use League\Flysystem\StorageAttributes;
|
use League\Flysystem\StorageAttributes;
|
||||||
|
@ -154,7 +154,11 @@ final class BatchAction
|
||||||
/*
|
/*
|
||||||
* NOTE: This iteration clears the entity manager.
|
* NOTE: This iteration clears the entity manager.
|
||||||
*/
|
*/
|
||||||
|
$mediaToReindex = [];
|
||||||
|
|
||||||
foreach ($this->batchUtilities->iterateMedia($storageLocation, $result->files) as $media) {
|
foreach ($this->batchUtilities->iterateMedia($storageLocation, $result->files) as $media) {
|
||||||
|
$mediaToReindex[] = $media->getIdRequired();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$mediaPlaylists = $this->playlistMediaRepo->clearPlaylistsFromMedia($media, $station);
|
$mediaPlaylists = $this->playlistMediaRepo->clearPlaylistsFromMedia($media, $station);
|
||||||
foreach ($mediaPlaylists as $playlistId => $playlistRecord) {
|
foreach ($mediaPlaylists as $playlistId => $playlistRecord) {
|
||||||
|
@ -193,6 +197,11 @@ final class BatchAction
|
||||||
|
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->batchUtilities->queuePlaylistsForUpdate(
|
||||||
|
$station,
|
||||||
|
$mediaToReindex
|
||||||
|
);
|
||||||
|
|
||||||
$this->writePlaylistChanges($station, $affectedPlaylists);
|
$this->writePlaylistChanges($station, $affectedPlaylists);
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
@ -214,12 +223,18 @@ final class BatchAction
|
||||||
$this->batchUtilities->iterateUnprocessableMedia($storageLocation, $result->files),
|
$this->batchUtilities->iterateUnprocessableMedia($storageLocation, $result->files),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$mediaToReindex = [];
|
||||||
|
|
||||||
foreach ($toMove as $iterator) {
|
foreach ($toMove as $iterator) {
|
||||||
foreach ($iterator as $record) {
|
foreach ($iterator as $record) {
|
||||||
/** @var Entity\Interfaces\PathAwareInterface $record */
|
/** @var Entity\Interfaces\PathAwareInterface $record */
|
||||||
$oldPath = $record->getPath();
|
$oldPath = $record->getPath();
|
||||||
$newPath = File::renameDirectoryInPath($oldPath, $from, $to);
|
$newPath = File::renameDirectoryInPath($oldPath, $from, $to);
|
||||||
|
|
||||||
|
if ($record instanceof Entity\StationMedia) {
|
||||||
|
$mediaToReindex[] = $record->getIdRequired();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$fs->move($oldPath, $newPath);
|
$fs->move($oldPath, $newPath);
|
||||||
$record->setPath($newPath);
|
$record->setPath($newPath);
|
||||||
|
@ -242,6 +257,10 @@ final class BatchAction
|
||||||
|
|
||||||
foreach ($toMove as $iterator) {
|
foreach ($toMove as $iterator) {
|
||||||
foreach ($iterator as $record) {
|
foreach ($iterator as $record) {
|
||||||
|
if ($record instanceof Entity\StationMedia) {
|
||||||
|
$mediaToReindex[] = $record->getIdRequired();
|
||||||
|
}
|
||||||
|
|
||||||
/** @var Entity\Interfaces\PathAwareInterface $record */
|
/** @var Entity\Interfaces\PathAwareInterface $record */
|
||||||
try {
|
try {
|
||||||
$record->setPath(
|
$record->setPath(
|
||||||
|
@ -255,6 +274,10 @@ final class BatchAction
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($mediaToReindex)) {
|
||||||
|
$this->batchUtilities->queueMediaForIndex($storageLocation, $mediaToReindex);
|
||||||
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,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\Meilisearch;
|
||||||
use App\Utilities;
|
use App\Utilities;
|
||||||
use Doctrine\Common\Collections\Criteria;
|
use Doctrine\Common\Collections\Criteria;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
@ -27,7 +28,8 @@ final class ListAction
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly CacheInterface $cache
|
private readonly CacheInterface $cache,
|
||||||
|
private readonly Meilisearch $meilisearch
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,7 +106,7 @@ final class ListAction
|
||||||
)->setParameter('storageLocation', $storageLocation)
|
)->setParameter('storageLocation', $storageLocation)
|
||||||
->setParameter('path', $pathLike);
|
->setParameter('path', $pathLike);
|
||||||
|
|
||||||
if (!empty($searchPhrase)) {
|
if ($isSearch) {
|
||||||
if ('special:unprocessable' === $searchPhrase) {
|
if ('special:unprocessable' === $searchPhrase) {
|
||||||
$mediaInDirRaw = [];
|
$mediaInDirRaw = [];
|
||||||
|
|
||||||
|
@ -130,29 +132,30 @@ final class ListAction
|
||||||
$mediaQueryBuilder->andWhere(
|
$mediaQueryBuilder->andWhere(
|
||||||
'sm.id NOT IN (SELECT spm2.media_id FROM App\Entity\StationPlaylistMedia spm2)'
|
'sm.id NOT IN (SELECT spm2.media_id FROM App\Entity\StationPlaylistMedia spm2)'
|
||||||
);
|
);
|
||||||
} elseif (str_starts_with($searchPhrase, 'playlist:')) {
|
} else {
|
||||||
[, $playlistName] = explode(':', $searchPhrase, 2);
|
[$searchPhrase, $playlist] = $this->parseSearchQuery($station, $searchPhrase);
|
||||||
|
|
||||||
$playlist = $this->em->getRepository(Entity\StationPlaylist::class)
|
if ($this->meilisearch->isSupported()) {
|
||||||
->findOneBy(
|
$ids = $this->meilisearch
|
||||||
[
|
->getIndex($storageLocation)
|
||||||
'station' => $station,
|
->searchMedia($searchPhrase, $playlist);
|
||||||
'name' => $playlistName,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$playlist instanceof Entity\StationPlaylist) {
|
$mediaQueryBuilder->andWhere(
|
||||||
return $response->withStatus(400)
|
'sm.id IN (:ids)'
|
||||||
->withJson(new Entity\Api\Error(400, 'Playlist not found.'));
|
)->setParameter('ids', $ids);
|
||||||
|
} else {
|
||||||
|
if (null !== $playlist) {
|
||||||
|
$mediaQueryBuilder->andWhere(
|
||||||
|
'sm.id IN (SELECT spm2.media_id FROM App\Entity\StationPlaylistMedia spm2 '
|
||||||
|
. 'WHERE spm2.playlist = :playlist)'
|
||||||
|
)->setParameter('playlist', $playlist);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($searchPhrase)) {
|
||||||
|
$mediaQueryBuilder->andWhere('(sm.title LIKE :query OR sm.artist LIKE :query)')
|
||||||
|
->setParameter('query', '%' . $searchPhrase . '%');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$mediaQueryBuilder->andWhere(
|
|
||||||
'sm.id IN (SELECT spm2.media_id FROM App\Entity\StationPlaylistMedia spm2 '
|
|
||||||
. 'WHERE spm2.playlist = :playlist)'
|
|
||||||
)->setParameter('playlist', $playlist);
|
|
||||||
} elseif (!in_array($searchPhrase, ['*', '%'], true)) {
|
|
||||||
$mediaQueryBuilder->andWhere('(sm.title LIKE :query OR sm.artist LIKE :query)')
|
|
||||||
->setParameter('query', '%' . $searchPhrase . '%');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$mediaQuery = $mediaQueryBuilder->getQuery();
|
$mediaQuery = $mediaQueryBuilder->getQuery();
|
||||||
|
@ -246,7 +249,7 @@ final class ListAction
|
||||||
$unprocessableMedia[$unprocessableRow['path']] = $unprocessableRow['error'];
|
$unprocessableMedia[$unprocessableRow['path']] = $unprocessableRow['error'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($searchPhrase)) {
|
if ($isSearch) {
|
||||||
if ('special:unprocessable' === $searchPhrase) {
|
if ('special:unprocessable' === $searchPhrase) {
|
||||||
$files = array_keys($unprocessableMedia);
|
$files = array_keys($unprocessableMedia);
|
||||||
} else {
|
} else {
|
||||||
|
@ -284,7 +287,7 @@ final class ListAction
|
||||||
|
|
||||||
$row->size = ($row->is_dir) ? 0 : $fs->fileSize($row->path);
|
$row->size = ($row->is_dir) ? 0 : $fs->fileSize($row->path);
|
||||||
|
|
||||||
$shortname = (!empty($searchPhrase))
|
$shortname = ($isSearch)
|
||||||
? $row->path
|
? $row->path
|
||||||
: basename($row->path);
|
: basename($row->path);
|
||||||
|
|
||||||
|
@ -353,6 +356,35 @@ final class ListAction
|
||||||
return $paginator->write($response);
|
return $paginator->write($response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function parseSearchQuery(
|
||||||
|
Entity\Station $station,
|
||||||
|
string $query
|
||||||
|
): array {
|
||||||
|
$playlist = null;
|
||||||
|
|
||||||
|
if (str_contains($query, 'playlist:')) {
|
||||||
|
preg_match('/playlist:(\w*)/', $query, $matches, PREG_UNMATCHED_AS_NULL);
|
||||||
|
|
||||||
|
if ($matches[1]) {
|
||||||
|
$playlist = $this->em->getRepository(Entity\StationPlaylist::class)
|
||||||
|
->findOneBy(
|
||||||
|
[
|
||||||
|
'station' => $station,
|
||||||
|
'name' => $matches[1],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = trim(str_replace($matches[0] ?? '', '', $query));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($query, ['*', '%'], true)) {
|
||||||
|
$query = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$query, $playlist];
|
||||||
|
}
|
||||||
|
|
||||||
private static function sortRows(
|
private static function sortRows(
|
||||||
Entity\Api\FileList $a,
|
Entity\Api\FileList $a,
|
||||||
Entity\Api\FileList $b,
|
Entity\Api\FileList $b,
|
||||||
|
|
|
@ -11,6 +11,7 @@ use App\Flysystem\StationFilesystems;
|
||||||
use App\Http\Response;
|
use App\Http\Response;
|
||||||
use App\Http\ServerRequest;
|
use App\Http\ServerRequest;
|
||||||
use App\Media\MediaProcessor;
|
use App\Media\MediaProcessor;
|
||||||
|
use App\Message\Meilisearch\AddMediaMessage;
|
||||||
use App\Message\WritePlaylistFileMessage;
|
use App\Message\WritePlaylistFileMessage;
|
||||||
use App\OpenApi;
|
use App\OpenApi;
|
||||||
use App\Radio\Adapters;
|
use App\Radio\Adapters;
|
||||||
|
@ -320,6 +321,9 @@ final class FilesController extends AbstractStationApiCrudController
|
||||||
|
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
// Reindex file in search.
|
||||||
|
$this->reindexMedia($record);
|
||||||
|
|
||||||
// Handle playlist changes.
|
// Handle playlist changes.
|
||||||
$backend = $this->adapters->getBackendAdapter($station);
|
$backend = $this->adapters->getBackendAdapter($station);
|
||||||
if ($backend instanceof Liquidsoap) {
|
if ($backend instanceof Liquidsoap) {
|
||||||
|
@ -396,6 +400,9 @@ final class FilesController extends AbstractStationApiCrudController
|
||||||
throw new InvalidArgumentException(sprintf('Record must be an instance of %s.', $this->entityClass));
|
throw new InvalidArgumentException(sprintf('Record must be an instance of %s.', $this->entityClass));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger search reindex.
|
||||||
|
$this->reindexMedia($record);
|
||||||
|
|
||||||
// Delete the media file off the filesystem.
|
// Delete the media file off the filesystem.
|
||||||
// Write new PLS playlist configuration.
|
// Write new PLS playlist configuration.
|
||||||
foreach ($this->mediaRepo->remove($record, true) as $playlist_id => $playlist) {
|
foreach ($this->mediaRepo->remove($record, true) as $playlist_id => $playlist) {
|
||||||
|
@ -409,4 +416,15 @@ final class FilesController extends AbstractStationApiCrudController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function reindexMedia(Entity\StationMedia $media): void
|
||||||
|
{
|
||||||
|
$indexMessage = new AddMediaMessage();
|
||||||
|
$indexMessage->storage_location_id = $media->getStorageLocation()->getIdRequired();
|
||||||
|
$indexMessage->media_ids = [
|
||||||
|
$media->getIdRequired(),
|
||||||
|
];
|
||||||
|
$indexMessage->include_playlists = true;
|
||||||
|
$this->messageBus->dispatch($indexMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,27 +4,20 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controller\Api\Stations\OnDemand;
|
namespace App\Controller\Api\Stations\OnDemand;
|
||||||
|
|
||||||
use App\Doctrine\ReadOnlyBatchIteratorAggregate;
|
|
||||||
use App\Entity;
|
use App\Entity;
|
||||||
use App\Http\Response;
|
use App\Http\Response;
|
||||||
use App\Http\RouterInterface;
|
|
||||||
use App\Http\ServerRequest;
|
use App\Http\ServerRequest;
|
||||||
use App\Paginator;
|
use App\Paginator;
|
||||||
use App\Utilities;
|
use App\Service\Meilisearch;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
|
||||||
use Doctrine\Common\Collections\Criteria;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Symfony\Component\Cache\CacheItem;
|
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
|
||||||
|
|
||||||
final class ListAction
|
final class ListAction
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly Entity\Repository\CustomFieldRepository $customFieldRepo,
|
|
||||||
private readonly Entity\ApiGenerator\SongApiGenerator $songApiGenerator,
|
private readonly Entity\ApiGenerator\SongApiGenerator $songApiGenerator,
|
||||||
private readonly CacheInterface $cache,
|
private readonly Meilisearch $meilisearch
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,92 +34,50 @@ final class ListAction
|
||||||
->withJson(new Entity\Api\Error(403, __('This station does not support on-demand streaming.')));
|
->withJson(new Entity\Api\Error(403, __('This station does not support on-demand streaming.')));
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheKey = 'ondemand_' . $station->getId();
|
if (!$this->meilisearch->isSupported()) {
|
||||||
$trackList = $this->cache->get(
|
return $response->withStatus(403)
|
||||||
$cacheKey,
|
->withJson(new Entity\Api\Error(403, __('This feature is not supported on this installation.')));
|
||||||
function (CacheItem $item) use ($station, $request) {
|
}
|
||||||
$item->expiresAfter(300);
|
|
||||||
return $this->buildTrackList($station, $request->getRouter());
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$trackList = new ArrayCollection($trackList);
|
$index = $this->meilisearch->getIndex($station->getMediaStorageLocation());
|
||||||
|
|
||||||
$queryParams = $request->getQueryParams();
|
$queryParams = $request->getQueryParams();
|
||||||
|
|
||||||
$searchPhrase = trim($queryParams['searchPhrase'] ?? '');
|
$searchPhrase = trim($queryParams['searchPhrase'] ?? '');
|
||||||
if (!empty($searchPhrase)) {
|
|
||||||
$searchFields = [
|
|
||||||
'media_title',
|
|
||||||
'media_artist',
|
|
||||||
'media_album',
|
|
||||||
'playlist',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach (array_keys($this->customFieldRepo->getFieldIds()) as $customField) {
|
|
||||||
$searchFields[] = 'media_custom_fields_' . $customField;
|
|
||||||
}
|
|
||||||
|
|
||||||
$trackList = $trackList->filter(
|
|
||||||
function ($row) use ($searchFields, $searchPhrase) {
|
|
||||||
foreach ($searchFields as $searchField) {
|
|
||||||
if (false !== stripos($row[$searchField] ?? '', $searchPhrase)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
$searchParams = [];
|
||||||
if (!empty($queryParams['sort'])) {
|
if (!empty($queryParams['sort'])) {
|
||||||
$sortField = (string)$queryParams['sort'];
|
$sortField = (string)$queryParams['sort'];
|
||||||
$sortDirection = (string)($queryParams['sortOrder'] ?? Criteria::ASC);
|
$sortDirection = strtolower($queryParams['sortOrder'] ?? 'asc');
|
||||||
|
$searchParams['sort'] = [$sortField . ':' . $sortDirection];
|
||||||
$criteria = new Criteria();
|
|
||||||
$criteria->orderBy([$sortField => $sortDirection]);
|
|
||||||
|
|
||||||
$trackList = $trackList->matching($criteria);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Paginator::fromCollection($trackList, $request)
|
$hydrateCallback = function (array $results) {
|
||||||
->write($response);
|
$ids = array_column($results, 'id');
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return $this->em->createQuery(
|
||||||
* @return mixed[]
|
|
||||||
*/
|
|
||||||
private function buildTrackList(Entity\Station $station, RouterInterface $router): array
|
|
||||||
{
|
|
||||||
$list = [];
|
|
||||||
|
|
||||||
$playlists = $this->em->createQuery(
|
|
||||||
<<<'DQL'
|
|
||||||
SELECT sp FROM App\Entity\StationPlaylist sp
|
|
||||||
WHERE sp.station = :station
|
|
||||||
AND sp.id IS NOT NULL
|
|
||||||
AND sp.is_enabled = 1
|
|
||||||
AND sp.include_in_on_demand = 1
|
|
||||||
DQL
|
|
||||||
)->setParameter('station', $station)
|
|
||||||
->getArrayResult();
|
|
||||||
|
|
||||||
foreach ($playlists as $playlist) {
|
|
||||||
$query = $this->em->createQuery(
|
|
||||||
<<<'DQL'
|
<<<'DQL'
|
||||||
SELECT sm FROM App\Entity\StationMedia sm
|
SELECT sm
|
||||||
WHERE sm.id IN (
|
FROM App\Entity\StationMedia sm
|
||||||
SELECT spm.media_id
|
WHERE sm.id IN (:ids)
|
||||||
FROM App\Entity\StationPlaylistMedia spm
|
ORDER BY FIELD(sm.id, :ids)
|
||||||
WHERE spm.playlist_id = :playlist_id
|
|
||||||
)
|
|
||||||
ORDER BY sm.artist ASC, sm.title ASC
|
|
||||||
DQL
|
DQL
|
||||||
)->setParameter('playlist_id', $playlist['id']);
|
)->setParameter('ids', $ids)
|
||||||
|
->toIterable();
|
||||||
|
};
|
||||||
|
|
||||||
foreach (ReadOnlyBatchIteratorAggregate::fromQuery($query, 50) as $media) {
|
$paginatorAdapter = $index->getOnDemandSearchPaginator(
|
||||||
/** @var Entity\StationMedia $media */
|
$station,
|
||||||
|
$hydrateCallback,
|
||||||
|
$searchPhrase,
|
||||||
|
$searchParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
$paginator = Paginator::fromAdapter($paginatorAdapter, $request);
|
||||||
|
|
||||||
|
$router = $request->getRouter();
|
||||||
|
|
||||||
|
$paginator->setPostprocessor(
|
||||||
|
function (Entity\StationMedia $media) use ($station, $router) {
|
||||||
$row = new Entity\Api\StationOnDemand();
|
$row = new Entity\Api\StationOnDemand();
|
||||||
|
|
||||||
$row->track_id = $media->getUniqueId();
|
$row->track_id = $media->getUniqueId();
|
||||||
|
@ -134,7 +85,7 @@ final class ListAction
|
||||||
song: $media,
|
song: $media,
|
||||||
station: $station
|
station: $station
|
||||||
);
|
);
|
||||||
$row->playlist = $playlist['name'];
|
|
||||||
$row->download_url = $router->named(
|
$row->download_url = $router->named(
|
||||||
'api:stations:ondemand:download',
|
'api:stations:ondemand:download',
|
||||||
[
|
[
|
||||||
|
@ -145,10 +96,10 @@ final class ListAction
|
||||||
|
|
||||||
$row->resolveUrls($router->getBaseUrl());
|
$row->resolveUrls($router->getBaseUrl());
|
||||||
|
|
||||||
$list[] = Utilities\Arrays::flattenArray($row, '_');
|
return $row;
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
|
||||||
return $list;
|
return $paginator->write($response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,15 +5,21 @@ declare(strict_types=1);
|
||||||
namespace App\Controller\Api\Stations;
|
namespace App\Controller\Api\Stations;
|
||||||
|
|
||||||
use App\Controller\Api\Traits\CanSortResults;
|
use App\Controller\Api\Traits\CanSortResults;
|
||||||
|
use App\Doctrine\ReloadableEntityManagerInterface;
|
||||||
use App\Entity;
|
use App\Entity;
|
||||||
use App\Http\Response;
|
use App\Http\Response;
|
||||||
use App\Http\ServerRequest;
|
use App\Http\ServerRequest;
|
||||||
|
use App\Message\Meilisearch\UpdatePlaylistsMessage;
|
||||||
use App\OpenApi;
|
use App\OpenApi;
|
||||||
|
use App\Radio\AutoDJ\Scheduler;
|
||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Symfony\Component\Messenger\MessageBus;
|
||||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||||
|
use Symfony\Component\Serializer\Serializer;
|
||||||
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
/** @extends AbstractScheduledEntityController<Entity\StationPlaylist> */
|
/** @extends AbstractScheduledEntityController<Entity\StationPlaylist> */
|
||||||
#[
|
#[
|
||||||
|
@ -145,6 +151,17 @@ final class PlaylistsController extends AbstractScheduledEntityController
|
||||||
protected string $entityClass = Entity\StationPlaylist::class;
|
protected string $entityClass = Entity\StationPlaylist::class;
|
||||||
protected string $resourceRouteName = 'api:stations:playlist';
|
protected string $resourceRouteName = 'api:stations:playlist';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
Entity\Repository\StationScheduleRepository $scheduleRepo,
|
||||||
|
Scheduler $scheduler,
|
||||||
|
ReloadableEntityManagerInterface $em,
|
||||||
|
Serializer $serializer,
|
||||||
|
ValidatorInterface $validator,
|
||||||
|
private readonly MessageBus $messageBus
|
||||||
|
) {
|
||||||
|
parent::__construct($scheduleRepo, $scheduler, $em, $serializer, $validator);
|
||||||
|
}
|
||||||
|
|
||||||
public function listAction(
|
public function listAction(
|
||||||
ServerRequest $request,
|
ServerRequest $request,
|
||||||
Response $response,
|
Response $response,
|
||||||
|
@ -323,4 +340,38 @@ final class PlaylistsController extends AbstractScheduledEntityController
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function editAction(
|
||||||
|
ServerRequest $request,
|
||||||
|
Response $response,
|
||||||
|
string $station_id,
|
||||||
|
string $id
|
||||||
|
): ResponseInterface {
|
||||||
|
$result = parent::editAction($request, $response, $station_id, $id);
|
||||||
|
|
||||||
|
$this->reindexPlaylists($request->getStation());
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteAction(
|
||||||
|
ServerRequest $request,
|
||||||
|
Response $response,
|
||||||
|
string $station_id,
|
||||||
|
string $id
|
||||||
|
): ResponseInterface {
|
||||||
|
$result = parent::deleteAction($request, $response, $station_id, $id);
|
||||||
|
|
||||||
|
$this->reindexPlaylists($request->getStation());
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reindexPlaylists(Entity\Station $station): void
|
||||||
|
{
|
||||||
|
$indexMessage = new UpdatePlaylistsMessage();
|
||||||
|
$indexMessage->station_id = $station->getIdRequired();
|
||||||
|
|
||||||
|
$this->messageBus->dispatch($indexMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,8 +10,7 @@ use App\Http\Response;
|
||||||
use App\Http\ServerRequest;
|
use App\Http\ServerRequest;
|
||||||
use App\OpenApi;
|
use App\OpenApi;
|
||||||
use App\Paginator;
|
use App\Paginator;
|
||||||
use App\Radio\AutoDJ\Scheduler;
|
use App\Service\Meilisearch;
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
@ -68,7 +67,7 @@ final class RequestsController
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly Entity\Repository\StationRequestRepository $requestRepo,
|
private readonly Entity\Repository\StationRequestRepository $requestRepo,
|
||||||
private readonly Entity\ApiGenerator\SongApiGenerator $songApiGenerator,
|
private readonly Entity\ApiGenerator\SongApiGenerator $songApiGenerator,
|
||||||
private readonly Scheduler $scheduler
|
private readonly Meilisearch $meilisearch
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,60 +78,68 @@ final class RequestsController
|
||||||
): ResponseInterface {
|
): ResponseInterface {
|
||||||
$station = $request->getStation();
|
$station = $request->getStation();
|
||||||
|
|
||||||
$playlistIds = $this->getRequestablePlaylists($station);
|
// Verify that the station supports on-demand streaming.
|
||||||
|
if (!$station->getEnableRequests()) {
|
||||||
|
return $response->withStatus(403)
|
||||||
|
->withJson(new Entity\Api\Error(403, __('This station does not support requests.')));
|
||||||
|
}
|
||||||
|
|
||||||
$qb = $this->em->createQueryBuilder();
|
if (!$this->meilisearch->isSupported()) {
|
||||||
$qb->select('sm, spm, sp')
|
return $response->withStatus(403)
|
||||||
->from(Entity\StationMedia::class, 'sm')
|
->withJson(new Entity\Api\Error(403, __('This feature is not supported on this installation.')));
|
||||||
->leftJoin('sm.playlists', 'spm')
|
}
|
||||||
->leftJoin('spm.playlist', 'sp')
|
|
||||||
->where('sm.storage_location = :storageLocation')
|
$index = $this->meilisearch->getIndex($station->getMediaStorageLocation());
|
||||||
->andWhere('sp.id IN (:playlistIds)')
|
|
||||||
->setParameter('storageLocation', $station->getMediaStorageLocation())
|
|
||||||
->setParameter('playlistIds', $playlistIds);
|
|
||||||
|
|
||||||
$queryParams = $request->getQueryParams();
|
$queryParams = $request->getQueryParams();
|
||||||
|
$searchPhrase = trim($queryParams['searchPhrase'] ?? '');
|
||||||
|
|
||||||
|
$searchParams = [];
|
||||||
if (!empty($queryParams['sort'])) {
|
if (!empty($queryParams['sort'])) {
|
||||||
$sortDirection = (($queryParams['sortOrder'] ?? 'ASC') === 'ASC') ? 'ASC' : 'DESC';
|
$sortField = (string)$queryParams['sort'];
|
||||||
|
$sortDirection = strtolower($queryParams['sortOrder'] ?? 'asc');
|
||||||
match ($queryParams['sort']) {
|
$searchParams['sort'] = [$sortField . ':' . $sortDirection];
|
||||||
'name', 'song_title' => $qb->addOrderBy('sm.title', $sortDirection),
|
|
||||||
'song_artist' => $qb->addOrderBy('sm.artist', $sortDirection),
|
|
||||||
'song_album' => $qb->addOrderBy('sm.album', $sortDirection),
|
|
||||||
'song_genre' => $qb->addOrderBy('sm.genre', $sortDirection),
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
$qb->orderBy('sm.artist', 'ASC')
|
|
||||||
->addOrderBy('sm.title', 'ASC');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$search_phrase = trim($queryParams['searchPhrase'] ?? '');
|
$hydrateCallback = function (array $results) {
|
||||||
if (!empty($search_phrase)) {
|
$ids = array_column($results, 'id');
|
||||||
$qb->andWhere('(sm.title LIKE :query OR sm.artist LIKE :query OR sm.album LIKE :query)')
|
|
||||||
->setParameter('query', '%' . $search_phrase . '%');
|
|
||||||
}
|
|
||||||
|
|
||||||
$paginator = Paginator::fromQueryBuilder($qb, $request);
|
return $this->em->createQuery(
|
||||||
|
<<<'DQL'
|
||||||
|
SELECT sm
|
||||||
|
FROM App\Entity\StationMedia sm
|
||||||
|
WHERE sm.id IN (:ids)
|
||||||
|
ORDER BY FIELD(sm.id, :ids)
|
||||||
|
DQL
|
||||||
|
)->setParameter('ids', $ids)
|
||||||
|
->toIterable();
|
||||||
|
};
|
||||||
|
|
||||||
|
$paginatorAdapter = $index->getOnDemandSearchPaginator(
|
||||||
|
$station,
|
||||||
|
$hydrateCallback,
|
||||||
|
$searchPhrase,
|
||||||
|
$searchParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
$paginator = Paginator::fromAdapter($paginatorAdapter, $request);
|
||||||
|
|
||||||
$router = $request->getRouter();
|
$router = $request->getRouter();
|
||||||
$baseUrl = $router->getBaseUrl();
|
|
||||||
|
|
||||||
$paginator->setPostprocessor(
|
$paginator->setPostprocessor(
|
||||||
function (Entity\StationMedia $media_row) use ($station, $baseUrl, $router) {
|
function (Entity\StationMedia $media) use ($station, $router) {
|
||||||
$row = new Entity\Api\StationRequest();
|
$row = new Entity\Api\StationRequest();
|
||||||
$row->song = ($this->songApiGenerator)($media_row, $station, $baseUrl);
|
$row->song = ($this->songApiGenerator)($media, $station, $router->getBaseUrl());
|
||||||
$row->request_id = $media_row->getUniqueId();
|
$row->request_id = $media->getUniqueId();
|
||||||
$row->request_url = $router->named(
|
$row->request_url = $router->named(
|
||||||
'api:requests:submit',
|
'api:requests:submit',
|
||||||
[
|
[
|
||||||
'station_id' => $station->getId(),
|
'station_id' => $station->getId(),
|
||||||
'media_id' => $media_row->getUniqueId(),
|
'media_id' => $media->getUniqueId(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
$row->resolveUrls($baseUrl);
|
$row->resolveUrls($router->getBaseUrl());
|
||||||
|
|
||||||
return $row;
|
return $row;
|
||||||
}
|
}
|
||||||
|
@ -141,33 +148,6 @@ final class RequestsController
|
||||||
return $paginator->write($response);
|
return $paginator->write($response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Entity\Station $station
|
|
||||||
*/
|
|
||||||
private function getRequestablePlaylists(Entity\Station $station): array
|
|
||||||
{
|
|
||||||
$playlists = $this->em->createQuery(
|
|
||||||
<<<DQL
|
|
||||||
SELECT sp FROM App\Entity\StationPlaylist sp
|
|
||||||
WHERE sp.station = :station
|
|
||||||
AND sp.is_enabled = 1 AND sp.include_in_requests = 1
|
|
||||||
DQL
|
|
||||||
)->setParameter('station', $station)
|
|
||||||
->toIterable();
|
|
||||||
|
|
||||||
$ids = [];
|
|
||||||
$now = CarbonImmutable::now($station->getTimezoneObject());
|
|
||||||
|
|
||||||
/** @var Entity\StationPlaylist $playlist */
|
|
||||||
foreach ($playlists as $playlist) {
|
|
||||||
if ($this->scheduler->isPlaylistScheduledToPlayNow($playlist, $now)) {
|
|
||||||
$ids[] = $playlist->getIdRequired();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function submitAction(
|
public function submitAction(
|
||||||
ServerRequest $request,
|
ServerRequest $request,
|
||||||
Response $response,
|
Response $response,
|
||||||
|
|
|
@ -45,7 +45,7 @@ final class OnDemandAction
|
||||||
$customFields = [];
|
$customFields = [];
|
||||||
foreach ($customFieldsRaw as $row) {
|
foreach ($customFieldsRaw as $row) {
|
||||||
$customFields[] = [
|
$customFields[] = [
|
||||||
'display_key' => 'media_custom_fields_' . $row['short_name'],
|
'display_key' => 'custom_field_' . $row['id'],
|
||||||
'key' => $row['short_name'],
|
'key' => $row['short_name'],
|
||||||
'label' => $row['name'],
|
'label' => $row['name'],
|
||||||
];
|
];
|
||||||
|
|
|
@ -54,6 +54,8 @@ final class Environment
|
||||||
|
|
||||||
public const ENABLE_WEB_UPDATER = 'ENABLE_WEB_UPDATER';
|
public const ENABLE_WEB_UPDATER = 'ENABLE_WEB_UPDATER';
|
||||||
|
|
||||||
|
public const MEILI_MASTER_KEY = 'MEILI_MASTER_KEY';
|
||||||
|
|
||||||
// Database and Cache Configuration Variables
|
// Database and Cache Configuration Variables
|
||||||
public const DB_HOST = 'MYSQL_HOST';
|
public const DB_HOST = 'MYSQL_HOST';
|
||||||
public const DB_PORT = 'MYSQL_PORT';
|
public const DB_PORT = 'MYSQL_PORT';
|
||||||
|
@ -90,6 +92,8 @@ final class Environment
|
||||||
self::PROFILING_EXTENSION_HTTP_KEY => 'dev',
|
self::PROFILING_EXTENSION_HTTP_KEY => 'dev',
|
||||||
|
|
||||||
self::ENABLE_WEB_UPDATER => false,
|
self::ENABLE_WEB_UPDATER => false,
|
||||||
|
|
||||||
|
self::MEILI_MASTER_KEY => 'azur4c457',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(array $elements = [])
|
public function __construct(array $elements = [])
|
||||||
|
@ -370,6 +374,11 @@ final class Environment
|
||||||
return $this->isDocker() && self::envToBool($this->data[self::ENABLE_WEB_UPDATER] ?? false);
|
return $this->isDocker() && self::envToBool($this->data[self::ENABLE_WEB_UPDATER] ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getMeiliMasterKey(): string
|
||||||
|
{
|
||||||
|
return $this->data[self::MEILI_MASTER_KEY] ?? $this->defaults[self::MEILI_MASTER_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
public static function getDefaultsForEnvironment(Environment $existingEnv): self
|
public static function getDefaultsForEnvironment(Environment $existingEnv): self
|
||||||
{
|
{
|
||||||
return new self([
|
return new self([
|
||||||
|
|
|
@ -7,8 +7,11 @@ namespace App\Media;
|
||||||
use App\Doctrine\ReadWriteBatchIteratorAggregate;
|
use App\Doctrine\ReadWriteBatchIteratorAggregate;
|
||||||
use App\Entity;
|
use App\Entity;
|
||||||
use App\Flysystem\ExtendedFilesystemInterface;
|
use App\Flysystem\ExtendedFilesystemInterface;
|
||||||
|
use App\Message\Meilisearch\AddMediaMessage;
|
||||||
|
use App\Message\Meilisearch\UpdatePlaylistsMessage;
|
||||||
use App\Utilities\File;
|
use App\Utilities\File;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Messenger\MessageBus;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class BatchUtilities
|
final class BatchUtilities
|
||||||
|
@ -17,6 +20,7 @@ final class BatchUtilities
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly Entity\Repository\StationMediaRepository $mediaRepo,
|
private readonly Entity\Repository\StationMediaRepository $mediaRepo,
|
||||||
private readonly Entity\Repository\UnprocessableMediaRepository $unprocessableMediaRepo,
|
private readonly Entity\Repository\UnprocessableMediaRepository $unprocessableMediaRepo,
|
||||||
|
private readonly MessageBus $messageBus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,8 +40,14 @@ final class BatchUtilities
|
||||||
$this->iteratePlaylistFoldersInDirectory($storageLocation, $from),
|
$this->iteratePlaylistFoldersInDirectory($storageLocation, $from),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$mediaToReindex = [];
|
||||||
|
|
||||||
foreach ($toRename as $iterator) {
|
foreach ($toRename as $iterator) {
|
||||||
foreach ($iterator as $record) {
|
foreach ($iterator as $record) {
|
||||||
|
if ($record instanceof Entity\StationMedia) {
|
||||||
|
$mediaToReindex[] = $record->getIdRequired();
|
||||||
|
}
|
||||||
|
|
||||||
/** @var Entity\Interfaces\PathAwareInterface $record */
|
/** @var Entity\Interfaces\PathAwareInterface $record */
|
||||||
$record->setPath(
|
$record->setPath(
|
||||||
File::renameDirectoryInPath($record->getPath(), $from, $to)
|
File::renameDirectoryInPath($record->getPath(), $from, $to)
|
||||||
|
@ -45,6 +55,8 @@ final class BatchUtilities
|
||||||
$this->em->persist($record);
|
$this->em->persist($record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->queueMediaForIndex($storageLocation, $mediaToReindex);
|
||||||
} else {
|
} else {
|
||||||
$record = $this->mediaRepo->findByPath($from, $storageLocation);
|
$record = $this->mediaRepo->findByPath($from, $storageLocation);
|
||||||
|
|
||||||
|
@ -52,6 +64,8 @@ final class BatchUtilities
|
||||||
$record->setPath($to);
|
$record->setPath($to);
|
||||||
$this->em->persist($record);
|
$this->em->persist($record);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->queueMediaForIndex($storageLocation, [$record->getIdRequired()]);
|
||||||
} else {
|
} else {
|
||||||
$record = $this->unprocessableMediaRepo->findByPath($from, $storageLocation);
|
$record = $this->unprocessableMediaRepo->findByPath($from, $storageLocation);
|
||||||
|
|
||||||
|
@ -84,7 +98,11 @@ final class BatchUtilities
|
||||||
/*
|
/*
|
||||||
* NOTE: This iteration clears the entity manager.
|
* NOTE: This iteration clears the entity manager.
|
||||||
*/
|
*/
|
||||||
|
$mediaToReindex = [];
|
||||||
|
|
||||||
foreach ($this->iterateMedia($storageLocation, $files) as $media) {
|
foreach ($this->iterateMedia($storageLocation, $files) as $media) {
|
||||||
|
$mediaToReindex[] = $media->getIdRequired();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
foreach ($this->mediaRepo->remove($media, false, $fs) as $playlistId => $playlist) {
|
foreach ($this->mediaRepo->remove($media, false, $fs) as $playlistId => $playlist) {
|
||||||
if (!isset($affectedPlaylists[$playlistId])) {
|
if (!isset($affectedPlaylists[$playlistId])) {
|
||||||
|
@ -95,6 +113,8 @@ final class BatchUtilities
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->queueMediaForIndex($storageLocation, $mediaToReindex);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* NOTE: This iteration clears the entity manager.
|
* NOTE: This iteration clears the entity manager.
|
||||||
*/
|
*/
|
||||||
|
@ -113,6 +133,30 @@ final class BatchUtilities
|
||||||
return $affectedPlaylists;
|
return $affectedPlaylists;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function queueMediaForIndex(
|
||||||
|
Entity\StorageLocation $storageLocation,
|
||||||
|
array $ids,
|
||||||
|
bool $includePlaylists = false
|
||||||
|
): void {
|
||||||
|
$queueMessage = new AddMediaMessage();
|
||||||
|
$queueMessage->storage_location_id = $storageLocation->getIdRequired();
|
||||||
|
$queueMessage->media_ids = $ids;
|
||||||
|
$queueMessage->include_playlists = $includePlaylists;
|
||||||
|
|
||||||
|
$this->messageBus->dispatch($queueMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function queuePlaylistsForUpdate(
|
||||||
|
Entity\Station $station,
|
||||||
|
?array $ids = null
|
||||||
|
): void {
|
||||||
|
$queueMessage = new UpdatePlaylistsMessage();
|
||||||
|
$queueMessage->station_id = $station->getIdRequired();
|
||||||
|
$queueMessage->media_ids = $ids;
|
||||||
|
|
||||||
|
$this->messageBus->dispatch($queueMessage);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iterate through the found media records, while occasionally flushing and clearing the entity manager.
|
* Iterate through the found media records, while occasionally flushing and clearing the entity manager.
|
||||||
*
|
*
|
||||||
|
|
19
src/Message/Meilisearch/AddMediaMessage.php
Normal file
19
src/Message/Meilisearch/AddMediaMessage.php
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Message\Meilisearch;
|
||||||
|
|
||||||
|
use App\Message\AbstractMessage;
|
||||||
|
|
||||||
|
final class AddMediaMessage extends AbstractMessage
|
||||||
|
{
|
||||||
|
/** @var int The numeric identifier for the StorageLocation entity. */
|
||||||
|
public int $storage_location_id;
|
||||||
|
|
||||||
|
/** @var int[] An array of media IDs to process. */
|
||||||
|
public array $media_ids;
|
||||||
|
|
||||||
|
/** @var bool Whether to include playlist data. */
|
||||||
|
public bool $include_playlists = false;
|
||||||
|
}
|
16
src/Message/Meilisearch/UpdatePlaylistsMessage.php
Normal file
16
src/Message/Meilisearch/UpdatePlaylistsMessage.php
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Message\Meilisearch;
|
||||||
|
|
||||||
|
use App\Message\AbstractMessage;
|
||||||
|
|
||||||
|
final class UpdatePlaylistsMessage extends AbstractMessage
|
||||||
|
{
|
||||||
|
/** @var int The numeric identifier for the Station entity. */
|
||||||
|
public int $station_id;
|
||||||
|
|
||||||
|
/** @var int[]|null Only update for specific media IDs. */
|
||||||
|
public ?array $media_ids = null;
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ use Doctrine\ORM\Query;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
use Generator;
|
use Generator;
|
||||||
use IteratorAggregate;
|
use IteratorAggregate;
|
||||||
|
use Pagerfanta\Adapter\AdapterInterface;
|
||||||
use Pagerfanta\Adapter\ArrayAdapter;
|
use Pagerfanta\Adapter\ArrayAdapter;
|
||||||
use Pagerfanta\Doctrine\Collections\CollectionAdapter;
|
use Pagerfanta\Doctrine\Collections\CollectionAdapter;
|
||||||
use Pagerfanta\Doctrine\ORM\QueryAdapter;
|
use Pagerfanta\Doctrine\ORM\QueryAdapter;
|
||||||
|
@ -185,6 +186,22 @@ final class Paginator implements IteratorAggregate, Countable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template X of mixed
|
||||||
|
*
|
||||||
|
* @param AdapterInterface<X> $adapter
|
||||||
|
* @return static<array-key, X>
|
||||||
|
*/
|
||||||
|
public static function fromAdapter(
|
||||||
|
AdapterInterface $adapter,
|
||||||
|
ServerRequestInterface $request
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
new Pagerfanta($adapter),
|
||||||
|
$request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @template XKey of array-key
|
* @template XKey of array-key
|
||||||
* @template X of mixed
|
* @template X of mixed
|
||||||
|
@ -194,10 +211,7 @@ final class Paginator implements IteratorAggregate, Countable
|
||||||
*/
|
*/
|
||||||
public static function fromArray(array $input, ServerRequestInterface $request): self
|
public static function fromArray(array $input, ServerRequestInterface $request): self
|
||||||
{
|
{
|
||||||
return new self(
|
return self::fromAdapter(new ArrayAdapter($input), $request);
|
||||||
new Pagerfanta(new ArrayAdapter($input)),
|
|
||||||
$request
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -209,10 +223,7 @@ final class Paginator implements IteratorAggregate, Countable
|
||||||
*/
|
*/
|
||||||
public static function fromCollection(Collection $collection, ServerRequestInterface $request): self
|
public static function fromCollection(Collection $collection, ServerRequestInterface $request): self
|
||||||
{
|
{
|
||||||
return new self(
|
return self::fromAdapter(new CollectionAdapter($collection), $request);
|
||||||
new Pagerfanta(new CollectionAdapter($collection)),
|
|
||||||
$request,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -220,10 +231,7 @@ final class Paginator implements IteratorAggregate, Countable
|
||||||
*/
|
*/
|
||||||
public static function fromQueryBuilder(QueryBuilder $qb, ServerRequestInterface $request): self
|
public static function fromQueryBuilder(QueryBuilder $qb, ServerRequestInterface $request): self
|
||||||
{
|
{
|
||||||
return new self(
|
return self::fromAdapter(new QueryAdapter($qb), $request);
|
||||||
new Pagerfanta(new QueryAdapter($qb)),
|
|
||||||
$request
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -231,9 +239,6 @@ final class Paginator implements IteratorAggregate, Countable
|
||||||
*/
|
*/
|
||||||
public static function fromQuery(Query $query, ServerRequestInterface $request): self
|
public static function fromQuery(Query $query, ServerRequestInterface $request): self
|
||||||
{
|
{
|
||||||
return new self(
|
return self::fromAdapter(new QueryAdapter($query), $request);
|
||||||
new Pagerfanta(new QueryAdapter($query)),
|
|
||||||
$request
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
70
src/Service/Meilisearch.php
Normal file
70
src/Service/Meilisearch.php
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\StorageLocation;
|
||||||
|
use App\Environment;
|
||||||
|
use App\Service\Meilisearch\Index;
|
||||||
|
use DI\FactoryInterface;
|
||||||
|
use GuzzleHttp\Client as GuzzleClient;
|
||||||
|
use GuzzleHttp\Psr7\HttpFactory;
|
||||||
|
use Meilisearch\Client;
|
||||||
|
|
||||||
|
final class Meilisearch
|
||||||
|
{
|
||||||
|
public const BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Environment $environment,
|
||||||
|
private readonly GuzzleClient $httpClient,
|
||||||
|
private readonly FactoryInterface $factory
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSupported(): bool
|
||||||
|
{
|
||||||
|
return $this->environment->isDocker() && !$this->environment->isTesting();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClient(): Client
|
||||||
|
{
|
||||||
|
static $client;
|
||||||
|
|
||||||
|
if (!$this->isSupported()) {
|
||||||
|
throw new \RuntimeException('This feature is not supported on this installation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($client)) {
|
||||||
|
$psrFactory = new HttpFactory();
|
||||||
|
$client = new Client(
|
||||||
|
'http://localhost:6070',
|
||||||
|
$this->environment->getMeiliMasterKey(),
|
||||||
|
$this->httpClient,
|
||||||
|
requestFactory: $psrFactory,
|
||||||
|
streamFactory: $psrFactory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIndex(StorageLocation $storageLocation): Index
|
||||||
|
{
|
||||||
|
$client = $this->getClient();
|
||||||
|
|
||||||
|
return $this->factory->make(
|
||||||
|
Index::class,
|
||||||
|
[
|
||||||
|
'storageLocation' => $storageLocation,
|
||||||
|
'indexClient' => $client->index(self::getIndexUid($storageLocation)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getIndexUid(StorageLocation $storageLocation): string
|
||||||
|
{
|
||||||
|
return 'media_' . $storageLocation->getIdRequired();
|
||||||
|
}
|
||||||
|
}
|
490
src/Service/Meilisearch/Index.php
Normal file
490
src/Service/Meilisearch/Index.php
Normal file
|
@ -0,0 +1,490 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Meilisearch;
|
||||||
|
|
||||||
|
use App\Doctrine\ReloadableEntityManagerInterface;
|
||||||
|
use App\Entity\Repository\CustomFieldRepository;
|
||||||
|
use App\Entity\Station;
|
||||||
|
use App\Entity\StationPlaylist;
|
||||||
|
use App\Entity\StorageLocation;
|
||||||
|
use App\Environment;
|
||||||
|
use App\Service\Meilisearch;
|
||||||
|
use Doctrine\ORM\AbstractQuery;
|
||||||
|
use Meilisearch\Contracts\DocumentsQuery;
|
||||||
|
use Meilisearch\Endpoints\Indexes;
|
||||||
|
use Meilisearch\Exceptions\ApiException;
|
||||||
|
use Meilisearch\Search\SearchResult;
|
||||||
|
|
||||||
|
final class Index
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ReloadableEntityManagerInterface $em,
|
||||||
|
private readonly CustomFieldRepository $customFieldRepo,
|
||||||
|
private readonly Environment $environment,
|
||||||
|
private readonly StorageLocation $storageLocation,
|
||||||
|
private readonly Indexes $indexClient,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configure(): void
|
||||||
|
{
|
||||||
|
$filterableAttributes = [];
|
||||||
|
|
||||||
|
$mediaFields = [
|
||||||
|
'id',
|
||||||
|
'path',
|
||||||
|
'mtime',
|
||||||
|
'length',
|
||||||
|
'title',
|
||||||
|
'artist',
|
||||||
|
'album',
|
||||||
|
'genre',
|
||||||
|
'isrc',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->getStationIds() as $stationId) {
|
||||||
|
$filterableAttributes[] = 'station_' . $stationId . '_playlists';
|
||||||
|
$filterableAttributes[] = 'station_' . $stationId . '_is_requestable';
|
||||||
|
$filterableAttributes[] = 'station_' . $stationId . '_is_on_demand';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->customFieldRepo->getFieldIds() as $fieldId => $fieldShortCode) {
|
||||||
|
$mediaFields[] = 'custom_field_' . $fieldId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$indexSettings = [
|
||||||
|
'filterableAttributes' => $filterableAttributes,
|
||||||
|
'sortableAttributes' => $mediaFields,
|
||||||
|
'displayedAttributes' => $this->environment->isProduction()
|
||||||
|
? ['id']
|
||||||
|
: ['*'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Avoid updating settings unless necessary to avoid triggering a reindex.
|
||||||
|
try {
|
||||||
|
$this->indexClient->fetchRawInfo();
|
||||||
|
} catch (ApiException) {
|
||||||
|
$response = $this->indexClient->create(
|
||||||
|
$this->indexClient->getUid() ?? '',
|
||||||
|
['primaryKey' => 'id']
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->indexClient->waitForTask($response['taskUid']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentSettings = $this->indexClient->getSettings();
|
||||||
|
$settingsToUpdate = [];
|
||||||
|
|
||||||
|
foreach ($indexSettings as $settingKey => $setting) {
|
||||||
|
$currentSetting = $currentSettings[$settingKey] ?? [];
|
||||||
|
sort($setting);
|
||||||
|
if ($currentSetting !== $setting) {
|
||||||
|
$settingsToUpdate[$settingKey] = $setting;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($settingsToUpdate)) {
|
||||||
|
$response = $this->indexClient->updateSettings($settingsToUpdate);
|
||||||
|
$this->indexClient->waitForTask($response['taskUid']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIdsInIndex(): array
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
foreach ($this->getAllDocuments(['id', 'mtime']) as $document) {
|
||||||
|
$ids[$document['id']] = $document['mtime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllDocuments(array $fields = ['*']): iterable
|
||||||
|
{
|
||||||
|
$perPage = Meilisearch::BATCH_SIZE;
|
||||||
|
$documentsQuery = (new DocumentsQuery())
|
||||||
|
->setOffset(0)
|
||||||
|
->setLimit($perPage)
|
||||||
|
->setFields($fields);
|
||||||
|
|
||||||
|
$documents = $this->indexClient->getDocuments($documentsQuery);
|
||||||
|
yield from $documents->getIterator();
|
||||||
|
|
||||||
|
if ($documents->getTotal() <= $perPage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalPages = ceil($documents->getTotal() / $perPage);
|
||||||
|
for ($page = 1; $page <= $totalPages; $page++) {
|
||||||
|
$documentsQuery->setOffset($page * $perPage);
|
||||||
|
$documents = $this->indexClient->getDocuments($documentsQuery);
|
||||||
|
yield from $documents->getIterator();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteIds(array $ids): void
|
||||||
|
{
|
||||||
|
$this->indexClient->deleteDocuments($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshMedia(
|
||||||
|
array $ids,
|
||||||
|
bool $includePlaylists = false
|
||||||
|
): void {
|
||||||
|
if ($includePlaylists) {
|
||||||
|
$mediaPlaylistsRaw = $this->em->createQuery(
|
||||||
|
<<<'DQL'
|
||||||
|
SELECT spm.media_id, spm.playlist_id
|
||||||
|
FROM App\Entity\StationPlaylistMedia spm
|
||||||
|
WHERE spm.media_id IN (:mediaIds)
|
||||||
|
DQL
|
||||||
|
)->setParameter('mediaIds', $ids)
|
||||||
|
->getArrayResult();
|
||||||
|
|
||||||
|
$mediaPlaylists = [];
|
||||||
|
$playlistIds = [];
|
||||||
|
|
||||||
|
foreach ($mediaPlaylistsRaw as $mediaPlaylistRow) {
|
||||||
|
$mediaId = $mediaPlaylistRow['media_id'];
|
||||||
|
$playlistId = $mediaPlaylistRow['playlist_id'];
|
||||||
|
|
||||||
|
$playlistIds[$playlistId] = $playlistId;
|
||||||
|
|
||||||
|
$mediaPlaylists[$mediaId] ??= [];
|
||||||
|
$mediaPlaylists[$mediaId][] = $playlistId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stationIds = $this->getStationIds();
|
||||||
|
|
||||||
|
$playlistsRaw = $this->em->createQuery(
|
||||||
|
<<<'DQL'
|
||||||
|
SELECT p.id, p.station_id, p.include_in_on_demand, p.include_in_requests
|
||||||
|
FROM App\Entity\StationPlaylist p
|
||||||
|
WHERE p.id IN (:playlistIds) AND p.station_id IN (:stationIds)
|
||||||
|
AND p.is_enabled = 1
|
||||||
|
DQL
|
||||||
|
)->setParameter('playlistIds', $playlistIds)
|
||||||
|
->setParameter('stationIds', $stationIds)
|
||||||
|
->getArrayResult();
|
||||||
|
|
||||||
|
$playlists = [];
|
||||||
|
foreach ($playlistsRaw as $playlistRow) {
|
||||||
|
$playlists[$playlistRow['id']] = $playlistRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$customFieldsRaw = $this->em->createQuery(
|
||||||
|
<<<'DQL'
|
||||||
|
SELECT smcf.media_id, smcf.field_id, smcf.value
|
||||||
|
FROM App\Entity\StationMediaCustomField smcf
|
||||||
|
WHERE smcf.media_id IN (:mediaIds)
|
||||||
|
DQL
|
||||||
|
)->setParameter('mediaIds', $ids)
|
||||||
|
->getArrayResult();
|
||||||
|
|
||||||
|
$customFields = [];
|
||||||
|
foreach ($customFieldsRaw as $customFieldRow) {
|
||||||
|
$mediaId = $customFieldRow['media_id'];
|
||||||
|
|
||||||
|
$customFields[$mediaId] ??= [];
|
||||||
|
$customFields[$mediaId]['custom_field_' . $customFieldRow['field_id']] = $customFieldRow['value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$mediaRaw = $this->em->createQuery(
|
||||||
|
<<<'DQL'
|
||||||
|
SELECT sm.id,
|
||||||
|
sm.path,
|
||||||
|
sm.mtime,
|
||||||
|
sm.length_text,
|
||||||
|
sm.title,
|
||||||
|
sm.artist,
|
||||||
|
sm.album,
|
||||||
|
sm.genre,
|
||||||
|
sm.isrc
|
||||||
|
FROM App\Entity\StationMedia sm
|
||||||
|
WHERE sm.storage_location = :storageLocation
|
||||||
|
AND sm.id IN (:ids)
|
||||||
|
DQL
|
||||||
|
)->setParameter('storageLocation', $this->storageLocation)
|
||||||
|
->setParameter('ids', $ids)
|
||||||
|
->toIterable([], AbstractQuery::HYDRATE_ARRAY);
|
||||||
|
|
||||||
|
$media = [];
|
||||||
|
|
||||||
|
foreach ($mediaRaw as $row) {
|
||||||
|
$mediaId = $row['id'];
|
||||||
|
|
||||||
|
$record = [
|
||||||
|
'id' => $row['id'],
|
||||||
|
'path' => $row['path'],
|
||||||
|
'mtime' => $row['mtime'],
|
||||||
|
'duration' => $row['length_text'],
|
||||||
|
'title' => $row['title'],
|
||||||
|
'artist' => $row['artist'],
|
||||||
|
'album' => $row['album'],
|
||||||
|
'genre' => $row['genre'],
|
||||||
|
'isrc' => $row['isrc'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isset($customFields[$mediaId])) {
|
||||||
|
$record = array_merge($record, $customFields[$mediaId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($includePlaylists) {
|
||||||
|
foreach ($stationIds as $stationId) {
|
||||||
|
$record['station_' . $stationId . '_playlists'] = [];
|
||||||
|
$record['station_' . $stationId . '_is_requestable'] = false;
|
||||||
|
$record['station_' . $stationId . '_is_on_demand'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($mediaPlaylists[$mediaId])) {
|
||||||
|
foreach ($mediaPlaylists[$mediaId] as $mediaPlaylistId) {
|
||||||
|
if (!isset($playlists[$mediaPlaylistId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$playlist = $playlists[$mediaPlaylistId];
|
||||||
|
$stationId = $playlist['station_id'];
|
||||||
|
|
||||||
|
$record['station_' . $stationId . '_playlists'][] = $mediaPlaylistId;
|
||||||
|
|
||||||
|
if ($playlist['include_in_requests']) {
|
||||||
|
$record['station_' . $stationId . '_is_requestable'] = true;
|
||||||
|
}
|
||||||
|
if ($playlist['include_in_on_demand']) {
|
||||||
|
$record['station_' . $stationId . '_is_on_demand'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$media[$mediaId] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($includePlaylists) {
|
||||||
|
$this->indexClient->addDocumentsInBatches(
|
||||||
|
$media,
|
||||||
|
Meilisearch::BATCH_SIZE
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->indexClient->updateDocumentsInBatches(
|
||||||
|
$media,
|
||||||
|
Meilisearch::BATCH_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshPlaylists(
|
||||||
|
Station $station,
|
||||||
|
?array $ids = null
|
||||||
|
): void {
|
||||||
|
$stationId = $station->getIdRequired();
|
||||||
|
|
||||||
|
$playlistsKey = 'station_' . $stationId . '_playlists';
|
||||||
|
$isRequestableKey = 'station_' . $stationId . '_is_requestable';
|
||||||
|
$isOnDemandKey = 'station_' . $stationId . '_is_on_demand';
|
||||||
|
|
||||||
|
$media = [];
|
||||||
|
|
||||||
|
if (null === $ids) {
|
||||||
|
$allMediaRaw = $this->em->createQuery(
|
||||||
|
<<<'DQL'
|
||||||
|
SELECT m.id FROM App\Entity\StationMedia m
|
||||||
|
WHERE m.storage_location = :storageLocation
|
||||||
|
DQL
|
||||||
|
)->setParameter('storageLocation', $this->storageLocation)
|
||||||
|
->toIterable([], AbstractQuery::HYDRATE_ARRAY);
|
||||||
|
|
||||||
|
foreach ($allMediaRaw as $mediaRow) {
|
||||||
|
$media[$mediaRow['id']] = [
|
||||||
|
'id' => $mediaRow['id'],
|
||||||
|
$playlistsKey => [],
|
||||||
|
$isRequestableKey => false,
|
||||||
|
$isOnDemandKey => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($ids as $mediaId) {
|
||||||
|
$media[$mediaId] = [
|
||||||
|
'id' => $mediaId,
|
||||||
|
$playlistsKey => [],
|
||||||
|
$isRequestableKey => false,
|
||||||
|
$isOnDemandKey => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$allPlaylists = $this->em->createQuery(
|
||||||
|
<<<'DQL'
|
||||||
|
SELECT p.id, p.include_in_on_demand, p.include_in_requests
|
||||||
|
FROM App\Entity\StationPlaylist p
|
||||||
|
WHERE p.station = :station AND p.is_enabled = 1
|
||||||
|
DQL
|
||||||
|
)->setParameter('station', $station)
|
||||||
|
->getArrayResult();
|
||||||
|
|
||||||
|
$allPlaylistIds = [];
|
||||||
|
$onDemandPlaylists = [];
|
||||||
|
$requestablePlaylists = [];
|
||||||
|
|
||||||
|
foreach ($allPlaylists as $playlist) {
|
||||||
|
$allPlaylistIds[] = $playlist['id'];
|
||||||
|
if ($playlist['include_in_on_demand']) {
|
||||||
|
$onDemandPlaylists[$playlist['id']] = $playlist['id'];
|
||||||
|
}
|
||||||
|
if ($playlist['include_in_requests']) {
|
||||||
|
$requestablePlaylists[$playlist['id']] = $playlist['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $ids) {
|
||||||
|
$mediaInPlaylists = $this->em->createQuery(
|
||||||
|
<<<'DQL'
|
||||||
|
SELECT spm.media_id, spm.playlist_id
|
||||||
|
FROM App\Entity\StationPlaylistMedia spm
|
||||||
|
WHERE spm.playlist_id IN (:allPlaylistIds)
|
||||||
|
DQL
|
||||||
|
)->setParameter('allPlaylistIds', $allPlaylistIds)
|
||||||
|
->toIterable([], AbstractQuery::HYDRATE_ARRAY);
|
||||||
|
} else {
|
||||||
|
$mediaInPlaylists = $this->em->createQuery(
|
||||||
|
<<<'DQL'
|
||||||
|
SELECT spm.media_id, spm.playlist_id
|
||||||
|
FROM App\Entity\StationPlaylistMedia spm
|
||||||
|
WHERE spm.playlist_id IN (:allPlaylistIds)
|
||||||
|
AND spm.media_id IN (:mediaIds)
|
||||||
|
DQL
|
||||||
|
)->setParameter('allPlaylistIds', $allPlaylistIds)
|
||||||
|
->setParameter('mediaIds', $ids)
|
||||||
|
->toIterable([], AbstractQuery::HYDRATE_ARRAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($mediaInPlaylists as $spmRow) {
|
||||||
|
$mediaId = $spmRow['media_id'];
|
||||||
|
$playlistId = $spmRow['playlist_id'];
|
||||||
|
|
||||||
|
$media[$mediaId][$playlistsKey][] = $playlistId;
|
||||||
|
if (isset($requestablePlaylists[$playlistId])) {
|
||||||
|
$media[$mediaId][$isRequestableKey] = true;
|
||||||
|
}
|
||||||
|
if (isset($onDemandPlaylists[$playlistId])) {
|
||||||
|
$media[$mediaId][$isOnDemandKey] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->indexClient->updateDocumentsInBatches(
|
||||||
|
array_values($media),
|
||||||
|
Meilisearch::BATCH_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PaginatorAdapter<int|string, mixed>
|
||||||
|
*/
|
||||||
|
public function getRequestableSearchPaginator(
|
||||||
|
Station $station,
|
||||||
|
callable $hydrateCallback,
|
||||||
|
?string $query,
|
||||||
|
array $searchParams = [],
|
||||||
|
array $options = [],
|
||||||
|
): PaginatorAdapter {
|
||||||
|
return $this->getSearchPaginator(
|
||||||
|
$hydrateCallback,
|
||||||
|
$query,
|
||||||
|
[
|
||||||
|
...$searchParams,
|
||||||
|
'filter' => [
|
||||||
|
[
|
||||||
|
'station_' . $station->getIdRequired() . '_is_requestable = true',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PaginatorAdapter<int|string, mixed>
|
||||||
|
*/
|
||||||
|
public function getOnDemandSearchPaginator(
|
||||||
|
Station $station,
|
||||||
|
callable $hydrateCallback,
|
||||||
|
?string $query,
|
||||||
|
array $searchParams = [],
|
||||||
|
array $options = [],
|
||||||
|
): PaginatorAdapter {
|
||||||
|
return $this->getSearchPaginator(
|
||||||
|
$hydrateCallback,
|
||||||
|
$query,
|
||||||
|
[
|
||||||
|
...$searchParams,
|
||||||
|
'filter' => [
|
||||||
|
[
|
||||||
|
'station_' . $station->getIdRequired() . '_is_on_demand = true',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PaginatorAdapter<int|string, mixed>
|
||||||
|
*/
|
||||||
|
public function getSearchPaginator(
|
||||||
|
callable $hydrateCallback,
|
||||||
|
?string $query,
|
||||||
|
array $searchParams = [],
|
||||||
|
array $options = [],
|
||||||
|
): PaginatorAdapter {
|
||||||
|
return new PaginatorAdapter(
|
||||||
|
$this->indexClient,
|
||||||
|
$hydrateCallback(...),
|
||||||
|
$query,
|
||||||
|
$searchParams,
|
||||||
|
$options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchMedia(
|
||||||
|
string $query,
|
||||||
|
?StationPlaylist $playlist = null
|
||||||
|
): array {
|
||||||
|
$searchParams = [
|
||||||
|
'hitsPerPage' => PHP_INT_MAX,
|
||||||
|
'page' => 1,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (null !== $playlist) {
|
||||||
|
$station = $playlist->getStation();
|
||||||
|
$searchParams['filter'] = [
|
||||||
|
[
|
||||||
|
'station_' . $station->getIdRequired() . '_playlists = ' . $playlist->getIdRequired(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var SearchResult $searchResult */
|
||||||
|
$searchResult = $this->indexClient->search(
|
||||||
|
$query,
|
||||||
|
$searchParams
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_column($searchResult->getHits(), 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int[] */
|
||||||
|
private function getStationIds(): array
|
||||||
|
{
|
||||||
|
return $this->em->createQuery(
|
||||||
|
<<<'DQL'
|
||||||
|
SELECT s.id FROM App\Entity\Station s
|
||||||
|
WHERE s.media_storage_location = :storageLocation
|
||||||
|
AND s.is_enabled = 1
|
||||||
|
DQL
|
||||||
|
)->setParameter('storageLocation', $this->storageLocation)
|
||||||
|
->getSingleColumnResult();
|
||||||
|
}
|
||||||
|
}
|
65
src/Service/Meilisearch/MessageHandler.php
Normal file
65
src/Service/Meilisearch/MessageHandler.php
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Meilisearch;
|
||||||
|
|
||||||
|
use App\Entity\Repository\StationRepository;
|
||||||
|
use App\Entity\Repository\StorageLocationRepository;
|
||||||
|
use App\Entity\Station;
|
||||||
|
use App\Entity\StorageLocation;
|
||||||
|
use App\Message\AbstractMessage;
|
||||||
|
use App\Message\Meilisearch\AddMediaMessage;
|
||||||
|
use App\Message\Meilisearch\UpdatePlaylistsMessage;
|
||||||
|
use App\Service\Meilisearch;
|
||||||
|
|
||||||
|
final class MessageHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Meilisearch $meilisearch,
|
||||||
|
private readonly StorageLocationRepository $storageLocationRepo,
|
||||||
|
private readonly StationRepository $stationRepo
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(AbstractMessage $message): void
|
||||||
|
{
|
||||||
|
if (!$this->meilisearch->isSupported()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match (true) {
|
||||||
|
$message instanceof AddMediaMessage => $this->addMedia($message),
|
||||||
|
$message instanceof UpdatePlaylistsMessage => $this->updatePlaylists($message),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addMedia(AddMediaMessage $message): void
|
||||||
|
{
|
||||||
|
$storageLocation = $this->storageLocationRepo->find($message->storage_location_id);
|
||||||
|
if (!($storageLocation instanceof StorageLocation)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$index = $this->meilisearch->getIndex($storageLocation);
|
||||||
|
|
||||||
|
$index->refreshMedia(
|
||||||
|
$message->media_ids,
|
||||||
|
$message->include_playlists
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updatePlaylists(UpdatePlaylistsMessage $message): void
|
||||||
|
{
|
||||||
|
$station = $this->stationRepo->find($message->station_id);
|
||||||
|
if (!($station instanceof Station)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$storageLocation = $station->getMediaStorageLocation();
|
||||||
|
|
||||||
|
$index = $this->meilisearch->getIndex($storageLocation);
|
||||||
|
$index->refreshPlaylists($station, $message->media_ids);
|
||||||
|
}
|
||||||
|
}
|
60
src/Service/Meilisearch/PaginatorAdapter.php
Normal file
60
src/Service/Meilisearch/PaginatorAdapter.php
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Meilisearch;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Meilisearch\Endpoints\Indexes;
|
||||||
|
use Meilisearch\Search\SearchResult;
|
||||||
|
use Pagerfanta\Adapter\AdapterInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter which uses Meilisearch to perform a search, then uses a callback to hydrate with database records.
|
||||||
|
*
|
||||||
|
* @template TKey of array-key
|
||||||
|
* @template T
|
||||||
|
* @implements AdapterInterface<T>
|
||||||
|
*/
|
||||||
|
final class PaginatorAdapter implements AdapterInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Indexes $indexClient,
|
||||||
|
private readonly Closure $hydrateCallback,
|
||||||
|
private readonly ?string $query,
|
||||||
|
private readonly array $searchParams = [],
|
||||||
|
private readonly array $options = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNbResults(): int
|
||||||
|
{
|
||||||
|
/** @var SearchResult $results */
|
||||||
|
$results = $this->indexClient->search(
|
||||||
|
$this->query,
|
||||||
|
[
|
||||||
|
...$this->searchParams,
|
||||||
|
'hitsPerPage' => 0,
|
||||||
|
],
|
||||||
|
$this->options
|
||||||
|
);
|
||||||
|
|
||||||
|
return abs($results->getTotalHits() ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSlice(int $offset, int $length): iterable
|
||||||
|
{
|
||||||
|
/** @var SearchResult $results */
|
||||||
|
$results = $this->indexClient->search(
|
||||||
|
$this->query,
|
||||||
|
[
|
||||||
|
...$this->searchParams,
|
||||||
|
'offset' => $offset,
|
||||||
|
'limit' => $length,
|
||||||
|
],
|
||||||
|
$this->options
|
||||||
|
);
|
||||||
|
|
||||||
|
return ($this->hydrateCallback)($results->getHits());
|
||||||
|
}
|
||||||
|
}
|
|
@ -84,6 +84,7 @@ final class ServiceControl
|
||||||
'redis' => __('Cache'),
|
'redis' => __('Cache'),
|
||||||
'sftpgo' => __('SFTP service'),
|
'sftpgo' => __('SFTP service'),
|
||||||
'centrifugo' => __('Live Now Playing updates'),
|
'centrifugo' => __('Live Now Playing updates'),
|
||||||
|
'meilisearch' => __('Meilisearch'),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!$this->centrifugo->isSupported()) {
|
if (!$this->centrifugo->isSupported()) {
|
||||||
|
|
|
@ -6,15 +6,18 @@ namespace App\Sync\Task;
|
||||||
|
|
||||||
use App\Doctrine\ReloadableEntityManagerInterface;
|
use App\Doctrine\ReloadableEntityManagerInterface;
|
||||||
use App\Entity;
|
use App\Entity;
|
||||||
use App\Flysystem\StationFilesystems;
|
|
||||||
use App\Flysystem\ExtendedFilesystemInterface;
|
use App\Flysystem\ExtendedFilesystemInterface;
|
||||||
|
use App\Flysystem\StationFilesystems;
|
||||||
|
use App\Message\Meilisearch\UpdatePlaylistsMessage;
|
||||||
use Doctrine\ORM\Query;
|
use Doctrine\ORM\Query;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Messenger\MessageBus;
|
||||||
|
|
||||||
final class CheckFolderPlaylistsTask extends AbstractTask
|
final class CheckFolderPlaylistsTask extends AbstractTask
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Entity\Repository\StationPlaylistMediaRepository $spmRepo,
|
private readonly Entity\Repository\StationPlaylistMediaRepository $spmRepo,
|
||||||
|
private readonly MessageBus $messageBus,
|
||||||
ReloadableEntityManagerInterface $em,
|
ReloadableEntityManagerInterface $em,
|
||||||
LoggerInterface $logger,
|
LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
|
@ -110,6 +113,8 @@ final class CheckFolderPlaylistsTask extends AbstractTask
|
||||||
->getArrayResult();
|
->getArrayResult();
|
||||||
|
|
||||||
$addedRecords = 0;
|
$addedRecords = 0;
|
||||||
|
$mediaToIndex = [];
|
||||||
|
|
||||||
foreach ($mediaInFolderRaw as $row) {
|
foreach ($mediaInFolderRaw as $row) {
|
||||||
$mediaId = $row['id'];
|
$mediaId = $row['id'];
|
||||||
|
|
||||||
|
@ -119,12 +124,21 @@ final class CheckFolderPlaylistsTask extends AbstractTask
|
||||||
if ($media instanceof Entity\StationMedia) {
|
if ($media instanceof Entity\StationMedia) {
|
||||||
$this->spmRepo->addMediaToPlaylist($media, $playlist);
|
$this->spmRepo->addMediaToPlaylist($media, $playlist);
|
||||||
|
|
||||||
|
$mediaToIndex[] = $mediaId;
|
||||||
$mediaInPlaylist[$mediaId] = $mediaId;
|
$mediaInPlaylist[$mediaId] = $mediaId;
|
||||||
$addedRecords++;
|
$addedRecords++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($mediaToIndex)) {
|
||||||
|
$indexMessage = new UpdatePlaylistsMessage();
|
||||||
|
$indexMessage->station_id = $station->getIdRequired();
|
||||||
|
$indexMessage->media_ids = $mediaToIndex;
|
||||||
|
|
||||||
|
$this->messageBus->dispatch($indexMessage);
|
||||||
|
}
|
||||||
|
|
||||||
$logMessage = (0 === $addedRecords)
|
$logMessage = (0 === $addedRecords)
|
||||||
? 'No changes detected in folder.'
|
? 'No changes detected in folder.'
|
||||||
: sprintf('%d media records added from folder.', $addedRecords);
|
: sprintf('%d media records added from folder.', $addedRecords);
|
||||||
|
|
149
src/Sync/Task/UpdateMeilisearchIndex.php
Normal file
149
src/Sync/Task/UpdateMeilisearchIndex.php
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Sync\Task;
|
||||||
|
|
||||||
|
use App\Doctrine\ReloadableEntityManagerInterface;
|
||||||
|
use App\Entity;
|
||||||
|
use App\Message\Meilisearch\AddMediaMessage;
|
||||||
|
use App\MessageQueue\QueueManagerInterface;
|
||||||
|
use App\Service\Meilisearch;
|
||||||
|
use Doctrine\ORM\AbstractQuery;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Messenger\MessageBus;
|
||||||
|
|
||||||
|
final class UpdateMeilisearchIndex extends AbstractTask
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MessageBus $messageBus,
|
||||||
|
private readonly QueueManagerInterface $queueManager,
|
||||||
|
private readonly Meilisearch $meilisearch,
|
||||||
|
ReloadableEntityManagerInterface $em,
|
||||||
|
LoggerInterface $logger
|
||||||
|
) {
|
||||||
|
parent::__construct($em, $logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSchedulePattern(): string
|
||||||
|
{
|
||||||
|
return '3-59/5 * * * *';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isLongTask(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(bool $force = false): void
|
||||||
|
{
|
||||||
|
if (!$this->meilisearch->isSupported()) {
|
||||||
|
$this->logger->debug('Meilisearch is not supported on this instance. Skipping sync task.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$storageLocations = $this->iterateStorageLocations(Entity\Enums\StorageLocationTypes::StationMedia);
|
||||||
|
|
||||||
|
foreach ($storageLocations as $storageLocation) {
|
||||||
|
$this->logger->info(
|
||||||
|
sprintf(
|
||||||
|
'Updating MeiliSearch index for storage location %s...',
|
||||||
|
$storageLocation
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->updateIndex($storageLocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateIndex(Entity\StorageLocation $storageLocation): void
|
||||||
|
{
|
||||||
|
$stats = [
|
||||||
|
'existing' => 0,
|
||||||
|
'queued' => 0,
|
||||||
|
'added' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'deleted' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$index = $this->meilisearch->getIndex($storageLocation);
|
||||||
|
$index->configure();
|
||||||
|
|
||||||
|
$existingIds = $index->getIdsInIndex();
|
||||||
|
$stats['existing'] = count($existingIds);
|
||||||
|
|
||||||
|
$queuedMedia = [];
|
||||||
|
|
||||||
|
foreach (
|
||||||
|
$this->queueManager->getMessagesInTransport(
|
||||||
|
QueueManagerInterface::QUEUE_NORMAL_PRIORITY
|
||||||
|
) as $message
|
||||||
|
) {
|
||||||
|
if ($message instanceof AddMediaMessage) {
|
||||||
|
foreach ($message->media_ids as $mediaId) {
|
||||||
|
$queuedMedia[$mediaId] = $mediaId;
|
||||||
|
$stats['queued']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$mediaRaw = $this->em->createQuery(
|
||||||
|
<<<'DQL'
|
||||||
|
SELECT sm.id, sm.mtime
|
||||||
|
FROM App\Entity\StationMedia sm
|
||||||
|
WHERE sm.storage_location = :storageLocation
|
||||||
|
DQL
|
||||||
|
)->setParameter('storageLocation', $storageLocation)
|
||||||
|
->toIterable([], AbstractQuery::HYDRATE_ARRAY);
|
||||||
|
|
||||||
|
$newIds = [];
|
||||||
|
$idsToUpdate = [];
|
||||||
|
|
||||||
|
foreach ($mediaRaw as $row) {
|
||||||
|
$mediaId = $row['id'];
|
||||||
|
|
||||||
|
if (isset($queuedMedia[$mediaId])) {
|
||||||
|
unset($existingIds[$mediaId]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($existingIds[$mediaId])) {
|
||||||
|
if ($existingIds[$mediaId] < $row['mtime']) {
|
||||||
|
$idsToUpdate[] = $mediaId;
|
||||||
|
$stats['updated']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($existingIds[$mediaId]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newIds[] = $mediaId;
|
||||||
|
$stats['added']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_chunk($idsToUpdate, Meilisearch::BATCH_SIZE) as $batchIds) {
|
||||||
|
$message = new AddMediaMessage();
|
||||||
|
$message->storage_location_id = $storageLocation->getIdRequired();
|
||||||
|
$message->media_ids = $batchIds;
|
||||||
|
$message->include_playlists = true;
|
||||||
|
|
||||||
|
$this->messageBus->dispatch($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_chunk($newIds, Meilisearch::BATCH_SIZE) as $batchIds) {
|
||||||
|
$message = new AddMediaMessage();
|
||||||
|
$message->storage_location_id = $storageLocation->getIdRequired();
|
||||||
|
$message->media_ids = $batchIds;
|
||||||
|
$message->include_playlists = true;
|
||||||
|
|
||||||
|
$this->messageBus->dispatch($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($existingIds)) {
|
||||||
|
$stats['deleted'] = count($existingIds);
|
||||||
|
$index->deleteIds($existingIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->debug(sprintf('Meilisearch processed for "%s".', $storageLocation), $stats);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,47 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Functional;
|
|
||||||
|
|
||||||
use App\Entity;
|
|
||||||
|
|
||||||
class Api_RequestsCest extends CestAbstract
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @before setupComplete
|
|
||||||
*/
|
|
||||||
public function checkRequestsAPI(\FunctionalTester $I): void
|
|
||||||
{
|
|
||||||
$I->wantTo('Check request API endpoints.');
|
|
||||||
|
|
||||||
// Enable requests on station.
|
|
||||||
$testStation = $this->getTestStation();
|
|
||||||
$station_id = $testStation->getId();
|
|
||||||
|
|
||||||
$testStation->setEnableRequests(true);
|
|
||||||
$this->em->persist($testStation);
|
|
||||||
$this->em->flush();
|
|
||||||
|
|
||||||
// Upload a test song.
|
|
||||||
$media = $this->uploadTestSong();
|
|
||||||
|
|
||||||
$playlist = new Entity\StationPlaylist($testStation);
|
|
||||||
$playlist->setName('Test Playlist');
|
|
||||||
$this->em->persist($playlist);
|
|
||||||
|
|
||||||
$spm = new Entity\StationPlaylistMedia($playlist, $media);
|
|
||||||
$this->em->persist($spm);
|
|
||||||
|
|
||||||
$this->em->flush();
|
|
||||||
$this->em->clear();
|
|
||||||
|
|
||||||
$I->sendGET('/api/station/' . $station_id . '/requests');
|
|
||||||
|
|
||||||
$I->seeResponseIsJson();
|
|
||||||
$I->seeResponseCodeIs(200);
|
|
||||||
|
|
||||||
$I->sendGET('/api/station/' . $station_id . '/request/' . $media->getUniqueId());
|
|
||||||
|
|
||||||
$I->seeResponseIsJson();
|
|
||||||
$I->seeResponseCodeIs(200);
|
|
||||||
}
|
|
||||||
}
|
|
3
util/docker/web/meilisearch/config.toml
Normal file
3
util/docker/web/meilisearch/config.toml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
db_path = "/var/azuracast/meilisearch/persist"
|
||||||
|
http_addr = "localhost:6070"
|
||||||
|
env = "production"
|
17
util/docker/web/service.full/meilisearch.conf
Normal file
17
util/docker/web/service.full/meilisearch.conf
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
[program:meilisearch]
|
||||||
|
command=meilisearch --config-file-path=/var/azuracast/meilisearch/config.toml
|
||||||
|
priority=500
|
||||||
|
numprocs=1
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
|
||||||
|
stdout_logfile=/var/azuracast/www_tmp/service_meilisearch.log
|
||||||
|
stdout_logfile_maxbytes=5MB
|
||||||
|
stdout_logfile_backups=5
|
||||||
|
redirect_stderr=true
|
||||||
|
|
||||||
|
stdout_events_enabled = true
|
||||||
|
stderr_events_enabled = true
|
|
@ -8,6 +8,8 @@ add-apt-repository -y ppa:chris-needham/ppa
|
||||||
add-apt-repository -y ppa:sftpgo/sftpgo
|
add-apt-repository -y ppa:sftpgo/sftpgo
|
||||||
add-apt-repository -y ppa:ondrej/php
|
add-apt-repository -y ppa:ondrej/php
|
||||||
|
|
||||||
|
echo "deb [trusted=yes] https://apt.fury.io/meilisearch/ /" | sudo tee /etc/apt/sources.list.d/fury.list
|
||||||
|
|
||||||
apt-get update
|
apt-get update
|
||||||
|
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
@ -16,4 +18,5 @@ apt-get install -y --no-install-recommends \
|
||||||
sftpgo \
|
sftpgo \
|
||||||
tmpreaper \
|
tmpreaper \
|
||||||
zstd \
|
zstd \
|
||||||
netbase
|
netbase \
|
||||||
|
meilisearch-http
|
||||||
|
|
7
util/docker/web/setup/meilisearch.sh
Normal file
7
util/docker/web/setup/meilisearch.sh
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
mkdir -p /var/azuracast/meilisearch/persist
|
||||||
|
|
||||||
|
cp /bd_build/web/meilisearch/config.toml /var/azuracast/meilisearch/config.toml
|
Loading…
Reference in New Issue
Block a user