768 lines
26 KiB
PHP
768 lines
26 KiB
PHP
<?php
|
|
namespace App\Radio;
|
|
|
|
use App\Entity;
|
|
use App\Event\Radio\AnnotateNextSong;
|
|
use App\Event\Radio\BuildQueue;
|
|
use App\EventDispatcher;
|
|
use App\Flysystem\Filesystem;
|
|
use App\Lock\LockManager;
|
|
use Cake\Chronos\Chronos;
|
|
use DateTimeZone;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Monolog\Logger;
|
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
|
|
|
class AutoDJ implements EventSubscriberInterface
|
|
{
|
|
protected Adapters $adapters;
|
|
|
|
protected EntityManagerInterface $em;
|
|
|
|
protected Entity\Repository\SongRepository $songRepo;
|
|
|
|
protected Entity\Repository\SongHistoryRepository $songHistoryRepo;
|
|
|
|
protected Entity\Repository\StationPlaylistMediaRepository $spmRepo;
|
|
|
|
protected Entity\Repository\StationStreamerRepository $streamerRepo;
|
|
|
|
protected Entity\Repository\StationRequestRepository $requestRepo;
|
|
|
|
protected EventDispatcher $dispatcher;
|
|
|
|
protected Filesystem $filesystem;
|
|
|
|
protected Logger $logger;
|
|
|
|
protected LockManager $lockManager;
|
|
|
|
public function __construct(
|
|
Adapters $adapters,
|
|
EntityManagerInterface $em,
|
|
Entity\Repository\SongRepository $songRepo,
|
|
Entity\Repository\SongHistoryRepository $songHistoryRepo,
|
|
Entity\Repository\StationPlaylistMediaRepository $spmRepo,
|
|
Entity\Repository\StationStreamerRepository $streamerRepo,
|
|
Entity\Repository\StationRequestRepository $requestRepo,
|
|
EventDispatcher $dispatcher,
|
|
Filesystem $filesystem,
|
|
Logger $logger,
|
|
LockManager $lockManager
|
|
) {
|
|
$this->adapters = $adapters;
|
|
$this->em = $em;
|
|
$this->songRepo = $songRepo;
|
|
$this->songHistoryRepo = $songHistoryRepo;
|
|
$this->spmRepo = $spmRepo;
|
|
$this->streamerRepo = $streamerRepo;
|
|
$this->requestRepo = $requestRepo;
|
|
$this->dispatcher = $dispatcher;
|
|
$this->filesystem = $filesystem;
|
|
$this->logger = $logger;
|
|
$this->lockManager = $lockManager;
|
|
}
|
|
|
|
/**
|
|
* @return array
|
|
*/
|
|
public static function getSubscribedEvents()
|
|
{
|
|
return [
|
|
AnnotateNextSong::class => [
|
|
['defaultAnnotationHandler', 0],
|
|
],
|
|
BuildQueue::class => [
|
|
['getNextSongFromRequests', 5],
|
|
['calculateNextSong', 0],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Pulls the next song from the AutoDJ, dispatches the AnnotateNextSong event and returns the built result.
|
|
*
|
|
* @param Entity\Station $station
|
|
* @param bool $as_autodj
|
|
*
|
|
* @return string
|
|
*/
|
|
public function annotateNextSong(Entity\Station $station, $as_autodj = false): string
|
|
{
|
|
$sh = $this->songHistoryRepo->getNextInQueue($station);
|
|
|
|
$event = new AnnotateNextSong($station, $sh);
|
|
$this->dispatcher->dispatch($event);
|
|
|
|
return $event->buildAnnotations();
|
|
}
|
|
|
|
/**
|
|
* Event Handler function for the AnnotateNextSong event.
|
|
*
|
|
* @param AnnotateNextSong $event
|
|
*/
|
|
public function defaultAnnotationHandler(AnnotateNextSong $event): void
|
|
{
|
|
$sh = $event->getNextSong();
|
|
|
|
if ($sh instanceof Entity\SongHistory) {
|
|
$sh->sentToAutodj();
|
|
$this->em->persist($sh);
|
|
|
|
// The "get next song" function is only called when a streamer is not live
|
|
$this->streamerRepo->onDisconnect($event->getStation());
|
|
$this->em->flush();
|
|
|
|
$media = $sh->getMedia();
|
|
if ($media instanceof Entity\StationMedia) {
|
|
$fs = $this->filesystem->getForStation($event->getStation());
|
|
$media_path = $fs->getFullPath($media->getPathUri());
|
|
|
|
$event->setSongPath($media_path);
|
|
|
|
$backend = $this->adapters->getBackendAdapter($event->getStation());
|
|
$event->addAnnotations($backend->annotateMedia($media));
|
|
|
|
$playlist = $sh->getPlaylist();
|
|
|
|
if ($playlist instanceof Entity\StationPlaylist) {
|
|
$event->addAnnotations([
|
|
'playlist_id' => $playlist->getId(),
|
|
]);
|
|
|
|
// Handle "Jingle mode" by sending the same metadata as the previous song.
|
|
if ($playlist->isJingle()) {
|
|
$np = $event->getStation()->getNowplaying();
|
|
if ($np instanceof Entity\Api\NowPlaying) {
|
|
$event->addAnnotations([
|
|
'title' => $np->now_playing->song->title,
|
|
'artist' => $np->now_playing->song->artist,
|
|
'playlist_id' => null,
|
|
'media_id' => null,
|
|
'jingle_mode' => 'true',
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
} elseif (!empty($sh->getAutodjCustomUri())) {
|
|
$custom_uri = $sh->getAutodjCustomUri();
|
|
|
|
$event->setSongPath($custom_uri);
|
|
if ($sh->getDuration()) {
|
|
$event->addAnnotations([
|
|
'length' => $sh->getDuration(),
|
|
]);
|
|
}
|
|
}
|
|
} elseif (null !== $sh) {
|
|
$event->setSongPath((string)$sh);
|
|
}
|
|
}
|
|
|
|
public function buildQueue(Entity\Station $station): void
|
|
{
|
|
$this->logger->pushProcessor(function ($record) use ($station) {
|
|
$record['extra']['station'] = [
|
|
'id' => $station->getId(),
|
|
'name' => $station->getName(),
|
|
];
|
|
return $record;
|
|
});
|
|
|
|
// Determine the "now" time for the queue.
|
|
$stationTz = new DateTimeZone($station->getTimezone());
|
|
|
|
$currentSong = $this->songHistoryRepo->getCurrent($station);
|
|
if ($currentSong instanceof Entity\SongHistory) {
|
|
$nowTimestamp = $currentSong->getTimestampStart() + ($currentSong->getDuration() ?? 1);
|
|
$now = Chronos::createFromTimestamp($nowTimestamp, $stationTz);
|
|
} else {
|
|
$now = Chronos::now($stationTz);
|
|
}
|
|
|
|
// Adjust "now" time from current queue.
|
|
$backendOptions = $station->getBackendConfig();
|
|
$maxQueueLength = $backendOptions->getAutoDjQueueLength();
|
|
|
|
$upcomingQueue = $this->songHistoryRepo->getUpcomingQueue($station);
|
|
$queueLength = count($upcomingQueue);
|
|
|
|
foreach ($upcomingQueue as $queueRow) {
|
|
$queueRow->setTimestampCued($now->getTimestamp());
|
|
$this->em->persist($queueRow);
|
|
|
|
$duration = $queueRow->getDuration() ?? 1;
|
|
$now = $now->addSeconds($duration);
|
|
}
|
|
|
|
$this->em->flush();
|
|
|
|
if ($queueLength >= $maxQueueLength) {
|
|
$this->logger->debug('AutoDJ queue is already at current max length (' . $maxQueueLength . ').');
|
|
$this->logger->popProcessor();
|
|
return;
|
|
}
|
|
|
|
// Build the remainder of the queue.
|
|
while ($queueLength < $maxQueueLength) {
|
|
|
|
$this->logger->debug('Adding to station queue.', [
|
|
'now' => (string)$now,
|
|
]);
|
|
|
|
$event = new BuildQueue($station, $now);
|
|
$this->dispatcher->dispatch($event);
|
|
|
|
$queueRow = $event->getNextSong();
|
|
if ($queueRow instanceof Entity\SongHistory) {
|
|
$duration = $queueRow->getDuration() ?? 1;
|
|
$now = $now->addSeconds($duration);
|
|
}
|
|
|
|
$queueLength++;
|
|
}
|
|
|
|
$this->logger->popProcessor();
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
$now = $event->getNow();
|
|
|
|
$song_history_count = 15;
|
|
|
|
// Pull all active, non-empty playlists and sort by type.
|
|
$has_any_valid_playlists = false;
|
|
$playlists_by_type = [];
|
|
foreach ($station->getPlaylists() as $playlist) {
|
|
/** @var Entity\StationPlaylist $playlist */
|
|
if ($playlist->isPlayable()) {
|
|
$has_any_valid_playlists = true;
|
|
$type = $playlist->getType();
|
|
|
|
if (Entity\StationPlaylist::TYPE_ONCE_PER_X_SONGS === $type) {
|
|
$song_history_count = max($song_history_count, $playlist->getPlayPerSongs());
|
|
}
|
|
|
|
$subType = ($playlist->getScheduleItems()->count() > 0) ? 'scheduled' : 'unscheduled';
|
|
$playlists_by_type[$type . '_' . $subType][$playlist->getId()] = $playlist;
|
|
}
|
|
}
|
|
|
|
if (!$has_any_valid_playlists) {
|
|
$this->logger->error('No valid playlists detected. Skipping AutoDJ calculations.');
|
|
return;
|
|
}
|
|
|
|
// Pull all recent cued songs for easy referencing below.
|
|
$cued_song_history = $this->em->createQuery(/** @lang DQL */ 'SELECT sh, s
|
|
FROM App\Entity\SongHistory sh JOIN sh.song s
|
|
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', $now->subDay()->getTimestamp())
|
|
->setMaxResults($song_history_count)
|
|
->getArrayResult();
|
|
|
|
$logSongHistory = [];
|
|
foreach ($cued_song_history as $row) {
|
|
$logSongHistory[] = [
|
|
'song' => $row['song']['text'],
|
|
'cued_at' => (string)(Chronos::createFromTimestamp($row['timestamp_cued'], $now->getTimezone())),
|
|
'duration' => $row['duration'],
|
|
'sent_to_autodj' => $row['sent_to_autodj'],
|
|
];
|
|
}
|
|
|
|
$this->logger->debug('AutoDJ recent song playback history', [
|
|
'history' => $logSongHistory,
|
|
]);
|
|
|
|
// Types of playlists that should play, sorted by priority.
|
|
$typesToPlay = [
|
|
Entity\StationPlaylist::TYPE_ONCE_PER_HOUR . '_scheduled',
|
|
Entity\StationPlaylist::TYPE_ONCE_PER_HOUR . '_unscheduled',
|
|
Entity\StationPlaylist::TYPE_ONCE_PER_X_SONGS . '_scheduled',
|
|
Entity\StationPlaylist::TYPE_ONCE_PER_X_SONGS . '_unscheduled',
|
|
Entity\StationPlaylist::TYPE_ONCE_PER_X_MINUTES . '_scheduled',
|
|
Entity\StationPlaylist::TYPE_ONCE_PER_X_MINUTES . '_unscheduled',
|
|
Entity\StationPlaylist::TYPE_DEFAULT . '_scheduled',
|
|
Entity\StationPlaylist::TYPE_DEFAULT . '_unscheduled',
|
|
];
|
|
|
|
foreach ($typesToPlay as $type) {
|
|
if (empty($playlists_by_type[$type])) {
|
|
continue;
|
|
}
|
|
|
|
$log_playlists = [];
|
|
$eligible_playlists = [];
|
|
foreach ($playlists_by_type[$type] as $playlist_id => $playlist) {
|
|
/** @var Entity\StationPlaylist $playlist */
|
|
if ($playlist->shouldPlayNow($now, $cued_song_history)) {
|
|
$eligible_playlists[$playlist_id] = $playlist->getWeight();
|
|
$log_playlists[] = [
|
|
'id' => $playlist->getId(),
|
|
'name' => $playlist->getName(),
|
|
'weight' => $playlist->getWeight(),
|
|
];
|
|
}
|
|
}
|
|
|
|
if (empty($eligible_playlists)) {
|
|
continue;
|
|
}
|
|
|
|
$this->logger->info(sprintf(
|
|
'%d playable playlist(s) of type "%s" found.',
|
|
count($eligible_playlists),
|
|
$type
|
|
), ['playlists' => $log_playlists]);
|
|
|
|
// Shuffle playlists by weight.
|
|
$this->weightedShuffle($eligible_playlists);
|
|
|
|
// 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 ($eligible_playlists as $playlist_id => $weight) {
|
|
$playlist = $playlists_by_type[$type][$playlist_id];
|
|
|
|
if ($event->setNextSong($this->playSongFromPlaylist(
|
|
$playlist,
|
|
$cued_song_history,
|
|
$now,
|
|
$allowDuplicates
|
|
))) {
|
|
$this->logger->info('Playable track 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 $array
|
|
*/
|
|
protected function weightedShuffle(array &$array): void
|
|
{
|
|
$arr = $array;
|
|
|
|
$max = 1.0 / mt_getrandmax();
|
|
array_walk($arr, function (&$v, $k) use ($max) {
|
|
$v = (mt_rand() * $max) ** (1.0 / $v);
|
|
});
|
|
arsort($arr);
|
|
array_walk($arr, function (&$v, $k) use ($array) {
|
|
$v = $array[$k];
|
|
});
|
|
|
|
$array = $arr;
|
|
}
|
|
|
|
/**
|
|
* 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 Chronos $now
|
|
* @param bool $allowDuplicates Whether to return a media ID even if duplicates can't be prevented.
|
|
*
|
|
* @return Entity\SongHistory|null
|
|
*/
|
|
protected function playSongFromPlaylist(
|
|
Entity\StationPlaylist $playlist,
|
|
array $recentSongHistory,
|
|
Chronos $now,
|
|
bool $allowDuplicates = false
|
|
): ?Entity\SongHistory {
|
|
$media_to_play = $this->getQueuedSong($playlist, $recentSongHistory, $allowDuplicates);
|
|
|
|
if ($media_to_play instanceof Entity\StationMedia) {
|
|
$playlist->setPlayedAt($now->getTimestamp());
|
|
$this->em->persist($playlist);
|
|
|
|
$spm = $media_to_play->getItemForPlaylist($playlist);
|
|
$spm->played($now->getTimestamp());
|
|
|
|
$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->setTimestampCued($now->getTimestamp());
|
|
|
|
$this->em->persist($sh);
|
|
$this->em->flush();
|
|
|
|
return $sh;
|
|
}
|
|
|
|
if (is_array($media_to_play)) {
|
|
[$media_uri, $media_duration] = $media_to_play;
|
|
|
|
$playlist->setPlayedAt($now->getTimestamp());
|
|
$this->em->persist($playlist);
|
|
|
|
$sh = new Entity\SongHistory($this->songRepo->getOrCreate([
|
|
'text' => 'Remote Playlist URL',
|
|
]), $playlist->getStation());
|
|
|
|
$sh->setPlaylist($playlist);
|
|
$sh->setAutodjCustomUri($media_uri);
|
|
$sh->setDuration($media_duration);
|
|
$sh->setTimestampCued($now->getTimestamp());
|
|
|
|
$this->em->persist($sh);
|
|
$this->em->flush();
|
|
|
|
return $sh;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param Entity\StationPlaylist $playlist
|
|
* @param array $recentSongHistory
|
|
* @param bool $allowDuplicates Whether to return a media ID even if duplicates can't be prevented.
|
|
*
|
|
* @return Entity\StationMedia|array|null
|
|
*/
|
|
protected function getQueuedSong(
|
|
Entity\StationPlaylist $playlist,
|
|
array $recentSongHistory,
|
|
bool $allowDuplicates = false
|
|
) {
|
|
if (Entity\StationPlaylist::SOURCE_REMOTE_URL === $playlist->getSource()) {
|
|
return $this->playRemoteUrl($playlist);
|
|
}
|
|
|
|
switch ($playlist->getOrder()) {
|
|
case Entity\StationPlaylist::ORDER_RANDOM:
|
|
$mediaQueue = $this->spmRepo->getPlayableMedia($playlist);
|
|
$mediaId = $this->preventDuplicates($mediaQueue, $recentSongHistory, $allowDuplicates);
|
|
break;
|
|
|
|
case Entity\StationPlaylist::ORDER_SEQUENTIAL:
|
|
$mediaQueue = $playlist->getQueue();
|
|
|
|
if (empty($mediaQueue)) {
|
|
$mediaQueue = $this->spmRepo->getPlayableMedia($playlist);
|
|
}
|
|
|
|
$media_arr = array_shift($mediaQueue);
|
|
$mediaId = $media_arr['id'];
|
|
|
|
$playlist->setQueue($mediaQueue);
|
|
$this->em->persist($playlist);
|
|
break;
|
|
|
|
case Entity\StationPlaylist::ORDER_SHUFFLE:
|
|
default:
|
|
$media_queue_cached = $playlist->getQueue();
|
|
|
|
if (empty($media_queue_cached)) {
|
|
$mediaQueue = $this->spmRepo->getPlayableMedia($playlist);
|
|
} else {
|
|
// Rekey the media queue because redis won't always properly store keys.
|
|
$mediaQueue = [];
|
|
foreach ($media_queue_cached as $media) {
|
|
$mediaQueue[$media['id']] = $media;
|
|
}
|
|
}
|
|
|
|
if ($playlist->getAvoidDuplicates()) {
|
|
if ($allowDuplicates) {
|
|
$mediaId = $this->preventDuplicates($mediaQueue, $recentSongHistory, false);
|
|
|
|
if (null === $mediaId) {
|
|
$this->logger->warning('Duplicate prevention yielded no playable song; resetting song queue.');
|
|
|
|
// Pull the entire shuffled playlist if a duplicate title can't be avoided.
|
|
$mediaQueue = $this->spmRepo->getPlayableMedia($playlist);
|
|
$mediaId = $this->preventDuplicates($mediaQueue, $recentSongHistory, true);
|
|
}
|
|
} else {
|
|
$mediaId = $this->preventDuplicates($mediaQueue, $recentSongHistory, false);
|
|
}
|
|
} else {
|
|
$mediaId = array_key_first($mediaQueue);
|
|
}
|
|
|
|
if (null !== $mediaId) {
|
|
unset($mediaQueue[$mediaId]);
|
|
}
|
|
|
|
// Save the modified cache, sans the now-missing entry.
|
|
$playlist->setQueue($mediaQueue);
|
|
$this->em->persist($playlist);
|
|
break;
|
|
}
|
|
|
|
$this->em->flush();
|
|
|
|
if (!$mediaId) {
|
|
$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;
|
|
}
|
|
|
|
return $this->em->find(Entity\StationMedia::class, $mediaId);
|
|
}
|
|
|
|
protected function playRemoteUrl(Entity\StationPlaylist $playlist): ?array
|
|
{
|
|
$remote_type = $playlist->getRemoteType() ?? Entity\StationPlaylist::REMOTE_TYPE_STREAM;
|
|
|
|
// Handle a raw stream URL of possibly indeterminate length.
|
|
if (Entity\StationPlaylist::REMOTE_TYPE_STREAM === $remote_type) {
|
|
// Annotate a hard-coded "duration" parameter to avoid infinite play for scheduled playlists.
|
|
$duration = $playlist->getScheduleDuration();
|
|
return [$playlist->getRemoteUrl(), $duration];
|
|
}
|
|
|
|
// Handle a remote playlist containing songs or streams.
|
|
$media_queue = $playlist->getQueue();
|
|
|
|
if (empty($media_queue)) {
|
|
$playlist_raw = file_get_contents($playlist->getRemoteUrl());
|
|
$media_queue = PlaylistParser::getSongs($playlist_raw);
|
|
}
|
|
|
|
if (!empty($media_queue)) {
|
|
$media_id = array_shift($media_queue);
|
|
} else {
|
|
$media_id = null;
|
|
}
|
|
|
|
// Save the modified cache, sans the now-missing entry.
|
|
$playlist->setQueue($media_queue);
|
|
$this->em->persist($playlist);
|
|
$this->em->flush();
|
|
|
|
return ($media_id)
|
|
? [$media_id, 0]
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* @param array $eligibleMedia
|
|
* @param array $playedMedia
|
|
* @param bool $allowDuplicates Whether to return a media ID even if duplicates can't be prevented.
|
|
*
|
|
* @return int|null
|
|
*/
|
|
protected function preventDuplicates(
|
|
array $eligibleMedia = [],
|
|
array $playedMedia = [],
|
|
bool $allowDuplicates = false
|
|
): ?int {
|
|
if (empty($eligibleMedia)) {
|
|
$this->logger->debug('Eligible song queue is empty!');
|
|
return null;
|
|
}
|
|
|
|
$latestSongIdsPlayed = [];
|
|
$playedTracks = [];
|
|
|
|
foreach ($playedMedia as $history) {
|
|
$playedTracks[] = [
|
|
'artist' => $history['song']['artist'],
|
|
'title' => $history['song']['title'],
|
|
];
|
|
|
|
$songId = $history['song']['id'];
|
|
|
|
if (!isset($latestSongIdsPlayed[$songId])) {
|
|
$latestSongIdsPlayed[$songId] = $history['timestamp_cued'];
|
|
}
|
|
}
|
|
|
|
$eligibleTracks = [];
|
|
|
|
foreach ($eligibleMedia as $media) {
|
|
$songId = $media['song_id'];
|
|
if (isset($latestSongIdsPlayed[$songId])) {
|
|
continue;
|
|
}
|
|
|
|
$eligibleTracks[$media['id']] = [
|
|
'artist' => $media['song']['artist'],
|
|
'title' => $media['song']['title'],
|
|
];
|
|
}
|
|
|
|
$mediaId = self::getDistinctTrack($eligibleTracks, $playedTracks);
|
|
|
|
if (null !== $mediaId) {
|
|
$this->logger->info('Found track that avoids duplicate title and artist.',
|
|
['media_id' => $mediaId]);
|
|
|
|
return $mediaId;
|
|
}
|
|
|
|
if ($allowDuplicates) {
|
|
|
|
// If we reach this point, there's no way to avoid a duplicate title.
|
|
$mediaIdsByTimePlayed = [];
|
|
|
|
// For each piece of eligible media, get its latest played timestamp.
|
|
foreach ($eligibleMedia as $media) {
|
|
$songId = $media['song_id'];
|
|
$mediaIdsByTimePlayed[$media['id']] = $latestSongIdsPlayed[$songId] ?? 0;
|
|
}
|
|
|
|
// Pull the lowest value, which corresponds to the least recently played song.
|
|
asort($mediaIdsByTimePlayed);
|
|
|
|
// More efficient way of getting first key.
|
|
foreach ($mediaIdsByTimePlayed as $mediaId => $unused) {
|
|
$this->logger->warning('No way to avoid same title OR same artist; using least recently played song.',
|
|
['media_id' => $mediaId]);
|
|
|
|
return $mediaId;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param BuildQueue $event
|
|
*/
|
|
public function getNextSongFromRequests(BuildQueue $event): void
|
|
{
|
|
$now = $event->getNow();
|
|
|
|
$request = $this->requestRepo->getNextPlayableRequest($event->getStation(), $now);
|
|
if (null === $request) {
|
|
return;
|
|
}
|
|
|
|
$this->logger->debug(sprintf('Queueing next song from request ID %d.', $request->getId()));
|
|
|
|
// 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($now->getTimestamp());
|
|
$this->em->persist($sh);
|
|
|
|
$request->setPlayedAt($now->getTimestamp());
|
|
$this->em->persist($request);
|
|
|
|
$this->em->flush();
|
|
|
|
$event->setNextSong($sh);
|
|
}
|
|
|
|
/**
|
|
* Given an array of eligible tracks, return the first ID that doesn't have a duplicate artist/
|
|
* title with any of the previously played tracks.
|
|
*
|
|
* Both should be in the form of an array, i.e.:
|
|
* [ 'id' => ['artist' => 'Foo', 'title' => 'Fighters'] ]
|
|
*
|
|
* @param array $eligibleTracks
|
|
* @param array $playedTracks
|
|
*
|
|
* @return int|string|null
|
|
*/
|
|
public static function getDistinctTrack(array $eligibleTracks, array $playedTracks)
|
|
{
|
|
$artistSeparators = [
|
|
', ',
|
|
' feat ',
|
|
' feat. ',
|
|
' & ',
|
|
];
|
|
$dividerString = chr(7);
|
|
|
|
$artists = [];
|
|
$titles = [];
|
|
|
|
foreach ($playedTracks as $song) {
|
|
$title = trim($song['title']);
|
|
$titles[$title] = $title;
|
|
|
|
$artistParts = explode($dividerString, str_replace($artistSeparators, $dividerString, $song['artist']));
|
|
foreach ($artistParts as $artist) {
|
|
$artist = trim($artist);
|
|
if (!empty($artist)) {
|
|
$artists[$artist] = $artist;
|
|
}
|
|
}
|
|
}
|
|
|
|
$eligibleTracksWithoutSameTitle = [];
|
|
|
|
foreach ($eligibleTracks as $trackId => $song) {
|
|
// Avoid all direct title matches.
|
|
$title = trim($song['title']);
|
|
|
|
if (isset($titles[$title])) {
|
|
continue;
|
|
}
|
|
|
|
// Attempt to avoid an artist match, if possible.
|
|
$artist = trim($song['artist']);
|
|
|
|
$artistMatchFound = false;
|
|
if (!empty($artist)) {
|
|
$artistParts = explode($dividerString, str_replace($artistSeparators, $dividerString, $artist));
|
|
foreach ($artistParts as $artist) {
|
|
$artist = trim($artist);
|
|
if (empty($artist)) {
|
|
continue;
|
|
}
|
|
|
|
if (isset($artists[$artist])) {
|
|
$artistMatchFound = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$artistMatchFound) {
|
|
return $trackId;
|
|
}
|
|
|
|
$eligibleTracksWithoutSameTitle[$trackId] = $song;
|
|
}
|
|
|
|
foreach ($eligibleTracksWithoutSameTitle as $trackId => $song) {
|
|
return $trackId;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|