AzuraCast/src/Sync/Task/RadioAutomation.php

306 lines
11 KiB
PHP

<?php
namespace App\Sync\Task;
use App\Entity;
use App\Exception;
use App\Radio\Adapters;
use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use DoctrineBatchUtils\BatchProcessing\SimpleBatchIteratorAggregate;
use Psr\Log\LoggerInterface;
class RadioAutomation extends AbstractTask
{
public const DEFAULT_THRESHOLD_DAYS = 14;
protected Entity\Repository\StationMediaRepository $mediaRepo;
protected Adapters $adapters;
public function __construct(
EntityManagerInterface $em,
Entity\Repository\SettingsRepository $settingsRepo,
LoggerInterface $logger,
Entity\Repository\StationMediaRepository $mediaRepo,
Adapters $adapters
) {
parent::__construct($em, $settingsRepo, $logger);
$this->mediaRepo = $mediaRepo;
$this->adapters = $adapters;
}
/**
* Iterate through all stations and attempt to run automated assignment.
*
* @param bool $force
*/
public function run(bool $force = false): void
{
// Check all stations for automation settings.
// Use this to avoid detached entity errors.
$stations = SimpleBatchIteratorAggregate::fromQuery(
$this->em->createQuery(/** @lang DQL */ 'SELECT s FROM App\Entity\Station s'),
1
);
foreach ($stations as $station) {
/** @var Entity\Station $station */
try {
if ($this->runStation($station)) {
$this->logger->info('Automated assignment [' . $station->getName() . ']: Successfully run.');
} else {
$this->logger->info('Automated assignment [' . $station->getName() . ']: Skipped.');
}
} catch (Exception $e) {
$this->logger->error('Automated assignment [' . $station->getName() . ']: Error: ' . $e->getMessage());
}
}
}
public function runStation(Entity\Station $station, bool $force = false): bool
{
$settings = (array)$station->getAutomationSettings();
if (empty($settings) || !$settings['is_enabled']) {
return false;
}
// Check whether assignment needs to be run.
$threshold_days = (int)$settings['threshold_days'];
$threshold = CarbonImmutable::now('UTC')
->subDays($threshold_days)
->getTimestamp();
if (!$force && $station->getAutomationTimestamp() >= $threshold) {
return false;
} // No error, but no need to run assignment.
// Pull songs in current playlists, then clear those playlists.
$getSongsInPlaylistQuery = $this->em->createQuery(/** @lang DQL */ 'SELECT
sm.id
FROM App\Entity\StationPlaylistMedia spm
JOIN spm.media sm
WHERE spm.playlist = :playlist');
$mediaToUpdate = [];
$playlists = [];
foreach ($station->getPlaylists() as $playlist) {
/** @var Entity\StationPlaylist $playlist */
if (
$playlist->getIsEnabled()
&& $playlist->getType() === Entity\StationPlaylist::TYPE_DEFAULT
&& $playlist->getIncludeInAutomation()
) {
$playlists[] = $playlist->getId();
// Clear all related media.
$mediaInPlaylist = $getSongsInPlaylistQuery->setParameter('playlist', $playlist)
->getArrayResult();
foreach ($mediaInPlaylist as $media) {
$mediaToUpdate[$media['id']] = [
'old_playlist_id' => $playlist->getId(),
'new_playlist_id' => $playlist->getId(),
];
}
}
}
if (0 === count($playlists)) {
throw new Exception('No playlists have automation enabled.');
}
// Generate the actual report for listenership.
$mediaReport = $this->generateReport($station, $threshold_days);
// Remove songs that weren't already in auto-assigned playlists.
$mediaReport = array_filter($mediaReport, function ($media) use ($mediaToUpdate) {
return (isset($mediaToUpdate[$media['id']]));
});
// Place all songs with 0 plays back in their original playlists.
foreach ($mediaReport as $song_id => $media) {
if ($media['num_plays'] === 0 && isset($original_playlists[$song_id])) {
unset($mediaToUpdate[$media['id']], $mediaReport[$song_id]);
}
}
// Sort songs by ratio descending.
uasort($mediaReport, function ($a_media, $b_media) {
return (int)$b_media['ratio'] <=> (int)$a_media['ratio'];
});
// Distribute media across the enabled playlists and assign media to playlist.
$numSongs = count($mediaReport);
$numPlaylists = count($playlists);
$songsPerPlaylist = (int)floor($numSongs / $numPlaylists);
$i = 0;
foreach ($playlists as $playlistId) {
if ($i === 0) {
$playlistNumSongs = $songsPerPlaylist + ($numSongs % $numPlaylists);
} else {
$playlistNumSongs = $songsPerPlaylist;
}
$media_in_playlist = array_slice($mediaReport, $i, $playlistNumSongs);
foreach ($media_in_playlist as $media) {
$mediaToUpdate[$media['id']]['new_playlist_id'] = $playlistId;
}
$i += $playlistNumSongs;
}
// Update media playlist placement.
$updateMediaPlaylistQuery = $this->em->createQuery(/** @lang DQL */ 'UPDATE
App\Entity\StationPlaylistMedia spm
SET spm.playlist_id = :new_playlist_id
WHERE spm.playlist_id = :old_playlist_id
AND spm.media_id = :media_id');
foreach ($mediaToUpdate as $mediaId => $playlists) {
$updateMediaPlaylistQuery->setParameter('media_id', $mediaId)
->setParameter('old_playlist_id', $playlists['old_playlist_id'])
->setParameter('new_playlist_id', $playlists['new_playlist_id'])
->execute();
}
$this->em->clear();
/** @var Entity\Station $station */
$station = $this->em->find(Entity\Station::class, $station->getId());
$station->setAutomationTimestamp(time());
$this->em->persist($station);
$this->em->flush();
// Write new PLS playlist configuration.
$backend_adapter = $this->adapters->getBackendAdapter($station);
$backend_adapter->write($station);
return true;
}
/**
* @return mixed[]
*/
public function generateReport(
Entity\Station $station,
int $threshold_days = self::DEFAULT_THRESHOLD_DAYS
): array {
$threshold = CarbonImmutable::now()
->subDays($threshold_days)
->getTimestamp();
// Pull all SongHistory data points.
$dataPointsRaw = $this->em->createQuery(/** @lang DQL */ 'SELECT
sh.song_id, sh.timestamp_start, sh.delta_positive, sh.delta_negative, sh.listeners_start
FROM App\Entity\SongHistory sh
WHERE sh.station = :station
AND sh.timestamp_end != 0
AND sh.timestamp_start >= :threshold')
->setParameter('station', $station)
->setParameter('threshold', $threshold)
->getArrayResult();
$total_plays = 0;
$data_points = [];
foreach ($dataPointsRaw as $row) {
$total_plays++;
if (!isset($data_points[$row['song_id']])) {
$data_points[$row['song_id']] = [];
}
$data_points[$row['song_id']][] = $row;
}
$mediaQuery = $this->em->createQuery(/** @lang DQL */ 'SELECT
sm
FROM App\Entity\StationMedia sm
WHERE sm.storage_location = :storageLocation
ORDER BY sm.artist ASC, sm.title ASC')
->setParameter('storageLocation', $station->getMediaStorageLocation());
$iterator = SimpleBatchIteratorAggregate::fromQuery($mediaQuery, 100);
$report = [];
foreach ($iterator as $row) {
/** @var Entity\StationMedia $row */
$songId = $row->getSongId();
$media = [
'id' => $row->getId(),
'song_id' => $songId,
'title' => $row->getTitle(),
'artist' => $row->getArtist(),
'length_raw' => $row->getLength(),
'length' => $row->getLengthText(),
'path' => $row->getPath(),
'playlists' => [],
'data_points' => [],
'num_plays' => 0,
'percent_plays' => 0,
'delta_negative' => 0,
'delta_positive' => 0,
'delta_total' => 0,
'ratio' => 0,
];
if ($row->getPlaylists()->count() > 0) {
foreach ($row->getPlaylists() as $playlist_item) {
/** @var Entity\StationPlaylistMedia $playlist_item */
$playlist = $playlist_item->getPlaylist();
$media['playlists'][] = $playlist->getName();
}
}
if (isset($data_points[$songId])) {
$ratio_points = [];
foreach ($data_points[$songId] as $data_row) {
$media['num_plays']++;
$media['delta_positive'] += $data_row['delta_positive'];
$media['delta_negative'] -= $data_row['delta_negative'];
/*
* The song ratio is determined by the total impact in listenership the song caused
* (both up and down) over its play time, divided by the number of listeners the song started
* with. Impacts are weighted higher for more significant percentage impacts up or down.
*
* i.e.
* 1 listener at start, gained 3 listeners => 3/1*100 = 300
* 100 listeners at start, lost 15 listeners => -15/100*100 = -15
*/
$delta_total = $data_row['delta_positive'] - $data_row['delta_negative'];
$ratio_points[] = ($data_row['listeners_start'] == 0)
? 0
: ($delta_total / $data_row['listeners_start']) * 100;
}
$media['delta_total'] = $media['delta_positive'] + $media['delta_negative'];
$media['percent_plays'] = round(($media['num_plays'] / $total_plays) * 100, 2);
$media['ratio'] = round(array_sum($ratio_points) / count($ratio_points), 3);
}
$report[$songId] = $media;
}
return $report;
}
}