Simplify routing file and add Playlist API endpoints.

This commit is contained in:
Buster Neece 2019-04-12 22:27:58 -05:00
parent 9f263a297e
commit 8dc072d1c8
No known key found for this signature in database
GPG Key ID: 6D9E12FF03411F4E
11 changed files with 620 additions and 245 deletions

View File

@ -200,42 +200,6 @@ return function(App $app)
$this->group('/admin', function() {
/** @var App $this */
$this->group('', function() {
/** @var App $this */
$this->get('/custom_fields', Controller\Api\Admin\CustomFieldsController::class.':listAction')
->setName('api:admin:custom_fields');
$this->post('/custom_fields', Controller\Api\Admin\CustomFieldsController::class.':createAction');
$this->get('/custom_field/{id}', Controller\Api\Admin\CustomFieldsController::class.':getAction')
->setName('api:admin:custom_field');
$this->put('/custom_field/{id}', Controller\Api\Admin\CustomFieldsController::class.':editAction');
$this->delete('/custom_field/{id}', Controller\Api\Admin\CustomFieldsController::class.':deleteAction');
})->add([Middleware\Permissions::class, Acl::GLOBAL_CUSTOM_FIELDS]);
$this->group('', function() {
/** @var App $this */
$this->get('/users', Controller\Api\Admin\UsersController::class.':listAction')
->setName('api:admin:users');
$this->post('/users', Controller\Api\Admin\UsersController::class.':createAction');
$this->get('/user/{id}', Controller\Api\Admin\UsersController::class.':getAction')
->setName('api:admin:user');
$this->put('/user/{id}', Controller\Api\Admin\UsersController::class.':editAction');
$this->delete('/user/{id}', Controller\Api\Admin\UsersController::class.':deleteAction');
})->add([Middleware\Permissions::class, Acl::GLOBAL_USERS]);
$this->group('', function() {
/** @var App $this */
$this->get('/roles', Controller\Api\Admin\RolesController::class.':listAction')
->setName('api:admin:roles');
$this->post('/roles', Controller\Api\Admin\RolesController::class.':createAction');
$this->get('/role/{id}', Controller\Api\Admin\RolesController::class.':getAction')
->setName('api:admin:role');
$this->put('/role/{id}', Controller\Api\Admin\RolesController::class.':editAction');
$this->delete('/role/{id}', Controller\Api\Admin\RolesController::class.':deleteAction');
})->add([Middleware\Permissions::class, Acl::GLOBAL_PERMISSIONS]);
$this->get('/permissions', Controller\Api\Admin\PermissionsController::class)
->add([Middleware\Permissions::class, Acl::GLOBAL_PERMISSIONS]);
@ -247,18 +211,26 @@ return function(App $app)
$this->put('/settings', Controller\Api\Admin\SettingsController::class.':updateAction');
})->add([Middleware\Permissions::class, Acl::GLOBAL_SETTINGS]);
$this->group('', function() {
/** @var App $this */
$this->get('/stations', Controller\Api\Admin\StationsController::class.':listAction')
->setName('api:admin:stations');
$this->post('/stations', Controller\Api\Admin\StationsController::class.':createAction');
$admin_api_endpoints = [
['custom_field', 'custom_fields', Controller\Api\Admin\CustomFieldsController::class, Acl::GLOBAL_CUSTOM_FIELDS],
['role', 'roles', Controller\Api\Admin\RolesController::class, Acl::GLOBAL_PERMISSIONS],
['station', 'stations', Controller\Api\Admin\StationsController::class, Acl::GLOBAL_STATIONS],
['user', 'users', Controller\Api\Admin\UsersController::class, Acl::GLOBAL_USERS],
];
$this->get('/station/{id}', Controller\Api\Admin\StationsController::class.':getAction')
->setName('api:admin:station');
$this->put('/station/{id}', Controller\Api\Admin\StationsController::class.':editAction');
$this->delete('/station/{id}', Controller\Api\Admin\StationsController::class.':deleteAction');
})->add([Middleware\Permissions::class, Acl::GLOBAL_STATIONS]);
foreach($admin_api_endpoints as [$singular, $plural, $class, $permission]) {
$this->group('', function() use ($singular, $plural, $class) {
/** @var App $this */
$this->get('/'.$plural, $class.':listAction')
->setName('api:admin:'.$plural);
$this->post('/'.$plural, $class.':createAction');
$this->get('/'.$singular.'/{id}', $class.':getAction')
->setName('api:admin:'.$singular);
$this->put('/'.$singular.'/{id}', $class.':editAction');
$this->delete('/'.$singular.'/{id}', $class.':deleteAction');
})->add([Middleware\Permissions::class, $permission]);
}
});
$this->group('/station/{station}', function () {
@ -301,41 +273,26 @@ return function(App $app)
$this->get('/art/{media_id:[a-zA-Z0-9]+}', Controller\Api\Stations\MediaController::class.':artAction');
$this->group('', function() {
/** @var App $this */
$this->get('/mounts', Controller\Api\Stations\MountsController::class.':listAction')
->setName('api:stations:mounts');
$this->post('/mounts', Controller\Api\Stations\MountsController::class.':createAction');
$station_api_endpoints = [
['mount', 'mounts', Controller\Api\Stations\MountsController::class, Acl::STATION_MOUNTS],
['playlist', 'playlists', Controller\Api\Stations\PlaylistsController::class, Acl::STATION_MEDIA],
['remote', 'remotes', Controller\Api\Stations\RemotesController::class, Acl::STATION_REMOTES],
['streamer', 'streamers', Controller\Api\Stations\StreamersController::class, Acl::STATION_STREAMERS],
];
$this->get('/mount/{id}', Controller\Api\Stations\MountsController::class.':getAction')
->setName('api:stations:mount');
$this->put('/mount/{id}', Controller\Api\Stations\MountsController::class.':editAction');
$this->delete('/mount/{id}', Controller\Api\Stations\MountsController::class.':deleteAction');
})->add([Middleware\Permissions::class, Acl::STATION_MOUNTS, true]);
foreach($station_api_endpoints as [$singular, $plural, $class, $permission]) {
$this->group('', function() use ($singular, $plural, $class) {
/** @var App $this */
$this->get('/'.$plural, $class.':listAction')
->setName('api:stations:'.$plural);
$this->post('/'.$plural, $class.':createAction');
$this->group('', function() {
/** @var App $this */
$this->get('/remotes', Controller\Api\Stations\RemotesController::class.':listAction')
->setName('api:stations:remotes');
$this->post('/remotes', Controller\Api\Stations\RemotesController::class.':createAction');
$this->get('/remote/{id}', Controller\Api\Stations\RemotesController::class.':getAction')
->setName('api:stations:remote');
$this->put('/remote/{id}', Controller\Api\Stations\RemotesController::class.':editAction');
$this->delete('/remote/{id}', Controller\Api\Stations\RemotesController::class.':deleteAction');
})->add([Middleware\Permissions::class, Acl::STATION_REMOTES, true]);
$this->group('', function() {
/** @var App $this */
$this->get('/streamers', Controller\Api\Stations\StreamersController::class.':listAction')
->setName('api:stations:streamers');
$this->post('/streamers', Controller\Api\Stations\StreamersController::class.':createAction');
$this->get('/streamer/{id}', Controller\Api\Stations\StreamersController::class.':getAction')
->setName('api:stations:streamer');
$this->put('/streamer/{id}', Controller\Api\Stations\StreamersController::class.':editAction');
$this->delete('/streamer/{id}', Controller\Api\Stations\StreamersController::class.':deleteAction');
})->add([Middleware\Permissions::class, Acl::STATION_STREAMERS, true]);
$this->get('/'.$singular.'/{id}', $class.':getAction')
->setName('api:stations:'.$singular);
$this->put('/'.$singular.'/{id}', $class.':editAction');
$this->delete('/'.$singular.'/{id}', $class.':deleteAction');
})->add([Middleware\Permissions::class, $permission, true]);
}
$this->get('/status', Controller\Api\Stations\ServicesController::class.':statusAction')
->setName('api:stations:status')

View File

@ -0,0 +1,99 @@
<?php
namespace App\Controller\Api\Stations;
use App\Entity;
use App\Http\Request;
use OpenApi\Annotations as OA;
/**
* @see \App\Provider\ApiProvider
*/
class PlaylistsController extends AbstractStationCrudController
{
protected $entityClass = Entity\StationPlaylist::class;
protected $resourceRouteName = 'api:stations:playlist';
/**
* @OA\Get(path="/station/{station_id}/playlists",
* tags={"Stations: Playlists"},
* description="List all current playlists.",
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(type="array", @OA\Items(ref="#/components/schemas/StationPlaylist"))
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*
* @OA\Post(path="/station/{station_id}/playlists",
* tags={"Stations: Playlists"},
* description="Create a new playlist.",
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\RequestBody(
* @OA\JsonContent(ref="#/components/schemas/StationPlaylist")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(ref="#/components/schemas/StationPlaylist")
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*
* @OA\Get(path="/station/{station_id}/playlist/{id}",
* tags={"Stations: Playlists"},
* description="Retrieve details for a single playlist.",
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Parameter(
* name="id",
* in="path",
* description="Playlist ID",
* required=true,
* @OA\Schema(type="integer", format="int64")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(ref="#/components/schemas/StationPlaylist")
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*
* @OA\Put(path="/station/{station_id}/playlist/{id}",
* tags={"Stations: Playlists"},
* description="Update details of a single playlist.",
* @OA\RequestBody(
* @OA\JsonContent(ref="#/components/schemas/StationPlaylist")
* ),
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Parameter(
* name="id",
* in="path",
* description="Playlist ID",
* required=true,
* @OA\Schema(type="integer", format="int64")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(ref="#/components/schemas/Api_Status")
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*
* @OA\Delete(path="/station/{station_id}/playlist/{id}",
* tags={"Stations: Playlists"},
* description="Delete a single playlist relay.",
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Parameter(
* name="id",
* in="path",
* description="Playlist ID",
* required=true,
* @OA\Schema(type="integer", format="int64")
* ),
* @OA\Response(response=200, description="Success",
* @OA\JsonContent(ref="#/components/schemas/Api_Status")
* ),
* @OA\Response(response=403, description="Access denied"),
* security={{"api_key": {}}},
* )
*/
}

View File

@ -46,7 +46,7 @@ class RemotesController extends AbstractStationCrudController
* @OA\Parameter(
* name="id",
* in="path",
* description="Streamer ID",
* description="Remote Relay ID",
* required=true,
* @OA\Schema(type="integer", format="int64")
* ),
@ -67,7 +67,7 @@ class RemotesController extends AbstractStationCrudController
* @OA\Parameter(
* name="id",
* in="path",
* description="Streamer ID",
* description="Remote Relay ID",
* required=true,
* @OA\Schema(type="integer", format="int64")
* ),
@ -85,7 +85,7 @@ class RemotesController extends AbstractStationCrudController
* @OA\Parameter(
* name="id",
* in="path",
* description="StationRemote ID",
* description="Remote Relay ID",
* required=true,
* @OA\Schema(type="integer", format="int64")
* ),

View File

@ -1,29 +1,24 @@
<?php
namespace App\Controller\Stations;
use App\Radio\PlaylistParser;
use App\Form\EntityForm;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManager;
use App\Entity;
use Psr\Http\Message\ResponseInterface;
use Slim\Http\UploadedFile;
use App\Http\Request;
use App\Http\Response;
use App\Http\Router;
class PlaylistsController
{
/** @var EntityManager */
protected $em;
/** @var Router */
protected $router;
/** @var string */
protected $csrf_namespace = 'stations_playlists';
/** @var array */
protected $form_config;
/** @var EntityForm */
protected $form;
/** @var \Azura\Doctrine\Repository */
protected $playlist_repo;
@ -32,16 +27,14 @@ class PlaylistsController
protected $playlist_media_repo;
/**
* @param EntityManager $em
* @param Router $router
* @param array $form_config
* @param EntityForm $form
*
* @see \App\Provider\StationsProvider
*/
public function __construct(EntityManager $em, Router $router, array $form_config)
public function __construct(EntityForm $form)
{
$this->em = $em;
$this->router = $router;
$this->form_config = $form_config;
$this->form = $form;
$this->em = $form->getEntityManager();
$this->playlist_repo = $this->em->getRepository(Entity\StationPlaylist::class);
$this->playlist_media_repo = $this->em->getRepository(Entity\StationPlaylistMedia::class);
@ -90,7 +83,7 @@ class PlaylistsController
'playlists' => $playlists,
'csrf' => $request->getSession()->getCsrf()->generate($this->csrf_namespace),
'schedule_now' => Chronos::now()->toIso8601String(),
'schedule_url' => $this->router->named('stations:playlists:schedule', ['station' => $station_id]),
'schedule_url' => $request->getRouter()->named('stations:playlists:schedule', ['station' => $station_id]),
]);
}
@ -152,7 +145,7 @@ class PlaylistsController
'allDay' => $playlist_start->eq($playlist_end),
'start' => $playlist_start->toIso8601String(),
'end' => $playlist_end->toIso8601String(),
'url' => (string)$this->router->named('stations:playlists:edit', ['station' => $station_id, 'id' => $playlist->getId()]),
'url' => (string)$request->getRouter()->named('stations:playlists:edit', ['station' => $station_id, 'id' => $playlist->getId()]),
];
}
@ -236,13 +229,7 @@ class PlaylistsController
$record->setIsEnabled($new_value);
$this->em->persist($record);
$station = $request->getStation();
$station->setNeedsRestart(true);
$this->em->persist($station);
$this->em->flush();
$this->em->refresh($station);
$flash_message = ($new_value)
? __('Playlist enabled.')
@ -258,136 +245,23 @@ class PlaylistsController
public function editAction(Request $request, Response $response, $station_id, $id = null): ResponseInterface
{
$station = $request->getStation();
$this->form->setStation($station);
$form = new \AzuraForms\Form($this->form_config);
if (!empty($id)) {
$record = $this->_getRecord($id, $station_id);
$data = $this->playlist_repo->toArray($record);
$form->populate($data);
} else {
$record = null;
}
if (!empty($_POST) && $form->isValid($_POST)) {
$data = $form->getValues();
if (!($record instanceof Entity\StationPlaylist)) {
$record = new Entity\StationPlaylist($station);
}
$this->playlist_repo->fromArray($record, $data);
// Handle importing a playlist file, if necessary.
$files = $request->getUploadedFiles();
/** @var UploadedFile $import_file */
$import_file = $files['import'];
if ($import_file->getError() == UPLOAD_ERR_OK) {
$matches = $this->_importPlaylist($record, $import_file, $station_id);
if (is_int($matches)) {
$request->getSession()->flash('<b>' . __('Existing playlist imported.') . '</b><br>' . __('%d song(s) were imported into the playlist.', $matches), 'blue');
}
}
$this->em->persist($record);
$this->em->flush();
// Reshuffle "shuffled" playlists and clear cache.
$this->playlist_media_repo->reshuffleMedia($record);
$this->playlist_media_repo->clearMediaQueue($record->getId());
$this->em->flush();
$this->em->refresh($station);
$record = (null !== $id)
? $this->_getRecord($id, $station_id)
: null;
if (false !== ($result = $this->form->process($request, $record))) {
$request->getSession()->flash('<b>' . sprintf(($id) ? __('%s updated.') : __('%s added.'), __('Playlist')) . '</b>', 'green');
return $response->withRedirect($request->getRouter()->fromHere('stations:playlists:index'));
}
return $request->getView()->renderToResponse($response, 'stations/playlists/edit', [
'form' => $form,
'form' => $this->form,
'title' => sprintf(($id) ? __('Edit %s') : __('Add %s'), __('Playlist'))
]);
}
protected function _importPlaylist(
Entity\StationPlaylist $playlist,
UploadedFile $playlist_file,
$station_id
)
{
$playlist_raw = (string)$playlist_file->getStream();
if (empty($playlist_raw)) {
return false;
}
$paths = PlaylistParser::getSongs($playlist_raw);
if (empty($paths)) {
return false;
}
// Assemble list of station media to match against.
$media_lookup = [];
$media_info_raw = $this->em->createQuery(/** @lang DQL */'SELECT sm.id, sm.path
FROM App\Entity\StationMedia sm
WHERE sm.station_id = :station_id')
->setParameter('station_id', $station_id)
->getArrayResult();
foreach($media_info_raw as $row) {
$path_hash = md5($row['path']);
$media_lookup[$path_hash] = $row['id'];
}
// Run all paths against the lookup list of hashes.
$matches = [];
foreach($paths as $path_raw) {
// De-Windows paths (if applicable)
$path_raw = str_replace('\\', '/', $path_raw);
// Work backwards from the basename to try to find matches.
$path_parts = explode('/', $path_raw);
for($i = 1; $i <= count($path_parts); $i++) {
$path_attempt = implode('/', array_slice($path_parts, 0-$i));
$path_hash = md5($path_attempt);
if (isset($media_lookup[$path_hash])) {
$matches[] = $media_lookup[$path_hash];
}
}
}
// Assign all matched media to the playlist.
if (!empty($matches)) {
$matched_media = $this->em->createQuery(/** @lang DQL */'SELECT sm
FROM App\Entity\StationMedia sm
WHERE sm.station_id = :station_id AND sm.id IN (:matched_ids)')
->setParameter('station_id', $station_id)
->setParameter('matched_ids', $matches)
->execute();
$weight = $this->playlist_media_repo->getHighestSongWeight($playlist);
foreach($matched_media as $media) {
$weight++;
/** @var Entity\StationMedia $media */
$this->playlist_media_repo->addMediaToPlaylist($media, $playlist, $weight);
}
$this->em->flush();
$this->playlist_media_repo->reshuffleMedia($playlist);
}
return count($matches);
}
public function deleteAction(Request $request, Response $response, $station_id, $id, $csrf_token): ResponseInterface
{
$request->getSession()->getCsrf()->verify($csrf_token, $this->csrf_namespace);

View File

@ -4,6 +4,8 @@ namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use OpenApi\Annotations as OA;
use Symfony\Component\Validator\Constraints as Assert;
use Cake\Chronos\Chronos;
use DateTime;
@ -12,6 +14,8 @@ use DateTime;
* @ORM\Table(name="station_playlists")
* @ORM\Entity
* @ORM\HasLifecycleCallbacks
*
* @OA\Schema(type="object")
*/
class StationPlaylist
{
@ -42,6 +46,8 @@ class StationPlaylist
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*
* @OA\Property(example=1)
* @var int
*/
protected $id;
@ -63,138 +69,212 @@ class StationPlaylist
/**
* @ORM\Column(name="name", type="string", length=200)
*
* @Assert\NotBlank()
* @OA\Property(example="Test Playlist")
*
* @var string
*/
protected $name;
/**
* @ORM\Column(name="type", type="string", length=50)
*
* @Assert\Choice(choices={"default", "scheduled", "once_per_x_songs", "once_per_x_minutes", "once_per_hour", "once_per_day", "custom"})
* @OA\Property(example="default")
*
* @var string
*/
protected $type = self::TYPE_DEFAULT;
/**
* @ORM\Column(name="source", type="string", length=50)
*
* @Assert\Choice(choices={"songs", "remote_url"})
* @OA\Property(example="songs")
*
* @var string
*/
protected $source = self::SOURCE_SONGS;
/**
* @ORM\Column(name="playback_order", type="string", length=50)
*
* @Assert\Choice(choices={"random", "shuffle", "sequential"})
* @OA\Property(example="shuffle")
*
* @var string
*/
protected $order = self::ORDER_SHUFFLE;
/**
* @ORM\Column(name="remote_url", type="string", length=255, nullable=true)
*
* @OA\Property(example="http://remote-url.example.com/stream.mp3")
*
* @var string|null
*/
protected $remote_url;
/**
* @ORM\Column(name="remote_type", type="string", length=25, nullable=true)
*
* @Assert\Choice(choices={"stream", "playlist"})
* @OA\Property(example="stream")
*
* @var string|null
*/
protected $remote_type = self::REMOTE_TYPE_STREAM;
/**
* @ORM\Column(name="remote_timeout", type="smallint")
*
* @OA\Property(example=0)
*
* @var int The total time (in seconds) that Liquidsoap should buffer remote URL streams.
*/
protected $remote_buffer = 0;
/**
* @ORM\Column(name="is_enabled", type="boolean")
*
* @OA\Property(example=true)
*
* @var bool
*/
protected $is_enabled = true;
/**
* @ORM\Column(name="is_jingle", type="boolean")
*
* @OA\Property(example=false)
*
* @var bool If yes, do not send jingle metadata to AutoDJ or trigger web hooks.
*/
protected $is_jingle = false;
/**
* @ORM\Column(name="play_per_songs", type="smallint")
*
* @OA\Property(example=5)
*
* @var int
*/
protected $play_per_songs = 0;
/**
* @ORM\Column(name="play_per_minutes", type="smallint")
*
* @OA\Property(example=120)
*
* @var int
*/
protected $play_per_minutes = 0;
/**
* @ORM\Column(name="play_per_hour_minute", type="smallint")
*
* @OA\Property(example=15)
*
* @var int
*/
protected $play_per_hour_minute = 0;
/**
* @ORM\Column(name="schedule_start_time", type="smallint")
*
* @OA\Property(example=900)
*
* @var int
*/
protected $schedule_start_time = 0;
/**
* @ORM\Column(name="schedule_end_time", type="smallint")
*
* @OA\Property(example=2200)
*
* @var int
*/
protected $schedule_end_time = 0;
/**
* @ORM\Column(name="schedule_days", type="string", length=50, nullable=true)
*
* @OA\Property(example="0,1,2,3")
*
* @var string
*/
protected $schedule_days;
/**
* @ORM\Column(name="play_once_time", type="smallint")
*
* @OA\Property(example=1500)
*
* @var int
*/
protected $play_once_time = 0;
/**
* @ORM\Column(name="play_once_days", type="string", length=50, nullable=true)
*
* @OA\Property(example="0,1,2,3")
*
* @var string
*/
protected $play_once_days;
/**
* @ORM\Column(name="weight", type="smallint")
*
* @OA\Property(example=3)
*
* @var int
*/
protected $weight = self::DEFAULT_WEIGHT;
/**
* @ORM\Column(name="include_in_requests", type="boolean")
*
* @OA\Property(example=true)
*
* @var bool
*/
protected $include_in_requests = true;
/**
* @ORM\Column(name="include_in_automation", type="boolean")
*
* @OA\Property(example=false)
*
* @var bool
*/
protected $include_in_automation = false;
/**
* @ORM\Column(name="interrupt_other_songs", type="boolean")
*
* @OA\Property(example=false)
*
* @var bool
*/
protected $interrupt_other_songs = false;
/**
* @ORM\Column(name="loop_playlist_once", type="boolean")
*
* @OA\Property(example=false)
*
* @var bool Whether to loop the playlist at the end of its playback.
*/
protected $loop_playlist_once = false;
/**
* @ORM\Column(name="play_single_track", type="boolean")
*
* @OA\Property(example=false)
*
* @var bool Whether to only play a single track from the specified playlist when scheduled.
*/
protected $play_single_track = false;

View File

@ -12,7 +12,11 @@ use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* A generic class that handles binding an entity to a
* A generic class that handles binding an entity to an AzuraForms
* instance and moving the data back and forth.
*
* This class exists primarily to facilitate the switch to Symfony's
* Serializer and Validator classes, to allow for API parity.
*/
class EntityForm extends \AzuraForms\Form
{
@ -31,6 +35,9 @@ class EntityForm extends \AzuraForms\Form
/** @var array The default context sent to form normalization/denormalization functions. */
protected $defaultContext = [];
/** @var Station|null */
protected $station;
/**
* @param EntityManager $em
* @param Serializer $serializer
@ -131,6 +138,12 @@ class EntityForm extends \AzuraForms\Form
$this->em->persist($record);
$this->em->flush($record);
// Intentionally refresh the station entity in case it didn't refresh elsewhere.
if ($this->station instanceof Station && APP_TESTING_MODE) {
$this->em->refresh($this->station);
}
return $record;
}
@ -210,6 +223,8 @@ class EntityForm extends \AzuraForms\Form
*/
public function setStation(Station $station): void
{
$this->station = $station;
$this->defaultContext[ObjectNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS] = [
$this->entityClass => [
'station' => $station,

131
src/Form/PlaylistForm.php Normal file
View File

@ -0,0 +1,131 @@
<?php
namespace App\Form;
use App\Entity;
use App\Http\Request;
use App\Radio\PlaylistParser;
use Doctrine\ORM\EntityManager;
use Slim\Http\UploadedFile;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class PlaylistForm extends EntityForm
{
/** @var Entity\Repository\StationPlaylistMediaRepository */
protected $playlist_media_repo;
public function __construct(
EntityManager $em,
Serializer $serializer,
ValidatorInterface $validator,
array $options = [],
?array $defaults = null
) {
parent::__construct($em, $serializer, $validator, $options, $defaults);
$this->entityClass = Entity\StationPlaylist::class;
$this->playlist_media_repo = $em->getRepository(Entity\StationPlaylistMedia::class);
}
public function process(Request $request, $record = null)
{
$record = parent::process($request, $record);
if ($record instanceof Entity\StationPlaylist) {
$files = $request->getUploadedFiles();
/** @var UploadedFile $import_file */
$import_file = $files['import'];
if (UPLOAD_ERR_OK === $import_file->getError()) {
$matches = $this->_importPlaylist($record, $import_file);
if (is_int($matches)) {
$request->getSession()->flash('<b>' . __('Existing playlist imported.') . '</b><br>' . __('%d song(s) were imported into the playlist.', $matches), 'blue');
}
}
$this->playlist_media_repo->reshuffleMedia($record);
$this->playlist_media_repo->clearMediaQueue($record->getId());
}
return $record;
}
/**
* @param Entity\StationPlaylist $playlist
* @param UploadedFile $playlist_file
* @return bool|int
*/
protected function _importPlaylist(Entity\StationPlaylist $playlist, UploadedFile $playlist_file)
{
$station_id = $this->station->getId();
$playlist_raw = (string)$playlist_file->getStream();
if (empty($playlist_raw)) {
return false;
}
$paths = PlaylistParser::getSongs($playlist_raw);
if (empty($paths)) {
return false;
}
// Assemble list of station media to match against.
$media_lookup = [];
$media_info_raw = $this->em->createQuery(/** @lang DQL */'SELECT sm.id, sm.path
FROM App\Entity\StationMedia sm
WHERE sm.station_id = :station_id')
->setParameter('station_id', $station_id)
->getArrayResult();
foreach($media_info_raw as $row) {
$path_hash = md5($row['path']);
$media_lookup[$path_hash] = $row['id'];
}
// Run all paths against the lookup list of hashes.
$matches = [];
foreach($paths as $path_raw) {
// De-Windows paths (if applicable)
$path_raw = str_replace('\\', '/', $path_raw);
// Work backwards from the basename to try to find matches.
$path_parts = explode('/', $path_raw);
for($i = 1; $i <= count($path_parts); $i++) {
$path_attempt = implode('/', array_slice($path_parts, 0-$i));
$path_hash = md5($path_attempt);
if (isset($media_lookup[$path_hash])) {
$matches[] = $media_lookup[$path_hash];
}
}
}
// Assign all matched media to the playlist.
if (!empty($matches)) {
$matched_media = $this->em->createQuery(/** @lang DQL */'SELECT sm
FROM App\Entity\StationMedia sm
WHERE sm.station_id = :station_id AND sm.id IN (:matched_ids)')
->setParameter('station_id', $station_id)
->setParameter('matched_ids', $matches)
->execute();
$weight = $this->playlist_media_repo->getHighestSongWeight($playlist);
foreach($matched_media as $media) {
$weight++;
/** @var Entity\StationMedia $media */
$this->playlist_media_repo->addMediaToPlaylist($media, $playlist, $weight);
}
$this->em->flush();
$this->playlist_media_repo->reshuffleMedia($playlist);
}
return count($matches);
}
}

View File

@ -102,6 +102,7 @@ class ApiProvider implements ServiceProviderInterface
Api\Admin\SettingsController::class,
Api\Admin\StationsController::class,
Api\Stations\MountsController::class,
Api\Stations\PlaylistsController::class,
Api\Stations\RemotesController::class,
Api\Stations\StreamersController::class,
];

View File

@ -33,6 +33,20 @@ class FormProvider implements ServiceProviderInterface
);
};
$di[Form\PlaylistForm::class] = function($di) {
/** @var \Azura\Config $config */
$config = $di[\Azura\Config::class];
return new Form\PlaylistForm(
$di[EntityManager::class],
$di[Serializer::class],
$di[ValidatorInterface::class],
$config->get('forms/playlist', [
'customization' => $di[\App\Customization::class]
])
);
};
$di[Form\StationForm::class] = function($di) {
/** @var \Azura\Config $config */
$config = $di[\Azura\Config::class];
@ -85,6 +99,7 @@ class FormProvider implements ServiceProviderInterface
Entity\Station::class => Form\StationForm::class,
Entity\User::class => Form\UserForm::class,
Entity\RolePermission::class => Form\PermissionsForm::class,
Entity\StationPlaylist::class => Form\PlaylistForm::class,
];
return new Form\EntityFormManager(

View File

@ -89,16 +89,7 @@ class StationsProvider implements ServiceProviderInterface
};
$di[Stations\PlaylistsController::class] = function($di) {
/** @var Azura\Config $config */
$config = $di[Azura\Config::class];
return new Stations\PlaylistsController(
$di[EntityManager::class],
$di['router'],
$config->get('forms/playlist', [
'customization' => $di[App\Customization::class]
])
);
return new Stations\PlaylistsController($di[App\Form\PlaylistForm::class]);
};
$di[Stations\RemotesController::class] = function($di) {

View File

@ -851,6 +851,139 @@ paths:
security:
-
api_key: []
'/station/{station_id}/playlists':
get:
tags:
- 'Stations: Playlists'
description: 'List all current playlists.'
parameters:
-
$ref: '#/components/parameters/station_id_required'
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/StationPlaylist'
'403':
description: 'Access denied'
security:
-
api_key: []
post:
tags:
- 'Stations: Playlists'
description: 'Create a new playlist.'
parameters:
-
$ref: '#/components/parameters/station_id_required'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/StationPlaylist'
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/StationPlaylist'
'403':
description: 'Access denied'
security:
-
api_key: []
'/station/{station_id}/playlist/{id}':
get:
tags:
- 'Stations: Playlists'
description: 'Retrieve details for a single playlist.'
parameters:
-
$ref: '#/components/parameters/station_id_required'
-
name: id
in: path
description: 'Playlist ID'
required: true
schema:
type: integer
format: int64
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/StationPlaylist'
'403':
description: 'Access denied'
security:
-
api_key: []
put:
tags:
- 'Stations: Playlists'
description: 'Update details of a single playlist.'
parameters:
-
$ref: '#/components/parameters/station_id_required'
-
name: id
in: path
description: 'Playlist ID'
required: true
schema:
type: integer
format: int64
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/StationPlaylist'
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Api_Status'
'403':
description: 'Access denied'
security:
-
api_key: []
delete:
tags:
- 'Stations: Playlists'
description: 'Delete a single playlist relay.'
parameters:
-
$ref: '#/components/parameters/station_id_required'
-
name: id
in: path
description: 'Playlist ID'
required: true
schema:
type: integer
format: int64
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Api_Status'
'403':
description: 'Access denied'
security:
-
api_key: []
'/station/{station_id}/queue':
get:
tags:
@ -988,7 +1121,7 @@ paths:
-
name: id
in: path
description: 'Streamer ID'
description: 'Remote Relay ID'
required: true
schema:
type: integer
@ -1015,7 +1148,7 @@ paths:
-
name: id
in: path
description: 'Streamer ID'
description: 'Remote Relay ID'
required: true
schema:
type: integer
@ -1047,7 +1180,7 @@ paths:
-
name: id
in: path
description: 'StationRemote ID'
description: 'Remote Relay ID'
required: true
schema:
type: integer
@ -1438,7 +1571,7 @@ components:
connected_on:
description: 'UNIX timestamp that the user first connected.'
type: integer
example: 1554805409
example: 1555103144
connected_time:
description: 'Number of seconds that the user has been connected.'
type: integer
@ -1534,7 +1667,7 @@ components:
cued_at:
description: 'UNIX timestamp when the item was cued for playback.'
type: integer
example: 1554805409
example: 1555103144
autodj_custom_uri:
description: 'Custom AutoDJ playback URI, if it exists.'
type: string
@ -1589,7 +1722,7 @@ components:
played_at:
description: 'UNIX timestamp when playback started.'
type: integer
example: 1554805409
example: 1555103144
duration:
description: 'Duration of the song in seconds'
type: integer
@ -1720,7 +1853,7 @@ components:
timestamp:
description: 'The current UNIX timestamp'
type: integer
example: 1554805409
example: 1555103144
type: object
Api_Time:
properties:
@ -1912,6 +2045,85 @@ components:
type: string
items: { }
type: object
StationPlaylist:
properties:
id:
type: integer
example: 1
name:
type: string
example: 'Test Playlist'
type:
type: string
example: default
source:
type: string
example: songs
order:
type: string
example: shuffle
remote_url:
type: string
example: 'http://remote-url.example.com/stream.mp3'
remote_type:
type: string
example: stream
remote_buffer:
description: 'The total time (in seconds) that Liquidsoap should buffer remote URL streams.'
type: integer
example: 0
is_enabled:
type: boolean
example: true
is_jingle:
description: 'If yes, do not send jingle metadata to AutoDJ or trigger web hooks.'
type: boolean
example: false
play_per_songs:
type: integer
example: 5
play_per_minutes:
type: integer
example: 120
play_per_hour_minute:
type: integer
example: 15
schedule_start_time:
type: integer
example: 900
schedule_end_time:
type: integer
example: 2200
schedule_days:
type: string
example: '0,1,2,3'
play_once_time:
type: integer
example: 1500
play_once_days:
type: string
example: '0,1,2,3'
weight:
type: integer
example: 3
include_in_requests:
type: boolean
example: true
include_in_automation:
type: boolean
example: false
interrupt_other_songs:
type: boolean
example: false
loop_playlist_once:
description: 'Whether to loop the playlist at the end of its playback.'
type: boolean
example: false
play_single_track:
description: 'Whether to only play a single track from the specified playlist when scheduled.'
type: boolean
example: false
type: object
StationRemote:
properties:
id:
@ -1982,7 +2194,7 @@ components:
example: true
reactivate_at:
type: integer
example: 1554805409
example: 1555103144
type: object
User:
properties:
@ -2012,10 +2224,10 @@ components:
example: A1B2C3D4
created_at:
type: integer
example: 1554805409
example: 1555103144
updated_at:
type: integer
example: 1554805409
example: 1555103144
roles:
items: { }
type: object