Remove automated playlist assignment.

This commit is contained in:
Buster "Silver Eagle" Neece 2022-06-10 08:52:58 -05:00
parent 7c9d86f40c
commit 51be731ee3
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
24 changed files with 37 additions and 1003 deletions

View File

@ -14,7 +14,9 @@ release channel, you can take advantage of these new features and fixes.
## Code Quality/Technical Changes
There have been no code quality/technical changes since the last stable release.
- Automated station playlist assignment (and the corresponding Song Performance Report) is being retired. Internally,
this functionality was not well-explained, and likely does not work the way station operators expect it to. With the
upcoming development of new, better reporting tools, this functionality will no longer be required.
## Bug Fixes

View File

@ -141,7 +141,6 @@ return function (CallableEventDispatcherInterface $dispatcher) {
App\Sync\Task\RenewAcmeCertTask::class,
App\Sync\Task\RotateLogsTask::class,
App\Sync\Task\RunAnalyticsTask::class,
App\Sync\Task\RunAutomatedAssignmentTask::class,
App\Sync\Task\RunBackupTask::class,
App\Sync\Task\UpdateGeoLiteTask::class,
App\Sync\Task\UpdateStorageLocationSizesTask::class,

View File

@ -119,21 +119,9 @@ return function (App\Event\BuildStationMenu $e) {
'playlists' => [
'label' => __('Playlists'),
'icon' => 'queue_music',
'items' => [
'playlists' => [
'label' => __('Playlists'),
'url' => (string)$router->fromHere('stations:playlists:index'),
'visible' => $backend->supportsMedia(),
'permission' => StationPermissions::Media,
],
'automation' => [
'label' => __('Automated Assignment'),
'class' => 'text-muted',
'url' => (string)$router->fromHere('stations:automation:index'),
'visible' => $backend->supportsMedia(),
'permission' => StationPermissions::Automation,
],
],
'url' => (string)$router->fromHere('stations:playlists:index'),
'visible' => $backend->supportsMedia(),
'permission' => StationPermissions::Media,
],
'podcasts' => [
@ -200,11 +188,6 @@ return function (App\Event\BuildStationMenu $e) {
'label' => __('Song Playback Timeline'),
'url' => (string)$router->fromHere('stations:reports:timeline'),
],
'reports_performance' => [
'label' => __('Song Listener Impact'),
'url' => (string)$router->fromHere('stations:reports:performance'),
'visible' => $backend->supportsMedia(),
],
'reports_soundexchange' => [
'label' => __('SoundExchange Royalties'),
'url' => (string)$router->fromHere('stations:reports:soundexchange'),

View File

@ -13,26 +13,6 @@ return static function (RouteCollectorProxy $group) {
->setName('api:stations:index')
->add(new Middleware\RateLimit('api', 5, 2));
$group->group(
'/automation',
function (RouteCollectorProxy $group) {
$group->get(
'/settings',
Controller\Api\Stations\Automation\GetSettingsAction::class
)->setName('api:stations:automation:settings');
$group->put(
'/settings',
Controller\Api\Stations\Automation\PutSettingsAction::class
);
$group->put(
'/run',
Controller\Api\Stations\Automation\RunAction::class
)->setName('api:stations:automation:run');
}
)->add(new Middleware\Permissions(StationPermissions::Automation, true));
$group->get('/nowplaying', Controller\Api\NowPlayingAction::class . ':indexAction');
$group->map(
@ -517,11 +497,6 @@ return static function (RouteCollectorProxy $group) {
}
)->add(new Middleware\Permissions(StationPermissions::Broadcasting, true));
$group->get(
'/performance',
Controller\Api\Stations\Reports\PerformanceAction::class
)->setName('api:stations:reports:performance');
$group->get(
'/overview/charts',
Controller\Api\Stations\Reports\Overview\ChartsAction::class

View File

@ -20,12 +20,6 @@ return static function (RouteCollectorProxy $app) {
}
)->setName('stations:index:index');
$group->get(
'/automation',
Controller\Stations\AutomationAction::class
)->setName('stations:automation:index')
->add(new Middleware\Permissions(StationPermissions::Automation, true));
$group->get('/bulk-media', Controller\Stations\BulkMediaAction::class)
->setName('stations:bulk-media')
->add(new Middleware\Permissions(StationPermissions::Media, true));
@ -104,11 +98,6 @@ return static function (RouteCollectorProxy $app) {
$group->get('/timeline', Controller\Stations\Reports\TimelineAction::class)
->setName('stations:reports:timeline');
$group->get(
'/performance',
Controller\Stations\Reports\PerformanceAction::class
)->setName('stations:reports:performance');
$group->get('/listeners', Controller\Stations\Reports\ListenersAction::class)
->setName('stations:reports:listeners');

View File

@ -1,159 +0,0 @@
<template>
<div>
<section class="card mb-3" role="region">
<b-card-header header-bg-variant="primary-dark">
<h2 class="card-title">
<translate key="lang_hdr_automated_assignment">Automated Assignment</translate>
</h2>
</b-card-header>
<div class="card-body">
<p class="card-text">
<translate key="lang_automated_1">Based on the previous performance of your station's songs, AzuraCast can automatically distribute songs evenly among your playlists, placing the highest performing songs in the highest-weighted playlists.</translate>
</p>
<p class="card-text">
<translate key="lang_automated_2">Once you have configured automated assignment, click the button below to run the automated assignment process.</translate>
</p>
<b-button variant="warning" :disabled="!settings.is_enabled" @click.prevent="doRun">
<translate key="lang_btn_run">Run Automated Assignment</translate>
</b-button>
</div>
</section>
<form class="form vue-form" @submit.prevent="submit">
<section class="card mb-3" role="region">
<b-card-header header-bg-variant="primary-dark">
<h2 class="card-title">
<translate key="lang_hdr_configure">Configure Automated Assignment</translate>
</h2>
</b-card-header>
<b-overlay variant="card" :show="settingsLoading">
<div class="card-body">
<b-form-fieldset>
<b-wrapped-form-checkbox id="edit_form_is_enabled"
:field="$v.settings.is_enabled">
<template #label="{lang}">
<translate :key="lang">Enable Automated Assignment</translate>
</template>
<template #description="{lang}">
<translate :key="lang">Allow the system to periodically automatically assign songs to playlists based on their performance. This process will run in the background, and will only run if this option is set to "Enabled" and at least one playlist is set to "Include in Automated Assignment".</translate>
</template>
</b-wrapped-form-checkbox>
<b-wrapped-form-group id="edit_form_threshold_days" :field="$v.settings.threshold_days">
<template #label="{lang}">
<translate :key="lang">Days Between Automated Assignments</translate>
</template>
<template #description="{lang}">
<translate :key="lang">Based on this setting, the system will automatically reassign songs every (this) days using data from the previous (this) days.</translate>
</template>
<template #default="props">
<b-form-radio-group stacked :id="props.id" v-model="props.field.$model"
:options="thresholdDaysOptions"></b-form-radio-group>
</template>
</b-wrapped-form-group>
</b-form-fieldset>
<b-button size="lg" type="submit" variant="primary" :disabled="$v.settings.$invalid">
<slot name="submitButtonName">
<translate key="lang_btn_save_changes">Save Changes</translate>
</slot>
</b-button>
</div>
</b-overlay>
</section>
</form>
</div>
</template>
<script>
import {validationMixin} from "vuelidate";
import BFormFieldset from "~/components/Form/BFormFieldset";
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
import BWrappedFormCheckbox from "~/components/Form/BWrappedFormCheckbox";
import mergeExisting from "~/functions/mergeExisting";
export default {
name: 'StationsAutomation',
components: {BFormFieldset, BWrappedFormGroup, BWrappedFormCheckbox},
mixins: [
validationMixin
],
props: {
settingsUrl: String,
runUrl: String
},
data() {
return {
settingsLoading: true,
settings: {
is_enabled: false,
threshold_days: 7
}
}
},
validations: {
settings: {
is_enabled: {},
threshold_days: {}
}
},
computed: {
thresholdDaysOptions() {
const langDays = this.$gettext('%{ days } Days');
return [
{value: '7', text: this.$gettextInterpolate(langDays, {days: 7})},
{value: '14', text: this.$gettextInterpolate(langDays, {days: 14})},
{value: '30', text: this.$gettextInterpolate(langDays, {days: 30})},
{value: '60', text: this.$gettextInterpolate(langDays, {days: 60})}
];
}
},
mounted() {
this.relist();
},
methods: {
relist() {
this.$v.settings.$reset();
this.settingsLoading = true;
this.axios.get(this.settingsUrl).then((resp) => {
this.settings = mergeExisting(this.settings, resp.data);
this.settingsLoading = false;
});
},
submit() {
this.$v.settings.$touch();
if (this.$v.settings.$anyError) {
return;
}
this.$wrapWithLoading(
this.axios({
method: 'PUT',
url: this.settingsUrl,
data: this.settings
})
).then((resp) => {
this.$notifySuccess();
this.relist();
});
},
doRun() {
this.$wrapWithLoading(
this.axios({
method: 'PUT',
url: this.runUrl
})
).then((resp) => {
this.$notifySuccess();
});
}
}
}
</script>

View File

@ -52,7 +52,6 @@ export default {
'play_per_minutes': {},
'play_per_hour_minute': {},
'include_in_requests': {},
'include_in_automation': {},
'avoid_duplicates': {},
'backend_options': {},
'schedule_items': {
@ -85,7 +84,6 @@ export default {
'play_per_minutes': 0,
'play_per_hour_minute': 0,
'include_in_requests': true,
'include_in_automation': false,
'avoid_duplicates': true,
'backend_options': [],
'schedule_items': []

View File

@ -158,7 +158,7 @@
<b-form-group>
<b-form-row>
<b-wrapped-form-group class="col-md-6" id="form_edit_weight" :field="form.weight">
<b-wrapped-form-group class="col-md-12" id="form_edit_weight" :field="form.weight">
<template #label="{lang}">
<translate :key="lang">Playlist Weight</translate>
</template>
@ -170,16 +170,6 @@
:state="props.state"></b-form-select>
</template>
</b-wrapped-form-group>
<b-wrapped-form-checkbox class="col-md-6" id="form_edit_include_in_automation"
:field="form.include_in_automation">
<template #label="{lang}">
<translate :key="lang">Include in Automated Assignment</translate>
</template>
<template #description="{lang}">
<translate :key="lang">If auto-assignment is enabled, use this playlist as one of the targets for songs to be redistributed into. This will overwrite the existing contents of this playlist.</translate>
</template>
</b-wrapped-form-checkbox>
</b-form-row>
</b-form-group>
</b-form-fieldset>

View File

@ -1,75 +0,0 @@
<template>
<div class="card">
<div class="card-header bg-primary-dark">
<div class="d-flex align-items-center">
<h2 class="card-title flex-fill my-0">
<translate key="lang_title">Song Listener Impact</translate>
</h2>
<div class="flex-shrink">
<a class="btn btn-bg" id="btn-export" :href="exportUrl" target="_blank">
<icon icon="file_download"></icon>
<translate key="lang_download_csv_button">Download CSV</translate>
</a>
</div>
</div>
</div>
<data-table ref="datatable" responsive paginated handle-client-side select-fields :fields="fields"
:apiUrl="apiUrl">
<template #cell(playlists)="row">
{{ row.item.playlists.join(', ') }}
</template>
<template #cell(delta_positive)="row">
<span class="text-success">
{{ row.item.delta_positive }}
</span>
</template>
<template #cell(delta_positive)="row">
<span class="text-success">
{{ row.item.delta_positive }}
</span>
</template>
<template #cell(delta_negative)="row">
<span class="text-danger">
{{ row.item.delta_negative }}
</span>
</template>
</data-table>
</div>
</template>
<script>
import Icon from "~/components/Common/Icon";
import DataTable from "~/components/Common/DataTable";
export default {
name: 'StationsReportsPerformance',
components: {Icon, DataTable},
props: {
apiUrl: String
},
data() {
return {
fields: [
{key: 'title', label: this.$gettext('Title'), sortable: true},
{key: 'artist', label: this.$gettext('Artist'), sortable: true},
{key: 'path', label: this.$gettext('File Name'), sortable: false},
{key: 'length_raw', label: this.$gettext('Length'), selectable: true, sortable: false},
{key: 'length', label: this.$gettext('Length Text'), visible: false, selectable: true, sortable: false},
{key: 'playlists', label: this.$gettext('Playlist(s)'), selectable: true, sortable: false},
{key: 'delta_positive', label: 'Δ ' + this.$gettext('Joins'), selectable: true, sortable: true},
{key: 'delta_negative', label: 'Δ ' + this.$gettext('Losses'), selectable: true, sortable: true},
{key: 'delta_total', label: 'Δ ' + this.$gettext('Total'), selectable: true, sortable: true},
{key: 'num_plays', label: this.$gettext('Num Plays'), selectable: true, sortable: true},
{key: 'percent_plays', label: this.$gettext('Play %'), selectable: true, sortable: false},
{key: 'ratio', label: this.$gettext('Ratio'), selectable: true, sortable: false},
],
}
},
computed: {
exportUrl() {
return this.apiUrl + '?format=csv';
}
}
};
</script>

View File

@ -1,7 +0,0 @@
import initBase from '~/base.js';
import '~/vendor/bootstrapVue.js';
import Automation from '~/components/Stations/Automation.vue';
export default initBase(Automation);

View File

@ -1,9 +0,0 @@
import initBase
from '~/base.js';
import '~/vendor/bootstrapVue.js';
import Performance
from '~/components/Stations/Reports/Performance.vue';
export default initBase(Performance);

View File

@ -33,7 +33,6 @@ module.exports = {
SetupRegister: '~/pages/Setup/Register.js',
SetupSettings: '~/pages/Setup/Settings.js',
SetupStation: '~/pages/Setup/Station.js',
StationsAutomation: '~/pages/Stations/Automation.js',
StationsBulkMedia: '~/pages/Stations/BulkMedia.js',
StationsFallback: '~/pages/Stations/Fallback.js',
StationsHlsStreams: '~/pages/Stations/HlsStreams.js',
@ -51,7 +50,6 @@ module.exports = {
StationsReportsListeners: '~/pages/Stations/Reports/Listeners.js',
StationsReportsRequests: '~/pages/Stations/Reports/Requests.js',
StationsReportsOverview: '~/pages/Stations/Reports/Overview.js',
StationsReportsPerformance: '~/pages/Stations/Reports/Performance.js',
StationsReportsSoundExchange: '~/pages/Stations/Reports/SoundExchange.js',
StationsReportsTimeline: '~/pages/Stations/Reports/Timeline.js',
StationsSftpUsers: '~/pages/Stations/SftpUsers.js',

View File

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Automation;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class GetSettingsAction
{
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
$station = $request->getStation();
return $response->withJson(
(array)$station->getAutomationSettings()
);
}
}

View File

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Automation;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class PutSettingsAction
{
public function __construct(
private readonly ReloadableEntityManagerInterface $em,
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
$station = $request->getStation();
$station = $this->em->refetch($station);
$station->setAutomationSettings((array)$request->getParsedBody());
$this->em->persist($station);
$this->em->flush();
return $response->withJson(Entity\Api\Status::updated());
}
}

View File

@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Automation;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
use App\Sync\Task\RunAutomatedAssignmentTask;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
use Throwable;
#[OA\Put(
path: '/station/{station_id}/automation/run',
description: 'Run automated assignment.',
security: OpenApi::API_KEY_SECURITY,
tags: ['Stations: Automation'],
parameters: [
new OA\Parameter(ref: OpenApi::REF_STATION_ID_REQUIRED),
],
responses: [
new OA\Response(ref: OpenApi::REF_RESPONSE_SUCCESS, response: 200),
new OA\Response(ref: OpenApi::REF_RESPONSE_ACCESS_DENIED, response: 403),
new OA\Response(ref: OpenApi::REF_RESPONSE_NOT_FOUND, response: 404),
new OA\Response(ref: OpenApi::REF_RESPONSE_GENERIC_ERROR, response: 500),
]
)]
final class RunAction
{
public function __construct(
private readonly RunAutomatedAssignmentTask $syncTask,
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
$station = $request->getStation();
try {
$this->syncTask->runStation($station, true);
return $response->withJson(Entity\Api\Status::success());
} catch (Throwable $e) {
return $response->withStatus(400)->withJson(Entity\Api\Error::fromException($e));
}
}
}

View File

@ -1,105 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Reports;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Paginator;
use App\Sync\Task\RunAutomatedAssignmentTask;
use League\Csv\Writer;
use Psr\Http\Message\ResponseInterface;
final class PerformanceAction
{
public function __construct(
private readonly RunAutomatedAssignmentTask $automationTask
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
$station = $request->getStation();
$automationConfig = (array)$station->getAutomationSettings();
$thresholdDays = (int)($automationConfig['threshold_days']
?? RunAutomatedAssignmentTask::DEFAULT_THRESHOLD_DAYS);
$reportData = $this->automationTask->generateReport($station, $thresholdDays);
// Do not show songs that are not in playlists.
$reportData = array_filter(
$reportData,
static function ($media) {
return !(empty($media['playlists']));
}
);
$queryParams = $request->getQueryParams();
$format = $queryParams['format'] ?? 'json';
if ($format === 'csv') {
return $this->exportReportAsCsv(
$response,
$reportData,
$station->getShortName() . '_media_' . date('Ymd') . '.csv'
);
}
return Paginator::fromArray($reportData, $request)->write($response);
}
/**
* @param Response $response
* @param mixed[] $reportData
* @param string $filename
*/
private function exportReportAsCsv(
Response $response,
array $reportData,
string $filename
): ResponseInterface {
if (!($tempFile = tmpfile())) {
throw new \RuntimeException('Could not create temp file.');
}
$csv = Writer::createFromStream($tempFile);
$csv->insertOne(
[
'Song Title',
'Song Artist',
'Filename',
'Length',
'Current Playlist',
'Delta Joins',
'Delta Losses',
'Delta Total',
'Play Count',
'Play Percentage',
'Weighted Ratio',
]
);
foreach ($reportData as $row) {
$csv->insertOne([
$row['title'],
$row['artist'],
$row['path'],
$row['length'],
implode('/', $row['playlists']),
$row['delta_positive'],
$row['delta_negative'],
$row['delta_total'],
$row['num_plays'],
$row['percent_plays'] . '%',
$row['ratio'],
]);
}
return $response->withFileDownload($tempFile, $filename, 'text/csv');
}
}

View File

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Stations;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class AutomationAction
{
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
$router = $request->getRouter();
return $request->getView()->renderVuePage(
response: $response,
component: 'Vue_StationsAutomation',
id: 'station-automation',
title: __('Automated Assignment'),
props: [
'settingsUrl' => (string)$router->fromHere('api:stations:automation:settings'),
'runUrl' => (string)$router->fromHere('api:stations:automation:run'),
],
);
}
}

View File

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Stations\Reports;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class PerformanceAction
{
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
$router = $request->getRouter();
return $request->getView()->renderVuePage(
response: $response,
component: 'Vue_StationsReportsPerformance',
id: 'station-report-performance',
title: __('Song Listener Impact'),
props: [
'apiUrl' => (string)$router->fromHere('api:stations:reports:performance'),
]
);
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220610132810 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remove automation settings.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE station DROP automation_settings, DROP automation_timestamp');
$this->addSql('ALTER TABLE station_playlists DROP include_in_automation');
}
public function down(Schema $schema): void
{
$this->addSql(
'ALTER TABLE station ADD automation_settings LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:json)\', ADD automation_timestamp INT DEFAULT NULL'
);
$this->addSql('ALTER TABLE station_playlists ADD include_in_automation TINYINT(1) NOT NULL');
}
}

View File

@ -46,9 +46,6 @@ class Station implements Stringable, IdentifiableEntityInterface
use Traits\HasAutoIncrementId;
use Traits\TruncateStrings;
// Taxonomical groups for permission-based serialization.
public const GROUP_AUTOMATION = 'automation';
#[
OA\Property(description: "The full display name of the station.", example: "AzuraTest Radio"),
ORM\Column(length: 100, nullable: false),
@ -166,20 +163,6 @@ class Station implements Stringable, IdentifiableEntityInterface
]
protected ?int $nowplaying_timestamp = null;
#[
OA\Property(type: "array", items: new OA\Items()),
ORM\Column(type: 'json', nullable: true),
Serializer\Groups([self::GROUP_AUTOMATION, EntityGroupsInterface::GROUP_ALL])
]
protected ?array $automation_settings = null;
#[
ORM\Column(nullable: true),
Attributes\AuditIgnore,
Serializer\Groups([self::GROUP_AUTOMATION, EntityGroupsInterface::GROUP_ALL])
]
protected ?int $automation_timestamp = 0;
#[
OA\Property(
description: "Whether listeners can request songs to play on this station.",
@ -767,29 +750,6 @@ class Station implements Stringable, IdentifiableEntityInterface
$this->nowplaying_timestamp = $nowplaying_timestamp;
}
/**
* @return mixed[]|null
*/
public function getAutomationSettings(): ?array
{
return $this->automation_settings;
}
public function setAutomationSettings(array $automation_settings = null): void
{
$this->automation_settings = $automation_settings;
}
public function getAutomationTimestamp(): ?int
{
return $this->automation_timestamp;
}
public function setAutomationTimestamp(int $automation_timestamp = null): void
{
$this->automation_timestamp = $automation_timestamp;
}
public function getEnableRequests(): bool
{
return $this->enable_requests;

View File

@ -150,12 +150,6 @@ class StationPlaylist implements
]
protected bool $include_in_on_demand = false;
#[
OA\Property(example: false),
ORM\Column
]
protected bool $include_in_automation = false;
#[
OA\Property(example: "interrupt,loop_once,single_track,merge"),
ORM\Column(length: 255, nullable: true)
@ -403,16 +397,6 @@ class StationPlaylist implements
return ($this->is_enabled && $this->include_in_requests);
}
public function getIncludeInAutomation(): bool
{
return $this->include_in_automation;
}
public function setIncludeInAutomation(bool $include_in_automation): void
{
$this->include_in_automation = $include_in_automation;
}
public function getAvoidDuplicates(): bool
{
return $this->avoid_duplicates;

View File

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

View File

@ -116,35 +116,6 @@ class Api_Stations_ReportsCest extends CestAbstract
$this->testReportCsv($I, $requestUrl, $csvHeaders);
}
/**
* @before setupComplete
* @before login
*/
public function downloadPerformanceReportCsv(\FunctionalTester $I): void
{
$I->wantTo('Download station song impact CSV via API.');
$station = $this->getTestStation();
$uriBase = '/api/station/' . $station->getId();
$requestUrl = $uriBase . '/reports/performance?format=csv';
$csvHeaders = [
'Song Title',
'Song Artist',
'Filename',
'Length',
'Current Playlist',
'Delta Joins',
'Delta Losses',
'Delta Total',
'Play Count',
'Play Percentage',
'Weighted Ratio',
];
$this->testReportCsv($I, $requestUrl, $csvHeaders);
}
protected function testReportCsv(
\FunctionalTester $I,
string $url,

View File

@ -25,11 +25,6 @@ class Station_ReportsCest extends CestAbstract
$I->seeResponseCodeIs(200);
$I->see('Song Playback Timeline');
$I->amOnPage('/station/' . $station_id . '/reports/performance');
$I->seeResponseCodeIs(200);
$I->see('Song Listener Impact');
$I->amOnPage('/station/' . $station_id . '/reports/requests');
$I->seeResponseCodeIs(200);