Statistics Overhaul: Initial Database Changes (#5300)
This commit is contained in:
parent
bd29e0c4ee
commit
019c2fa92f
|
@ -64,20 +64,25 @@
|
|||
{{ row.item.connected_time }}
|
||||
</template>
|
||||
<template #cell(user_agent)="row">
|
||||
<span v-if="row.item.is_mobile">
|
||||
<icon icon="smartphone"></icon>
|
||||
<span class="sr-only">
|
||||
<translate key="lang_device_mobile">Mobile Device</translate>
|
||||
<div>
|
||||
<span v-if="row.item.is_mobile">
|
||||
<icon icon="smartphone"></icon>
|
||||
<span class="sr-only">
|
||||
<translate key="lang_device_mobile">Mobile Device</translate>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<icon icon="desktop_windows"></icon>
|
||||
<span class="sr-only">
|
||||
<translate key="lang_device_desktop">Desktop Device</translate>
|
||||
<span v-else>
|
||||
<icon icon="desktop_windows"></icon>
|
||||
<span class="sr-only">
|
||||
<translate key="lang_device_desktop">Desktop Device</translate>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{{ row.item.user_agent }} <br>
|
||||
<small>{{ row.item.client }}</small>
|
||||
|
||||
{{ row.item.user_agent }}
|
||||
</div>
|
||||
<div v-if="row.item.device.client">
|
||||
<small>{{ row.item.device.client }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(stream)="row">
|
||||
<span v-if="row.item.mount_name == ''">
|
||||
|
@ -94,11 +99,8 @@
|
|||
</span>
|
||||
</template>
|
||||
<template #cell(location)="row">
|
||||
<span v-if="row.item.location.status == 'success'">
|
||||
{{ row.item.location.region }}, {{ row.item.location.country }}
|
||||
</span>
|
||||
<span v-else-if="row.item.location.message">
|
||||
{{ row.item.location.message }}
|
||||
<span v-if="row.item.location.description">
|
||||
{{ row.item.location.description }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<translate key="lang_location_unknown">Unknown</translate>
|
||||
|
|
|
@ -58,7 +58,7 @@ export default {
|
|||
computed: {
|
||||
mapPoints() {
|
||||
return _.filter(this.listeners, function (l) {
|
||||
return l.location.status === 'success';
|
||||
return null !== l.location.lat && null !== l.location.lon;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||
namespace App\Controller\Api\Stations;
|
||||
|
||||
use App\Entity;
|
||||
use App\Enums\SupportedLocales;
|
||||
use App\Environment;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
|
@ -13,8 +12,8 @@ use App\OpenApi;
|
|||
use App\Service\DeviceDetector;
|
||||
use App\Service\IpGeolocation;
|
||||
use App\Utilities\File;
|
||||
use Azura\DoctrineBatchUtils\ReadOnlyBatchIteratorAggregate;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Doctrine\ORM\AbstractQuery;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\Csv\Writer;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
@ -51,14 +50,13 @@ class ListenersAction
|
|||
ServerRequest $request,
|
||||
Response $response,
|
||||
EntityManagerInterface $em,
|
||||
Entity\Repository\ListenerRepository $listenerRepo,
|
||||
Entity\Repository\StationMountRepository $mountRepo,
|
||||
Entity\Repository\StationRemoteRepository $remoteRepo,
|
||||
IpGeolocation $geoLite,
|
||||
DeviceDetector $deviceDetector,
|
||||
Environment $environment
|
||||
): ResponseInterface {
|
||||
set_time_limit($environment->getSyncLongExecutionTime());
|
||||
|
||||
$station = $request->getStation();
|
||||
$stationTz = $station->getTimezoneObject();
|
||||
|
||||
|
@ -67,19 +65,12 @@ class ListenersAction
|
|||
$isLive = empty($params['start']);
|
||||
$now = CarbonImmutable::now($stationTz);
|
||||
|
||||
$qb = $em->createQueryBuilder()
|
||||
->select('l')
|
||||
->from(Entity\Listener::class, 'l')
|
||||
->where('l.station = :station')
|
||||
->setParameter('station', $station)
|
||||
->orderBy('l.timestamp_start', 'ASC');
|
||||
|
||||
if ($isLive) {
|
||||
$range = 'live';
|
||||
$startTimestamp = $now->getTimestamp();
|
||||
$endTimestamp = $now->getTimestamp();
|
||||
|
||||
$qb = $qb->andWhere('l.timestamp_end = 0');
|
||||
$listenersIterator = $listenerRepo->iterateLiveListenersArray($station);
|
||||
} else {
|
||||
$start = CarbonImmutable::parse($params['start'], $stationTz)
|
||||
->setSecond(0);
|
||||
|
@ -91,44 +82,48 @@ class ListenersAction
|
|||
|
||||
$range = $start->format('Y-m-d_H-i-s') . '_to_' . $end->format('Y-m-d_H-i-s');
|
||||
|
||||
$qb = $qb->andWhere('l.timestamp_start < :time_end')
|
||||
->andWhere('(l.timestamp_end = 0 OR l.timestamp_end > :time_start)')
|
||||
$listenersIterator = $em->createQuery(
|
||||
<<<'DQL'
|
||||
SELECT l
|
||||
FROM App\Entity\Listener l
|
||||
WHERE l.station = :station
|
||||
AND l.timestamp_start < :time_end
|
||||
AND (l.timestamp_end = 0 OR l.timestamp_end > :time_start)
|
||||
ORDER BY l.timestamp_start ASC
|
||||
DQL
|
||||
)->setParameter('station', $station)
|
||||
->setParameter('time_start', $startTimestamp)
|
||||
->setParameter('time_end', $endTimestamp);
|
||||
->setParameter('time_end', $endTimestamp)
|
||||
->toIterable([], AbstractQuery::HYDRATE_ARRAY);
|
||||
}
|
||||
|
||||
$locale = $request->getAttribute(ServerRequest::ATTR_LOCALE)
|
||||
?? SupportedLocales::default();
|
||||
|
||||
$mountNames = $mountRepo->getDisplayNames($station);
|
||||
$remoteNames = $remoteRepo->getDisplayNames($station);
|
||||
|
||||
$listenersIterator = ReadOnlyBatchIteratorAggregate::fromQuery($qb->getQuery(), 250);
|
||||
|
||||
/** @var Entity\Api\Listener[] $listeners */
|
||||
$listeners = [];
|
||||
$listenersByHash = [];
|
||||
|
||||
$groupByUnique = ('false' !== ($params['unique'] ?? 'true'));
|
||||
$nowTimestamp = $now->getTimestamp();
|
||||
|
||||
foreach ($listenersIterator as $listener) {
|
||||
/** @var Entity\Listener $listener */
|
||||
$listenerStart = $listener->getTimestampStart();
|
||||
$listenerStart = $listener['timestamp_start'];
|
||||
|
||||
if ($isLive) {
|
||||
$listenerEnd = $now->getTimestamp();
|
||||
$listenerEnd = $nowTimestamp;
|
||||
} else {
|
||||
if ($listenerStart < $startTimestamp) {
|
||||
$listenerStart = $startTimestamp;
|
||||
}
|
||||
|
||||
$listenerEnd = $listener->getTimestampEnd();
|
||||
$listenerEnd = $listener['timestamp_end'];
|
||||
if (0 === $listenerEnd || $listenerEnd > $endTimestamp) {
|
||||
$listenerEnd = $endTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
$hash = $listener->getListenerHash();
|
||||
$hash = $listener['listener_hash'];
|
||||
if ($groupByUnique && isset($listenersByHash[$hash])) {
|
||||
$listenersByHash[$hash]['intervals'][] = [
|
||||
'start' => $listenerStart,
|
||||
|
@ -137,30 +132,16 @@ class ListenersAction
|
|||
continue;
|
||||
}
|
||||
|
||||
$userAgent = $listener->getListenerUserAgent();
|
||||
$dd = $deviceDetector->parse($userAgent);
|
||||
|
||||
$api = new Entity\Api\Listener();
|
||||
$api->ip = $listener->getListenerIp();
|
||||
$api->user_agent = $userAgent;
|
||||
$api->hash = $hash;
|
||||
$api->client = $dd->getClient() ?? 'Unknown';
|
||||
$api->is_mobile = $dd->isMobile();
|
||||
|
||||
if ($listener->getMountId()) {
|
||||
$mountId = $listener->getMountId();
|
||||
$api = Entity\Api\Listener::fromArray($listener);
|
||||
|
||||
if (null !== $listener['mount_id']) {
|
||||
$api->mount_is_local = true;
|
||||
$api->mount_name = $mountNames[$mountId];
|
||||
} elseif ($listener->getRemoteId()) {
|
||||
$remoteId = $listener->getRemoteId();
|
||||
|
||||
$api->mount_name = $mountNames[$listener['mount_id']];
|
||||
} elseif (null !== $listener['remote_id']) {
|
||||
$api->mount_is_local = false;
|
||||
$api->mount_name = $remoteNames[$remoteId];
|
||||
$api->mount_name = $remoteNames[$listener['remote_id']];
|
||||
}
|
||||
|
||||
$api->location = $geoLite->getLocationInfo($api->ip, $locale);
|
||||
|
||||
if ($groupByUnique) {
|
||||
$listenersByHash[$hash] = [
|
||||
'api' => $api,
|
||||
|
@ -183,7 +164,7 @@ class ListenersAction
|
|||
foreach ($listenersByHash as $listenerInfo) {
|
||||
$intervals = (array)$listenerInfo['intervals'];
|
||||
|
||||
$startTime = $now->getTimestamp();
|
||||
$startTime = $nowTimestamp;
|
||||
$endTime = 0;
|
||||
foreach ($intervals as $interval) {
|
||||
$startTime = min($interval['start'], $startTime);
|
||||
|
@ -239,14 +220,20 @@ class ListenersAction
|
|||
'End Time',
|
||||
'Seconds Connected',
|
||||
'User Agent',
|
||||
'Client',
|
||||
'Is Mobile',
|
||||
'Mount Type',
|
||||
'Mount Name',
|
||||
'Location',
|
||||
'Country',
|
||||
'Region',
|
||||
'City',
|
||||
'Device: Client',
|
||||
'Device: Is Mobile',
|
||||
'Device: Is Browser',
|
||||
'Device: Is Bot',
|
||||
'Device: Browser Family',
|
||||
'Device: OS Family',
|
||||
'Location: Description',
|
||||
'Location: Country',
|
||||
'Location: Region',
|
||||
'Location: City',
|
||||
'Location: Latitude',
|
||||
'Location: Longitude',
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -260,31 +247,22 @@ class ListenersAction
|
|||
$endTime->toIso8601String(),
|
||||
$listener->connected_time,
|
||||
$listener->user_agent,
|
||||
$listener->client,
|
||||
$listener->is_mobile ? 'True' : 'False',
|
||||
($listener->mount_is_local) ? 'Local' : 'Remote',
|
||||
$listener->mount_name,
|
||||
$listener->device['client'],
|
||||
$listener->device['is_mobile'] ? 'True' : 'False',
|
||||
$listener->device['is_browser'] ? 'True' : 'False',
|
||||
$listener->device['is_bot'] ? 'True' : 'False',
|
||||
$listener->device['browser_family'],
|
||||
$listener->device['os_family'],
|
||||
$listener->location['description'],
|
||||
$listener->location['country'],
|
||||
$listener->location['region'],
|
||||
$listener->location['city'],
|
||||
$listener->location['lat'],
|
||||
$listener->location['lon'],
|
||||
];
|
||||
|
||||
if ('' === $listener->mount_name) {
|
||||
$exportRow[] = 'Unknown';
|
||||
$exportRow[] = 'Unknown';
|
||||
} else {
|
||||
$exportRow[] = ($listener->mount_is_local) ? 'Local' : 'Remote';
|
||||
$exportRow[] = $listener->mount_name;
|
||||
}
|
||||
|
||||
$location = $listener->location;
|
||||
if ('success' === $location['status']) {
|
||||
$exportRow[] = $location['region'] . ', ' . $location['country'];
|
||||
$exportRow[] = $location['country'];
|
||||
$exportRow[] = $location['region'];
|
||||
$exportRow[] = $location['city'];
|
||||
} else {
|
||||
$exportRow[] = $location['message'] ?? 'N/A';
|
||||
$exportRow[] = '';
|
||||
$exportRow[] = '';
|
||||
$exportRow[] = '';
|
||||
}
|
||||
|
||||
$csv->insertOne($exportRow);
|
||||
}
|
||||
|
||||
|
|
|
@ -32,18 +32,6 @@ class Listener
|
|||
)]
|
||||
public string $hash = '';
|
||||
|
||||
#[OA\Property(
|
||||
description: 'The listener\'s client details (extracted from user-agent)',
|
||||
example: ''
|
||||
)]
|
||||
public string $client = '';
|
||||
|
||||
#[OA\Property(
|
||||
description: 'Whether the user-agent is likely a mobile browser.',
|
||||
example: true
|
||||
)]
|
||||
public bool $is_mobile = false;
|
||||
|
||||
#[OA\Property(
|
||||
description: 'Whether the user is connected to a local mount point or a remote one.',
|
||||
example: false
|
||||
|
@ -74,9 +62,41 @@ class Listener
|
|||
)]
|
||||
public int $connected_time = 0;
|
||||
|
||||
#[OA\Property(
|
||||
description: 'Device metadata, if available',
|
||||
items: new OA\Items()
|
||||
)]
|
||||
public array $device = [];
|
||||
|
||||
#[OA\Property(
|
||||
description: 'Location metadata, if available',
|
||||
items: new OA\Items()
|
||||
)]
|
||||
public array $location = [];
|
||||
|
||||
public static function fromArray(array $row): self
|
||||
{
|
||||
$api = new self();
|
||||
$api->ip = $row['listener_ip'];
|
||||
$api->user_agent = $row['listener_user_agent'];
|
||||
$api->hash = $row['listener_hash'];
|
||||
$api->connected_on = $row['timestamp_start'];
|
||||
$api->connected_until = $row['timestamp_end'];
|
||||
$api->connected_time = $api->connected_until - $api->connected_on;
|
||||
|
||||
$device = [];
|
||||
$location = [];
|
||||
foreach ($row as $key => $val) {
|
||||
if (str_starts_with($key, 'device.')) {
|
||||
$device[str_replace('device.', '', $key)] = $val;
|
||||
} elseif (str_starts_with($key, 'location.')) {
|
||||
$location[str_replace('location.', '', $key)] = $val;
|
||||
}
|
||||
}
|
||||
|
||||
$api->device = $device;
|
||||
$api->location = $location;
|
||||
|
||||
return $api;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,10 @@ use NowPlaying\Result\Client;
|
|||
#[
|
||||
ORM\Entity,
|
||||
ORM\Table(name: 'listener'),
|
||||
ORM\Index(columns: ['timestamp_end', 'timestamp_start'], name: 'idx_timestamps')
|
||||
ORM\Index(columns: ['timestamp_end', 'timestamp_start'], name: 'idx_timestamps'),
|
||||
ORM\Index(columns: ['location_country'], name: 'idx_statistics_country'),
|
||||
ORM\Index(columns: ['device_os_family'], name: 'idx_statistics_os'),
|
||||
ORM\Index(columns: ['device_browser_family'], name: 'idx_statistics_browser')
|
||||
]
|
||||
class Listener implements IdentifiableEntityInterface
|
||||
{
|
||||
|
@ -57,6 +60,12 @@ class Listener implements IdentifiableEntityInterface
|
|||
#[ORM\Column]
|
||||
protected int $timestamp_end;
|
||||
|
||||
#[ORM\Embedded(class: ListenerLocation::class, columnPrefix: 'location_')]
|
||||
protected ListenerLocation $location;
|
||||
|
||||
#[ORM\Embedded(class: ListenerDevice::class, columnPrefix: 'device_')]
|
||||
protected ListenerDevice $device;
|
||||
|
||||
public function __construct(Station $station, Client $client)
|
||||
{
|
||||
$this->station = $station;
|
||||
|
@ -68,6 +77,9 @@ class Listener implements IdentifiableEntityInterface
|
|||
$this->listener_user_agent = $this->truncateString($client->userAgent);
|
||||
$this->listener_ip = $client->ip;
|
||||
$this->listener_hash = self::calculateListenerHash($client);
|
||||
|
||||
$this->location = new ListenerLocation();
|
||||
$this->device = new ListenerDevice();
|
||||
}
|
||||
|
||||
public function getStation(): Station
|
||||
|
@ -150,6 +162,16 @@ class Listener implements IdentifiableEntityInterface
|
|||
return $this->timestamp_end - $this->timestamp_start;
|
||||
}
|
||||
|
||||
public function getLocation(): ListenerLocation
|
||||
{
|
||||
return $this->location;
|
||||
}
|
||||
|
||||
public function getDevice(): ListenerDevice
|
||||
{
|
||||
return $this->device;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter clients to exclude any listeners that shouldn't be included (i.e. relays).
|
||||
*
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Embeddable]
|
||||
class ListenerDevice implements \JsonSerializable
|
||||
{
|
||||
#[ORM\Column(length: 255)]
|
||||
protected ?string $client = null;
|
||||
|
||||
#[ORM\Column]
|
||||
protected bool $is_browser = false;
|
||||
|
||||
#[ORM\Column]
|
||||
protected bool $is_mobile = false;
|
||||
|
||||
#[ORM\Column]
|
||||
protected bool $is_bot = false;
|
||||
|
||||
#[ORM\Column(length: 150)]
|
||||
protected ?string $browser_family = null;
|
||||
|
||||
#[ORM\Column(length: 150)]
|
||||
protected ?string $os_family = null;
|
||||
|
||||
public function getClient(): ?string
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function isBrowser(): bool
|
||||
{
|
||||
return $this->is_browser;
|
||||
}
|
||||
|
||||
public function isMobile(): bool
|
||||
{
|
||||
return $this->is_mobile;
|
||||
}
|
||||
|
||||
public function isBot(): bool
|
||||
{
|
||||
return $this->is_bot;
|
||||
}
|
||||
|
||||
public function getBrowserFamily(): ?string
|
||||
{
|
||||
return $this->browser_family;
|
||||
}
|
||||
|
||||
public function getOsFamily(): ?string
|
||||
{
|
||||
return $this->os_family;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
return [
|
||||
'client' => $this->client,
|
||||
'is_browser' => $this->is_browser,
|
||||
'is_mobile' => $this->is_mobile,
|
||||
'is_bot' => $this->is_bot,
|
||||
'browser_family' => $this->browser_family,
|
||||
'os_family' => $this->os_family,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Embeddable]
|
||||
class ListenerLocation implements \JsonSerializable
|
||||
{
|
||||
#[ORM\Column(length: 255)]
|
||||
protected string $description;
|
||||
|
||||
#[ORM\Column(length: 150, nullable: true)]
|
||||
protected ?string $region = null;
|
||||
|
||||
#[ORM\Column(length: 150, nullable: true)]
|
||||
protected ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 2, nullable: true)]
|
||||
protected ?string $country = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 6, nullable: true)]
|
||||
protected ?float $lat = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 6, nullable: true)]
|
||||
protected ?float $lon = null;
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function getRegion(): ?string
|
||||
{
|
||||
return $this->region;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function getCountry(): ?string
|
||||
{
|
||||
return $this->country;
|
||||
}
|
||||
|
||||
public function getLat(): ?float
|
||||
{
|
||||
return $this->lat;
|
||||
}
|
||||
|
||||
public function getLon(): ?float
|
||||
{
|
||||
return $this->lon;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
return [
|
||||
'description' => $this->description,
|
||||
'region' => $this->region,
|
||||
'city' => $this->city,
|
||||
'country' => $this->country,
|
||||
'lat' => $this->lat,
|
||||
'lon' => $this->lon,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Migration;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20220415093355 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add device and location metadata to Listeners table.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(
|
||||
'ALTER TABLE listener ADD location_description VARCHAR(255) NOT NULL, ADD location_region VARCHAR(150) DEFAULT NULL, ADD location_city VARCHAR(150) DEFAULT NULL, ADD location_country VARCHAR(2) DEFAULT NULL, ADD location_lat NUMERIC(10, 6) DEFAULT NULL, ADD location_lon NUMERIC(10, 6) DEFAULT NULL, ADD device_client VARCHAR(255) NOT NULL, ADD device_is_browser TINYINT(1) NOT NULL, ADD device_is_mobile TINYINT(1) NOT NULL, ADD device_is_bot TINYINT(1) NOT NULL, ADD device_browser_family VARCHAR(150) NOT NULL, ADD device_os_family VARCHAR(150) NOT NULL'
|
||||
);
|
||||
$this->addSql('CREATE INDEX idx_statistics_country ON listener (location_country)');
|
||||
$this->addSql('CREATE INDEX idx_statistics_os ON listener (device_os_family)');
|
||||
$this->addSql('CREATE INDEX idx_statistics_browser ON listener (device_browser_family)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX idx_statistics_country ON listener');
|
||||
$this->addSql('DROP INDEX idx_statistics_os ON listener');
|
||||
$this->addSql('DROP INDEX idx_statistics_browser ON listener');
|
||||
$this->addSql(
|
||||
'ALTER TABLE listener DROP location_description, DROP location_region, DROP location_city, DROP location_country, DROP location_lat, DROP location_lon, DROP device_client, DROP device_is_browser, DROP device_is_mobile, DROP device_is_bot, DROP device_browser_family, DROP device_os_family'
|
||||
);
|
||||
}
|
||||
}
|
|
@ -4,17 +4,44 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Entity\Repository;
|
||||
|
||||
use App\Doctrine\ReloadableEntityManagerInterface;
|
||||
use App\Doctrine\Repository;
|
||||
use App\Entity;
|
||||
use App\Environment;
|
||||
use App\Service\DeviceDetector;
|
||||
use App\Service\IpGeolocation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use NowPlaying\Result\Client;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
|
||||
/**
|
||||
* @extends Repository<Entity\Listener>
|
||||
*/
|
||||
class ListenerRepository extends Repository
|
||||
{
|
||||
use Entity\Traits\TruncateStrings;
|
||||
|
||||
protected string $tableName;
|
||||
|
||||
protected Connection $conn;
|
||||
|
||||
public function __construct(
|
||||
protected DeviceDetector $deviceDetector,
|
||||
protected IpGeolocation $ipGeolocation,
|
||||
ReloadableEntityManagerInterface $em,
|
||||
Serializer $serializer,
|
||||
Environment $environment,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct($em, $serializer, $environment, $logger);
|
||||
|
||||
$this->tableName = $this->em->getClassMetadata(Entity\Listener::class)->getTableName();
|
||||
$this->conn = $this->em->getConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of unique listeners for a station during a specified time period.
|
||||
*
|
||||
|
@ -56,6 +83,7 @@ class ListenerRepository extends Repository
|
|||
FROM App\Entity\Listener l
|
||||
WHERE l.station = :station
|
||||
AND l.timestamp_end = 0
|
||||
ORDER BY l.timestamp_start ASC
|
||||
DQL
|
||||
)->setParameter('station', $station);
|
||||
|
||||
|
@ -88,10 +116,6 @@ class ListenerRepository extends Repository
|
|||
$existingClients[$identifier] = $client['id'];
|
||||
}
|
||||
|
||||
$listenerTable = $this->em->getClassMetadata(Entity\Listener::class)->getTableName();
|
||||
|
||||
$conn = $this->em->getConnection();
|
||||
|
||||
foreach ($clients as $client) {
|
||||
$listenerHash = Entity\Listener::calculateListenerHash($client);
|
||||
$identifier = $client->uid . '_' . $listenerHash;
|
||||
|
@ -101,32 +125,7 @@ class ListenerRepository extends Repository
|
|||
unset($existingClients[$identifier]);
|
||||
} else {
|
||||
// Create a new record.
|
||||
$record = [
|
||||
'station_id' => $station->getId(),
|
||||
'timestamp_start' => time(),
|
||||
'timestamp_end' => 0,
|
||||
'listener_uid' => (int)$client->uid,
|
||||
'listener_user_agent' => mb_substr(
|
||||
$client->userAgent ?? '',
|
||||
0,
|
||||
255,
|
||||
'UTF-8'
|
||||
),
|
||||
'listener_ip' => $client->ip,
|
||||
'listener_hash' => Entity\Listener::calculateListenerHash($client),
|
||||
];
|
||||
|
||||
if (!empty($client->mount)) {
|
||||
[$mountType, $mountId] = explode('_', $client->mount, 2);
|
||||
|
||||
if ('local' === $mountType) {
|
||||
$record['mount_id'] = (int)$mountId;
|
||||
} elseif ('remote' === $mountType) {
|
||||
$record['remote_id'] = (int)$mountId;
|
||||
}
|
||||
}
|
||||
|
||||
$conn->insert($listenerTable, $record);
|
||||
$this->batchAddRow($station, $client);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -146,6 +145,82 @@ class ListenerRepository extends Repository
|
|||
);
|
||||
}
|
||||
|
||||
public function batchAddRow(Entity\Station $station, Client $client): array
|
||||
{
|
||||
$record = [
|
||||
'station_id' => $station->getId(),
|
||||
'timestamp_start' => time(),
|
||||
'timestamp_end' => 0,
|
||||
'listener_uid' => (int)$client->uid,
|
||||
'listener_user_agent' => $this->truncateString($client->userAgent ?? ''),
|
||||
'listener_ip' => $client->ip,
|
||||
'listener_hash' => Entity\Listener::calculateListenerHash($client),
|
||||
];
|
||||
|
||||
if (!empty($client->mount)) {
|
||||
[$mountType, $mountId] = explode('_', $client->mount, 2);
|
||||
|
||||
if ('local' === $mountType) {
|
||||
$record['mount_id'] = (int)$mountId;
|
||||
} elseif ('remote' === $mountType) {
|
||||
$record['remote_id'] = (int)$mountId;
|
||||
}
|
||||
}
|
||||
|
||||
$record = $this->batchAddDeviceDetails($record);
|
||||
$record = $this->batchAddLocationDetails($record);
|
||||
|
||||
$this->conn->insert($this->tableName, $record);
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
protected function batchAddDeviceDetails(array $record): array
|
||||
{
|
||||
$userAgent = $record['listener_user_agent'];
|
||||
|
||||
try {
|
||||
$browserResult = $this->deviceDetector->parse($userAgent);
|
||||
|
||||
$record['device_client'] = $this->truncateNullableString($browserResult->client);
|
||||
$record['device_is_browser'] = $browserResult->isBrowser ? 1 : 0;
|
||||
$record['device_is_mobile'] = $browserResult->isMobile ? 1 : 0;
|
||||
$record['device_is_bot'] = $browserResult->isBot ? 1 : 0;
|
||||
$record['device_browser_family'] = $this->truncateNullableString($browserResult->browserFamily, 150);
|
||||
$record['device_os_family'] = $this->truncateNullableString($browserResult->osFamily, 150);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Device Detector error: ' . $e->getMessage(), [
|
||||
'user_agent' => $userAgent,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
protected function batchAddLocationDetails(array $record): array
|
||||
{
|
||||
$ip = $record['listener_ip'];
|
||||
|
||||
try {
|
||||
$ipInfo = $this->ipGeolocation->getLocationInfo($ip);
|
||||
|
||||
$record['location_description'] = $this->truncateString($ipInfo->description);
|
||||
$record['location_region'] = $this->truncateNullableString($ipInfo->region, 150);
|
||||
$record['location_city'] = $this->truncateNullableString($ipInfo->city, 150);
|
||||
$record['location_country'] = $this->truncateNullableString($ipInfo->country, 2);
|
||||
$record['location_lat'] = $ipInfo->lat;
|
||||
$record['location_lon'] = $ipInfo->lon;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('IP Geolocation error: ' . $e->getMessage(), [
|
||||
'ip' => $ip,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
public function clearAll(): void
|
||||
{
|
||||
$this->em->createQuery(
|
||||
|
|
|
@ -73,7 +73,7 @@ class StationRequestRepository extends Repository
|
|||
// Forbid web crawlers from using this feature.
|
||||
$dd = $this->deviceDetector->parse($userAgent);
|
||||
|
||||
if ($dd->isBot()) {
|
||||
if ($dd->isBot) {
|
||||
throw new Exception(__('Search engine crawlers are not permitted to use this feature.'));
|
||||
}
|
||||
|
||||
|
|
|
@ -5,12 +5,10 @@ declare(strict_types=1);
|
|||
namespace App\Radio\Frontend\Blocklist;
|
||||
|
||||
use App\Entity;
|
||||
use App\Enums\SupportedLocales;
|
||||
use App\Radio\Enums\FrontendAdapters;
|
||||
use App\Service\IpGeolocation;
|
||||
use PhpIP\IP;
|
||||
use PhpIP\IPBlock;
|
||||
use Symfony\Component\Intl\Countries;
|
||||
|
||||
class BlocklistParser
|
||||
{
|
||||
|
@ -106,22 +104,11 @@ class BlocklistParser
|
|||
return false;
|
||||
}
|
||||
|
||||
$listenerLocation = $this->ipGeolocation->getLocationInfo($listenerIp, SupportedLocales::default());
|
||||
if ('success' === $listenerLocation['status']) {
|
||||
$listenerCountry = $listenerLocation['country'];
|
||||
|
||||
$countries = Countries::getNames(SupportedLocales::default()->value);
|
||||
|
||||
$listenerCountryCode = '';
|
||||
foreach ($countries as $countryCode => $countryName) {
|
||||
if ($countryName === $listenerCountry) {
|
||||
$listenerCountryCode = $countryCode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$listenerLocation = $this->ipGeolocation->getLocationInfo($listenerIp);
|
||||
|
||||
if (null !== $listenerLocation->country) {
|
||||
foreach ($bannedCountries as $countryCode) {
|
||||
if ($countryCode === $listenerCountryCode) {
|
||||
if ($countryCode === $listenerLocation->country) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@ class DeviceDetector
|
|||
$this->dd->setUserAgent($userAgent);
|
||||
$this->dd->parse();
|
||||
|
||||
return DeviceResult::fromDeviceDetector($this->dd);
|
||||
return DeviceResult::fromDeviceDetector($userAgent, $this->dd);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -3,56 +3,57 @@
|
|||
namespace App\Service\DeviceDetector;
|
||||
|
||||
use DeviceDetector\DeviceDetector;
|
||||
use DeviceDetector\Parser\Client\Browser;
|
||||
use DeviceDetector\Parser\OperatingSystem;
|
||||
|
||||
final class DeviceResult
|
||||
{
|
||||
public function __construct(
|
||||
protected bool $isBot = false,
|
||||
protected bool $isMobile = false,
|
||||
protected ?string $client = null
|
||||
public readonly string $userAgent
|
||||
) {
|
||||
}
|
||||
|
||||
public function isBot(): bool
|
||||
{
|
||||
return $this->isBot;
|
||||
}
|
||||
public ?string $client = null;
|
||||
|
||||
public function isMobile(): bool
|
||||
{
|
||||
return $this->isMobile;
|
||||
}
|
||||
public bool $isBrowser = false;
|
||||
|
||||
public function getClient(): ?string
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
public bool $isMobile = false;
|
||||
|
||||
public static function fromDeviceDetector(DeviceDetector $dd): self
|
||||
{
|
||||
$isBot = $dd->isBot();
|
||||
public bool $isBot = false;
|
||||
|
||||
if ($isBot) {
|
||||
public ?string $browserFamily = null;
|
||||
|
||||
public ?string $osFamily = null;
|
||||
|
||||
public static function fromDeviceDetector(string $userAgent, DeviceDetector $dd): self
|
||||
{
|
||||
$record = new self($userAgent);
|
||||
$record->isBot = $dd->isBot();
|
||||
|
||||
if ($record->isBot) {
|
||||
$clientBot = (array)$dd->getBot();
|
||||
|
||||
$clientBotName = $clientBot['name'] ?? 'Unknown Crawler';
|
||||
$clientBotType = $clientBot['category'] ?? 'Generic Crawler';
|
||||
$client = $clientBotName . ' (' . $clientBotType . ')';
|
||||
$record->client = $clientBotName . ' (' . $clientBotType . ')';
|
||||
|
||||
$record->browserFamily = 'Crawler';
|
||||
$record->osFamily = 'Crawler';
|
||||
} else {
|
||||
$record->isMobile = $dd->isMobile();
|
||||
$record->isBrowser = $dd->isBrowser();
|
||||
|
||||
$clientInfo = (array)$dd->getClient();
|
||||
$clientBrowser = $clientInfo['name'] ?? 'Unknown Browser';
|
||||
$clientVersion = $clientInfo['version'] ?? '0.00';
|
||||
$record->browserFamily = Browser::getBrowserFamily($clientBrowser);
|
||||
|
||||
$clientOsInfo = (array)$dd->getOs();
|
||||
$clientOs = $clientOsInfo['name'] ?? 'Unknown OS';
|
||||
$record->osFamily = OperatingSystem::getOsFamily($clientOs);
|
||||
|
||||
$client = $clientBrowser . ' ' . $clientVersion . ', ' . $clientOs;
|
||||
$record->client = $clientBrowser . ' ' . $clientVersion . ', ' . $clientOs;
|
||||
}
|
||||
|
||||
return new self(
|
||||
isBot: $isBot,
|
||||
isMobile: $dd->isMobile(),
|
||||
client: $client
|
||||
);
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\SupportedLocales;
|
||||
use App\Service\IpGeolocator;
|
||||
use Exception;
|
||||
use MaxMind\Db\Reader;
|
||||
|
@ -69,10 +68,7 @@ class IpGeolocation
|
|||
return $this->attribution;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed[]
|
||||
*/
|
||||
public function getLocationInfo(string $ip, SupportedLocales $locale): array
|
||||
public function getLocationInfo(string $ip): IpGeolocator\IpResult
|
||||
{
|
||||
if (!$this->isInitialized) {
|
||||
$this->initialize();
|
||||
|
@ -80,10 +76,7 @@ class IpGeolocation
|
|||
|
||||
$reader = $this->reader;
|
||||
if (null === $reader) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => $this->getAttribution(),
|
||||
];
|
||||
throw new \RuntimeException('No IP Geolocation reader available.');
|
||||
}
|
||||
|
||||
$cacheKey = $this->readerShortName . '_' . str_replace([':', '.'], '_', $ip);
|
||||
|
@ -101,50 +94,18 @@ class IpGeolocation
|
|||
}
|
||||
|
||||
return [
|
||||
'status' => 'error',
|
||||
'status' => 'error',
|
||||
'message' => 'Internal/Reserved IP',
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (isset($ipInfo['status']) && $ipInfo['status'] === 'error') {
|
||||
return $ipInfo;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'lat' => $ipInfo['location']['latitude'] ?? 0.0,
|
||||
'lon' => $ipInfo['location']['longitude'] ?? 0.0,
|
||||
'timezone' => $ipInfo['location']['time_zone'] ?? '',
|
||||
'region' => $this->getLocalizedString($ipInfo['subdivisions'][0]['names'] ?? null, $locale),
|
||||
'country' => $this->getLocalizedString($ipInfo['country']['names'] ?? null, $locale),
|
||||
'city' => $this->getLocalizedString($ipInfo['city']['names'] ?? null, $locale),
|
||||
'message' => $this->attribution,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getLocalizedString(?array $names, SupportedLocales $locale): string
|
||||
{
|
||||
if (empty($names)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Convert "en_US" to "en-US", the format MaxMind uses.
|
||||
$localeStr = str_replace('_', '-', $locale->value);
|
||||
|
||||
// Check for an exact match.
|
||||
if (isset($names[$localeStr])) {
|
||||
return $names[$localeStr];
|
||||
}
|
||||
|
||||
// Check for a match of the first portion, i.e. "en"
|
||||
$localeStr = strtolower(substr($localeStr, 0, 2));
|
||||
return $names[$localeStr] ?? $names['en'];
|
||||
return IpGeolocator\IpResult::fromIpInfo($ip, $ipInfo);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service\IpGeolocator;
|
||||
|
||||
final class IpResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $ip
|
||||
) {
|
||||
}
|
||||
|
||||
public string $description;
|
||||
|
||||
public ?string $region = null;
|
||||
|
||||
public ?string $city = null;
|
||||
|
||||
public ?string $country = null;
|
||||
|
||||
public ?float $lat = null;
|
||||
|
||||
public ?float $lon = null;
|
||||
|
||||
public static function fromIpInfo(string $ip, array $ipInfo = []): self
|
||||
{
|
||||
$record = new self($ip);
|
||||
|
||||
if (isset($ipInfo['status']) && $ipInfo['status'] === 'error') {
|
||||
$record->description = 'Internal/Reserved IP';
|
||||
return $record;
|
||||
}
|
||||
|
||||
$record->region = $ipInfo['subdivisions'][0]['names']['en'] ?? null;
|
||||
$record->city = $ipInfo['city'][0]['names']['en'] ?? null;
|
||||
$record->country = $ipInfo['country']['iso_code'] ?? null;
|
||||
$record->description = implode(
|
||||
', ',
|
||||
array_filter([
|
||||
$record->region,
|
||||
$record->city,
|
||||
$record->country,
|
||||
])
|
||||
);
|
||||
|
||||
$record->lat = $ipInfo['location']['latitude'] ?? null;
|
||||
$record->lon = $ipInfo['location']['longitude'] ?? null;
|
||||
return $record;
|
||||
}
|
||||
}
|
|
@ -60,14 +60,20 @@ class Api_Stations_ReportsCest extends CestAbstract
|
|||
'End Time',
|
||||
'Seconds Connected',
|
||||
'User Agent',
|
||||
'Client',
|
||||
'Is Mobile',
|
||||
'Mount Type',
|
||||
'Mount Name',
|
||||
'Location',
|
||||
'Country',
|
||||
'Region',
|
||||
'City',
|
||||
'Device: Client',
|
||||
'Device: Is Mobile',
|
||||
'Device: Is Browser',
|
||||
'Device: Is Bot',
|
||||
'Device: Browser Family',
|
||||
'Device: OS Family',
|
||||
'Location: Description',
|
||||
'Location: Country',
|
||||
'Location: Region',
|
||||
'Location: City',
|
||||
'Location: Latitude',
|
||||
'Location: Longitude',
|
||||
];
|
||||
|
||||
$this->testReportCsv($I, $requestUrl, $csvHeaders);
|
||||
|
|
Loading…
Reference in New Issue