Merge commit '3c78bd0a30f6f9cf175ea6bcc63d246951b8437b'

This commit is contained in:
Buster "Silver Eagle" Neece 2022-06-14 05:59:42 -05:00
parent a1cd3f3007
commit 4c9c773a28
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
19 changed files with 898 additions and 358 deletions

View File

@ -172,7 +172,7 @@ return function (App\Event\BuildStationMenu $e) {
'permission' => StationPermissions::Reports,
'items' => [
'reports_overview' => [
'label' => __('Statistics Overview'),
'label' => __('Station Statistics'),
'url' => (string)$router->fromHere('stations:reports:overview'),
],
'reports_listeners' => [

View File

@ -508,9 +508,14 @@ return static function (RouteCollectorProxy $group) {
)->setName('api:stations:reports:best-and-worst');
$group->get(
'/overview/most-played',
Controller\Api\Stations\Reports\Overview\MostPlayedAction::class
)->setName('api:stations:reports:most-played');
'/overview/by-browser',
Controller\Api\Stations\Reports\Overview\ByBrowser::class
)->setName('api:stations:reports:by-browser');
$group->get(
'/overview/by-country',
Controller\Api\Stations\Reports\Overview\ByCountry::class
)->setName('api:stations:reports:by-country');
$group->get(
'/soundexchange',

View File

@ -10,23 +10,27 @@ import {Chart} from 'chart.js';
import {Tableau20} from '~/vendor/chartjs-colorschemes/colorschemes.tableau.js';
export default {
name: 'DayOfWeekChart',
name: 'PieChart',
inheritAttrs: true,
props: {
options: Object,
data: Array,
labels: Array
labels: Array,
aspectRatio: {
type: Number,
default: 2
}
},
data () {
data() {
return {
_chart: null
};
},
mounted () {
mounted() {
this.renderChart();
},
methods: {
renderChart () {
renderChart() {
const defaultOptions = {
type: 'pie',
data: {
@ -34,7 +38,7 @@ export default {
datasets: this.data
},
options: {
aspectRatio: 4,
aspectRatio: this.aspectRatio,
plugins: {
colorschemes: {
scheme: Tableau20
@ -50,7 +54,7 @@ export default {
this._chart = new Chart(this.$refs.canvas.getContext('2d'), chartOptions);
}
},
beforeDestroy () {
beforeDestroy() {
if (this._chart) {
this._chart.destroy();
}

View File

@ -1,251 +1,65 @@
<template>
<div id="reports-overview">
<h2 class="outside-card-header mb-1">
<translate key="hdr">Statistics Overview</translate>
</h2>
<section class="card mb-4" role="region">
<b-overlay variant="card" :show="chartsLoading">
<div class="card-body py-5" v-if="chartsLoading">
&nbsp;
<section class="card mb-4" role="region">
<div class="card-header bg-primary-dark">
<div class="d-flex align-items-center">
<h2 class="card-title flex-fill my-0">
<translate key="hdr">Station Statistics</translate>
</h2>
<div class="flex-shrink">
<date-range-dropdown time-picker v-model="dateRange" :tz="stationTimeZone"></date-range-dropdown>
</div>
<b-tabs pills card lazy v-else>
<b-tab :title="langListenersByDay" active>
<time-series-chart style="width: 100%;" :data="chartsData.daily.metrics">
<span v-html="chartsData.daily.alt"></span>
</time-series-chart>
</b-tab>
<b-tab :title="langListenersByDayOfWeek">
<day-of-week-chart style="width: 100%;" :data="chartsData.day_of_week.metrics" :labels="chartsData.day_of_week.labels">
<span v-html="chartsData.day_of_week.alt"></span>
</day-of-week-chart>
</b-tab>
<b-tab :title="langListenersByHour">
<hour-chart style="width: 100%;" :data="chartsData.hourly.metrics" :labels="chartsData.hourly.labels">
<span v-html="chartsData.hourly.alt"></span>
</hour-chart>
</b-tab>
</b-tabs>
</b-overlay>
</section>
<div class="row">
<div class="col-sm-6">
<section class="card mb-3" role="region">
<div class="card-header bg-primary-dark">
<h3 class="card-title">
<translate key="reports_overview_best_songs">Best Performing Songs</translate>
<small>
<translate key="reports_overview_timeframe">in the last 48 hours</translate>
</small>
</h3>
</div>
<div class="table-responsive">
<table class="table table-striped table-condensed table-nopadding">
<colgroup>
<col width="20%">
<col width="80%">
</colgroup>
<thead>
<tr>
<th>
<translate key="reports_overview_col_change">Change</translate>
</th>
<th>
<translate key="reports_overview_col_song">Song</translate>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in bestAndWorst.best">
<td class="text-center text-success">
<icon icon="keyboard_arrow_up"></icon>
{{ row.stat_delta }}
<br>
<small>{{ row.stat_start }} to {{ row.stat_end }}</small>
</td>
<td>
<span v-html="getSongText(row.song)"></span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<div class="col-sm-6">
<section class="card mb-3" role="region">
<div class="card-header bg-primary-dark">
<h3 class="card-title">
<translate key="reports_overview_worst_songs">Worst Performing Songs</translate>
<small>
<translate key="reports_overview_timeframe">in the last 48 hours</translate>
</small>
</h3>
</div>
<div class="table-responsive">
<table class="table table-striped table-condensed table-nopadding">
<colgroup>
<col width="20%">
<col width="80%">
</colgroup>
<thead>
<tr>
<th>
<translate key="reports_overview_col_change">Change</translate>
</th>
<th>
<translate key="reports_overview_col_song">Song</translate>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in bestAndWorst.worst">
<td class="text-center text-danger">
<icon icon="keyboard_arrow_down"></icon>
{{ row.stat_delta }}
<br>
<small>{{ row.stat_start }} to {{ row.stat_end }}</small>
</td>
<td>
<span v-html="getSongText(row.song)"></span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<section class="card" role="region">
<div class="card-header bg-primary-dark">
<h3 class="card-title">
<translate key="reports_overview_most_played">Most Played Songs</translate>
<small>
<translate key="reports_overview_most_played_timeframe">in the last month</translate>
</small>
</h3>
</div>
<div class="table-responsive">
<table class="table table-striped table-condensed table-nopadding">
<colgroup>
<col width="10%">
<col width="90%">
</colgroup>
<thead>
<tr>
<th>
<translate key="reports_overview_col_plays">Plays</translate>
</th>
<th>
<translate key="reports_overview_col_song">Song</translate>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in mostPlayed">
<td class="text-center">
{{ row.num_plays }}
</td>
<td>
<span v-html="getSongText(row.song)"></span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
</div>
<b-tabs pills lazy nav-class="card-header-pills" nav-wrapper-class="card-header">
<best-and-worst-tab :api-url="bestAndWorstUrl" :date-range="dateRange">
</best-and-worst-tab>
<listeners-by-time-period-tab :api-url="listenersByTimePeriodUrl" :date-range="dateRange">
</listeners-by-time-period-tab>
<browsers-tab v-if="showFullAnalytics" :api-url="byBrowserUrl" :date-range="dateRange">
</browsers-tab>
<countries-tab v-if="showFullAnalytics" :api-url="byCountryUrl" :date-range="dateRange">
</countries-tab>
</b-tabs>
</section>
</template>
<script>
import TimeSeriesChart from '~/components/Common/TimeSeriesChart';
import DataTable from '~/components/Common/DataTable';
import Icon from '~/components/Common/Icon';
import DayOfWeekChart from './Overview/DayOfWeekChart';
import HourChart from './Overview/HourChart';
import {DateTime} from "luxon";
import DateRangeDropdown from "~/components/Common/DateRangeDropdown";
import ListenersByTimePeriodTab from "./Overview/ListenersByTimePeriodTab";
import BestAndWorstTab from "./Overview/BestAndWorstTab";
import BrowsersTab from "./Overview/BrowsersTab";
import CountriesTab from "~/components/Stations/Reports/Overview/CountriesTab";
export default {
components: {HourChart, DayOfWeekChart, Icon, DataTable, TimeSeriesChart},
components: {
CountriesTab,
BrowsersTab,
BestAndWorstTab,
ListenersByTimePeriodTab,
DateRangeDropdown
},
props: {
chartsUrl: String,
stationTimeZone: String,
showFullAnalytics: Boolean,
listenersByTimePeriodUrl: String,
bestAndWorstUrl: String,
mostPlayedUrl: String
byBrowserUrl: String,
byCountryUrl: String,
},
data() {
let nowTz = DateTime.now().setZone(this.stationTimeZone);
return {
chartsLoading: true,
chartsData: {
daily: {
metrics: [],
alt: ''
},
day_of_week: {
labels: [],
metrics: [],
alt: ''
},
hourly: {
labels: [],
metrics: [],
alt: ''
}
dateRange: {
startDate: nowTz.minus({days: 13}).toJSDate(),
endDate: nowTz.toJSDate(),
},
bestAndWorstLoading: true,
bestAndWorst: {
best: [],
worst: []
},
mostPlayedLoading: true,
mostPlayed: []
};
},
computed: {
langListenersByDay () {
return this.$gettext('Listeners by Day');
},
langListenersByDayOfWeek () {
return this.$gettext('Listeners by Day of Week');
},
langListenersByHour () {
return this.$gettext('Listeners by Hour');
}
},
created () {
this.axios.get(this.chartsUrl).then((response) => {
this.chartsData = response.data;
this.chartsLoading = false;
}).catch((error) => {
console.error(error);
});
this.axios.get(this.bestAndWorstUrl).then((response) => {
this.bestAndWorst = response.data;
this.bestAndWorstLoading = false;
}).catch((error) => {
console.error(error);
});
this.axios.get(this.mostPlayedUrl).then((response) => {
this.mostPlayed = response.data;
this.mostPlayedLoading = false;
}).catch((error) => {
console.error(error);
});
},
methods: {
getSongText (song) {
if (song.title !== '') {
return '<b>' + song.title + '</b><br>' + song.artist;
}
return song.text;
}
}
};
</script>

View File

@ -0,0 +1,181 @@
<template>
<b-tab :title="langTitle" active>
<b-overlay variant="card" :show="loading">
<div class="card-body py-5" v-if="loading">
&nbsp;
</div>
<div class="card-body" v-else>
<b-row>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate key="reports_overview_best_songs">Best Performing Songs</translate>
</legend>
<table class="table table-striped table-condensed table-nopadding">
<colgroup>
<col width="20%">
<col width="80%">
</colgroup>
<thead>
<tr>
<th>
<translate key="reports_overview_col_change">Change</translate>
</th>
<th>
<translate key="reports_overview_col_song">Song</translate>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in bestAndWorst.best">
<td class="text-center text-success">
<icon icon="keyboard_arrow_up"></icon>
{{ row.stat_delta }}
<br>
<small>{{ row.stat_start }} to {{ row.stat_end }}</small>
</td>
<td>
<span v-html="getSongText(row.song)"></span>
</td>
</tr>
</tbody>
</table>
</fieldset>
</b-col>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate key="reports_overview_worst_songs">Worst Performing Songs</translate>
</legend>
<table class="table table-striped table-condensed table-nopadding">
<colgroup>
<col width="20%">
<col width="80%">
</colgroup>
<thead>
<tr>
<th>
<translate key="reports_overview_col_change">Change</translate>
</th>
<th>
<translate key="reports_overview_col_song">Song</translate>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in bestAndWorst.worst">
<td class="text-center text-danger">
<icon icon="keyboard_arrow_down"></icon>
{{ row.stat_delta }}
<br>
<small>{{ row.stat_start }} to {{ row.stat_end }}</small>
</td>
<td>
<span v-html="getSongText(row.song)"></span>
</td>
</tr>
</tbody>
</table>
</fieldset>
</b-col>
<b-col md="12" class="mb-4">
<fieldset>
<legend>
<translate key="reports_overview_most_played">Most Played Songs</translate>
</legend>
<table class="table table-striped table-condensed table-nopadding">
<colgroup>
<col width="10%">
<col width="90%">
</colgroup>
<thead>
<tr>
<th>
<translate key="reports_overview_col_plays">Plays</translate>
</th>
<th>
<translate key="reports_overview_col_song">Song</translate>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in mostPlayed">
<td class="text-center">
{{ row.num_plays }}
</td>
<td>
<span v-html="getSongText(row.song)"></span>
</td>
</tr>
</tbody>
</table>
</fieldset>
</b-col>
</b-row>
</div>
</b-overlay>
</b-tab>
</template>
<script>
import {DateTime} from "luxon";
import Icon from "~/components/Common/Icon";
export default {
name: 'BestAndWorstTab',
components: {Icon},
props: {
dateRange: Object,
apiUrl: String,
},
data() {
return {
loading: true,
bestAndWorst: {
best: [],
worst: []
},
mostPlayed: [],
};
},
watch: {
dateRange() {
this.relist();
}
},
computed: {
langTitle() {
return this.$gettext('Best & Worst Tracks');
}
},
mounted() {
this.relist();
},
methods: {
relist() {
this.loading = true;
this.axios.get(this.apiUrl, {
params: {
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
}
}).then((response) => {
this.bestAndWorst = response.data.bestAndWorst;
this.mostPlayed = response.data.mostPlayed;
this.loading = false;
});
},
getSongText(song) {
if (song.title !== '') {
return '<b>' + song.title + '</b><br>' + song.artist;
}
return song.text;
}
}
}
</script>

View File

@ -0,0 +1,118 @@
<template>
<b-tab :title="langTitle">
<b-overlay variant="card" :show="loading">
<div class="card-body py-5" v-if="loading">
&nbsp;
</div>
<div v-else>
<div class="card-body">
<b-row>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate key="hdr_top_by_listeners">Top Browsers by Listeners</translate>
</legend>
<pie-chart style="width: 100%;" :data="top_listeners.datasets"
:labels="top_listeners.labels">
<span v-html="top_listeners.alt"></span>
</pie-chart>
</fieldset>
</b-col>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate
key="hdr_top_by_connected_seconds">Top Browsers by Connected Time</translate>
</legend>
<pie-chart style="width: 100%;" :data="top_connected_time.datasets"
:labels="top_connected_time.labels">
<span v-html="top_connected_time.alt"></span>
</pie-chart>
</fieldset>
</b-col>
</b-row>
</div>
<data-table ref="datatable" id="browsers_table" paginated handle-client-side
:fields="fields" :responsive="false" :items="all">
<template #cell(connected_seconds_calc)="row">
{{ formatTime(row.item.connected_seconds) }}
</template>
</data-table>
</div>
</b-overlay>
</b-tab>
</template>
<script>
import {DateTime} from "luxon";
import PieChart from "~/components/Common/PieChart";
import formatTime from "~/functions/formatTime";
import DataTable from "~/components/Common/DataTable";
export default {
name: 'BrowsersTab',
components: {DataTable, PieChart},
props: {
dateRange: Object,
apiUrl: String,
},
data() {
return {
loading: true,
all: [],
top_listeners: {
labels: [],
datasets: [],
alt: ''
},
top_connected_time: {
labels: [],
datasets: [],
alt: ''
},
fields: [
{key: 'browser', label: this.$gettext('Browser'), sortable: true},
{key: 'listeners', label: this.$gettext('Listeners'), sortable: true},
{key: 'connected_seconds_calc', label: this.$gettext('Time'), sortable: false},
{key: 'connected_seconds', label: this.$gettext('Time (sec)'), sortable: true}
]
};
},
watch: {
dateRange() {
this.relist();
}
},
computed: {
langTitle() {
return this.$gettext('Browsers');
}
},
mounted() {
this.relist();
},
methods: {
relist() {
this.loading = true;
this.axios.get(this.apiUrl, {
params: {
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
}
}).then((response) => {
this.all = response.data.all;
this.top_listeners = response.data.top_listeners;
this.top_connected_time = response.data.top_connected_time;
this.loading = false;
});
},
formatTime(time) {
return formatTime(time);
}
}
}
</script>

View File

@ -0,0 +1,118 @@
<template>
<b-tab :title="langTitle">
<b-overlay variant="card" :show="loading">
<div class="card-body py-5" v-if="loading">
&nbsp;
</div>
<div v-else>
<div class="card-body">
<b-row>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate key="hdr_top_by_listeners">Top Countries by Listeners</translate>
</legend>
<pie-chart style="width: 100%;" :data="top_listeners.datasets"
:labels="top_listeners.labels">
<span v-html="top_listeners.alt"></span>
</pie-chart>
</fieldset>
</b-col>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate
key="hdr_top_by_connected_seconds">Top Countries by Connected Time</translate>
</legend>
<pie-chart style="width: 100%;" :data="top_connected_time.datasets"
:labels="top_connected_time.labels">
<span v-html="top_connected_time.alt"></span>
</pie-chart>
</fieldset>
</b-col>
</b-row>
</div>
<data-table ref="datatable" id="browsers_table" paginated handle-client-side
:fields="fields" :responsive="false" :items="all">
<template #cell(connected_seconds_calc)="row">
{{ formatTime(row.item.connected_seconds) }}
</template>
</data-table>
</div>
</b-overlay>
</b-tab>
</template>
<script>
import {DateTime} from "luxon";
import PieChart from "~/components/Common/PieChart";
import formatTime from "~/functions/formatTime";
import DataTable from "~/components/Common/DataTable";
export default {
name: 'CountriesTab',
components: {DataTable, PieChart},
props: {
dateRange: Object,
apiUrl: String,
},
data() {
return {
loading: true,
all: [],
top_listeners: {
labels: [],
datasets: [],
alt: ''
},
top_connected_time: {
labels: [],
datasets: [],
alt: ''
},
fields: [
{key: 'country', label: this.$gettext('Country'), sortable: true},
{key: 'listeners', label: this.$gettext('Listeners'), sortable: true},
{key: 'connected_seconds_calc', label: this.$gettext('Time'), sortable: false},
{key: 'connected_seconds', label: this.$gettext('Time (sec)'), sortable: true}
]
};
},
watch: {
dateRange() {
this.relist();
}
},
computed: {
langTitle() {
return this.$gettext('Countries');
}
},
mounted() {
this.relist();
},
methods: {
relist() {
this.loading = true;
this.axios.get(this.apiUrl, {
params: {
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
}
}).then((response) => {
this.all = response.data.all;
this.top_listeners = response.data.top_listeners;
this.top_connected_time = response.data.top_connected_time;
this.loading = false;
});
},
formatTime(time) {
return formatTime(time);
}
}
}
</script>

View File

@ -16,16 +16,16 @@ export default {
data: Array,
labels: Array
},
data () {
data() {
return {
_chart: null
};
},
mounted () {
mounted() {
this.renderChart();
},
methods: {
renderChart () {
renderChart() {
const defaultOptions = {
type: 'bar',
data: {
@ -33,7 +33,7 @@ export default {
datasets: this.data
},
options: {
aspectRatio: 4,
aspectRatio: 2,
plugins: {
colorschemes: {
scheme: Tableau20
@ -65,7 +65,7 @@ export default {
this._chart = new Chart(this.$refs.canvas.getContext('2d'), chartOptions);
}
},
beforeDestroy () {
beforeDestroy() {
if (this._chart) {
this._chart.destroy();
}

View File

@ -0,0 +1,109 @@
<template>
<b-tab :title="langTitle">
<b-overlay variant="card" :show="loading">
<div class="card-body py-5" v-if="loading">
&nbsp;
</div>
<div class="card-body" v-else>
<b-row>
<b-col md="12" class="mb-4">
<fieldset>
<legend>
<translate key="hdr_listeners_by_day">Listeners by Day</translate>
</legend>
<time-series-chart style="width: 100%;" :data="chartData.daily.metrics">
<span v-html="chartData.daily.alt"></span>
</time-series-chart>
</fieldset>
</b-col>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate key="hdr_listeners_by_dow">Listeners by Day of Week</translate>
</legend>
<pie-chart style="width: 100%;" :data="chartData.day_of_week.metrics"
:labels="chartData.day_of_week.labels">
<span v-html="chartData.day_of_week.alt"></span>
</pie-chart>
</fieldset>
</b-col>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate key="hdr_listeners_by_hour">Listeners by Hour</translate>
</legend>
<hour-chart style="width: 100%;" :data="chartData.hourly.metrics"
:labels="chartData.hourly.labels">
<span v-html="chartData.hourly.alt"></span>
</hour-chart>
</fieldset>
</b-col>
</b-row>
</div>
</b-overlay>
</b-tab>
</template>
<script>
import TimeSeriesChart from "~/components/Common/TimeSeriesChart";
import HourChart from "~/components/Stations/Reports/Overview/HourChart";
import {DateTime} from "luxon";
import PieChart from "~/components/Common/PieChart";
export default {
name: 'ListenersByTimePeriodTab',
components: {PieChart, HourChart, TimeSeriesChart},
props: {
dateRange: Object,
apiUrl: String,
},
data() {
return {
loading: true,
chartData: {
daily: {},
day_of_week: {
labels: [],
metrics: [],
alt: ''
},
hourly: {
labels: [],
metrics: [],
alt: ''
}
},
};
},
watch: {
dateRange() {
this.relist();
}
},
computed: {
langTitle() {
return this.$gettext('Listeners by Time Period');
}
},
mounted() {
this.relist();
},
methods: {
relist() {
this.loading = true;
this.axios.get(this.apiUrl, {
params: {
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
}
}).then((response) => {
this.chartData = response.data;
this.loading = false;
});
}
}
}
</script>

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Reports\Overview;
use App\Controller\Api\Traits\AcceptsDateRange;
use App\Entity\Enums\AnalyticsLevel;
use App\Entity\Repository\SettingsRepository;
use Doctrine\ORM\EntityManagerInterface;
abstract class AbstractReportAction
{
use AcceptsDateRange;
public function __construct(
protected readonly SettingsRepository $settingsRepo,
protected readonly EntityManagerInterface $em,
) {
}
protected function isAllAnalyticsEnabled(): bool
{
return AnalyticsLevel::All !== $this->settingsRepo->readSettings()->getAnalyticsEnum();
}
protected function isAnalyticsEnabled(): bool
{
return $this->settingsRepo->readSettings()->isAnalyticsEnabled();
}
protected function buildChart(
array $rows,
string $valueLabel
): array {
arsort($rows);
$topRows = array_slice($rows, 0, 10);
$alt = ['<dl>'];
$labels = [];
$data = [];
foreach ($topRows as $key => $value) {
$labels[] = $key;
$data[] = (int)$value;
$alt[] = '<dt>' . $key . '</dt>';
$alt[] = '<dd>' . $value . ' ' . $valueLabel . '</dd>';
}
$alt[] = '</dl>';
return [
'labels' => $labels,
'datasets' => [
[
'label' => $valueLabel,
'data' => $data,
],
],
'alt' => implode('', $alt),
];
}
}

View File

@ -7,17 +7,18 @@ namespace App\Controller\Api\Stations\Reports\Overview;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Carbon\CarbonImmutable;
use App\Utilities\DateRange;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
final class BestAndWorstAction
final class BestAndWorstAction extends AbstractReportAction
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Entity\Repository\SettingsRepository $settingsRepo,
Entity\Repository\SettingsRepository $settingsRepo,
EntityManagerInterface $em,
private readonly Entity\ApiGenerator\SongApiGenerator $songApiGenerator
) {
parent::__construct($settingsRepo, $em);
}
public function __invoke(
@ -25,17 +26,25 @@ final class BestAndWorstAction
Response $response,
string $station_id
): ResponseInterface {
$station = $request->getStation();
$station_tz = $station->getTimezoneObject();
// Get current analytics level.
if (!$this->settingsRepo->readSettings()->isAnalyticsEnabled()) {
if (!$this->isAnalyticsEnabled()) {
return $response->withStatus(400)
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
}
/* Song "Deltas" (Changes in Listener Count) */
$songPerformanceThreshold = CarbonImmutable::parse('-2 days', $station_tz)->getTimestamp();
$dateRange = $this->getDateRange($request, $request->getStation()->getTimezoneObject());
return $response->withJson([
'bestAndWorst' => $this->getBestAndWorst($request, $dateRange),
'mostPlayed' => $this->getMostPlayed($request, $dateRange),
]);
}
private function getBestAndWorst(
ServerRequest $request,
DateRange $dateRange
): array {
$station = $request->getStation();
// Get all songs played in timeline.
$baseQuery = $this->em->createQueryBuilder()
@ -43,8 +52,9 @@ final class BestAndWorstAction
->from(Entity\SongHistory::class, 'sh')
->where('sh.station = :station')
->setParameter('station', $station)
->andWhere('sh.timestamp_start >= :timestamp')
->setParameter('timestamp', $songPerformanceThreshold)
->andWhere('sh.timestamp_start <= :end AND sh.timestamp_end >= :start')
->setParameter('start', $dateRange->getStartTimestamp())
->setParameter('end', $dateRange->getEndTimestamp())
->andWhere('sh.listeners_start IS NOT NULL')
->andWhere('sh.timestamp_end != 0')
->setMaxResults(5);
@ -76,6 +86,44 @@ final class BestAndWorstAction
);
}
return $response->withJson($stats);
return $stats;
}
private function getMostPlayed(
ServerRequest $request,
DateRange $dateRange
): array {
$station = $request->getStation();
$rawRows = $this->em->createQuery(
<<<'DQL'
SELECT sh.song_id, sh.text, sh.artist, sh.title, COUNT(sh.id) AS records
FROM App\Entity\SongHistory sh
WHERE sh.station = :station
AND sh.timestamp_start <= :end
AND sh.timestamp_end >= :start
GROUP BY sh.song_id
ORDER BY records DESC
DQL
)->setParameter('station', $request->getStation())
->setParameter('start', $dateRange->getStartTimestamp())
->setParameter('end', $dateRange->getEndTimestamp())
->setMaxResults(10)
->getArrayResult();
$baseUrl = $request->getRouter()->getBaseUrl();
return array_map(
function ($row) use ($station, $baseUrl) {
$song = ($this->songApiGenerator)(Entity\Song::createFromArray($row), $station);
$song->resolveUrls($baseUrl);
return [
'song' => $song,
'num_plays' => $row['records'],
];
},
$rawRows
);
}
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Reports\Overview;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class ByBrowser extends AbstractReportAction
{
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
// Get current analytics level.
if ($this->isAllAnalyticsEnabled()) {
return $response->withStatus(400)
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
}
$station = $request->getStation();
$stationTz = $station->getTimezoneObject();
$dateRange = $this->getDateRange($request, $stationTz);
$stats = $this->em->getConnection()->fetchAllAssociative(
<<<'SQL'
SELECT DISTINCT l.device_browser_family AS browser,
COUNT(l.listener_hash) AS listeners,
SUM(l.connected_seconds) AS connected_seconds
FROM (
SELECT device_browser_family,
SUM(timestamp_end - timestamp_start) AS connected_seconds,
listener_hash
FROM listener
WHERE station_id = :station_id
AND device_browser_family IS NOT NULL
AND timestamp_end >= :start
AND timestamp_start <= :end
AND device_is_browser = 1
GROUP BY listener_hash
) AS l
SQL,
[
'station_id' => $station->getIdRequired(),
'start' => $dateRange->getStartTimestamp(),
'end' => $dateRange->getEndTimestamp(),
]
);
$listenersByBrowser = array_column($stats, 'listeners', 'browser');
$connectedTimeByBrowser = array_column($stats, 'connected_seconds', 'browser');
return $response->withJson([
'all' => $stats,
'top_listeners' => $this->buildChart($listenersByBrowser, __('Listeners')),
'top_connected_time' => $this->buildChart($connectedTimeByBrowser, __('Connected Seconds')),
]);
}
}

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Reports\Overview;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Intl\Countries;
final class ByCountry extends AbstractReportAction
{
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
// Get current analytics level.
if ($this->isAllAnalyticsEnabled()) {
return $response->withStatus(400)
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
}
$station = $request->getStation();
$stationTz = $station->getTimezoneObject();
$dateRange = $this->getDateRange($request, $stationTz);
$statsRaw = $this->em->getConnection()->fetchAllAssociative(
<<<'SQL'
SELECT DISTINCT l.location_country AS country_code,
COUNT(l.listener_hash) AS listeners,
SUM(l.connected_seconds) AS connected_seconds
FROM (
SELECT location_country, SUM(timestamp_end - timestamp_start) AS connected_seconds, listener_hash
FROM listener
WHERE station_id = :station_id
AND location_country IS NOT NULL
AND timestamp_end >= :start
AND timestamp_start <= :end
GROUP BY listener_hash
) AS l
SQL,
[
'station_id' => $station->getIdRequired(),
'start' => $dateRange->getStartTimestamp(),
'end' => $dateRange->getEndTimestamp(),
]
);
$countryNames = Countries::getNames($request->getLocale()->getLocaleWithoutEncoding());
$listenersByCountry = [];
$connectedTimeByCountry = [];
$stats = [];
foreach ($statsRaw as $stat) {
if (empty($stat['country_code'])) {
continue;
}
$stat['country'] = $countryNames[$stat['country_code']];
$stats[] = $stat;
$listenersByCountry[$stat['country']] = $stat['listeners'];
$connectedTimeByCountry[$stat['country']] = $stat['connected_seconds'];
}
return $response->withJson([
'all' => $stats,
'top_listeners' => $this->buildChart($listenersByCountry, __('Listeners')),
'top_connected_time' => $this->buildChart($connectedTimeByCountry, __('Connected Seconds')),
]);
}
}

View File

@ -8,15 +8,18 @@ use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
use stdClass;
final class ChartsAction
final class ChartsAction extends AbstractReportAction
{
public function __construct(
private readonly Entity\Repository\SettingsRepository $settingsRepo,
Entity\Repository\SettingsRepository $settingsRepo,
EntityManagerInterface $em,
private readonly Entity\Repository\AnalyticsRepository $analyticsRepo,
) {
parent::__construct($settingsRepo, $em);
}
public function __invoke(
@ -24,24 +27,23 @@ final class ChartsAction
Response $response,
string $station_id
): ResponseInterface {
$station = $request->getStation();
$station_tz = $station->getTimezoneObject();
// Get current analytics level.
if (!$this->settingsRepo->readSettings()->isAnalyticsEnabled()) {
if (!$this->isAnalyticsEnabled()) {
return $response->withStatus(400)
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
}
/* Statistics */
$statisticsThreshold = CarbonImmutable::parse('-1 month', $station_tz);
$station = $request->getStation();
$stationTz = $station->getTimezoneObject();
$dateRange = $this->getDateRange($request, $stationTz);
$stats = [];
// Statistics by day.
$dailyStats = $this->analyticsRepo->findForStationAfterTime(
$dailyStats = $this->analyticsRepo->findForStationInRange(
$station,
$statisticsThreshold
$dateRange
);
$daily_chart = new stdClass();
@ -60,7 +62,7 @@ final class ChartsAction
foreach ($dailyStats as $stat) {
/** @var CarbonImmutable $statTime */
$statTime = $stat['moment'];
$statTime = $statTime->shiftTimezone($station_tz);
$statTime = $statTime->shiftTimezone($stationTz);
$avg_row = new stdClass();
$avg_row->x = $statTime->getTimestampMs();
@ -127,9 +129,9 @@ final class ChartsAction
];
// Statistics by hour.
$hourlyStats = $this->analyticsRepo->findForStationAfterTime(
$hourlyStats = $this->analyticsRepo->findForStationInRange(
$station,
$statisticsThreshold,
$dateRange,
Entity\Analytics::INTERVAL_HOURLY
);
@ -138,7 +140,7 @@ final class ChartsAction
foreach ($hourlyStats as $stat) {
/** @var CarbonImmutable $statTime */
$statTime = $stat['moment'];
$statTime = $statTime->shiftTimezone($station_tz);
$statTime = $statTime->shiftTimezone($stationTz);
$hour = $statTime->hour;
$totals_by_hour[$hour][] = $stat['number_avg'];

View File

@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Reports\Overview;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
final class MostPlayedAction
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Entity\Repository\SettingsRepository $settingsRepo,
private readonly Entity\ApiGenerator\SongApiGenerator $songApiGenerator
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
$station = $request->getStation();
$station_tz = $station->getTimezoneObject();
// Get current analytics level.
if (!$this->settingsRepo->readSettings()->isAnalyticsEnabled()) {
return $response->withStatus(400)
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
}
$statisticsThreshold = CarbonImmutable::parse('-1 month', $station_tz)
->getTimestamp();
/* Song "Deltas" (Changes in Listener Count) */
$rawRows = $this->em->createQuery(
<<<'DQL'
SELECT sh.song_id, sh.text, sh.artist, sh.title, COUNT(sh.id) AS records
FROM App\Entity\SongHistory sh
WHERE sh.station_id = :station_id AND sh.timestamp_start >= :timestamp
GROUP BY sh.song_id
ORDER BY records DESC
DQL
)->setParameter('station_id', $station->getId())
->setParameter('timestamp', $statisticsThreshold)
->setMaxResults(10)
->getArrayResult();
$baseUrl = $request->getRouter()->getBaseUrl();
$stats = array_map(
function ($row) use ($station, $baseUrl) {
$song = ($this->songApiGenerator)(Entity\Song::createFromArray($row), $station);
$song->resolveUrls($baseUrl);
return [
'song' => $song,
'num_plays' => $row['records'],
];
},
$rawRows
);
return $response->withJson($stats);
}
}

View File

@ -22,22 +22,28 @@ final class OverviewAction
string $station_id
): ResponseInterface {
// Get current analytics level.
if (!$this->settingsRepo->readSettings()->isAnalyticsEnabled()) {
$settings = $this->settingsRepo->readSettings();
if (!$settings->isAnalyticsEnabled()) {
// The entirety of the dashboard can't be shown, so redirect user to the profile page.
return $request->getView()->renderToResponse($response, 'stations/reports/restricted');
}
$router = $request->getRouter();
$analyticsLevel = $settings->getAnalyticsEnum();
return $request->getView()->renderVuePage(
response: $response,
component: 'Vue_StationsReportsOverview',
id: 'vue-reports-overview',
title: __('Statistics Overview'),
title: __('Station Statistics'),
props: [
'chartsUrl' => (string)$router->fromHere('api:stations:reports:overview-charts'),
'stationTz' => $request->getStation()->getTimezone(),
'showFullAnalytics' => Entity\Enums\AnalyticsLevel::All === $analyticsLevel,
'listenersByTimePeriodUrl' => (string)$router->fromHere('api:stations:reports:overview-charts'),
'bestAndWorstUrl' => (string)$router->fromHere('api:stations:reports:best-and-worst'),
'mostPlayedUrl' => (string)$router->fromHere('api:stations:reports:most-played'),
'byBrowserUrl' => (string)$router->fromHere('api:stations:reports:by-browser'),
'byCountryUrl' => (string)$router->fromHere('api:stations:reports:by-country'),
]
);
}

View File

@ -6,9 +6,9 @@ namespace App\Entity\Repository;
use App\Doctrine\Repository;
use App\Entity;
use App\Utilities\DateRange;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use DateTimeInterface;
/**
* @extends Repository<Entity\Analytics>
@ -18,19 +18,20 @@ final class AnalyticsRepository extends Repository
/**
* @return mixed[]
*/
public function findForStationAfterTime(
public function findForStationInRange(
Entity\Station $station,
DateTimeInterface $threshold,
DateRange $dateRange,
string $type = Entity\Analytics::INTERVAL_DAILY
): array {
return $this->em->createQuery(
<<<'DQL'
SELECT a FROM App\Entity\Analytics a
WHERE a.station = :station AND a.type = :type AND a.moment >= :threshold
WHERE a.station = :station AND a.type = :type AND a.moment BETWEEN :start AND :end
DQL
)->setParameter('station', $station)
->setParameter('type', $type)
->setParameter('threshold', $threshold)
->setParameter('start', $dateRange->getStart())
->setParameter('end', $dateRange->getEnd())
->getArrayResult();
}

View File

@ -21,15 +21,15 @@ class Api_Stations_ReportsCest extends CestAbstract
$uriBase = '/api/station/' . $station->getId();
$I->sendGet($uriBase . '/reports/overview/charts');
$I->seeResponseCodeIs(200);
$I->sendGet($uriBase . '/reports/overview/best-and-worst');
$I->seeResponseCodeIs(200);
$I->sendGet($uriBase . '/reports/overview/most-played');
$I->sendGet($uriBase . '/reports/overview/by-browser');
$I->seeResponseCodeIs(200);
$I->sendGet($uriBase . '/reports/overview/by-country');
$I->seeResponseCodeIs(200);
}

View File

@ -18,7 +18,7 @@ class Station_ReportsCest extends CestAbstract
$I->amOnPAge('/station/' . $station_id . '/reports/overview');
$I->seeResponseCodeIs(200);
$I->see('Statistics Overview');
$I->see('Station Statistics');
$I->amOnPage('/station/' . $station_id . '/reports/timeline');