diff --git a/config/routes/api_station.php b/config/routes/api_station.php
index c91455690..5a061a6be 100644
--- a/config/routes/api_station.php
+++ b/config/routes/api_station.php
@@ -78,14 +78,14 @@ return static function (RouteCollectorProxy $group) {
/*
* Song Requests
*/
- $group->get('/requests', Controller\Api\Stations\RequestsController::class . ':listAction')
+ $group->get('/requests', Controller\Api\Stations\Requests\ListAction::class)
->add(new Middleware\StationSupportsFeature(StationFeatures::Requests))
->setName('api:requests:list');
$group->map(
['GET', 'POST'],
'/request/{media_id}',
- Controller\Api\Stations\RequestsController::class . ':submitAction'
+ Controller\Api\Stations\Requests\SubmitAction::class
)
->setName('api:requests:submit')
->add(new Middleware\StationSupportsFeature(StationFeatures::Requests))
diff --git a/frontend/vue/components/Public/OnDemand.vue b/frontend/vue/components/Public/OnDemand.vue
index 55d3e6091..de40da69d 100644
--- a/frontend/vue/components/Public/OnDemand.vue
+++ b/frontend/vue/components/Public/OnDemand.vue
@@ -59,18 +59,7 @@
-
-
-
+
@@ -94,6 +83,7 @@ import Icon from '~/components/Common/Icon';
import PlayButton from "~/components/Common/PlayButton";
import {useTranslate} from "~/vendor/gettext";
import formatFileSize from "../../functions/formatFileSize";
+import AlbumArt from "~/components/Common/AlbumArt.vue";
const props = defineProps({
listUrl: {
@@ -165,7 +155,7 @@ forEach(props.customFields.slice(), (field) => {
}
}
-#station_on_demand_table {
+#public_on_demand {
.datatable-main {
overflow-y: auto;
}
@@ -190,11 +180,5 @@ forEach(props.customFields.slice(), (field) => {
padding-left: 0.5rem;
}
}
-
- img.media_manager_album_art {
- width: 40px;
- height: auto;
- border-radius: 5px;
- }
}
diff --git a/frontend/vue/pages/Public/OnDemand.js b/frontend/vue/pages/Public/OnDemand.js
index 285fb25dd..93a41ae0b 100644
--- a/frontend/vue/pages/Public/OnDemand.js
+++ b/frontend/vue/pages/Public/OnDemand.js
@@ -1,5 +1,7 @@
import initBase from '~/base.js';
+import '~/vendor/fancybox';
+
import OnDemand from '~/components/Public/OnDemand.vue';
export default initBase(OnDemand);
diff --git a/frontend/vue/pages/Public/Requests.js b/frontend/vue/pages/Public/Requests.js
index e262f3035..c5903b1fd 100644
--- a/frontend/vue/pages/Public/Requests.js
+++ b/frontend/vue/pages/Public/Requests.js
@@ -1,5 +1,7 @@
import initBase from '~/base.js';
+import '~/vendor/fancybox';
+
import Requests from '~/components/Public/Requests.vue';
export default initBase(Requests);
diff --git a/src/Controller/Api/Stations/OnDemand/ListAction.php b/src/Controller/Api/Stations/OnDemand/ListAction.php
index 215d46fc1..bf0b02d2a 100644
--- a/src/Controller/Api/Stations/OnDemand/ListAction.php
+++ b/src/Controller/Api/Stations/OnDemand/ListAction.php
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Controller\Api\Stations\OnDemand;
+use App\Doctrine\Paginator\HydratingAdapter;
use App\Entity;
+use App\Entity\StationMedia;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Paginator;
@@ -34,45 +36,88 @@ final class ListAction
->withJson(new Entity\Api\Error(403, __('This station does not support on-demand streaming.')));
}
- if (!$this->meilisearch->isSupported()) {
- return $response->withStatus(403)
- ->withJson(new Entity\Api\Error(403, __('This feature is not supported on this installation.')));
- }
-
- $index = $this->meilisearch->getIndex($station->getMediaStorageLocation());
-
$queryParams = $request->getQueryParams();
$searchPhrase = trim($queryParams['searchPhrase'] ?? '');
- $searchParams = [];
- if (!empty($queryParams['sort'])) {
- $sortField = (string)$queryParams['sort'];
- $sortDirection = strtolower($queryParams['sortOrder'] ?? 'asc');
- $searchParams['sort'] = [$sortField . ':' . $sortDirection];
- }
+ $sortField = (string)($queryParams['sort'] ?? '');
+ $sortDirection = strtolower($queryParams['sortOrder'] ?? 'asc');
- $hydrateCallback = function (array $results) {
- $ids = array_column($results, 'id');
+ if ($this->meilisearch->isSupported()) {
+ $index = $this->meilisearch->getIndex($station->getMediaStorageLocation());
- return $this->em->createQuery(
- <<<'DQL'
+ $searchParams = [];
+ if (!empty($sortField)) {
+ $searchParams['sort'] = [$sortField . ':' . $sortDirection];
+ }
+
+ $paginatorAdapter = $index->getOnDemandSearchPaginator(
+ $station,
+ $searchPhrase,
+ $searchParams,
+ );
+
+ $hydrateCallback = function (iterable $results) {
+ $ids = array_column([...$results], 'id');
+
+ 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();
- };
+ )->setParameter('ids', $ids)
+ ->toIterable();
+ };
- $paginatorAdapter = $index->getOnDemandSearchPaginator(
- $station,
- $hydrateCallback,
- $searchPhrase,
- $searchParams,
- );
+ $hydrateAdapter = new HydratingAdapter(
+ $paginatorAdapter,
+ $hydrateCallback(...)
+ );
- $paginator = Paginator::fromAdapter($paginatorAdapter, $request);
+ $paginator = Paginator::fromAdapter($hydrateAdapter, $request);
+ } else {
+ $playlistsRaw = $this->em->createQuery(
+ <<<'DQL'
+ SELECT sp.id FROM App\Entity\StationPlaylist sp
+ WHERE sp.station = :station
+ AND sp.is_enabled = 1 AND sp.include_in_on_demand = 1
+ DQL
+ )->setParameter('station', $station)
+ ->getArrayResult();
+
+ $playlistIds = array_column($playlistsRaw, 'id');
+
+ $qb = $this->em->createQueryBuilder();
+ $qb->select('sm, spm, sp')
+ ->from(StationMedia::class, 'sm')
+ ->leftJoin('sm.playlists', 'spm')
+ ->leftJoin('spm.playlist', 'sp')
+ ->where('sm.storage_location = :storageLocation')
+ ->andWhere('sp.id IN (:playlistIds)')
+ ->setParameter('storageLocation', $station->getMediaStorageLocation())
+ ->setParameter('playlistIds', $playlistIds);
+
+ if (!empty($sortField)) {
+ match ($sortField) {
+ 'name', 'title' => $qb->addOrderBy('sm.title', $sortDirection),
+ 'artist' => $qb->addOrderBy('sm.artist', $sortDirection),
+ 'album' => $qb->addOrderBy('sm.album', $sortDirection),
+ 'genre' => $qb->addOrderBy('sm.genre', $sortDirection),
+ default => null,
+ };
+ } else {
+ $qb->orderBy('sm.artist', 'ASC')
+ ->addOrderBy('sm.title', 'ASC');
+ }
+
+ if (!empty($searchPhrase)) {
+ $qb->andWhere('(sm.title LIKE :query OR sm.artist LIKE :query OR sm.album LIKE :query)')
+ ->setParameter('query', '%' . $searchPhrase . '%');
+ }
+
+ $paginator = Paginator::fromQueryBuilder($qb, $request);
+ }
$router = $request->getRouter();
diff --git a/src/Controller/Api/Stations/Requests/ListAction.php b/src/Controller/Api/Stations/Requests/ListAction.php
new file mode 100644
index 000000000..0cd76c8c7
--- /dev/null
+++ b/src/Controller/Api/Stations/Requests/ListAction.php
@@ -0,0 +1,172 @@
+getStation();
+
+ // Verify that the station supports on-demand streaming.
+ if (!$station->getEnableRequests()) {
+ return $response->withStatus(403)
+ ->withJson(new Error(403, __('This station does not support requests.')));
+ }
+
+ $queryParams = $request->getQueryParams();
+ $searchPhrase = trim($queryParams['searchPhrase'] ?? '');
+ $sortField = (string)($queryParams['sort'] ?? '');
+ $sortDirection = strtolower($queryParams['sortOrder'] ?? 'asc');
+
+ if ($this->meilisearch->isSupported()) {
+ $index = $this->meilisearch->getIndex($station->getMediaStorageLocation());
+
+ $searchParams = [];
+ if (!empty($sortField)) {
+ $searchParams['sort'] = [$sortField . ':' . $sortDirection];
+ }
+
+ $paginatorAdapter = $index->getRequestableSearchPaginator(
+ $station,
+ $searchPhrase,
+ $searchParams,
+ );
+
+ $hydrateCallback = function (iterable $results) {
+ $ids = array_column([...$results], 'id');
+
+ 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();
+ };
+
+ $hydratingAdapter = new HydratingAdapter(
+ $paginatorAdapter,
+ $hydrateCallback(...)
+ );
+
+ $paginator = Paginator::fromAdapter($hydratingAdapter, $request);
+ } else {
+ $playlistsRaw = $this->em->createQuery(
+ <<<'DQL'
+ SELECT sp.id FROM App\Entity\StationPlaylist sp
+ WHERE sp.station = :station
+ AND sp.is_enabled = 1 AND sp.include_in_requests = 1
+ DQL
+ )->setParameter('station', $station)
+ ->getArrayResult();
+
+ $playlistIds = array_column($playlistsRaw, 'id');
+
+ $qb = $this->em->createQueryBuilder();
+ $qb->select('sm, spm, sp')
+ ->from(StationMedia::class, 'sm')
+ ->leftJoin('sm.playlists', 'spm')
+ ->leftJoin('spm.playlist', 'sp')
+ ->where('sm.storage_location = :storageLocation')
+ ->andWhere('sp.id IN (:playlistIds)')
+ ->setParameter('storageLocation', $station->getMediaStorageLocation())
+ ->setParameter('playlistIds', $playlistIds);
+
+ if (!empty($sortField)) {
+ match ($sortField) {
+ 'name', 'title' => $qb->addOrderBy('sm.title', $sortDirection),
+ 'artist' => $qb->addOrderBy('sm.artist', $sortDirection),
+ 'album' => $qb->addOrderBy('sm.album', $sortDirection),
+ 'genre' => $qb->addOrderBy('sm.genre', $sortDirection),
+ default => null,
+ };
+ } else {
+ $qb->orderBy('sm.artist', 'ASC')
+ ->addOrderBy('sm.title', 'ASC');
+ }
+
+ if (!empty($searchPhrase)) {
+ $qb->andWhere('(sm.title LIKE :query OR sm.artist LIKE :query OR sm.album LIKE :query)')
+ ->setParameter('query', '%' . $searchPhrase . '%');
+ }
+
+ $paginator = Paginator::fromQueryBuilder($qb, $request);
+ }
+
+ $router = $request->getRouter();
+
+ $paginator->setPostprocessor(
+ function (StationMedia $media) use ($station, $router) {
+ $row = new StationRequest();
+ $row->song = ($this->songApiGenerator)($media, $station, $router->getBaseUrl());
+ $row->request_id = $media->getUniqueId();
+ $row->request_url = $router->named(
+ 'api:requests:submit',
+ [
+ 'station_id' => $station->getId(),
+ 'media_id' => $media->getUniqueId(),
+ ]
+ );
+
+ $row->resolveUrls($router->getBaseUrl());
+
+ return $row;
+ }
+ );
+
+ return $paginator->write($response);
+ }
+}
diff --git a/src/Controller/Api/Stations/Requests/SubmitAction.php b/src/Controller/Api/Stations/Requests/SubmitAction.php
new file mode 100644
index 000000000..becef2072
--- /dev/null
+++ b/src/Controller/Api/Stations/Requests/SubmitAction.php
@@ -0,0 +1,81 @@
+getStation();
+
+ try {
+ $user = $request->getUser();
+ } catch (InvalidRequestAttribute) {
+ $user = null;
+ }
+
+ $isAuthenticated = ($user instanceof User);
+
+ try {
+ $this->requestRepo->submit(
+ $station,
+ $media_id,
+ $isAuthenticated,
+ $request->getIp(),
+ $request->getHeaderLine('User-Agent')
+ );
+
+ return $response->withJson(Status::success());
+ } catch (Exception $e) {
+ return $response->withStatus(400)
+ ->withJson(Error::fromException($e));
+ }
+ }
+}
diff --git a/src/Controller/Api/Stations/RequestsController.php b/src/Controller/Api/Stations/RequestsController.php
deleted file mode 100644
index 8df94fcd4..000000000
--- a/src/Controller/Api/Stations/RequestsController.php
+++ /dev/null
@@ -1,182 +0,0 @@
-getStation();
-
- // 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.')));
- }
-
- if (!$this->meilisearch->isSupported()) {
- return $response->withStatus(403)
- ->withJson(new Entity\Api\Error(403, __('This feature is not supported on this installation.')));
- }
-
- $index = $this->meilisearch->getIndex($station->getMediaStorageLocation());
-
- $queryParams = $request->getQueryParams();
- $searchPhrase = trim($queryParams['searchPhrase'] ?? '');
-
- $searchParams = [];
- if (!empty($queryParams['sort'])) {
- $sortField = (string)$queryParams['sort'];
- $sortDirection = strtolower($queryParams['sortOrder'] ?? 'asc');
- $searchParams['sort'] = [$sortField . ':' . $sortDirection];
- }
-
- $hydrateCallback = function (array $results) {
- $ids = array_column($results, 'id');
-
- 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();
-
- $paginator->setPostprocessor(
- function (Entity\StationMedia $media) use ($station, $router) {
- $row = new Entity\Api\StationRequest();
- $row->song = ($this->songApiGenerator)($media, $station, $router->getBaseUrl());
- $row->request_id = $media->getUniqueId();
- $row->request_url = $router->named(
- 'api:requests:submit',
- [
- 'station_id' => $station->getId(),
- 'media_id' => $media->getUniqueId(),
- ]
- );
-
- $row->resolveUrls($router->getBaseUrl());
-
- return $row;
- }
- );
-
- return $paginator->write($response);
- }
-
- public function submitAction(
- ServerRequest $request,
- Response $response,
- string $station_id,
- string $media_id
- ): ResponseInterface {
- $station = $request->getStation();
-
- try {
- $user = $request->getUser();
- } catch (Exception\InvalidRequestAttribute) {
- $user = null;
- }
-
- $isAuthenticated = ($user instanceof Entity\User);
-
- try {
- $this->requestRepo->submit(
- $station,
- $media_id,
- $isAuthenticated,
- $request->getIp(),
- $request->getHeaderLine('User-Agent')
- );
-
- return $response->withJson(Entity\Api\Status::success());
- } catch (Exception $e) {
- return $response->withStatus(400)
- ->withJson(new Entity\Api\Error(400, $e->getMessage()));
- }
- }
-}
diff --git a/src/Doctrine/Paginator/HydratingAdapter.php b/src/Doctrine/Paginator/HydratingAdapter.php
new file mode 100644
index 000000000..2929a9c87
--- /dev/null
+++ b/src/Doctrine/Paginator/HydratingAdapter.php
@@ -0,0 +1,37 @@
+
+ */
+final class HydratingAdapter implements AdapterInterface
+{
+ /**
+ * @param AdapterInterface $wrapped
+ */
+ public function __construct(
+ private readonly AdapterInterface $wrapped,
+ private readonly Closure $hydrateCallback,
+ ) {
+ }
+
+ public function getNbResults(): int
+ {
+ return $this->wrapped->getNbResults();
+ }
+
+ public function getSlice(int $offset, int $length): iterable
+ {
+ $results = $this->wrapped->getSlice($offset, $length);
+ yield from ($this->hydrateCallback)($results);
+ }
+}
diff --git a/src/Service/Meilisearch/Index.php b/src/Service/Meilisearch/Index.php
index e7781fc50..c83985492 100644
--- a/src/Service/Meilisearch/Index.php
+++ b/src/Service/Meilisearch/Index.php
@@ -381,17 +381,15 @@ final class Index
}
/**
- * @return PaginatorAdapter
+ * @return PaginatorAdapter
*/
public function getRequestableSearchPaginator(
Station $station,
- callable $hydrateCallback,
?string $query,
array $searchParams = [],
array $options = [],
): PaginatorAdapter {
return $this->getSearchPaginator(
- $hydrateCallback,
$query,
[
...$searchParams,
@@ -406,17 +404,15 @@ final class Index
}
/**
- * @return PaginatorAdapter
+ * @return PaginatorAdapter
*/
public function getOnDemandSearchPaginator(
Station $station,
- callable $hydrateCallback,
?string $query,
array $searchParams = [],
array $options = [],
): PaginatorAdapter {
return $this->getSearchPaginator(
- $hydrateCallback,
$query,
[
...$searchParams,
@@ -431,17 +427,15 @@ final class Index
}
/**
- * @return PaginatorAdapter
+ * @return PaginatorAdapter
*/
public function getSearchPaginator(
- callable $hydrateCallback,
?string $query,
array $searchParams = [],
array $options = [],
): PaginatorAdapter {
return new PaginatorAdapter(
$this->indexClient,
- $hydrateCallback(...),
$query,
$searchParams,
$options,
diff --git a/src/Service/Meilisearch/PaginatorAdapter.php b/src/Service/Meilisearch/PaginatorAdapter.php
index 133145c70..a0dfcd8ba 100644
--- a/src/Service/Meilisearch/PaginatorAdapter.php
+++ b/src/Service/Meilisearch/PaginatorAdapter.php
@@ -4,23 +4,20 @@ 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.
+ * Adapter which uses Meilisearch to perform a search.
*
- * @template TKey of array-key
- * @template T
+ * @template T of array
* @implements AdapterInterface
*/
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 = [],
@@ -55,6 +52,6 @@ final class PaginatorAdapter implements AdapterInterface
$this->options
);
- return ($this->hydrateCallback)($results->getHits());
+ yield from $results->getHits();
}
}