#49 -- Begin tracking unique listening clients for royalty reporting purposes.

This commit is contained in:
Buster Silver 2017-05-16 02:46:43 -05:00
parent ee6858f332
commit 9260af42de
11 changed files with 298 additions and 45 deletions

59
app/models/Listener.php Normal file
View File

@ -0,0 +1,59 @@
<?php
namespace Entity;
/**
* @Table(name="listener", indexes={
* @index(name="update_idx", columns={"listener_uid", "listener_ip"}),
* })
* @Entity(repositoryClass="Entity\Repository\ListenerRepository")
*/
class Listener extends \App\Doctrine\Entity
{
public function __construct()
{
$this->timestamp_start = time();
}
/**
* @Column(name="id", type="integer")
* @Id
* @GeneratedValue(strategy="AUTO")
*/
protected $id;
/** @Column(name="station_id", type="integer") */
protected $station_id;
/** @Column(name="listener_uid", type="integer") */
protected $listener_uid;
/** @Column(name="listener_ip", type="string", length=45) */
protected $listener_ip;
/** @Column(name="listener_user_agent", type="string", length=255) */
protected $listener_user_agent;
/** @Column(name="timestamp_start", type="integer") */
protected $timestamp_start;
public function getTimestamp()
{
return $this->timestamp_start;
}
/** @Column(name="timestamp_end", type="integer") */
protected $timestamp_end;
public function getConnectedSeconds()
{
return $this->timestamp_end - $this->timestamp_start;
}
/**
* @ManyToOne(targetEntity="Station", inversedBy="history")
* @JoinColumns({
* @JoinColumn(name="station_id", referencedColumnName="id", onDelete="CASCADE")
* })
*/
protected $station;
}

View File

@ -0,0 +1,37 @@
<?php
namespace Migration;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
class Version20170516073708 extends AbstractMigration
{
/**
* @param Schema $schema
*/
public function up(Schema $schema)
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('CREATE TABLE listener (id INT AUTO_INCREMENT NOT NULL, station_id INT NOT NULL, listener_uid INT NOT NULL, listener_ip VARCHAR(45) NOT NULL, listener_user_agent VARCHAR(255) NOT NULL, timestamp_start INT NOT NULL, timestamp_end INT NOT NULL, INDEX IDX_959C342221BDB235 (station_id), INDEX update_idx (listener_uid, listener_ip), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB');
$this->addSql('ALTER TABLE listener ADD CONSTRAINT FK_959C342221BDB235 FOREIGN KEY (station_id) REFERENCES station (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE song_history ADD unique_listeners SMALLINT DEFAULT NULL');
}
/**
* @param Schema $schema
*/
public function down(Schema $schema)
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('DROP TABLE listener');
$this->addSql('ALTER TABLE song_history DROP unique_listeners');
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace Entity\Repository;
use Entity;
class ListenerRepository extends \App\Doctrine\Repository
{
/**
* Get the number of unique listeners for a station during a specified time period.
*
* @param Entity\Station $station
* @param $timestamp_start
* @param $timestamp_end
* @return mixed
*/
public function getUniqueListeners(Entity\Station $station, $timestamp_start, $timestamp_end)
{
return $this->_em->createQuery('SELECT COUNT(l.id)
FROM '.$this->_entityName.' l
WHERE l.station_id = :station_id
AND l.timestamp_start <= :end
AND l.timestamp_end >= :start')
->setParameter('station_id', $station->id)
->setParameter('end', $timestamp_end)
->setParameter('start', $timestamp_start)
->getSingleScalarResult();
}
/**
* Update listener data for a station.
*
* @param Entity\Station $station
* @param $clients
* @return Entity\SongHistory|mixed
*/
public function update(Entity\Station $station, $clients)
{
$clients = (array)$clients;
$listener_ids = [];
foreach($clients as $client) {
// Check for an existing record for this client.
$existing_id = $this->_em->createQuery('SELECT l.id FROM '.$this->_entityName.' l
WHERE l.station_id = :station_id
AND l.listener_uid = :uid
AND l.listener_ip = :ip
AND l.timestamp_end = 0')
->setParameter('station_id', $station->id)
->setParameter('uid', $client['uid'])
->setParameter('ip', $client['ip'])
->getSingleScalarResult();
$listener_ids[] = $existing_id;
}
$this->_em->flush();
// Mark the end of all other clients on this station.
$this->_em->createQuery('UPDATE '.$this->_entityName.' l
SET l.timestamp_end = :time
WHERE l.station_id = :station_id
AND l.timestamp_end = 0
AND l.id NOT IN (:ids)')
->setParameter('time', time())
->setParameter('station_id', $station->id)
->setParameter('ids', $listener_ids)
->execute();
}
}

View File

@ -91,6 +91,10 @@ class SongHistoryRepository extends \App\Doctrine\Repository
$last_sh->delta_negative = $delta_negative;
$last_sh->delta_total = $delta_total;
/** @var ListenerRepository $listener_repo */
$listener_repo = $this->_em->getRepository(Entity\Listener::class);
$last_sh->unique_listeners = $listener_repo->getUniqueListeners($station, $last_sh->timestamp_start, time());
$this->_em->persist($last_sh);
}

View File

@ -19,6 +19,8 @@ class SongHistory extends \App\Doctrine\Entity
$this->timestamp_end = 0;
$this->listeners_end = 0;
$this->unique_listeners = 0;
$this->delta_total = 0;
$this->delta_negative = 0;
$this->delta_positive = 0;
@ -68,6 +70,9 @@ class SongHistory extends \App\Doctrine\Entity
/** @Column(name="listeners_end", type="smallint", nullable=true) */
protected $listeners_end;
/** @Column(name="unique_listeners", type="smallint", nullable=true) */
protected $unique_listeners;
/** @Column(name="delta_total", type="smallint") */
protected $delta_total;

View File

@ -64,6 +64,10 @@ class Curl
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_MAXREDIRS, 3);
if (!empty($c_opts['basic_auth'])) {
curl_setopt($curl, CURLOPT_USERPWD, $c_opts['basic_auth']);
}
// Custom DNS management.
curl_setopt($curl, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
curl_setopt($curl, CURLOPT_DNS_CACHE_TIMEOUT, 600);

View File

@ -191,7 +191,7 @@ class LiquidSoap extends BackendAbstract
$output_params = [
$output_format, // Required output format (%mp3 etc)
'id="radio_out_' . $mount_row->id . '"',
'id="radio_out_' . $i . '"',
'host = "localhost"',
'port = ' . ($broadcast_port),
'password = "' . $broadcast_source_pw . ':#'.$i.'"',
@ -206,7 +206,10 @@ class LiquidSoap extends BackendAbstract
case 'icecast':
default:
$i = 0;
foreach ($this->station->mounts as $mount_row) {
$i++;
if (!$mount_row->enable_autodj) {
continue;
}
@ -223,7 +226,7 @@ class LiquidSoap extends BackendAbstract
if (!empty($output_format)) {
$output_params = [
$output_format, // Required output format (%mp3 or %ogg)
'id="radio_out_' . $mount_row->id . '"',
'id="radio_out_' . $i . '"',
'host = "localhost"',
'port = ' . $broadcast_port,
'password = "' . $broadcast_source_pw . '"',

View File

@ -1,6 +1,7 @@
<?php
namespace AzuraCast\Radio\Frontend;
use App\Debug;
use App\Service\Curl;
abstract class FrontendAbstract extends \AzuraCast\Radio\AdapterAbstract
@ -110,6 +111,8 @@ abstract class FrontendAbstract extends \AzuraCast\Radio\AdapterAbstract
$np['listeners']['total'] = $np['listeners']['current'];
}
Debug::print_r($np);
return $np;
}

View File

@ -1,6 +1,7 @@
<?php
namespace AzuraCast\Radio\Frontend;
use App\Debug;
use App\Utilities;
use Doctrine\ORM\EntityManager;
use Entity\StationMount;
@ -11,31 +12,29 @@ class IceCast extends FrontendAbstract
protected function _getNowPlaying(&$np)
{
$fe_config = (array)$this->station->frontend_config;
$reader = new \App\Xml\Reader();
$radio_port = $fe_config['port'];
$np_url = 'http://localhost:' . $radio_port . '/admin/stats';
$np_url = 'http://localhost:' . $radio_port . '/status-json.xsl';
Debug::log($np_url);
\App\Debug::log($np_url);
$return_raw = $this->getUrl($np_url);
$return_raw = $this->getUrl($np_url, [
'basic_auth' => 'admin:'.$fe_config['admin_pw'],
]);
if (!$return_raw) {
return false;
}
$return = @json_decode($return_raw, true);
$return = $reader->fromString($return_raw);
Debug::print_r($return);
\App\Debug::print_r($return);
if (!$return || !isset($return['icestats']['source'])) {
if (!$return || empty($return['source'])) {
return false;
}
$sources = $return['icestats']['source'];
if (empty($sources)) {
return false;
}
$sources = $return['source'];
if (key($sources) === 0) {
$mounts = $sources;
@ -43,10 +42,6 @@ class IceCast extends FrontendAbstract
$mounts = [$sources];
}
if (count($mounts) == 0) {
return false;
}
$mounts = array_filter($mounts, function ($mount) {
return (!empty($mount['title']) || !empty($mount['artist']));
});
@ -81,6 +76,41 @@ class IceCast extends FrontendAbstract
$np['listeners']['current'] = (int)$temp_array['listeners'];
if (!empty($temp_array['@mount'])) {
// Attempt to fetch detailed listener information for better unique statistics.
$selected_mount = $temp_array['@mount'];
$listeners_url = 'http://localhost:' . $radio_port . '/admin/listclients?mount='.urlencode($selected_mount);
$return_raw = $this->getUrl($listeners_url, [
'basic_auth' => 'admin:'.$fe_config['admin_pw'],
]);
if (!empty($return_raw)) {
$listeners_raw = $reader->fromString($return_raw);
$np['listeners']['clients'] = [];
if (!empty($listeners_raw['source']['listener']))
{
if (key($listeners_raw['source']['listener']) === 0) {
$listeners = $listeners_raw['source']['listener'];
} else {
$listeners = [$listeners_raw['source']['listener']];
}
foreach($listeners as $listener) {
$np['listeners']['clients'][] = [
'uid' => $listener['ID'],
'ip' => $listener['IP'],
'user_agent' => $listener['UserAgent'],
'connected_seconds' => $listener['Connected'],
];
}
}
}
}
return true;
}

View File

@ -16,35 +16,55 @@ class ShoutCast2 extends FrontendAbstract
$fe_config = (array)$this->station->frontend_config;
$radio_port = $fe_config['port'];
$np_url = 'http://localhost:' . $radio_port . '/stats';
$np_url = 'http://localhost:' . $radio_port . '/statistics?json=1';
$return_raw = $this->getUrl($np_url);
if (empty($return_raw)) {
return false;
}
$current_data = \App\Export::xml_to_array($return_raw);
$current_data = json_decode($return_raw, true);
Debug::print_r($return_raw);
Debug::print_r($current_data);
$song_data = $current_data['SHOUTCASTSERVER'];
Debug::print_r($song_data);
$song_data = $current_data['streams'][0];
$np['meta']['status'] = 'online';
$np['meta']['bitrate'] = $song_data['BITRATE'];
$np['meta']['format'] = $song_data['CONTENT'];
$np['meta']['bitrate'] = $song_data['bitrate'];
$np['meta']['format'] = $song_data['content'];
$np['current_song'] = $this->getSongFromString($song_data['SONGTITLE'], '-');
$np['current_song'] = $this->getSongFromString($song_data['songtitle'], '-');
$u_list = (int)$song_data['UNIQUELISTENERS'];
$t_list = (int)$song_data['CURRENTLISTENERS'];
$u_list = (int)$song_data['uniquelisteners'];
$t_list = (int)$song_data['currentlisteners'];
$np['listeners'] = [
'current' => $this->getListenerCount($u_list, $t_list),
'unique' => $u_list,
'total' => $t_list,
];
// Attempt to fetch detailed listener information for better unique statistics.
$listeners_url = 'http://localhost:' . $radio_port . '/admin.cgi?sid=1&mode=viewjson&page=3';
$return_raw = $this->getUrl($listeners_url, [
'basic_auth' => 'admin:'.$fe_config['admin_pw'],
]);
if (!empty($return_raw)) {
$listeners = json_decode($return_raw, true);
$np['listeners']['clients'] = [];
foreach((array)$listeners as $listener) {
$np['listeners']['clients'][] = [
'uid' => $listener['uid'],
'ip' => $listener['xff'] ?: $listener['hostname'],
'user_agent' => $listener['useragent'],
'connected_seconds' => $listener['connecttime'],
];
}
}
return true;
}

View File

@ -3,9 +3,7 @@ namespace AzuraCast\Sync;
use App\Debug;
use Doctrine\ORM\EntityManager;
use Entity\Song;
use Entity\SongHistory;
use Entity\Station;
use Entity;
class NowPlaying extends SyncAbstract
{
@ -57,15 +55,29 @@ class NowPlaying extends SyncAbstract
$nowplaying[$station]['cache'] = 'database';
}
$this->di['em']->getRepository('Entity\Settings')->setSetting('nowplaying', $nowplaying);
$this->di['em']->getRepository(Entity\Settings::class)
->setSetting('nowplaying', $nowplaying);
}
/** @var Entity\Repository\SongHistoryRepository */
protected $history_repo;
/** @var Entity\Repository\SongRepository */
protected $song_repo;
/** @var Entity\Repository\ListenerRepository */
protected $listener_repo;
protected function _loadNowPlaying()
{
/** @var EntityManager $em */
$em = $this->di['em'];
$stations = $em->getRepository(Station::class)->findAll();
$this->history_repo = $em->getRepository(Entity\SongHistory::class);
$this->song_repo = $em->getRepository(Entity\Song::class);
$this->listener_repo = $em->getRepository(Entity\Listener::class);
$stations = $em->getRepository(Entity\Station::class)->findAll();
$nowplaying = [];
foreach ($stations as $station) {
@ -83,10 +95,10 @@ class NowPlaying extends SyncAbstract
/**
* Generate Structured NowPlaying Data
*
* @param Station $station
* @param Entity\Station $station
* @return array Structured NowPlaying Data
*/
protected function _processStation(Station $station)
protected function _processStation(Entity\Station $station)
{
/** @var EntityManager $em */
$em = $this->di['em'];
@ -94,7 +106,7 @@ class NowPlaying extends SyncAbstract
$np_old = (array)$station->nowplaying_data;
$np = [];
$np['station'] = Station::api($station, $this->di);
$np['station'] = Entity\Station::api($station, $this->di);
$frontend_adapter = $station->getFrontendAdapter($this->di);
$np_new = $frontend_adapter->getNowPlaying();
@ -103,26 +115,32 @@ class NowPlaying extends SyncAbstract
$np['listeners'] = $np_new['listeners'];
// Pull from current NP data if song details haven't changed.
$current_song_hash = Song::getSongHash($np_new['current_song']);
$current_song_hash = Entity\Song::getSongHash($np_new['current_song']);
if (empty($np['current_song']['text'])) {
$np['current_song'] = [];
$np['song_history'] = $em->getRepository(SongHistory::class)->getHistoryForStation($station);
$np['song_history'] = $this->history_repo->getHistoryForStation($station);
} else {
if (strcmp($current_song_hash, $np_old['current_song']['id']) == 0) {
$np['song_history'] = $np_old['song_history'];
$song_obj = $em->getRepository(Song::class)->find($current_song_hash);
$song_obj = $this->song_repo->find($current_song_hash);
} else {
$np['song_history'] = $em->getRepository(SongHistory::class)->getHistoryForStation($station);
$np['song_history'] = $this->history_repo->getHistoryForStation($station);
$song_obj = $em->getRepository(Song::class)->getOrCreate($np_new['current_song'], true);
$song_obj = $this->song_repo->getOrCreate($np_new['current_song'], true);
}
// Update detailed listener statistics, if they exist for the station
if (isset($np['listeners']['clients'])) {
$this->listener_repo->update($station, $np['listeners']['clients']);
unset($np['listeners']['clients']);
}
// Register a new item in song history.
$sh_obj = $em->getRepository(SongHistory::class)->register($song_obj, $station, $np);
$sh_obj = $this->history_repo->register($song_obj, $station, $np);
$current_song = Song::api($song_obj);
$current_song = Entity\Song::api($song_obj);
$current_song['sh_id'] = $sh_obj->id;
$np['current_song'] = $current_song;