AzuraCast/src/Sync/Task/RadioAutomation.php

316 lines
11 KiB
PHP

<?php
namespace App\Sync\Task;
use App\Entity;
use App\Radio\Adapters;
use Azura\Exception;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManager;
use Monolog\Logger;
class RadioAutomation extends AbstractTask
{
const DEFAULT_THRESHOLD_DAYS = 14;
/** @var Adapters */
protected $adapters;
/**
* @param EntityManager $em
* @param Adapters $adapters
*/
public function __construct(EntityManager $em, Adapters $adapters)
{
parent::__construct($em);
$this->adapters = $adapters;
}
/**
* Iterate through all stations and attempt to run automated assignment.
* @param bool $force
*/
public function run($force = false): void
{
// Check all stations for automation settings.
$stations = $this->em->getRepository(Entity\Station::class)->findAll();
/** @var Entity\Repository\SettingsRepository $settings_repo */
$settings_repo = $this->em->getRepository(Entity\Settings::class);
$automation_log = $settings_repo->getSetting('automation_log', []);
foreach ($stations as $station) {
/** @var Entity\Station $station */
try {
if ($this->runStation($station)) {
$automation_log[$station->getId()] = $station->getName() . ': SUCCESS';
}
} catch (Exception $e) {
$automation_log[$station->getId()] = $station->getName() . ': ERROR - ' . $e->getMessage();
}
}
$settings_repo->setSetting('automation_log', $automation_log);
}
/**
* Run automated assignment (if enabled) for a given $station.
*
* @param Entity\Station $station
* @param bool $force
* @return bool
* @throws Exception
*/
public function runStation(Entity\Station $station, $force = false)
{
$settings = (array)$station->getAutomationSettings();
if (empty($settings)) {
throw new Exception('Automation has not been configured for this station yet.');
}
if (!$settings['is_enabled']) {
throw new Exception('Automation is not enabled for this station.');
}
// Check whether assignment needs to be run.
$threshold_days = (int)$settings['threshold_days'];
$threshold = time() - (86400 * $threshold_days);
if (!$force && $station->getAutomationTimestamp() >= $threshold) {
return false;
} // No error, but no need to run assignment.
$playlists = [];
$original_playlists = [];
// Related playlists are already automatically sorted by weight.
$i = 0;
foreach ($station->getPlaylists() as $playlist) {
/** @var Entity\StationPlaylist $playlist */
if ($playlist->getIsEnabled() &&
$playlist->getType() == Entity\StationPlaylist::TYPE_DEFAULT &&
$playlist->getIncludeInAutomation()
) {
// Clear all related media.
foreach ($playlist->getMediaItems() as $media_item) {
$media = $media_item->getMedia();
$song = $media->getSong();
if ($song instanceof Entity\Song) {
$original_playlists[$song->getId()][] = $i;
}
$this->em->remove($media_item);
}
$playlists[$i] = $playlist;
$i++;
}
}
if (count($playlists) == 0) {
throw new Exception('No playlists have automation enabled.');
}
$this->em->flush();
$media_report = $this->generateReport($station, $threshold_days);
$media_report = array_filter($media_report, function ($media) use ($original_playlists) {
// Remove songs that are already in non-auto-assigned playlists.
if (!empty($media['playlists'])) {
return false;
}
// Remove songs that weren't already in auto-assigned playlists.
if (!isset($original_playlists[$media['song_id']])) {
return false;
}
return true;
});
// Place all songs with 0 plays back in their original playlists.
foreach ($media_report as $song_id => $media) {
if ($media['num_plays'] == 0 && isset($original_playlists[$song_id])) {
$media_row = $media['record'];
foreach ($original_playlists[$song_id] as $playlist_key) {
$spm = new Entity\StationPlaylistMedia($playlists[$playlist_key], $media_row);
$this->em->persist($spm);
}
unset($media_report[$song_id]);
}
}
$this->em->flush();
// Sort songs by ratio descending.
uasort($media_report, function ($a_media, $b_media) {
$a = (int)$a_media['ratio'];
$b = (int)$b_media['ratio'];
return ($a < $b) ? 1 : (($a > $b) ? -1 : 0);
});
// Distribute media across the enabled playlists and assign media to playlist.
$num_songs = count($media_report);
$num_playlists = count($playlists);
$songs_per_playlist = floor($num_songs / $num_playlists);
$i = 0;
foreach ($playlists as $playlist) {
if ($i == 0) {
$playlist_num_songs = $songs_per_playlist + ($num_songs % $num_playlists);
} else {
$playlist_num_songs = $songs_per_playlist;
}
$media_in_playlist = array_slice($media_report, $i, $playlist_num_songs);
foreach ($media_in_playlist as $media) {
$spm = new Entity\StationPlaylistMedia($playlist, $media['record']);
$this->em->persist($spm);
}
$i += $playlist_num_songs;
}
$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;
}
/**
* Generate a Performance Report for station $station's songs over the last $threshold_days days.
*
* @param Entity\Station $station
* @param int $threshold_days
* @return array
*/
public function generateReport(Entity\Station $station, $threshold_days = self::DEFAULT_THRESHOLD_DAYS)
{
$threshold = Chronos::now()->subDays((int)$threshold_days)->getTimestamp();
// Pull all SongHistory data points.
$data_points_raw = $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_id = :station_id
AND sh.timestamp_end != 0
AND sh.timestamp_start >= :threshold')
->setParameter('station_id', $station->getId())
->setParameter('threshold', $threshold)
->getArrayResult();
$total_plays = 0;
$data_points = [];
foreach ($data_points_raw as $row) {
$total_plays++;
if (!isset($data_points[$row['song_id']])) {
$data_points[$row['song_id']] = [];
}
$data_points[$row['song_id']][] = $row;
}
/** @var Entity\Repository\StationMediaRepository $media_repo */
$media_repo = $this->em->getRepository(Entity\StationMedia::class);
$media_raw = $this->em->createQuery(/** @lang DQL */ 'SELECT
sm, spm, sp
FROM App\Entity\StationMedia sm
LEFT JOIN sm.playlists spm
LEFT JOIN spm.playlist sp
WHERE sm.station_id = :station_id
ORDER BY sm.artist ASC, sm.title ASC')
->setParameter('station_id', $station->getId())
->execute();
$report = [];
foreach ($media_raw as $row_obj) {
/** @var Entity\StationMedia $row_obj */
$row = $media_repo->toArray($row_obj);
$media = [
'song_id' => $row['song_id'],
'record' => $row_obj,
'title' => $row['title'],
'artist' => $row['artist'],
'length_raw' => $row['length'],
'length' => $row['length_text'],
'path' => $row['path'],
'playlists' => [],
'data_points' => [],
'num_plays' => 0,
'percent_plays' => 0,
'delta_negative' => 0,
'delta_positive' => 0,
'delta_total' => 0,
'ratio' => 0,
];
if ($row_obj->getPlaylists()->count() > 0) {
foreach ($row_obj->getPlaylists() as $playlist_item) {
/** @var Entity\StationPlaylistMedia $playlist_item */
$playlist = $playlist_item->getPlaylist();
$media['playlists'][] = $playlist->getName();
}
}
if (isset($data_points[$row['song_id']])) {
$ratio_points = [];
foreach ($data_points[$row['song_id']] 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[$row['song_id']] = $media;
}
return $report;
}
}