- 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:
parent
de80e76f05
commit
1f08c307e0
|
@ -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
|
|
@ -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'],
|
||||
],
|
||||
];
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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' => [
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
]);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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"
|
||||
}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -6,4 +6,4 @@
|
|||
@import 'pages/radio';
|
||||
@import 'pages/public';
|
||||
@import 'pages/embed';
|
||||
@import 'pages/timetable';
|
||||
@import 'pages/playlists';
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue