AzuraCast/src/Radio/AutoDJ/QueueBuilder.php

453 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Radio\AutoDJ;
use App\Entity;
use App\Event\Radio\BuildQueue;
use App\Radio\PlaylistParser;
use Carbon\CarbonInterface;
use Doctrine\ORM\EntityManagerInterface;
use Monolog\Logger;
use Psr\SimpleCache\CacheInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* The internal steps of the AutoDJ Queue building process.
*/
final class QueueBuilder implements EventSubscriberInterface
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Logger $logger,
private readonly Scheduler $scheduler,
private readonly DuplicatePrevention $duplicatePrevention,
private readonly CacheInterface $cache,
private readonly Entity\Repository\StationPlaylistMediaRepository $spmRepo,
private readonly Entity\Repository\StationRequestRepository $requestRepo,
private readonly Entity\Repository\StationQueueRepository $queueRepo
) {
}
/**
* @inheritDoc
*/
public static function getSubscribedEvents(): array
{
return [
BuildQueue::class => [
['getNextSongFromRequests', 5],
['calculateNextSong', 0],
],
];
}
/**
* Determine the next-playing song for this station based on its playlist rotation rules.
*
* @param BuildQueue $event
*/
public function calculateNextSong(BuildQueue $event): void
{
$this->logger->info('AzuraCast AutoDJ is calculating the next song to play...');
$station = $event->getStation();
$expectedPlayTime = $event->getExpectedPlayTime();
$activePlaylistsByType = [];
foreach ($station->getPlaylists() as $playlist) {
/** @var Entity\StationPlaylist $playlist */
if ($playlist->isPlayable($event->isInterrupting())) {
$type = $playlist->getType();
$subType = ($playlist->getScheduleItems()->count() > 0) ? 'scheduled' : 'unscheduled';
$activePlaylistsByType[$type . '_' . $subType][$playlist->getId()] = $playlist;
}
}
if (empty($activePlaylistsByType)) {
$this->logger->error('No valid playlists detected. Skipping AutoDJ calculations.');
return;
}
$recentSongHistoryForDuplicatePrevention = $this->queueRepo->getRecentlyPlayedByTimeRange(
$station,
$expectedPlayTime,
$station->getBackendConfig()->getDuplicatePreventionTimeRange()
);
$this->logger->debug(
'AutoDJ recent song playback history',
[
'history_duplicate_prevention' => $recentSongHistoryForDuplicatePrevention,
]
);
$typesToPlay = [
Entity\Enums\PlaylistTypes::OncePerHour->value,
Entity\Enums\PlaylistTypes::OncePerXSongs->value,
Entity\Enums\PlaylistTypes::OncePerXMinutes->value,
Entity\Enums\PlaylistTypes::Standard->value,
];
$typesToPlayByPriority = [];
foreach ($typesToPlay as $type) {
$typesToPlayByPriority[] = $type . '_scheduled';
$typesToPlayByPriority[] = $type . '_unscheduled';
}
foreach ($typesToPlayByPriority as $currentPlaylistType) {
if (empty($activePlaylistsByType[$currentPlaylistType])) {
continue;
}
$eligiblePlaylists = [];
$logPlaylists = [];
foreach ($activePlaylistsByType[$currentPlaylistType] as $playlistId => $playlist) {
/** @var Entity\StationPlaylist $playlist */
if (!$this->scheduler->shouldPlaylistPlayNow($playlist, $expectedPlayTime)) {
continue;
}
$eligiblePlaylists[$playlistId] = $playlist->getWeight();
$logPlaylists[] = [
'id' => $playlist->getId(),
'name' => $playlist->getName(),
'weight' => $playlist->getWeight(),
];
}
if (empty($eligiblePlaylists)) {
continue;
}
$this->logger->info(
sprintf(
'%d playable playlist(s) of type "%s" found.',
count($eligiblePlaylists),
$type
),
['playlists' => $logPlaylists]
);
$eligiblePlaylists = $this->weightedShuffle($eligiblePlaylists);
// Loop through the playlists and attempt to play them with no duplicates first,
// then loop through them again while allowing duplicates.
foreach ([false, true] as $allowDuplicates) {
foreach ($eligiblePlaylists as $playlistId => $weight) {
$playlist = $activePlaylistsByType[$currentPlaylistType][$playlistId];
if (
$event->setNextSongs(
$this->playSongFromPlaylist(
$playlist,
$recentSongHistoryForDuplicatePrevention,
$expectedPlayTime,
$allowDuplicates
)
)
) {
$this->logger->info(
'Playable track(s) found and registered.',
[
'next_song' => (string)$event,
]
);
return;
}
}
}
}
$this->logger->error('No playable tracks were found.');
}
/**
* Apply a weighted shuffle to the given array in the form:
* [ key1 => weight1, key2 => weight2 ]
*
* Based on: https://gist.github.com/savvot/e684551953a1716208fbda6c4bb2f344
*
* @param array $original
* @return array
*/
private function weightedShuffle(array $original): array
{
$new = $original;
$max = 1.0 / mt_getrandmax();
array_walk(
$new,
static function (&$value) use ($max): void {
$value = (mt_rand() * $max) ** (1.0 / $value);
}
);
arsort($new);
array_walk(
$new,
static function (&$value, $key) use ($original): void {
$value = $original[$key];
}
);
return $new;
}
/**
* Given a specified (sequential or shuffled) playlist, choose a song from the playlist to play and return it.
*
* @param Entity\StationPlaylist $playlist
* @param array $recentSongHistory
* @param CarbonInterface $expectedPlayTime
* @param bool $allowDuplicates Whether to return a media ID even if duplicates can't be prevented.
* @return Entity\StationQueue|Entity\StationQueue[]|null
*/
private function playSongFromPlaylist(
Entity\StationPlaylist $playlist,
array $recentSongHistory,
CarbonInterface $expectedPlayTime,
bool $allowDuplicates = false
): Entity\StationQueue|array|null {
if (Entity\Enums\PlaylistSources::RemoteUrl === $playlist->getSourceEnum()) {
return $this->getSongFromRemotePlaylist($playlist, $expectedPlayTime);
}
if ($playlist->backendMerge()) {
$this->spmRepo->resetQueue($playlist);
$queueEntries = array_filter(
array_map(
function (Entity\Api\StationPlaylistQueue $validTrack) use ($playlist, $expectedPlayTime) {
return $this->makeQueueFromApi($validTrack, $playlist, $expectedPlayTime);
},
$this->spmRepo->getQueue($playlist)
)
);
if (!empty($queueEntries)) {
$playlist->setPlayedAt($expectedPlayTime->getTimestamp());
$this->em->persist($playlist);
return $queueEntries;
}
} else {
$validTrack = match ($playlist->getOrderEnum()) {
Entity\Enums\PlaylistOrders::Random => $this->getRandomMediaIdFromPlaylist(
$playlist,
$recentSongHistory,
$allowDuplicates
),
Entity\Enums\PlaylistOrders::Sequential => $this->getSequentialMediaIdFromPlaylist($playlist),
Entity\Enums\PlaylistOrders::Shuffle => $this->getShuffledMediaIdFromPlaylist(
$playlist,
$recentSongHistory,
$allowDuplicates
)
};
if (null !== $validTrack) {
$queueEntry = $this->makeQueueFromApi($validTrack, $playlist, $expectedPlayTime);
if (null !== $queueEntry) {
$playlist->setPlayedAt($expectedPlayTime->getTimestamp());
$this->em->persist($playlist);
return $queueEntry;
}
}
}
$this->logger->warning(
sprintf('Playlist "%s" did not return a playable track.', $playlist->getName()),
[
'playlist_id' => $playlist->getId(),
'playlist_order' => $playlist->getOrder(),
'allow_duplicates' => $allowDuplicates,
]
);
return null;
}
private function makeQueueFromApi(
Entity\Api\StationPlaylistQueue $validTrack,
Entity\StationPlaylist $playlist,
CarbonInterface $expectedPlayTime,
): ?Entity\StationQueue {
$mediaToPlay = $this->em->find(Entity\StationMedia::class, $validTrack->media_id);
if (!$mediaToPlay instanceof Entity\StationMedia) {
return null;
}
$spm = $this->em->find(Entity\StationPlaylistMedia::class, $validTrack->spm_id);
if ($spm instanceof Entity\StationPlaylistMedia) {
$spm->played($expectedPlayTime->getTimestamp());
$this->em->persist($spm);
}
$stationQueueEntry = Entity\StationQueue::fromMedia($playlist->getStation(), $mediaToPlay);
$stationQueueEntry->setPlaylist($playlist);
$this->em->persist($stationQueueEntry);
return $stationQueueEntry;
}
private function getSongFromRemotePlaylist(
Entity\StationPlaylist $playlist,
CarbonInterface $expectedPlayTime
): ?Entity\StationQueue {
$mediaToPlay = $this->getMediaFromRemoteUrl($playlist);
if (is_array($mediaToPlay)) {
[$mediaUri, $mediaDuration] = $mediaToPlay;
$playlist->setPlayedAt($expectedPlayTime->getTimestamp());
$this->em->persist($playlist);
$stationQueueEntry = new Entity\StationQueue(
$playlist->getStation(),
Entity\Song::createFromText('Remote Playlist URL')
);
$stationQueueEntry->setPlaylist($playlist);
$stationQueueEntry->setAutodjCustomUri($mediaUri);
$stationQueueEntry->setDuration($mediaDuration);
$this->em->persist($stationQueueEntry);
return $stationQueueEntry;
}
return null;
}
/**
* Returns either an array containing the URL of a remote stream and the duration,
* an array with a media id and the duration or null if no media has been found.
*
* @return mixed[]|null
*/
private function getMediaFromRemoteUrl(Entity\StationPlaylist $playlist): ?array
{
$remoteType = $playlist->getRemoteTypeEnum() ?? Entity\Enums\PlaylistRemoteTypes::Stream;
// Handle a raw stream URL of possibly indeterminate length.
if (Entity\Enums\PlaylistRemoteTypes::Stream === $remoteType) {
// Annotate a hard-coded "duration" parameter to avoid infinite play for scheduled playlists.
$duration = $this->scheduler->getPlaylistScheduleDuration($playlist);
return [$playlist->getRemoteUrl(), $duration];
}
// Handle a remote playlist containing songs or streams.
$queueCacheKey = 'playlist_queue.' . $playlist->getId();
$mediaQueue = $this->cache->get($queueCacheKey);
if (empty($mediaQueue)) {
$mediaQueue = [];
$playlistRemoteUrl = $playlist->getRemoteUrl();
if (null !== $playlistRemoteUrl) {
$playlistRaw = file_get_contents($playlistRemoteUrl);
if (false !== $playlistRaw) {
$mediaQueue = PlaylistParser::getSongs($playlistRaw);
}
}
}
$mediaId = null;
if (!empty($mediaQueue)) {
$mediaId = array_shift($mediaQueue);
}
// Save the modified cache, sans the now-missing entry.
$this->cache->set($queueCacheKey, $mediaQueue, 6000);
return ($mediaId)
? [$mediaId, 0]
: null;
}
private function getRandomMediaIdFromPlaylist(
Entity\StationPlaylist $playlist,
array $recentSongHistory,
bool $allowDuplicates
): ?Entity\Api\StationPlaylistQueue {
$mediaQueue = $this->spmRepo->getQueue($playlist);
if ($playlist->getAvoidDuplicates()) {
return $this->duplicatePrevention->preventDuplicates($mediaQueue, $recentSongHistory, $allowDuplicates);
}
return array_shift($mediaQueue);
}
private function getSequentialMediaIdFromPlaylist(
Entity\StationPlaylist $playlist
): ?Entity\Api\StationPlaylistQueue {
$mediaQueue = $this->spmRepo->getQueue($playlist);
if (empty($mediaQueue)) {
$this->spmRepo->resetQueue($playlist);
$mediaQueue = $this->spmRepo->getQueue($playlist);
}
return array_shift($mediaQueue);
}
private function getShuffledMediaIdFromPlaylist(
Entity\StationPlaylist $playlist,
array $recentSongHistory,
bool $allowDuplicates
): ?Entity\Api\StationPlaylistQueue {
$mediaQueue = $this->spmRepo->getQueue($playlist);
if (empty($mediaQueue)) {
$this->spmRepo->resetQueue($playlist);
$mediaQueue = $this->spmRepo->getQueue($playlist);
}
if (!$playlist->getAvoidDuplicates()) {
return array_shift($mediaQueue);
}
$queueItem = $this->duplicatePrevention->preventDuplicates($mediaQueue, $recentSongHistory, $allowDuplicates);
if (null !== $queueItem || $allowDuplicates) {
return $queueItem;
}
// Reshuffle the queue.
$this->logger->warning(
'Duplicate prevention yielded no playable song; resetting song queue.'
);
$this->spmRepo->resetQueue($playlist);
$mediaQueue = $this->spmRepo->getQueue($playlist);
return $this->duplicatePrevention->preventDuplicates($mediaQueue, $recentSongHistory, $allowDuplicates);
}
public function getNextSongFromRequests(BuildQueue $event): void
{
// Don't use this to cue requests.
if ($event->isInterrupting()) {
return;
}
$expectedPlayTime = $event->getExpectedPlayTime();
$request = $this->requestRepo->getNextPlayableRequest($event->getStation(), $expectedPlayTime);
if (null === $request) {
return;
}
$this->logger->debug(sprintf('Queueing next song from request ID %d.', $request->getId()));
$stationQueueEntry = Entity\StationQueue::fromRequest($request);
$this->em->persist($stationQueueEntry);
$request->setPlayedAt($expectedPlayTime->getTimestamp());
$this->em->persist($request);
$event->setNextSongs($stationQueueEntry);
}
}