#757 -- Refactor the AzuraCast AutoDJ into its own class, employ new shuffle method.

This commit is contained in:
Buster "Silver Eagle" Neece 2018-08-29 01:30:09 -05:00
parent 77541fc276
commit f19a114881
7 changed files with 420 additions and 149 deletions

View file

@ -159,6 +159,15 @@ services:
- "8888:8888"
command: "chronograf --influxdb-url=http://influxdb:8086"
redis-commander:
image: rediscommander/redis-commander:latest
depends_on:
- redis
environment:
REDIS_HOSTS: "local-app:redis:6379:0,local-sessions:redis:6379:1,local-doctrine:redis:6379:2"
ports:
- "127.0.0.1:8081:8081"
volumes:
nginx_letsencrypt_certs: {}
nginx_letsencrypt_www: {}

View file

@ -343,143 +343,4 @@ class SongHistoryRepository extends BaseRepository
return null;
}
protected function _playSongFromRequest(Entity\StationRequest $request)
{
// Log in history
$sh = new Entity\SongHistory($request->getTrack()->getSong(), $request->getStation());
$sh->setRequest($request);
$sh->setMedia($request->getTrack());
$sh->setDuration($request->getTrack()->getCalculatedLength());
$sh->setTimestampCued(time());
$this->_em->persist($sh);
$request->setPlayedAt(time());
$this->_em->persist($request);
$this->_em->flush();
return $sh;
}
protected function _playSongFromPlaylist(Entity\StationPlaylist $playlist)
{
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;
}
return null;
}
protected function _playRandomSongFromPlaylist(Entity\StationPlaylist $playlist)
{
// Get some random songs from playlist.
$random_songs = $this->_em->createQuery('SELECT sm, spm, s, st FROM '.Entity\StationMedia::class.' sm
JOIN sm.song s
JOIN sm.station st
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)
->execute();
/** @var bool Whether to use "last song ID played" or "genuine random shuffle" mode. */
$use_song_ids = true;
// Get all song IDs from the random songs.
$song_timestamps = [];
$songs_by_id = [];
foreach($random_songs as $media_row) {
/** @var Entity\StationMedia $media_row */
if ($media_row->getLength() == 0) {
$use_song_ids = false;
break;
} else {
$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) {
asort($song_timestamps);
reset($song_timestamps);
$id_to_play = key($song_timestamps);
$random_song = $songs_by_id[$id_to_play];
} else {
shuffle($random_songs);
$random_song = array_pop($random_songs);
}
return $random_song;
}
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::class.' 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;
}
$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::class.' spm
JOIN spm.media sm
JOIN sm.song s
JOIN sm.station st
WHERE spm.playlist_id = :playlist_id
AND spm.weight >= :weight
ORDER BY spm.weight ASC')
->setParameter('playlist_id', $playlist->getId())
->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

@ -2,6 +2,7 @@
namespace App\Provider;
use App\Radio\Adapters;
use App\Radio\AutoDJ;
use App\Radio\Backend;
use App\Radio\Configuration;
use App\Radio\Frontend;
@ -22,6 +23,13 @@ class RadioProvider implements ServiceProviderInterface
]));
};
$di[AutoDJ::class] = function($di) {
return new AutoDJ(
$di[\Doctrine\ORM\EntityManager::class],
$di[\App\Cache::class]
);
};
$di[Configuration::class] = function($di) {
return new Configuration(
$di[\Doctrine\ORM\EntityManager::class],
@ -34,7 +42,8 @@ class RadioProvider implements ServiceProviderInterface
return new Backend\Liquidsoap(
$di[\Doctrine\ORM\EntityManager::class],
$di[\Supervisor\Supervisor::class],
$di[\Monolog\Logger::class]
$di[\Monolog\Logger::class],
$di[AutoDJ::class]
);
});

View file

@ -57,7 +57,8 @@ class SyncProvider implements ServiceProviderInterface
$di[\App\Cache::class],
$di[\App\Radio\Adapters::class],
$di[\App\Webhook\Dispatcher::class],
$di[\App\ApiUtilities::class]
$di[\App\ApiUtilities::class],
$di[\App\Radio\AutoDJ::class]
);
};

360
src/Radio/AutoDJ.php Normal file
View file

@ -0,0 +1,360 @@
<?php
namespace App\Radio;
use App\Entity;
use Doctrine\ORM\EntityManager;
use App\Cache;
class AutoDJ
{
/** @var int The time to live (in seconds) of cached playlist queues. */
const CACHE_TTL = 43200;
/** @var EntityManager */
protected $em;
/** @var Cache */
protected $cache;
/**
* @param EntityManager $em
* @param Cache $cache
* @see \App\Provider\RadioProvider
*/
public function __construct(EntityManager $em, Cache $cache)
{
$this->em = $em;
$this->cache = $cache;
}
/**
* If the next song for a station has already been calculated, return the calculated result; otherwise,
* calculate the next playing song.
*
* @param Entity\Station $station
* @param bool $is_autodj
* @return Entity\SongHistory|null
*/
public function getNextSong(Entity\Station $station, $is_autodj = false): ?Entity\SongHistory
{
if ($station->useManualAutoDJ()) {
return null;
}
$next_song = $this->em->createQuery('SELECT sh, s, sm
FROM ' . Entity\SongHistory::class . ' sh JOIN sh.song s JOIN sh.media sm
WHERE sh.station_id = :station_id
AND sh.timestamp_cued >= :threshold
AND sh.sent_to_autodj = 0
AND sh.timestamp_start = 0
AND sh.timestamp_end = 0
ORDER BY sh.id DESC')
->setParameter('station_id', $station->getId())
->setParameter('threshold', time() - 60 * 15)
->setMaxResults(1)
->getOneOrNullResult();
if (!($next_song instanceof Entity\SongHistory)) {
$next_song = $this->calculateNextSong($station);
}
if ($next_song instanceof Entity\SongHistory && $is_autodj) {
$next_song->sentToAutodj();
$this->em->persist($next_song);
// The "get next song" function is only called when a streamer is not live
$station->setIsStreamerLive(false);
$this->em->persist($station);
$this->em->flush();
}
return $next_song;
}
/**
* Determine the next-playing song for this station based on its playlist rotation rules.
*
* @param Entity\Station $station
* @return Entity\SongHistory|null
*/
public function calculateNextSong(Entity\Station $station)
{
// Process requests first (if applicable)
if ($station->getEnableRequests()) {
$min_minutes = (int)$station->getRequestDelay();
$threshold_minutes = $min_minutes + mt_rand(0, $min_minutes);
$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')
->setParameter('station_id', $station->getId())
->setParameter('threshold', $threshold)
->setMaxResults(1)
->getOneOrNullResult();
if ($request instanceof Entity\StationRequest) {
return $this->_playSongFromRequest($request);
}
}
// Pull all active, non-empty playlists and sort by type.
$playlists_by_type = [];
foreach($station->getPlaylists() as $playlist) {
/** @var Entity\StationPlaylist $playlist */
// Don't include empty playlists or nonstandard ones
if ($playlist->getIsEnabled()
&& $playlist->getSource() === Entity\StationPlaylist::SOURCE_SONGS
&& $playlist->getMediaItems()->count() > 0) {
$playlists_by_type[$playlist->getType()][$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
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')
->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);
$was_played = false;
foreach($relevant_song_history as $sh_row) {
if ($sh_row['playlist_id'] == $playlist->getId()) {
$was_played = true;
break;
}
}
if (!$was_played) {
$sh = $this->_playSongFromPlaylist($playlist);
if ($sh) {
return $sh;
}
}
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) {
$sh = $this->_playSongFromPlaylist($playlist);
if ($sh) {
return $sh;
}
}
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) {
$sh = $this->_playSongFromPlaylist($playlist);
if ($sh) {
return $sh;
}
}
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()) {
$sh = $this->_playSongFromPlaylist($playlist);
if ($sh) {
return $sh;
}
}
}
}
// 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();
}
$rand = random_int(1, (int)array_sum($playlist_weights));
foreach ($playlist_weights as $playlist_id => $weight) {
$rand -= $weight;
if ($rand <= 0) {
$playlist = $playlists_by_type['default'][$playlist_id];
$sh = $this->_playSongFromPlaylist($playlist);
if ($sh) {
return $sh;
}
}
}
}
return null;
}
/**
* Mark a playlist's cache as invalidated and force regeneration on the next "next song" call.
*
* @param Entity\StationPlaylist $playlist
*/
public function clearPlaybackCache(Entity\StationPlaylist $playlist): void
{
$this->cache->remove($this->_getCacheName($playlist));
}
/**
* Given a StationRequest object, create a new SongHistory entry that cues the requested song to play next.
*
* @param Entity\StationRequest $request
* @return Entity\SongHistory
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
protected function _playSongFromRequest(Entity\StationRequest $request)
{
// Log in history
$sh = new Entity\SongHistory($request->getTrack()->getSong(), $request->getStation());
$sh->setRequest($request);
$sh->setMedia($request->getTrack());
$sh->setDuration($request->getTrack()->getCalculatedLength());
$sh->setTimestampCued(time());
$this->em->persist($sh);
$request->setPlayedAt(time());
$this->em->persist($request);
$this->em->flush();
return $sh;
}
/**
* Given a specified (sequential or shuffled) playlist, choose a song from the playlist to play and return it.
*
* @param Entity\StationPlaylist $playlist
* @return Entity\SongHistory|null
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
protected function _playSongFromPlaylist(Entity\StationPlaylist $playlist)
{
$cache_name = $this->_getCacheName($playlist);
$media_queue = (array)$this->cache->get($cache_name);
if (empty($media_queue)) {
$all_media = $this->em->createQuery('SELECT sm.id FROM '.Entity\StationMedia::class.' sm
JOIN sm.playlist_items spm
WHERE spm.playlist_id = :playlist_id
ORDER BY spm.weight ASC')
->setParameter('playlist_id', $playlist->getId())
->getArrayResult();
$media_queue = [];
foreach($all_media as $media_row) {
$media_queue[] = $media_row['id'];
}
if ($playlist->getOrder() === Entity\StationPlaylist::ORDER_RANDOM) {
shuffle($media_queue);
}
}
$media_id = array_shift($media_queue);
// Save the modified cache, sans the now-missing entry.
$this->cache->set($media_queue, $cache_name, self::CACHE_TTL);
/** @var Entity\Repository\StationMediaRepository $media_repo */
$media_repo = $this->em->getRepository(Entity\StationMedia::class);
$media_to_play = $media_repo->find($media_id);
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;
}
return null;
}
/**
* Get the cache name for the given playlist.
*
* @param Entity\StationPlaylist $playlist
* @return string
*/
protected function _getCacheName(Entity\StationPlaylist $playlist): string
{
return 'autodj/playlist_'.$playlist->getId().'_'.$playlist->getOrder().'_'.$playlist->getType();
}
}

View file

@ -1,11 +1,31 @@
<?php
namespace App\Radio\Backend;
use App\Radio\AutoDJ;
use Doctrine\ORM\EntityManager;
use App\Entity;
use Monolog\Logger;
use Supervisor\Supervisor;
class Liquidsoap extends BackendAbstract
{
/** @var AutoDJ */
protected $autodj;
/**
* @param EntityManager $em
* @param Supervisor $supervisor
* @param Logger $logger
* @param AutoDJ $autodj
* @see \App\Provider\RadioProvider
*/
public function __construct(EntityManager $em, Supervisor $supervisor, Logger $logger, AutoDJ $autodj)
{
parent::__construct($em, $supervisor, $logger);
$this->autodj = $autodj;
}
/**
* @inheritdoc
*/
@ -715,11 +735,8 @@ class Liquidsoap extends BackendAbstract
public function getNextSong($as_autodj = false)
{
/** @var Entity\Repository\SongHistoryRepository $history_repo */
$history_repo = $this->em->getRepository(Entity\SongHistory::class);
/** @var Entity\SongHistory|null $sh */
$sh = $history_repo->getNextSongForStation($this->station, $as_autodj);
$sh = $this->autodj->getNextSong($this->station, $as_autodj);
if ($sh instanceof Entity\SongHistory) {
$media = $sh->getMedia();

View file

@ -2,7 +2,7 @@
namespace App\Sync\Task;
use App\Cache;
use App\Url;
use App\Radio\AutoDJ;
use App\ApiUtilities;
use App\Radio\Adapters;
use App\Webhook\Dispatcher;
@ -24,6 +24,9 @@ class NowPlaying extends TaskAbstract
/** @var Adapters */
protected $adapters;
/** @var AutoDJ */
protected $autodj;
/** @var Dispatcher */
protected $webhook_dispatcher;
@ -45,8 +48,18 @@ class NowPlaying extends TaskAbstract
/** @var string */
protected $analytics_level;
/**
* @param EntityManager $em
* @param Database $influx
* @param Cache $cache
* @param Adapters $adapters
* @param Dispatcher $webhook_dispatcher
* @param ApiUtilities $api_utils
* @param AutoDJ $autodj
* @see \App\Provider\SyncProvider
*/
public function __construct(EntityManager $em, Database $influx, Cache $cache, Adapters $adapters,
Dispatcher $webhook_dispatcher, ApiUtilities $api_utils)
Dispatcher $webhook_dispatcher, ApiUtilities $api_utils, AutoDJ $autodj)
{
$this->em = $em;
$this->influx = $influx;
@ -54,6 +67,7 @@ class NowPlaying extends TaskAbstract
$this->adapters = $adapters;
$this->webhook_dispatcher = $webhook_dispatcher;
$this->api_utils = $api_utils;
$this->autodj = $autodj;
$this->history_repo = $this->em->getRepository(Entity\SongHistory::class);
$this->song_repo = $this->em->getRepository(Entity\Song::class);
@ -178,7 +192,7 @@ class NowPlaying extends TaskAbstract
$np->song_history = $this->history_repo->getHistoryForStation($station, $this->api_utils);
$next_song = $this->history_repo->getNextSongForStation($station);
$next_song = $this->autodj->getNextSong($station);
if ($next_song instanceof Entity\SongHistory) {
$np->playing_next = $next_song->api(new Entity\Api\SongHistory, $this->api_utils);
} else {
@ -206,7 +220,7 @@ class NowPlaying extends TaskAbstract
$np->song_history = $this->history_repo->getHistoryForStation($station, $this->api_utils);
$next_song = $this->history_repo->getNextSongForStation($station);
$next_song = $this->autodj->getNextSong($station);
if ($next_song instanceof Entity\SongHistory) {
$np->playing_next = $next_song->api(new Entity\Api\SongHistory, $this->api_utils);