#566 / #567 Sequential Playlists with Reordering Page (#573)

- Expand the ManyToMany relationship between StationMedia and StationPlaylist to include both a "weight" and "last_played" parameter, to allow for sequential playlists.
- Implement Sequential playlist support
- Create new "reorder playlist" page
This commit is contained in:
Buster "Silver Eagle" Neece 2018-04-29 18:48:48 -05:00 committed by GitHub
parent de80e76f05
commit 1f08c307e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 3285 additions and 419 deletions

View File

@ -29,4 +29,4 @@ script:
- docker-compose -f docker-compose.sample.yml -f docker-compose.testing.yml run --rm cli azuracast_testing
after_failure:
- cat tests/_output/*
- cat tests/_output/*.html

View File

@ -381,5 +381,19 @@ return [
]
],
'require' => ['moment'],
]
],
[
'name' => 'jquery-sortable',
'order' => 10,
'group' => 'body',
'files' => [
'js' => [
[
'src' => 'https://cdnjs.cloudflare.com/ajax/libs/jquery-sortable/0.9.13/jquery-sortable-min.js',
'sri' => 'sha256-wWIfHlrIpCbyDbt+VSBUsc54ApQZWKqBmF38yUKLGeY=',
],
],
],
'require' => ['jquery'],
],
];

View File

@ -342,6 +342,9 @@ return function(\Slim\App $app) {
$this->get('/delete/{id}/{csrf}', Controller\Stations\PlaylistsController::class.':deleteAction')
->setName('stations:playlists:delete');
$this->map(['GET', 'POST'], '/reorder/{id}', Controller\Stations\PlaylistsController::class.':reorderAction')
->setName('stations:playlists:reorder');
$this->get('/export/{id}[/{format}]', Controller\Stations\PlaylistsController::class.':exportAction')
->setName('stations:playlists:export');

View File

@ -76,6 +76,74 @@ return [
]
],
'weight' => [
'select',
[
'label' => __('Playlist Weight'),
'description' => __('Higher weight playlists are played more frequently compared to other lower-weight playlists.'),
'default' => 3,
'required' => true,
'options' => [
1 => '1 - '.__('Low'),
2 => '2',
3 => '3 - '.__('Default'),
4 => '4',
5 => '5 - '.__('High'),
] + \App\Utilities::pairs(range(6, 25)),
]
],
'source' => [
'radio',
[
'label' => __('Source'),
'options' => [
StationPlaylist::SOURCE_SONGS => '<b>' . __('Song-Based Playlist') .':</b> ' . __('A playlist containing media files hosted on this server.'),
StationPlaylist::SOURCE_REMOTE_URL => '<b>'.__('Remote URL Playlist').':</b> ' . __('A playlist that instructs the station to play from a remote URL.'),
],
'default' => StationPlaylist::SOURCE_SONGS,
'required' => true,
]
],
'type' => [
'radio',
[
'label' => __('Scheduling'),
'options' => [
StationPlaylist::TYPE_DEFAULT => '<b>' . __('General Rotation') . ':</b> ' . __('Plays all day, shuffles with other standard playlists based on weight.'),
StationPlaylist::TYPE_SCHEDULED => '<b>' . __('Scheduled') . ':</b> ' . __('Play during a scheduled time range. Useful for mood-based time playlists.'),
StationPlaylist::TYPE_ONCE_PER_X_SONGS => '<b>' . __('Once per x Songs') . ':</b> ' . __('Play exactly once every <i>x</i> songs. Useful for station ID/jingles.'),
StationPlaylist::TYPE_ONCE_PER_X_MINUTES => '<b>' . __('Once Per x Minutes') . ':</b> ' . __('Play exactly once every <i>x</i> minutes. Useful for station ID/jingles.'),
StationPlaylist::TYPE_ONCE_PER_DAY => '<b>' . __('Daily') . '</b>: ' . __('Play once per day at the specified time. Useful for timely reminders.'),
StationPlaylist::TYPE_ADVANCED => '<b>' . __('Advanced') .'</b>: ' . __('Manually define how this playlist is used in Liquidsoap configuration. <a href="%s" target="_blank">Learn about Advanced Playlists</a>', 'https://github.com/AzuraCast/AzuraCast/wiki/Using-Advanced-Playlists'),
],
'default' => StationPlaylist::TYPE_DEFAULT,
'required' => true,
]
],
],
],
'source_'.StationPlaylist::SOURCE_SONGS => [
'legend' => __('Song-Based Playlist'),
'class' => 'source_fieldset',
'elements' => [
'order' => [
'radio',
[
'label' => __('Song Playback Order'),
'required' => true,
'options' => [
StationPlaylist::ORDER_RANDOM => __('Random (Shuffled)'),
StationPlaylist::ORDER_SEQUENTIAL => __('Sequential'),
],
'default' => StationPlaylist::ORDER_RANDOM,
],
],
'include_in_requests' => [
'radio',
[
@ -108,45 +176,26 @@ return [
]
],
'weight' => [
'select',
[
'label' => __('Playlist Weight'),
'description' => __('Higher weight playlists are played more frequently compared to other lower-weight playlists.'),
'default' => 3,
'required' => true,
'options' => [
1 => '1 - '.__('Low'),
2 => '2',
3 => '3 - '.__('Default'),
4 => '4',
5 => '5 - '.__('High'),
] + \App\Utilities::pairs(range(6, 25)),
]
],
'type' => [
'radio',
[
'label' => __('Playlist Type'),
'options' => [
StationPlaylist::TYPE_DEFAULT => '<b>' . __('Standard Playlist') . ':</b> ' . __('Plays all day, shuffles with other standard playlists based on weight.'),
StationPlaylist::TYPE_SCHEDULED => '<b>' . __('Scheduled Playlist') . ':</b> ' . __('Play during a scheduled time range. Useful for mood-based time playlists.'),
StationPlaylist::TYPE_ONCE_PER_X_SONGS => '<b>' . __('Once per x Songs Playlist') . ':</b> ' . __('Play exactly once every <i>x</i> songs. Useful for station ID/jingles.'),
StationPlaylist::TYPE_ONCE_PER_X_MINUTES => '<b>' . __('Once Per x Minutes Playlist') . ':</b> ' . __('Play exactly once every <i>x</i> minutes. Useful for station ID/jingles.'),
StationPlaylist::TYPE_ONCE_PER_DAY => '<b>' . __('Daily Playlist') . '</b>: ' . __('Play once per day at the specified time. Useful for timely reminders.'),
StationPlaylist::TYPE_ADVANCED => '<b>' . __('Advanced Playlist') .'</b>: ' . __('Manually define how this playlist is used in Liquidsoap configuration. <a href="%s" target="_blank">Learn about Advanced Playlists</a>', 'https://github.com/AzuraCast/AzuraCast/wiki/Using-Advanced-Playlists'),
],
'default' => 'default',
'required' => true,
]
],
],
],
'source_'.StationPlaylist::SOURCE_REMOTE_URL => [
'legend' => __('Remote URL Playlist'),
'class' => 'source_fieldset',
'elements' => [
'remote_url' => [
'text',
[
'label' => __('Remote URL'),
]
],
]
],
'type_default' => [
'legend' => __('Standard Playlist'),
'legend' => __('General Rotation'),
'class' => 'type_fieldset',
'elements' => [
@ -167,8 +216,8 @@ return [
],
],
'type_scheduled' => [
'legend' => __('Scheduled Playlist'),
'type_'.StationPlaylist::TYPE_SCHEDULED => [
'legend' => __('Customize Schedule'),
'class' => 'type_fieldset',
'elements' => [
@ -210,8 +259,8 @@ return [
],
],
'type_once_per_x_songs' => [
'legend' => __('Once per x Songs Playlist'),
'type_'.StationPlaylist::TYPE_ONCE_PER_X_SONGS => [
'legend' => __('Once per x Songs'),
'class' => 'type_fieldset',
'elements' => [
@ -227,8 +276,8 @@ return [
],
],
'type_once_per_x_minutes' => [
'legend' => __('Once per x Minutes Playlist'),
'type_'.StationPlaylist::TYPE_ONCE_PER_X_MINUTES => [
'legend' => __('Once per x Minutes'),
'class' => 'type_fieldset',
'elements' => [
@ -244,8 +293,8 @@ return [
],
],
'type_once_per_day' => [
'legend' => __('Daily Playlist'),
'type_'.StationPlaylist::TYPE_ONCE_PER_DAY => [
'legend' => __('Daily'),
'class' => 'type_fieldset',
'elements' => [

View File

@ -64,7 +64,7 @@ class ErrorHandler
$user = $req->getAttribute('user');
$show_detailed = $this->acl->userAllowed($user, 'administer all') || !APP_IN_PRODUCTION;
if ($req->isXhr() || (APP_IS_COMMAND_LINE && !APP_TESTING_MODE)) {
if ($req->isXhr() || APP_IS_COMMAND_LINE || APP_TESTING_MODE) {
$api_response = new Entity\Api\Error(
$e->getCode(),
$e->getMessage(),

View File

@ -128,6 +128,8 @@ class Liquidsoap extends BackendAbstract
foreach ($playlist_objects as $playlist) {
/** @var Entity\StationPlaylist $playlist */
$playlist_file_contents = $playlist->export('m3u', true);
$playlist_var_name = 'playlist_' . $playlist->getShortName();

View File

@ -179,10 +179,8 @@ class Media extends SyncAbstract
/** @var Entity\StationMedia $media_record */
$media_record = $media_lookup[$line_hash];
$media_record->getPlaylists()->add($record);
$record->getMedia()->add($media_record);
$this->em->persist($media_record);
$spm = new Entity\StationPlaylistMedia($record, $media_record);
$this->em->persist($spm);
}
}
}

View File

@ -5,7 +5,6 @@ use App\Exception;
use AzuraCast\Radio\Adapters;
use Doctrine\ORM\EntityManager;
use Entity;
use Entity\Station;
class RadioAutomation extends SyncAbstract
{
@ -18,7 +17,9 @@ class RadioAutomation extends SyncAbstract
protected $adapters;
/**
* RadioAutomation constructor.
* @param EntityManager $em
* @param Adapters $adapters
*/
public function __construct(EntityManager $em, Adapters $adapters)
{
@ -28,15 +29,20 @@ class RadioAutomation extends SyncAbstract
/**
* Iterate through all stations and attempt to run automated assignment.
* @param bool $force
*/
public function run($force = false)
{
// Check all stations for automation settings.
$stations = $this->em->getRepository(Station::class)->findAll();
$stations = $this->em->getRepository(Entity\Station::class)->findAll();
$automation_log = $this->em->getRepository('Entity\Settings')->getSetting('automation_log', []);
/** @var Entity\Repository\SettingsRepository $settings_repo */
$settings_repo = $this->em->getRepository(Entity\Settings::class);
$automation_log = $settings_repo->getSetting('automation_log', []);
foreach ($stations as $station) {
/** @var Entity\Station $station */
try {
if ($this->runStation($station)) {
$automation_log[$station->getId()] = $station->getName() . ': SUCCESS';
@ -46,18 +52,18 @@ class RadioAutomation extends SyncAbstract
}
}
$this->em->getRepository('Entity\Settings')->setSetting('automation_log', $automation_log);
$settings_repo->setSetting('automation_log', $automation_log);
}
/**
* Run automated assignment (if enabled) for a given $station.
*
* @param Station $station
* @param Entity\Station $station
* @param bool $force
* @return bool
* @throws Exception
*/
public function runStation(Station $station, $force = false)
public function runStation(Entity\Station $station, $force = false)
{
$settings = (array)$station->getAutomationSettings();
@ -87,18 +93,18 @@ class RadioAutomation extends SyncAbstract
/** @var Entity\StationPlaylist $playlist */
if ($playlist->getIsEnabled() &&
$playlist->getType() == 'default' &&
$playlist->getIncludeInAutomation() == true
$playlist->getType() == Entity\StationPlaylist::TYPE_DEFAULT &&
$playlist->getIncludeInAutomation()
) {
// Clear all related media.
foreach ($playlist->getMedia() as $media) {
foreach ($playlist->getMediaItems() as $media_item) {
$media = $media_item->getMedia();
$song = $media->getSong();
if ($song instanceof Entity\Song) {
$original_playlists[$song->getId()][] = $i;
}
$media->getPlaylists()->removeElement($playlist);
$this->em->persist($media);
$this->em->remove($media_item);
}
$playlists[$i] = $playlist;
@ -135,11 +141,10 @@ class RadioAutomation extends SyncAbstract
$media_row = $media['record'];
foreach ($original_playlists[$song_id] as $playlist_key) {
$media_row->getPlaylists()->add($playlists[$playlist_key]);
$spm = new Entity\StationPlaylistMedia($playlists[$playlist_key], $media_row);
$this->em->persist($spm);
}
$this->em->persist($media_row);
unset($media_report[$song_id]);
}
}
@ -170,10 +175,8 @@ class RadioAutomation extends SyncAbstract
$media_in_playlist = array_slice($media_report, $i, $playlist_num_songs);
foreach ($media_in_playlist as $media) {
$media_row = $media['record'];
$media_row->getPlaylists()->add($playlist);
$this->em->persist($media_row);
$spm = new Entity\StationPlaylistMedia($playlist, $media['record']);
$this->em->persist($spm);
}
$i += $playlist_num_songs;
@ -193,11 +196,11 @@ class RadioAutomation extends SyncAbstract
/**
* Generate a Performance Report for station $station's songs over the last $threshold_days days.
*
* @param Station $station
* @param Entity\Station $station
* @param int $threshold_days
* @return array
*/
public function generateReport(Station $station, $threshold_days = self::DEFAULT_THRESHOLD_DAYS)
public function generateReport(Entity\Station $station, $threshold_days = self::DEFAULT_THRESHOLD_DAYS)
{
$threshold = strtotime('-' . (int)$threshold_days . ' days');
@ -250,15 +253,18 @@ class RadioAutomation extends SyncAbstract
*/
// Pull all media and playlists.
/** @var Entity\Repository\StationMediaRepository $media_repo */
$media_repo = $this->em->getRepository(Entity\StationMedia::class);
$media_raw = $this->em->createQuery('SELECT sm, sp FROM Entity\StationMedia sm LEFT JOIN sm.playlists sp WHERE sm.station_id = :station_id ORDER BY sm.artist ASC, sm.title ASC')
$media_raw = $this->em->createQuery('SELECT sm, spm, sp FROM Entity\StationMedia sm LEFT JOIN sm.playlist_items spm LEFT JOIN spm.playlist sp WHERE sm.station_id = :station_id ORDER BY sm.artist ASC, sm.title ASC')
->setParameter('station_id', $station->getId())
->execute();
$report = [];
foreach ($media_raw as $row_obj) {
/** @var Entity\StationMedia $row_obj */
$row = $media_repo->toArray($row_obj);
$media = [
@ -284,8 +290,10 @@ class RadioAutomation extends SyncAbstract
'ratio' => 0,
];
if ($row_obj->getPlaylists()->count() > 0) {
foreach ($row_obj->getPlaylists() as $playlist) {
if ($row_obj->getPlaylistItems()->count() > 0) {
foreach ($row_obj->getPlaylistItems() as $playlist_item) {
/** @var Entity\StationPlaylistMedia $playlist_item */
$playlist = $playlist_item->getPlaylist();
$media['playlists'][] = $playlist->getName();
}
}

View File

@ -66,8 +66,10 @@ class RequestsController
return $response->withJson('This station does not accept requests currently.', 403);
}
$requestable_media = $this->em->createQuery('SELECT sm, s, sp
FROM Entity\StationMedia sm JOIN sm.song s LEFT JOIN sm.playlists sp
$requestable_media = $this->em->createQuery('SELECT sm, s, spm, sp
FROM Entity\StationMedia sm JOIN sm.song s
LEFT JOIN sm.playlist_items spm
LEFT JOIN spm.playlist sp
WHERE sm.station_id = :station_id
AND sp.id IS NOT NULL
AND sp.is_enabled = 1

View File

@ -22,8 +22,12 @@ class FilesController extends FilesControllerAbstract
/** @var Entity\Station $station */
$station = $request->getAttribute('station');
$playlists = $this->em->createQuery('SELECT sp.id, sp.name FROM Entity\StationPlaylist sp WHERE sp.station_id = :station_id ORDER BY sp.name ASC')
$playlists = $this->em->createQuery('SELECT sp.id, sp.name
FROM Entity\StationPlaylist sp
WHERE sp.station_id = :station_id AND sp.source = :source
ORDER BY sp.name ASC')
->setParameter('station_id', $station_id)
->setParameter('source', Entity\StationPlaylist::SOURCE_SONGS)
->getArrayResult();
// Show available file space in the station directory.
@ -127,10 +131,11 @@ class FilesController extends FilesControllerAbstract
if (is_dir($file_path)) {
$media_in_dir_raw = $this->em->createQuery('SELECT
partial sm.{id, unique_id, path, length, length_text, artist, title, album}, partial sp.{id, name}, partial smcf.{id, field_id, value}
partial sm.{id, unique_id, path, length, length_text, artist, title, album}, partial spm.{id}, partial sp.{id, name}, partial smcf.{id, field_id, value}
FROM Entity\StationMedia sm
LEFT JOIN sm.playlists sp
LEFT JOIN sm.custom_fields smcf
LEFT JOIN sm.custom_fields smcf
LEFT JOIN sm.playlist_items spm
LEFT JOIN spm.playlist sp
WHERE sm.station_id = :station_id
AND sm.path LIKE :path')
->setParameter('station_id', $station_id)
@ -140,8 +145,8 @@ class FilesController extends FilesControllerAbstract
$media_in_dir = [];
foreach ($media_in_dir_raw as $media_row) {
$playlists = [];
foreach ($media_row['playlists'] as $playlist_row) {
$playlists[] = $playlist_row['name'];
foreach ($media_row['playlist_items'] as $playlist_row) {
$playlists[] = $playlist_row['playlist']['name'];
}
$custom_fields = [];
@ -306,6 +311,7 @@ class FilesController extends FilesControllerAbstract
$files_affected = 0;
$response_record = null;
$errors = [];
list($action, $action_id) = explode('_', $_POST['do']);
@ -320,6 +326,7 @@ class FilesController extends FilesControllerAbstract
$media = $this->media_repo->getOrCreate($station, $file);
$this->em->remove($media);
} catch (\Exception $e) {
$errors[] = $file.': '.$e->getMessage();
@unlink($file);
}
@ -345,9 +352,10 @@ class FilesController extends FilesControllerAbstract
foreach ($music_files as $file) {
try {
$media = $this->media_repo->getOrCreate($station, $file);
$media->getPlaylists()->clear();
$this->em->persist($media);
} catch (\Exception $e) { }
$this->playlists_media_repo->clearPlaylistsFromMedia($media);
} catch (\Exception $e) {
$errors[] = $file.': '.$e->getMessage();
}
$files_affected++;
}
@ -389,16 +397,14 @@ class FilesController extends FilesControllerAbstract
$music_files = $this->_getMusicFiles($files);
$files_found = count($music_files);
$weight = 0;
foreach ($music_files as $file) {
try {
$media = $this->media_repo->getOrCreate($station, $file);
if (!$media->getPlaylists()->contains($playlist)) {
$media->getPlaylists()->add($playlist);
}
$this->em->persist($media);
$weight = $this->playlists_media_repo->addMediaToPlaylist($media, $playlist, $weight);
$weight++;
} catch (\Exception $e) {
$errors[] = $file.': '.$e->getMessage();
}
$files_affected++;
@ -411,10 +417,15 @@ class FilesController extends FilesControllerAbstract
break;
}
$this->em->clear(Entity\StationMedia::class);
$this->em->clear(Entity\StationPlaylist::class);
$this->em->clear(Entity\StationPlaylistMedia::class);
return $response->withJson([
'success' => true,
'files_found' => $files_found,
'files_affected' => $files_affected,
'errors' => $errors,
'record' => $response_record,
]);
}

View File

@ -30,6 +30,9 @@ abstract class FilesControllerAbstract
/** @var Entity\Repository\StationMediaRepository */
protected $media_repo;
/** @var Entity\Repository\StationPlaylistMediaRepository */
protected $playlists_media_repo;
/**
* FilesController constructor.
* @param EntityManager $em
@ -46,6 +49,7 @@ abstract class FilesControllerAbstract
$this->form_config = $form_config;
$this->media_repo = $this->em->getRepository(Entity\StationMedia::class);
$this->playlists_media_repo = $this->em->getRepository(Entity\StationPlaylistMedia::class);
}
protected function _filterPath($base_path, $path)

View File

@ -33,6 +33,12 @@ class PlaylistsController
/** @var array */
protected $form_config;
/** @var Entity\Repository\BaseRepository */
protected $playlist_repo;
/** @var Entity\Repository\StationPlaylistMediaRepository */
protected $playlist_media_repo;
/**
* PlaylistsController constructor.
* @param EntityManager $em
@ -47,6 +53,9 @@ class PlaylistsController
$this->flash = $flash;
$this->csrf = $csrf;
$this->form_config = $form_config;
$this->playlist_repo = $this->em->getRepository(Entity\StationPlaylist::class);
$this->playlist_media_repo = $this->em->getRepository(Entity\StationPlaylistMedia::class);
}
public function indexAction(Request $request, Response $response, $station_id): Response
@ -64,9 +73,6 @@ class PlaylistsController
/** @var Entity\StationPlaylist[] $all_playlists */
$all_playlists = $station->getPlaylists();
/** @var Entity\Repository\BaseRepository $playlist_repo */
$playlist_repo = $this->em->getRepository(Entity\StationPlaylist::class);
$total_weights = 0;
foreach ($all_playlists as $playlist) {
if ($playlist->getIsEnabled() && $playlist->getType() === 'default') {
@ -77,13 +83,13 @@ class PlaylistsController
$playlists = [];
foreach ($all_playlists as $playlist) {
$playlist_row = $playlist_repo->toArray($playlist);
$playlist_row = $this->playlist_repo->toArray($playlist);
if ($playlist->getIsEnabled() && $playlist->getType() === 'default') {
$playlist_row['probability'] = round(($playlist->getWeight() / $total_weights) * 100, 1) . '%';
}
$playlist_row['num_songs'] = $playlist->getMedia()->count();
$playlist_row['num_songs'] = $playlist->getMediaItems()->count();
$playlists[$playlist->getId()] = $playlist_row;
}
@ -98,6 +104,14 @@ class PlaylistsController
]);
}
/**
* Controller used to respond to AJAX requests from the playlist "Schedule View".
*
* @param Request $request
* @param Response $response
* @param $station_id
* @return Response
*/
public function scheduleAction(Request $request, Response $response, $station_id): Response
{
$utc = new \DateTimeZone('UTC');
@ -159,15 +173,69 @@ class PlaylistsController
return $response->withJson($events);
}
public function exportAction(Request $request, Response $response, $station_id, $id, $format = 'pls'): Response
public function reorderAction(Request $request, Response $response, $station_id, $id): Response
{
$record = $this->em->getRepository(Entity\StationPlaylist::class)->findOneBy([
$record = $this->playlist_repo->findOneBy([
'id' => $id,
'station_id' => $station_id
]);
if (!($record instanceof Entity\StationPlaylist)) {
throw new \Exception('Playlist not found!');
throw new \App\Exception\NotFound(__('%s not found.', __('Playlist')));
}
if ($record->getSource() !== Entity\StationPlaylist::SOURCE_SONGS
|| $record->getOrder() !== Entity\StationPlaylist::ORDER_SEQUENTIAL) {
throw new \App\Exception(__('This playlist is not a sequential playlist.'));
}
if ($request->isPost()) {
try {
$this->csrf->verify($request->getParam('csrf'), $this->csrf_namespace);
} catch(\App\Exception\CsrfValidation $e) {
return $response->withStatus(403)
->withJson(['error' => ['code' => 403, 'msg' => 'CSRF Failure: '.$e->getMessage()]]);
}
$order_raw = $request->getParam('order');
$order = json_decode($order_raw, true);
$mapping = [];
foreach($order as $weight => $row) {
$mapping[$row['id']] = $weight+1;
}
$this->playlist_media_repo->setMediaOrder($record, $mapping);
return $response->withJson($mapping);
}
$media_items = $this->em->createQuery('SELECT spm, sm FROM Entity\StationPlaylistMedia spm
JOIN spm.media sm
WHERE spm.playlist_id = :playlist_id
ORDER BY spm.weight ASC')
->setParameter('playlist_id', $id)
->getArrayResult();
/** @var View $view */
$view = $request->getAttribute('view');
return $view->renderToResponse($response, 'stations/playlists/reorder', [
'playlist' => $record,
'csrf' => $this->csrf->generate($this->csrf_namespace),
'media_items' => $media_items,
]);
}
public function exportAction(Request $request, Response $response, $station_id, $id, $format = 'pls'): Response
{
$record = $this->playlist_repo->findOneBy([
'id' => $id,
'station_id' => $station_id
]);
if (!($record instanceof Entity\StationPlaylist)) {
throw new \App\Exception\NotFound(__('%s not found.', __('Playlist')));
}
$formats = [
@ -176,7 +244,7 @@ class PlaylistsController
];
if (!isset($formats[$format])) {
throw new \Exception('Format not found!');
throw new \App\Exception\NotFound(__('%s not found.', __('Format')));
}
$file_name = 'playlist_' . $record->getShortName().'.'.$format;
@ -192,19 +260,16 @@ class PlaylistsController
/** @var Entity\Station $station */
$station = $request->getAttribute('station');
/** @var Entity\Repository\BaseRepository $playlist_repo */
$playlist_repo = $this->em->getRepository(Entity\StationPlaylist::class);
$form = new \AzuraForms\Form($this->form_config);
if (!empty($id)) {
$record = $playlist_repo->findOneBy([
$record = $this->playlist_repo->findOneBy([
'id' => $id,
'station_id' => $station_id
]);
if ($record instanceof Entity\StationPlaylist) {
$data = $playlist_repo->toArray($record);
$data = $this->playlist_repo->toArray($record);
$form->populate($data);
}
} else {
@ -218,7 +283,7 @@ class PlaylistsController
$record = new Entity\StationPlaylist($station);
}
$playlist_repo->fromArray($record, $data);
$this->playlist_repo->fromArray($record, $data);
$this->em->persist($record);
// Handle importing a playlist file, if necessary.
@ -322,12 +387,7 @@ class PlaylistsController
foreach($matched_media as $media) {
/** @var Entity\StationMedia $media */
if (!$media->getPlaylists()->contains($playlist)) {
$media->getPlaylists()->add($playlist);
$playlist->getMedia()->add($media);
$this->em->persist($media);
}
$this->playlist_media_repo->addMediaToPlaylist($media, $playlist);
}
$this->em->persist($playlist);
@ -344,7 +404,7 @@ class PlaylistsController
/** @var Entity\Station $station */
$station = $request->getAttribute('station');
$record = $this->em->getRepository(Entity\StationPlaylist::class)->findOneBy([
$record = $this->playlist_repo->findOneBy([
'id' => $id,
'station_id' => $station_id
]);

View File

@ -70,7 +70,7 @@ class ProfileController
}
// Statistics about backend playback.
$num_songs = $this->em->createQuery('SELECT COUNT(sm.id) FROM Entity\StationMedia sm LEFT JOIN sm.playlists sp WHERE sp.id IS NOT NULL AND sm.station_id = :station_id')
$num_songs = $this->em->createQuery('SELECT COUNT(sm.id) FROM Entity\StationMedia sm LEFT JOIN sm.playlist_items spm LEFT JOIN spm.playlist sp WHERE sp.id IS NOT NULL AND sm.station_id = :station_id')
->setParameter('station_id', $station->getId())
->getSingleScalarResult();

View File

@ -179,7 +179,7 @@ class ReportsController
public function duplicatesAction(Request $request, Response $response, $station_id): Response
{
$media_raw = $this->em->createQuery('SELECT sm, s, sp FROM Entity\StationMedia sm JOIN sm.song s LEFT JOIN sm.playlists sp WHERE sm.station_id = :station_id ORDER BY sm.mtime ASC')
$media_raw = $this->em->createQuery('SELECT sm, s, spm, sp FROM Entity\StationMedia sm JOIN sm.song s LEFT JOIN sm.playlist_items spm LEFT JOIN spm.playlist sp WHERE sm.station_id = :station_id ORDER BY sm.mtime ASC')
->setParameter('station_id', $station_id)
->getArrayResult();
@ -188,6 +188,10 @@ class ReportsController
// Find exact duplicates and sort other songs into a searchable array.
foreach ($media_raw as $media_row) {
foreach($media_row['playlist_items'] as $playlist_item) {
$media_row['playlists'][] = $playlist_item['playlist'];
}
if (isset($songs_to_compare[$media_row['song_id']])) {
$dupes[] = [$songs_to_compare[$media_row['song_id']], $media_row];
} else {

View File

@ -1,6 +1,7 @@
<?php
namespace Entity\Repository;
use Doctrine\ORM\NoResultException;
use Entity;
class StationMediaRepository extends BaseRepository
@ -108,7 +109,7 @@ class StationMediaRepository extends BaseRepository
foreach($station->getPlaylists() as $playlist) {
/** @var Entity\StationPlaylist $playlist */
// Don't include empty playlists
if ($playlist->getIsEnabled() && $playlist->getMedia()->count() > 0) {
if ($playlist->canBeCued()) {
$playlists_by_type[$playlist->getType()][$playlist->getId()] = $playlist;
}
}
@ -263,13 +264,48 @@ class StationMediaRepository extends BaseRepository
}
protected function _playSongFromPlaylist(Entity\StationPlaylist $playlist)
{
if ($playlist->getSource() === Entity\StationPlaylist::SOURCE_SONGS) {
if ($playlist->getOrder() === Entity\StationPlaylist::ORDER_SEQUENTIAL) {
$media_to_play = $this->_playSequentialSongFromPlaylist($playlist);
} else {
$media_to_play = $this->_playRandomSongFromPlaylist($playlist);
}
if ($media_to_play instanceof Entity\StationMedia) {
$spm = $media_to_play->getItemForPlaylist($playlist);
$spm->played();
$this->_em->persist($spm);
// Log in history
$sh = new Entity\SongHistory($media_to_play->getSong(), $playlist->getStation());
$sh->setPlaylist($playlist);
$sh->setMedia($media_to_play);
$sh->setDuration($media_to_play->getCalculatedLength());
$sh->setTimestampCued(time());
$this->_em->persist($sh);
$this->_em->flush();
return $sh;
}
} else {
return $playlist->getRemoteUrl();
}
return null;
}
protected function _playRandomSongFromPlaylist(Entity\StationPlaylist $playlist)
{
// Get some random songs from playlist.
$random_songs = $this->_em->createQuery('SELECT sm, s, st FROM Entity\StationMedia sm
$random_songs = $this->_em->createQuery('SELECT sm, spm, s, st FROM Entity\StationMedia sm
JOIN sm.song s
JOIN sm.station st
LEFT JOIN sm.playlists sp
WHERE sp.id = :playlist_id
JOIN sm.playlist_items spm
JOIN spm.playlist sp
WHERE spm.playlist_id = :playlist_id
GROUP BY sm.id ORDER BY RAND()')
->setParameter('playlist_id', $playlist->getId())
->setMaxResults(15)
@ -288,28 +324,14 @@ class StationMediaRepository extends BaseRepository
$use_song_ids = false;
break;
} else {
$song_timestamps[$media_row->getSong()->getId()] = 0;
$playlist_item = $media_row->getItemForPlaylist($playlist);
$song_timestamps[$media_row->getSong()->getId()] = $playlist_item->getLastPlayed();
$songs_by_id[$media_row->getSong()->getId()] = $media_row;
}
}
if ($use_song_ids) {
// Get the last played timestamps of each song.
$last_played = $this->_em->createQuery('SELECT sh.song_id AS song_id, MAX(sh.timestamp_cued) AS latest_played
FROM Entity\SongHistory sh
WHERE sh.song_id IN (:ids)
AND sh.station_id = :station_id
AND sh.timestamp_cued != 0
GROUP BY sh.song_id')
->setParameter('ids', array_keys($song_timestamps))
->setParameter('station_id', $playlist->getStation()->getId())
->getArrayResult();
// Sort to always play the least recently played song out of the random selection.
foreach ($last_played as $last_played_row) {
$song_timestamps[$last_played_row['song_id']] = $last_played_row['latest_played'];
}
asort($song_timestamps);
reset($song_timestamps);
$id_to_play = key($song_timestamps);
@ -320,22 +342,47 @@ class StationMediaRepository extends BaseRepository
$random_song = array_pop($random_songs);
}
if ($random_song instanceof Entity\StationMedia) {
// Log in history
$sh = new Entity\SongHistory($random_song->getSong(), $playlist->getStation());
$sh->setPlaylist($playlist);
$sh->setMedia($random_song);
return $random_song;
}
$sh->setDuration($random_song->getCalculatedLength());
$sh->setTimestampCued(time());
$this->_em->persist($sh);
$this->_em->flush();
return $sh;
protected function _playSequentialSongFromPlaylist(Entity\StationPlaylist $playlist)
{
// Fetch the most recently played song
try {
/** @var Entity\StationPlaylistMedia $last_played_media */
$last_played_media = $this->_em->createQuery('SELECT spm FROM Entity\StationPlaylistMedia spm
WHERE spm.playlist_id = :playlist_id
ORDER BY spm.last_played DESC')
->setParameter('playlist_id', $playlist->getId())
->setMaxResults(1)
->getSingleResult();
} catch(NoResultException $e) {
return null;
}
return null;
$last_weight = (int)$last_played_media->getWeight();
// Try to find a song of greater weight. If none exists, start back with zero.
$next_song_query = $this->_em->createQuery('SELECT spm, sm, s, st FROM Entity\StationPlaylistMedia spm
JOIN spm.media sm
JOIN sm.song s
JOIN sm.station st
WHERE spm.weight >= :weight
ORDER BY spm.weight ASC')
->setMaxResults(1);
try {
$next_song = $next_song_query
->setParameter('weight', $last_weight+1)
->getSingleResult();
} catch(NoResultException $e) {
$next_song = $next_song_query
->setParameter('weight', 0)
->getSingleResult();
}
/** @var Entity\StationPlaylistMedia $next_song */
return $next_song->getMedia();
}
/**

View File

@ -0,0 +1,86 @@
<?php
namespace Entity\Repository;
use Doctrine\ORM\NoResultException;
use Entity;
class StationPlaylistMediaRepository extends BaseRepository
{
/**
* Add the specified media to the specified playlist.
* Must flush the EntityManager after using.
*
* @param Entity\StationMedia $media
* @param Entity\StationPlaylist $playlist
* @return int The weight assigned to the newly added record.
*/
public function addMediaToPlaylist(Entity\StationMedia $media, Entity\StationPlaylist $playlist, $weight = 0): int
{
$record = $this->findOneBy([
'media_id' => $media->getId(),
'playlist_id' => $playlist->getId(),
]);
if (($record instanceof Entity\StationPlaylistMedia)) {
if ($weight != 0) {
$record->setWeight($weight);
$this->_em->persist($record);
}
} else {
if ($weight === 0) {
try {
$highest_weight = $this->_em->createQuery('SELECT MAX(e.weight) FROM ' . $this->_entityName . ' e WHERE e.playlist_id = :playlist_id')
->setParameter('playlist_id', $playlist->getId())
->getSingleScalarResult();
} catch (NoResultException $e) {
$highest_weight = 1;
}
$weight = $highest_weight + 1;
}
$record = new Entity\StationPlaylistMedia($playlist, $media);
$record->setWeight($weight);
$this->_em->persist($record);
}
return $weight;
}
/**
* Remove all playlist associations from the specified media object.
*
* @param Entity\StationMedia $media
*/
public function clearPlaylistsFromMedia(Entity\StationMedia $media)
{
$this->_em->createQuery('DELETE FROM '.$this->_entityName.' e WHERE e.media_id = :media_id')
->setParameter('media_id', $media->getId())
->execute();
}
/**
* Set the order of the media, specified as
* [
* media_id => new_weight,
* ...
* ]
*
* @param Entity\StationPlaylist $playlist
* @param $mapping
*/
public function setMediaOrder(Entity\StationPlaylist $playlist, $mapping)
{
$update_query = $this->_em->createQuery('UPDATE '.$this->_entityName.' e
SET e.weight = :weight
WHERE e.playlist_id = :playlist_id AND e.media_id = :media_id')
->setParameter('playlist_id', $playlist->getId());
foreach($mapping as $media_id => $weight) {
$update_query->setParameter('media_id', $media_id)
->setParameter('weight', $weight)
->execute();
}
}
}

View File

@ -147,14 +147,10 @@ class StationMedia
protected $cue_out;
/**
* @ManyToMany(targetEntity="StationPlaylist", inversedBy="media")
* @JoinTable(name="station_playlist_has_media",
* joinColumns={@JoinColumn(name="media_id", referencedColumnName="id", onDelete="CASCADE")},
* inverseJoinColumns={@JoinColumn(name="playlists_id", referencedColumnName="id", onDelete="CASCADE")}
* )
* @OneToMany(targetEntity="StationPlaylistMedia", mappedBy="media")
* @var Collection
*/
protected $playlists;
protected $playlist_items;
/**
* @OneToMany(targetEntity="StationMediaCustomField", mappedBy="media")
@ -172,7 +168,7 @@ class StationMedia
$this->mtime = 0;
$this->playlists = new ArrayCollection;
$this->playlist_items = new ArrayCollection;
$this->custom_fields = new ArrayCollection;
}
@ -483,9 +479,19 @@ class StationMedia
/**
* @return Collection
*/
public function getPlaylists(): Collection
public function getPlaylistItems(): Collection
{
return $this->playlists;
return $this->playlist_items;
}
public function getItemForPlaylist(StationPlaylist $playlist): ?StationPlaylistMedia
{
$item = $this->playlist_items->filter(function($spm) use ($playlist) {
/** @var StationPlaylistMedia $spm */
return ($spm->getPlaylist()->getId() == $playlist->getId());
});
return $item->first() ?? null;
}
/**
@ -691,9 +697,9 @@ class StationMedia
*/
public function isRequestable(): bool
{
$playlists = $this->getPlaylists();
foreach($playlists as $playlist) {
$playlists = $this->getPlaylistItems();
foreach($playlists as $playlist_item) {
$playlist = $playlist_item->getPlaylist();
/** @var StationPlaylist $playlist */
if ($playlist->isRequestable()) {
return true;

View File

@ -22,9 +22,14 @@ class StationPlaylist
public const TYPE_ONCE_PER_DAY = 'once_per_day';
public const TYPE_ADVANCED = 'custom';
public const SOURCE_RANDOM = 'random_songs';
public const SOURCE_SEQUENTIAL = 'sequential_songs';
public const SOURCE_REMOTE_URL = 'remote_url';
public const SOURCE_SONGS = 'songs';
public const SOURCE_REMOTE_URL ='remote_url';
public const ORDER_RANDOM = 'random';
public const ORDER_SEQUENTIAL = 'sequential';
// public const SOURCE_RANDOM = 'random_songs';
// public const SOURCE_SEQUENTIAL = 'sequential_songs';
/**
* @Column(name="id", type="integer")
@ -67,6 +72,18 @@ class StationPlaylist
*/
protected $source;
/**
* @Column(name="playback_order", type="string", length=50)
* @var string
*/
protected $order;
/**
* @Column(name="remote_url", type="string", length=255, nullable=true)
* @var string|null
*/
protected $remote_url;
/**
* @Column(name="is_enabled", type="boolean")
* @var bool
@ -134,17 +151,18 @@ class StationPlaylist
protected $include_in_automation;
/**
* @ManyToMany(targetEntity="StationMedia", mappedBy="playlists", fetch="EXTRA_LAZY")
* @OneToMany(targetEntity="StationPlaylistMedia", mappedBy="playlist", fetch="EXTRA_LAZY")
* @var Collection
*/
protected $media;
protected $media_items;
public function __construct(Station $station)
{
$this->station = $station;
$this->type = self::TYPE_DEFAULT;
$this->source = self::SOURCE_RANDOM;
$this->source = self::SOURCE_SONGS;
$this->order = self::ORDER_RANDOM;
$this->is_enabled = 1;
$this->weight = 3;
@ -156,7 +174,7 @@ class StationPlaylist
$this->schedule_start_time = 0;
$this->schedule_end_time = 0;
$this->media = new ArrayCollection;
$this->media_items = new ArrayCollection;
}
/**
@ -231,6 +249,38 @@ class StationPlaylist
$this->source = $source;
}
/**
* @return string
*/
public function getOrder(): string
{
return $this->order;
}
/**
* @param string $order
*/
public function setOrder(string $order): void
{
$this->order = $order;
}
/**
* @return null|string
*/
public function getRemoteUrl(): ?string
{
return $this->remote_url;
}
/**
* @param null|string $remote_url
*/
public function setRemoteUrl(?string $remote_url): void
{
$this->remote_url = $remote_url;
}
/**
* @return bool
*/
@ -503,9 +553,27 @@ class StationPlaylist
/**
* @return Collection
*/
public function getMedia(): Collection
public function getMediaItems(): Collection
{
return $this->media;
return $this->media_items;
}
/**
* Returns whether a playlist is sufficiently populated to be "cued" as a next song for a station.
*
* @return bool
*/
public function canBeCued(): bool
{
if (!$this->is_enabled) {
return false;
}
if ($this->source === self::SOURCE_REMOTE_URL) {
return !empty($this->remote_url);
}
return $this->media_items->count() > 0;
}
/**
@ -523,7 +591,8 @@ class StationPlaylist
{
case 'm3u':
$playlist_file = [];
foreach ($this->media as $media_file) {
foreach ($this->media_items as $media_item) {
$media_file = $media_item->getMedia();
$media_file_path = $media_path . $media_file->getPath();
$playlist_file[] = $media_file_path;
}
@ -540,9 +609,10 @@ class StationPlaylist
];
$i = 0;
foreach($this->media as $media_file) {
foreach($this->media_items as $media_item) {
$i++;
$media_file = $media_item->getMedia();
$media_file_path = $media_path . $media_file->getPath();
$playlist_file[] = 'File'.$i.'='.$media_file_path;
$playlist_file[] = 'Title'.$i.'='.$media_file->getArtist().' - '.$media_file->getTitle();

View File

@ -0,0 +1,127 @@
<?php
namespace Entity;
/**
* @Table(name="station_playlist_media", uniqueConstraints={
* @UniqueConstraint(name="idx_playlist_media", columns={"playlist_id", "media_id"})
* })
* @Entity(repositoryClass="Entity\Repository\StationPlaylistMediaRepository")
*/
class StationPlaylistMedia
{
/**
* @Column(name="id", type="integer")
* @Id
* @GeneratedValue(strategy="IDENTITY")
* @var int
*/
protected $id;
/**
* @Column(name="playlist_id", type="integer")
* @var int
*/
protected $playlist_id;
/**
* @ManyToOne(targetEntity="StationPlaylist", inversedBy="media_items", fetch="EAGER")
* @JoinColumns({
* @JoinColumn(name="playlist_id", referencedColumnName="id", onDelete="CASCADE")
* })
* @var StationPlaylist
*/
protected $playlist;
/**
* @Column(name="media_id", type="integer")
* @var int
*/
protected $media_id;
/**
* @ManyToOne(targetEntity="StationMedia", inversedBy="playlist_items", fetch="EAGER")
* @JoinColumns({
* @JoinColumn(name="media_id", referencedColumnName="id", onDelete="CASCADE")
* })
* @var StationMedia
*/
protected $media;
/**
* @Column(name="weight", type="smallint")
* @var int
*/
protected $weight;
/**
* @Column(name="last_played", type="integer")
* @var int
*/
protected $last_played;
/**
* StationPlaylistMedia constructor.
* @param StationPlaylist $playlist
* @param StationMedia $media
*/
public function __construct(StationPlaylist $playlist, StationMedia $media)
{
$this->playlist = $playlist;
$this->media = $media;
$this->weight = 0;
$this->last_played = 0;
}
/**
* @return int
*/
public function getId(): int
{
return $this->id;
}
/**
* @return StationPlaylist
*/
public function getPlaylist(): StationPlaylist
{
return $this->playlist;
}
/**
* @return StationMedia
*/
public function getMedia(): StationMedia
{
return $this->media;
}
/**
* @return int
*/
public function getWeight(): int
{
return $this->weight;
}
/**
* @param int $weight
*/
public function setWeight(int $weight): void
{
$this->weight = $weight;
}
/**
* @return int
*/
public function getLastPlayed(): int
{
return $this->last_played;
}
public function played()
{
$this->last_played = time();
}
}

View File

@ -0,0 +1,40 @@
<?php declare(strict_types = 1);
namespace Migration;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
class Version20180428062526 extends AbstractMigration
{
public function up(Schema $schema)
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('CREATE TABLE station_playlist_media (id INT AUTO_INCREMENT NOT NULL, playlist_id INT NOT NULL, media_id INT NOT NULL, weight SMALLINT NOT NULL, last_played INT NOT NULL, INDEX IDX_EA70D7796BBD148 (playlist_id), INDEX IDX_EA70D779EA9FDD75 (media_id), UNIQUE INDEX idx_playlist_media (playlist_id, media_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
$this->addSql('ALTER TABLE station_playlist_media ADD CONSTRAINT FK_EA70D7796BBD148 FOREIGN KEY (playlist_id) REFERENCES station_playlists (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE station_playlist_media ADD CONSTRAINT FK_EA70D779EA9FDD75 FOREIGN KEY (media_id) REFERENCES station_media (id) ON DELETE CASCADE');
$this->addSql('INSERT INTO station_playlist_media (playlist_id, media_id, weight, last_played) SELECT playlists_id, media_id, \'0\', \'0\' FROM station_playlist_has_media');
$this->addSql('DROP TABLE station_playlist_has_media');
}
public function down(Schema $schema)
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('CREATE TABLE station_playlist_has_media (media_id INT NOT NULL, playlists_id INT NOT NULL, INDEX IDX_668E6486EA9FDD75 (media_id), INDEX IDX_668E64869F70CF56 (playlists_id), PRIMARY KEY(media_id, playlists_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB');
$this->addSql('ALTER TABLE station_playlist_has_media ADD CONSTRAINT FK_668E64869F70CF56 FOREIGN KEY (playlists_id) REFERENCES station_playlists (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE station_playlist_has_media ADD CONSTRAINT FK_668E6486EA9FDD75 FOREIGN KEY (media_id) REFERENCES station_media (id) ON DELETE CASCADE');
$this->addSql('INSERT INTO station_playlist_has_media (playlists_id, media_id) SELECT playlist_id, media_id FROM station_playlist_media');
$this->addSql('DROP TABLE station_playlist_media');
}
}

View File

@ -0,0 +1,36 @@
<?php declare(strict_types = 1);
namespace Migration;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
class Version20180429013130 extends AbstractMigration
{
public function up(Schema $schema)
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE station_playlists ADD playback_order VARCHAR(50) NOT NULL, ADD remote_url VARCHAR(255) DEFAULT NULL');
}
public function postUp(Schema $schema)
{
$this->connection->update('station_playlists', [
'source' => 'songs',
'playback_order' => 'random',
], [1 => 1]);
}
public function down(Schema $schema)
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE station_playlists DROP playback_order, DROP remote_url');
}
}

View File

@ -5,17 +5,21 @@
<script type="text/javascript" nonce="<?=$assets->getCspNonce() ?>">
$(function() {
showFieldset($('form #field_type input:checked').val());
showFieldset('type', $('form #field_type input:checked').val());
showFieldset('source', $('form #field_source input:checked').val());
$('form #field_type input').on('change', function() {
showFieldset($(this).val());
showFieldset('type', $(this).val());
});
$('form #field_source input').on('change', function() {
showFieldset('source', $(this).val());
});
});
function showFieldset(fieldset_id)
function showFieldset(fieldset_prefix, fieldset_id)
{
$('form fieldset.type_fieldset').hide();
$('form fieldset#type_'+fieldset_id).show();
$('form fieldset.'+fieldset_prefix+'_fieldset').hide();
$('form fieldset#'+fieldset_prefix+'_'+fieldset_id).show();
}
</script>

View File

@ -1,4 +1,6 @@
<?php
use Entity\StationPlaylist;
$this->layout('main', ['title' => __('Playlists'), 'manual' => true]);
/** @var \AzuraCast\Assets $assets */
@ -26,16 +28,16 @@ $assets->load('fullcalendar');
<table class="table table-striped">
<colgroup>
<col width="25%">
<col width="30%">
<col width="30%">
<col width="15%">
<col width="28%">
<col width="12%">
</colgroup>
<thead>
<tr>
<th><?=__('Actions') ?></th>
<th><?=__('Playlist') ?></th>
<th><?=__('Type') ?></th>
<th><?=__('Scheduling') ?></th>
<th><?=__('# Songs') ?></th>
</tr>
</thead>
@ -45,6 +47,10 @@ $assets->load('fullcalendar');
<td>
<a class="btn btn-sm btn-primary" href="<?=$url->named('stations:playlists:edit', ['station' => $station->getId(), 'id' => $row['id']]) ?>"><?=__('Edit') ?></a>
<?php if ($row['source'] === StationPlaylist::SOURCE_SONGS && $row['order'] === StationPlaylist::ORDER_SEQUENTIAL): ?>
<a class="btn btn-sm btn-default" href="<?=$url->named('stations:playlists:reorder', ['station' => $station->getId(), 'id' => $row['id']]) ?>"><?=__('Reorder') ?></a>
<?php endif; ?>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<?=__('Export') ?> <span class="caret"></span>
@ -61,27 +67,32 @@ $assets->load('fullcalendar');
<big>
<a href="<?=$url->named('stations:files:index', ['station' => $station->getId()]).'#playlist:'.urlencode($row['name']) ?>"><?=$this->e($row['name']) ?></a>
</big>
<br>
<span class="label label-default"><?=(($row['source'] === StationPlaylist::SOURCE_SONGS) ? __('Song-based') : __('Remote URL')) ?></span>
<?php if ($row['source'] === StationPlaylist::SOURCE_SONGS && $row['order'] === StationPlaylist::ORDER_SEQUENTIAL): ?>
<span class="label label-info"><?=__('Sequential') ?></span>
<?php endif; ?>
<?php if ($row['include_in_automation']): ?>
<br><span class="label label-success"><?=__('Auto-Assigned') ?></span>
<span class="label label-success"><?=__('Auto-Assigned') ?></span>
<?php endif; ?>
</td>
<td>
<?php if (!$row['is_enabled']): ?>
<?=__('Disabled') ?>
<?php elseif ($row['type'] === 'default'): ?>
<?=__('Standard Playlist') ?><br>
<?php elseif ($row['type'] === StationPlaylist::TYPE_DEFAULT): ?>
<?=__('General Rotation') ?><br>
<?=__('Weight') ?>: <?=(int)$row['weight'] ?> (<?=$row['probability'] ?>)
<?php elseif ($row['type'] === 'scheduled'): ?>
<?=__('Scheduled Playlist') ?><br>
<?php elseif ($row['type'] === StationPlaylist::TYPE_SCHEDULED): ?>
<?=__('Scheduled') ?><br>
<?=sprintf(__('Plays between %s and %s'), $customization->formatTime(\Entity\StationPlaylist::getTimestamp($row['schedule_start_time'])), $customization->formatTime(\Entity\StationPlaylist::getTimestamp($row['schedule_end_time']))) ?>
<?php elseif ($row['type'] === 'once_per_x_songs'): ?>
<?php elseif ($row['type'] === StationPlaylist::TYPE_ONCE_PER_X_SONGS): ?>
<?=sprintf(__('Once per %d Songs'), $row['play_per_songs']) ?>
<?php elseif ($row['type'] === 'once_per_x_minutes'): ?>
<?php elseif ($row['type'] === StationPlaylist::TYPE_ONCE_PER_X_MINUTES): ?>
<?=sprintf(__('Once per %d Minutes'), $row['play_per_minutes']) ?>
<?php elseif ($row['type'] === 'once_per_day'): ?>
<?php elseif ($row['type'] === StationPlaylist::TYPE_ONCE_PER_DAY): ?>
<?=__('Once per Day') ?><br>
<?=sprintf(__('Plays at %s'), $customization->formatTime(\Entity\StationPlaylist::getTimestamp($row['play_once_time']))) ?>
<?php elseif ($row['type'] === 'custom'): ?>
<?php elseif ($row['type'] === StationPlaylist::TYPE_ADVANCED): ?>
<?=__('Custom') ?>
<?php endif; ?>
</td>

View File

@ -0,0 +1,63 @@
<?php
use Entity\StationPlaylist;
$this->layout('main', ['title' => __('Reorder Playlist'), 'manual' => true]);
/** @var \AzuraCast\Assets $assets */
$assets->load('jquery-sortable');
?>
<div class="card">
<div class="card-header ch-alt">
<h2><?=__('Reorder Playlist: %s', $this->e($playlist->getName())) ?></h2>
</div>
<div class="table-responsive">
<table class="table table-striped sortable">
<colgroup>
<col width="40%">
<col width="30%">
<col width="30%">
</colgroup>
<thead>
<tr>
<th><?=__('Title') ?></th>
<th><?=__('Artist') ?></th>
<th><?=__('Album') ?></th>
</tr>
</thead>
<tbody>
<?php foreach($media_items as $row): ?>
<?php /** @var \Entity\StationMount $row */ ?>
<tr class="vertical-align-middle" data-id="<?=$row['media_id'] ?>">
<td><big><?=$this->e($row['media']['title']) ?></big></td>
<td><?=$this->e($row['media']['artist']) ?></td>
<td><?=$this->e($row['media']['album']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<script type="text/javascript" nonce="<?=$assets->getCspNonce() ?>">
$(function() {
var CSRF = '<?=$csrf ?>';
var group = $('.sortable').sortable({
containerSelector: 'table',
itemPath: '> tbody',
itemSelector: 'tr',
placeholder: '<tr class="placeholder"></tr>',
onDrop: function ($item, container, _super) {
var data = group.sortable("serialize").get();
var jsonString = JSON.stringify(data[0], null, ' ');
$.post('',{
csrf: CSRF,
order: jsonString
},function(data){},'json');
_super($item, container);
}
});
});
</script>

View File

@ -3,6 +3,7 @@
"description": "The AzuraCast standalone radio station management tool.",
"license": "Apache-2.0",
"require": {
"php": ">=7.2",
"roave/security-advisories": "dev-master",
"slim/slim": "^3.0",
"league/plates": "^3.1",

View File

@ -21,10 +21,12 @@ class C05_Station_AutomationCest extends CestAbstract
$this->em->persist($playlist);
$media = new \Entity\StationMedia($this->test_station, 'test.mp3');
$media->getPlaylists()->add($playlist);
$media->loadFromFile();
$this->em->persist($media);
$spm = new \Entity\StationPlaylistMedia($playlist, $media);
$this->em->persist($spm);
$this->em->flush();
$this->em->refresh($this->test_station);

View File

@ -24,12 +24,17 @@ class D02_Api_RequestsCest extends CestAbstract
$this->em->persist($playlist);
$media = new \Entity\StationMedia($this->test_station, 'test.mp3');
$media->getPlaylists()->add($playlist);
$media->loadFromFile();
$this->em->persist($media);
$spm = new \Entity\StationPlaylistMedia($playlist, $media);
$this->em->persist($spm);
$this->em->flush();
$this->em->refresh($media);
$this->em->refresh($playlist);
$station_id = $this->test_station->getId();
$I->sendGET('/api/station/'.$station_id.'/requests');

View File

@ -1,5 +1,5 @@
{
"dist/app.min.js": "dist/app-00181489b8.min.js",
"dist/dark.css": "dist/dark-d2b7d6a1b1.css",
"dist/light.css": "dist/light-cd6466d1ac.css"
"dist/dark.css": "dist/dark-40b72754e4.css",
"dist/light.css": "dist/light-cbf9b4c9b6.css"
}

6
web/static/dist/dark-40b72754e4.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

6
web/static/dist/light-cbf9b4c9b6.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,4 +6,4 @@
@import 'pages/radio';
@import 'pages/public';
@import 'pages/embed';
@import 'pages/timetable';
@import 'pages/playlists';

View File

@ -0,0 +1,38 @@
/* Draggable items from the playlist reorder page */
body.dragging, body.dragging * {
cursor: move !important;
}
.dragged {
position: absolute;
opacity: 0.5;
z-index: 2000;
}
table.sortable {
cursor: pointer;
tr.placeholder {
display: block;
background: @brand-primary;
position: relative;
margin: 0;
padding: 0;
&::before {
content: "";
position: absolute;
width: 0;
height: 0;
border: 5px solid transparent;
border-right-width: 5px;
border-right-style: solid;
border-right-color: transparent;
border-left-color: transparent;
border-left-color: @brand-primary;
margin-top: -5px;
left: 0;
border-right: none;
}
}
}

View File

@ -1,212 +0,0 @@
// dimensions
@timetable-hour-column-width: 96px;
@timetable-hour-row-height: 46px;
@timetable-heading-height: 30px;
// colors & decoration
@timetable-grid-color: @table-border-color;
@timetable-grid: 1px solid @timetable-grid-color;
@timetable-row-header-padding: 15px;
@timetable-row-header-color: @table-bg-accent;
@timetable-legend-row-separator: 1px solid @table-border-color;
@timetable-entry-row-separator: none;
@timetable-row-header-gap: 5px solid transparent;
@timetable-row-uneven-color: transparent;
@timetable-row-even-color: @table-bg-accent;
@timetable-entry-color: @brand-primary;
@timetable-entry-color-hover: darken(@timetable-entry-color, 10%);
@timetable-entry-border: 1px solid darken(@timetable-entry-color, 15%);
@timetable-entry-padding: 10px;
.valign-middle() {
position: relative;
top: 50%;
transform: translateY(-50%);
}
.valign-parent() {
transform-style: preserve-3d;
}
.ellipsis(@width: 100%) {
display: inline-block;
max-width: @width;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-wrap: normal;
}
.timetable {
text-align: center;
display: flex;
flex-direction: row;
align-items: stretch;
width: 100%;
box-sizing: border-box;
.clearfix();
*, *:before, *:after {
box-sizing: inherit;
}
ul, li {
margin: 0;
padding: 0;
list-style-type: none;
}
aside, section {
float: left;
}
aside {
max-width: 30%;
padding: 0 !important;
margin-top: @timetable-hour-row-height;
border-right: @timetable-row-header-gap;
li {
padding: 0 @timetable-row-header-padding;
background-color: @timetable-row-header-color;
line-height: @timetable-hour-row-height;
&:not(:last-of-type) {
border-bottom: @timetable-legend-row-separator;
}
.row-heading {
.ellipsis();
}
}
}
ul {
list-style-type: none;
margin: 0;
}
aside li, time li {
height: @timetable-hour-row-height;
}
section {
flex-grow: 1;
padding: 0 !important;
overflow-x: scroll;
-webkit-overflow-scrolling: touch;
time {
white-space: nowrap;
display: inline-block;
}
header {
height: @timetable-hour-row-height;
transform-style: preserve-3d;
.clearfix();
font-size: 0;
li {
display: inline-block;
font-size: 1rem;
overflow: visible;
width: 0;
line-height: @timetable-hour-row-height;
display: inline-block;
position: relative;
&:not(:last-of-type) {
width: @timetable-hour-column-width
}
.time-label {
display: block;
position: absolute;
left: 0;
}
&:not(:first-of-type) .time-label {
transform: translateX(-50%);
}
&:last-of-type .time-label {
transform: translateX(-100%);
}
}
}
}
ul.room-timeline {
border-left: none;
position: relative;
overflow: hidden;
li {
position: relative;
background-color: @timetable-row-even-color;
&:nth-of-type(odd) {
background-color: @timetable-row-uneven-color;
}
&:first-of-type {
border-top: @timetable-grid;
}
&:last-of-type {
border-bottom: @timetable-grid;
}
&:not(:last-of-type) {
border-bottom: @timetable-entry-row-separator;
}
&:first-child .time-entry {
height: @timetable-hour-row-height - 2px;
}
&:after, &:before {
content: "";
position: absolute;
z-index: 1;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
&:before {
background-image: linear-gradient(to right, @timetable-grid-color 1px, transparent 1px);
background-size: @timetable-hour-column-width / 4 auto;
}
&:after {
background-image: linear-gradient(to right, @timetable-grid-color, @timetable-grid-color 1px, @timetable-row-even-color 1px, @timetable-row-even-color 2px, @timetable-grid-color 2px, @timetable-grid-color 3px, transparent 3px, transparent);
background-size: @timetable-hour-column-width auto;
background-position: -2px 0;
}
}
}
.time-entry {
background-color: @timetable-entry-color;
transition: 200ms background-color;
height: @timetable-hour-row-height - 1px;
display: block;
position: absolute;
z-index: 2;
padding: 0 @timetable-entry-padding;
white-space: normal;
overflow: hidden;
color: white;
border: @timetable-entry-border;
.valign-parent();
small {
.valign-middle();
display: block;
}
&:hover {
background-color: @timetable-entry-color-hover;
}
}
}

2387
web/static/yarn-error.log Normal file

File diff suppressed because it is too large Load Diff