4
0
mirror of https://github.com/AzuraCast/AzuraCast.git synced 2024-06-26 19:07:05 +00:00

#1176 -- Refactor AutoDJ to "weight" playlists of all types.

This commit is contained in:
Buster "Silver Eagle" Neece 2019-02-18 21:08:01 -06:00
parent 50f4a1a394
commit 9a7d0f92db
3 changed files with 282 additions and 197 deletions

View File

@ -442,65 +442,6 @@ class StationPlaylist
$this->schedule_days = implode(',', (array)$schedule_days);
}
/**
* Returns whether the playlist is scheduled to play according to schedule rules.
*
* @param Chronos|null $now
* @return bool
*/
public function canPlayScheduled(Chronos $now = null): bool
{
if ($now === null) {
$now = Chronos::now(new \DateTimeZone('UTC'));
}
$day_to_check = (int)$now->format('N');
$current_timecode = self::getCurrentTimeCode($now);
$schedule_start_time = $this->getScheduleStartTime();
$schedule_end_time = $this->getScheduleEndTime();
// Handle all-day playlists.
if ($schedule_start_time === $schedule_end_time) {
return $this->canPlayScheduledOnDay($day_to_check);
}
// Special handling for playlists ending at midnight (hour code "000").
if (0 === $schedule_end_time) {
$schedule_end_time = 2400;
}
// Handle overnight playlists that stretch into the next day.
if ($schedule_end_time < $schedule_start_time) {
if ($current_timecode <= $schedule_end_time) {
// Check the previous day, since it's before the end time.
$day_to_check = (1 === $day_to_check) ? 7 : $day_to_check - 1;
} else if ($current_timecode < $schedule_start_time) {
// The playlist shouldn't be playing before the start time on the current date.
return false;
}
return $this->canPlayScheduledOnDay($day_to_check);
}
// Non-overnight playlist check
return $this->canPlayScheduledOnDay($day_to_check) &&
($current_timecode >= $schedule_start_time && $current_timecode <= $schedule_end_time);
}
/**
* Given a day code (1-7) a-la date('N'), return if the playlist can be played on that day.
*
* @param int $day_to_check
* @return bool
*/
public function canPlayScheduledOnDay($day_to_check): bool
{
$play_once_days = $this->getScheduleDays();
return empty($play_once_days)
|| in_array($day_to_check, $play_once_days);
}
/**
* @return int
*/
@ -541,26 +482,6 @@ class StationPlaylist
$this->play_once_days = implode(',', (array)$play_once_days);
}
/**
* Returns whether the playlist is scheduled to play once.
* @return bool
*/
public function canPlayOnce(): bool
{
$play_once_days = $this->getPlayOnceDays();
if (!empty($play_once_days) && !in_array(gmdate('N'), $play_once_days)) {
return false;
}
$current_timecode = self::getCurrentTimeCode();
$playlist_play_time = $this->getPlayOnceTime();
$playlist_diff = $current_timecode - $playlist_play_time;
return ($playlist_diff > 0 && $playlist_diff <= 15);
}
/**
* @return int
*/
@ -569,6 +490,25 @@ class StationPlaylist
return $this->weight;
}
/**
* Returns the "calculated" weight, factoring in the total number of songs in each playlist.
*
* @return int
*/
public function getCalculatedWeight(): int
{
$weight = $this->weight;
if ($weight < 1) {
$weight = 1;
}
if (self::SOURCE_SONGS === $this->source) {
$weight *= $this->media_items->count();
}
return $weight;
}
/**
* @param int $weight
*/
@ -627,6 +567,175 @@ class StationPlaylist
return $this->media_items;
}
/**
* @param array $recentSongHistory
* @return bool
*/
public function canPlay(array $recentSongHistory = []): bool
{
switch($this->type) {
case self::TYPE_ONCE_PER_DAY:
return $this->canPlayOnce($recentSongHistory);
break;
case self::TYPE_ONCE_PER_X_SONGS:
return !$this->wasPlayedRecently($recentSongHistory, $this->getPlayPerSongs());
break;
case self::TYPE_ONCE_PER_X_MINUTES:
return $this->canPlayPerMinutes($recentSongHistory);
break;
case self::TYPE_SCHEDULED:
return $this->canPlayScheduled();
break;
case self::TYPE_ADVANCED:
return false;
break;
case self::TYPE_DEFAULT:
default:
return true;
break;
}
}
/**
* Returns whether the playlist is scheduled to play according to schedule rules.
*
* @param Chronos|null $now
* @return bool
*/
public function canPlayScheduled(Chronos $now = null): bool
{
if ($now === null) {
$now = Chronos::now(new \DateTimeZone('UTC'));
}
$day_to_check = (int)$now->format('N');
$current_timecode = self::getCurrentTimeCode($now);
$schedule_start_time = $this->getScheduleStartTime();
$schedule_end_time = $this->getScheduleEndTime();
// Handle all-day playlists.
if ($schedule_start_time === $schedule_end_time) {
return $this->canPlayScheduledOnDay($day_to_check);
}
// Special handling for playlists ending at midnight (hour code "000").
if (0 === $schedule_end_time) {
$schedule_end_time = 2400;
}
// Handle overnight playlists that stretch into the next day.
if ($schedule_end_time < $schedule_start_time) {
if ($current_timecode <= $schedule_end_time) {
// Check the previous day, since it's before the end time.
$day_to_check = (1 === $day_to_check) ? 7 : $day_to_check - 1;
} else if ($current_timecode < $schedule_start_time) {
// The playlist shouldn't be playing before the start time on the current date.
return false;
}
return $this->canPlayScheduledOnDay($day_to_check);
}
// Non-overnight playlist check
return $this->canPlayScheduledOnDay($day_to_check) &&
($current_timecode >= $schedule_start_time && $current_timecode <= $schedule_end_time);
}
/**
* Given a day code (1-7) a-la date('N'), return if the playlist can be played on that day.
*
* @param int $day_to_check
* @return bool
*/
protected function canPlayScheduledOnDay($day_to_check): bool
{
$play_once_days = $this->getScheduleDays();
return empty($play_once_days)
|| in_array($day_to_check, $play_once_days);
}
/**
* @param array $songHistoryEntries
* @return bool
*/
public function canPlayPerMinutes(array $songHistoryEntries = []): bool
{
$threshold = time() - ($this->getPlayPerMinutes() * 60);
$was_played = false;
foreach($songHistoryEntries as $sh_row) {
if ($sh_row['timestamp_cued'] < $threshold) {
break;
}
if ((int)$sh_row['playlist_id'] === $this->getId()) {
$was_played = true;
break;
}
}
reset($songHistoryEntries);
return !$was_played;
}
/**
* Returns whether the playlist is scheduled to play once.
*
* @param array $songHistoryEntries
* @return bool
*/
public function canPlayOnce(array $songHistoryEntries = []): bool
{
$play_once_days = $this->getPlayOnceDays();
if (!empty($play_once_days) && !in_array(gmdate('N'), $play_once_days)) {
return false;
}
$current_timecode = self::getCurrentTimeCode();
$playlist_play_time = $this->getPlayOnceTime();
$playlist_diff = $current_timecode - $playlist_play_time;
if ($playlist_diff <= 0 || $playlist_diff > 15) {
return false;
}
return !$this->wasPlayedRecently($songHistoryEntries);
}
/**
* @param array $songHistoryEntries
* @param int $length
* @return bool
*/
public function wasPlayedRecently(array $songHistoryEntries = [], $length = 15): bool
{
if (empty($songHistoryEntries)) {
return true;
}
// Check if already played
$relevant_song_history = array_slice($songHistoryEntries, 0, 15);
$was_played = false;
foreach($relevant_song_history as $sh_row) {
if ((int)$sh_row['playlist_id'] === $this->id) {
$was_played = true;
break;
}
}
reset($songHistoryEntries);
return $was_played;
}
/**
* Export the playlist into a reusable format.
*

View File

@ -25,7 +25,8 @@ class RadioProvider implements ServiceProviderInterface
$di[Radio\AutoDJ::class] = function($di) {
return new Radio\AutoDJ(
$di[\Doctrine\ORM\EntityManager::class],
$di[\Azura\EventDispatcher::class]
$di[\Azura\EventDispatcher::class],
$di[\Monolog\Logger::class]
);
};

View File

@ -6,6 +6,7 @@ use App\Event\Radio\GetNextSong;
use App\Radio\Backend\Liquidsoap;
use Azura\EventDispatcher;
use Doctrine\ORM\EntityManager;
use Monolog\Logger;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class AutoDJ implements EventSubscriberInterface
@ -16,18 +17,29 @@ class AutoDJ implements EventSubscriberInterface
/** @var EventDispatcher */
protected $dispatcher;
/** @var Logger */
protected $logger;
/**
* @param EntityManager $em
* @param EventDispatcher $dispatcher
* @param Logger $logger
*
* @see \App\Provider\RadioProvider
*/
public function __construct(EntityManager $em, EventDispatcher $dispatcher)
public function __construct(
EntityManager $em,
EventDispatcher $dispatcher,
Logger $logger)
{
$this->em = $em;
$this->dispatcher = $dispatcher;
$this->logger = $logger;
}
/**
* @return array
*/
public static function getSubscribedEvents()
{
return [
@ -53,9 +65,19 @@ class AutoDJ implements EventSubscriberInterface
return null;
}
$this->logger->pushProcessor(function($record) use ($station) {
$record['extra']['station'] = [
'id' => $station->getId(),
'name' => $station->getName(),
];
return $record;
});
$event = new GetNextSong($station);
$this->dispatcher->dispatch(GetNextSong::NAME, $event);
$this->logger->popProcessor();
$next_song = $event->getNextSong();
if ($next_song instanceof Entity\SongHistory && $is_autodj) {
@ -72,10 +94,10 @@ class AutoDJ implements EventSubscriberInterface
return $next_song;
}
public function checkDatabaseForNextSong(GetNextSong $event)
public function checkDatabaseForNextSong(GetNextSong $event): void
{
$next_song = $this->em->createQuery('SELECT sh, s, sp, sm
FROM ' . Entity\SongHistory::class . ' sh
$nextSongQuery = /** @lang DQL */ 'SELECT sh, s, sp, sm
FROM App\Entity\SongHistory sh
LEFT JOIN sh.song s
LEFT JOIN sh.media sm
LEFT JOIN sh.playlist sp
@ -84,17 +106,23 @@ class AutoDJ implements EventSubscriberInterface
AND sh.sent_to_autodj = 0
AND sh.timestamp_start = 0
AND sh.timestamp_end = 0
ORDER BY sh.id DESC')
ORDER BY sh.id DESC';
$next_song = $this->em->createQuery($nextSongQuery)
->setParameter('station_id', $event->getStation()->getId())
->setMaxResults(1)
->getOneOrNullResult();
if ($next_song instanceof Entity\SongHistory) {
$this->logger->debug('Database has a next song already registered.');
$event->setNextSong($next_song);
}
}
public function getNextSongFromRequests(GetNextSong $event)
/**
* @param GetNextSong $event
*/
public function getNextSongFromRequests(GetNextSong $event): void
{
$station = $event->getStation();
@ -107,16 +135,22 @@ class AutoDJ implements EventSubscriberInterface
$threshold = time() - ($threshold_minutes * 60);
// Look up all requests that have at least waited as long as the threshold.
$request = $this->em->createQuery('SELECT sr, sm
FROM '.Entity\StationRequest::class.' sr JOIN sr.track sm
WHERE sr.played_at = 0 AND sr.station_id = :station_id AND sr.timestamp <= :threshold
ORDER BY sr.id ASC')
$requestQuery = /** @lang DQL */ 'SELECT sr, sm
FROM App\Entity\StationRequest sr JOIN sr.track sm
WHERE sr.played_at = 0
AND sr.station_id = :station_id
AND sr.timestamp <= :threshold
ORDER BY sr.id ASC';
$request = $this->em->createQuery($requestQuery)
->setParameter('station_id', $station->getId())
->setParameter('threshold', $threshold)
->setMaxResults(1)
->getOneOrNullResult();
if ($request instanceof Entity\StationRequest) {
$this->logger->debug(sprintf('Queueing next song from request ID %d.', $request->getId()));
$event->setNextSong($this->_playSongFromRequest($request));
}
}
@ -127,10 +161,8 @@ class AutoDJ implements EventSubscriberInterface
*
* @param Entity\StationRequest $request
* @return Entity\SongHistory
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
protected function _playSongFromRequest(Entity\StationRequest $request)
protected function _playSongFromRequest(Entity\StationRequest $request): Entity\SongHistory
{
// Log in history
$sh = new Entity\SongHistory($request->getTrack()->getSong(), $request->getStation());
@ -158,133 +190,74 @@ class AutoDJ implements EventSubscriberInterface
{
$station = $event->getStation();
$songHistoryCount = 15;
// Pull all active, non-empty playlists and sort by type.
$playlists_by_type = [];
foreach($station->getPlaylists() as $playlist) {
/** @var Entity\StationPlaylist $playlist */
if ($playlist->isPlayable()) {
$playlists_by_type[$playlist->getType()][$playlist->getId()] = $playlist;
$type = $playlist->getType();
if (Entity\StationPlaylist::TYPE_ONCE_PER_X_SONGS === $type) {
$songHistoryCount = max($songHistoryCount, $playlist->getPlayPerSongs());
}
$playlists_by_type[$type][$playlist->getId()] = $playlist;
}
}
// Pull all recent cued songs for easy referencing below.
$cued_song_history = $this->em->createQuery('SELECT sh FROM '.Entity\SongHistory::class.' sh
$cuedSongHistoryQuery = /** @lang DQL */ 'SELECT sh
FROM App\Entity\SongHistory sh
WHERE sh.station_id = :station_id
AND (sh.timestamp_cued != 0 AND sh.timestamp_cued IS NOT NULL)
AND sh.timestamp_cued >= :threshold
ORDER BY sh.timestamp_cued DESC')
ORDER BY sh.timestamp_cued DESC';
$cued_song_history = $this->em->createQuery($cuedSongHistoryQuery)
->setParameter('station_id', $station->getId())
->setParameter('threshold', time()-86399)
->getArrayResult();
// Once per day playlists
if (!empty($playlists_by_type['once_per_day'])) {
foreach ($playlists_by_type['once_per_day'] as $playlist) {
/** @var Entity\StationPlaylist $playlist */
if ($playlist->canPlayOnce()) {
// Check if already played
$relevant_song_history = array_slice($cued_song_history, 0, 15);
// Types of playlists that should play, sorted by priority.
$typesToPlay = [
Entity\StationPlaylist::TYPE_ONCE_PER_DAY,
Entity\StationPlaylist::TYPE_ONCE_PER_X_SONGS,
Entity\StationPlaylist::TYPE_ONCE_PER_X_MINUTES,
Entity\StationPlaylist::TYPE_SCHEDULED,
Entity\StationPlaylist::TYPE_DEFAULT,
];
$was_played = false;
foreach($relevant_song_history as $sh_row) {
if ($sh_row['playlist_id'] == $playlist->getId()) {
$was_played = true;
break;
}
}
if (!$was_played) {
if ($event->setNextSong($this->_playSongFromPlaylist($playlist))) {
return;
};
}
reset($cued_song_history);
}
}
}
// Once per X songs playlists
if (!empty($playlists_by_type['once_per_x_songs'])) {
foreach($playlists_by_type['once_per_x_songs'] as $playlist) {
/** @var Entity\StationPlaylist $playlist */
$relevant_song_history = array_slice($cued_song_history, 0, $playlist->getPlayPerSongs());
$was_played = false;
foreach($relevant_song_history as $sh_row) {
if ($sh_row['playlist_id'] == $playlist->getId()) {
$was_played = true;
break;
}
}
if (!$was_played) {
if ($event->setNextSong($this->_playSongFromPlaylist($playlist))) {
return;
};
}
reset($cued_song_history);
}
}
// Once per X minutes playlists
if (!empty($playlists_by_type['once_per_x_minutes'])) {
foreach($playlists_by_type['once_per_x_minutes'] as $playlist) {
/** @var Entity\StationPlaylist $playlist */
$threshold = time() - ($playlist->getPlayPerMinutes() * 60);
$was_played = false;
foreach($cued_song_history as $sh_row) {
if ($sh_row['timestamp_cued'] < $threshold) {
break;
} else if ($sh_row['playlist_id'] == $playlist->getId()) {
$was_played = true;
break;
}
}
if (!$was_played) {
if ($event->setNextSong($this->_playSongFromPlaylist($playlist))) {
return;
};
}
reset($cued_song_history);
}
}
// Time-block scheduled playlists
if (!empty($playlists_by_type['scheduled'])) {
foreach ($playlists_by_type['scheduled'] as $playlist) {
/** @var Entity\StationPlaylist $playlist */
if ($playlist->canPlayScheduled()) {
if ($event->setNextSong($this->_playSongFromPlaylist($playlist))) {
return;
};
}
}
}
// Default rotation playlists
if (!empty($playlists_by_type['default'])) {
$playlist_weights = [];
foreach ($playlists_by_type['default'] as $playlist_id => $playlist) {
/** @var Entity\StationPlaylist $playlist */
$playlist_weights[$playlist_id] = $playlist->getWeight();
foreach($typesToPlay as $type) {
if (empty($playlists_by_type[$type])) {
continue;
}
$rand = random_int(1, (int)array_sum($playlist_weights));
foreach ($playlist_weights as $playlist_id => $weight) {
$eligible_playlists = [];
foreach($playlists_by_type[$type] as $playlist_id => $playlist) {
/** @var Entity\StationPlaylist $playlist */
if ($playlist->canPlay($cued_song_history)) {
$eligible_playlists[$playlist_id] = $playlist->getCalculatedWeight();
}
}
if (empty($eligible_playlists)) {
continue;
}
$this->logger->debug(sprintf('Playable playlists of type "%s" found.', $type), $eligible_playlists);
// Shuffle playlists by weight.
$rand = random_int(1, (int)array_sum($eligible_playlists));
foreach ($eligible_playlists as $playlist_id => $weight) {
$rand -= $weight;
if ($rand <= 0) {
$playlist = $playlists_by_type['default'][$playlist_id];
$playlist = $playlists_by_type[$type][$playlist_id];
if ($event->setNextSong($this->_playSongFromPlaylist($playlist))) {
return;
};
}
}
}
}
@ -326,7 +299,9 @@ class AutoDJ implements EventSubscriberInterface
$this->em->flush();
return $sh;
} else if (is_array($media_to_play)) {
}
if (is_array($media_to_play)) {
[$media_uri, $media_duration] = $media_to_play;
$sh = new Entity\SongHistory($song_repo->getOrCreate([