diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 209341989..a0fa50461 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: {} diff --git a/src/Entity/Repository/SongHistoryRepository.php b/src/Entity/Repository/SongHistoryRepository.php index c51901dd2..0e0c89c95 100644 --- a/src/Entity/Repository/SongHistoryRepository.php +++ b/src/Entity/Repository/SongHistoryRepository.php @@ -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(); - } } diff --git a/src/Provider/RadioProvider.php b/src/Provider/RadioProvider.php index 92e86ee07..98fe60b2a 100644 --- a/src/Provider/RadioProvider.php +++ b/src/Provider/RadioProvider.php @@ -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] ); }); diff --git a/src/Provider/SyncProvider.php b/src/Provider/SyncProvider.php index 25524b65d..35b64d5ca 100644 --- a/src/Provider/SyncProvider.php +++ b/src/Provider/SyncProvider.php @@ -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] ); }; diff --git a/src/Radio/AutoDJ.php b/src/Radio/AutoDJ.php new file mode 100644 index 000000000..e432f5450 --- /dev/null +++ b/src/Radio/AutoDJ.php @@ -0,0 +1,360 @@ +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(); + } +} diff --git a/src/Radio/Backend/Liquidsoap.php b/src/Radio/Backend/Liquidsoap.php index c6da8f7c1..e3b176dc6 100644 --- a/src/Radio/Backend/Liquidsoap.php +++ b/src/Radio/Backend/Liquidsoap.php @@ -1,11 +1,31 @@ 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(); diff --git a/src/Sync/Task/NowPlaying.php b/src/Sync/Task/NowPlaying.php index aead3c9e2..58ef9da98 100644 --- a/src/Sync/Task/NowPlaying.php +++ b/src/Sync/Task/NowPlaying.php @@ -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);