453 lines
16 KiB
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);
|
|
}
|
|
}
|