Add support for per-streamer custom artwork.

This commit is contained in:
Buster "Silver Eagle" Neece 2022-05-30 23:23:03 -05:00
parent fe87dc2fae
commit 6278bbd53d
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
20 changed files with 525 additions and 62 deletions

View File

@ -22,6 +22,9 @@ release channel, you can take advantage of these new features and fixes.
will automatically disconnect the listener and prevent them from reconnecting for a time period (configurable via the will automatically disconnect the listener and prevent them from reconnecting for a time period (configurable via the
station profile). THis can help prevent DJs from accidentally leaving their stream online and broadcasting "dead air". station profile). THis can help prevent DJs from accidentally leaving their stream online and broadcasting "dead air".
- Streamers/DJs can have custom artwork uploaded for each streamer; during the streamer's broadcasts, if no other album
art is available, the streamer's artwork will appear as the cover art instead.
## Code Quality/Technical Changes ## Code Quality/Technical Changes
- Since AzuraCast's services are all now accessible via `localhost`, several connections have been switched from TCP/IP - Since AzuraCast's services are all now accessible via `localhost`, several connections have been switched from TCP/IP

View File

@ -279,6 +279,32 @@ return static function (RouteCollectorProxy $group) {
} }
)->add(new Middleware\Permissions(StationPermissions::Podcasts, true)); )->add(new Middleware\Permissions(StationPermissions::Podcasts, true));
// Streamers public pages
$group->get(
'/streamer/{streamer_id}/art',
Controller\Api\Stations\Streamers\Art\GetArtAction::class
)->setName('api:stations:streamer:art');
// Streamers internal pages
$group->post('/streamers/art', Controller\Api\Stations\Streamers\Art\PostArtAction::class)
->setName('api:stations:streamers:new-art')
->add(new Middleware\Permissions(StationPermissions::Streamers, true));
$group->group(
'/streamer/{streamer_id}',
function (RouteCollectorProxy $group) {
$group->post(
'/art',
Controller\Api\Stations\Streamers\Art\PostArtAction::class
)->setName('api:stations:streamer:art-internal');
$group->delete(
'/art',
Controller\Api\Stations\Streamers\Art\DeleteArtAction::class
);
}
)->add(new Middleware\Permissions(StationPermissions::Streamers, true));
$station_api_endpoints = [ $station_api_endpoints = [
[ [
'file', 'file',

View File

@ -24,6 +24,9 @@
<data-table ref="datatable" id="station_streamers" :fields="fields" <data-table ref="datatable" id="station_streamers" :fields="fields"
:api-url="listUrl"> :api-url="listUrl">
<template #cell(art)="row">
<album-art :src="row.item.art"></album-art>
</template>
<template #cell(streamer_username)="row"> <template #cell(streamer_username)="row">
<code>{{ row.item.streamer_username }}</code> <code>{{ row.item.streamer_username }}</code>
<div> <div>
@ -60,7 +63,7 @@
</div> </div>
<edit-modal ref="editModal" :create-url="listUrl" :station-time-zone="stationTimeZone" <edit-modal ref="editModal" :create-url="listUrl" :station-time-zone="stationTimeZone"
@relist="relist"></edit-modal> :new-art-url="newArtUrl" @relist="relist"></edit-modal>
<broadcasts-modal ref="broadcastsModal"></broadcasts-modal> <broadcasts-modal ref="broadcastsModal"></broadcasts-modal>
</div> </div>
</template> </template>
@ -72,20 +75,22 @@ import BroadcastsModal from './Streamers/BroadcastsModal';
import Schedule from '~/components/Common/ScheduleView'; import Schedule from '~/components/Common/ScheduleView';
import Icon from '~/components/Common/Icon'; import Icon from '~/components/Common/Icon';
import ConnectionInfo from "./Streamers/ConnectionInfo"; import ConnectionInfo from "./Streamers/ConnectionInfo";
import AlbumArt from "~/components/Common/AlbumArt";
export default { export default {
name: 'StationStreamers', name: 'StationStreamers',
components: {ConnectionInfo, Icon, EditModal, BroadcastsModal, DataTable, Schedule}, components: {AlbumArt, ConnectionInfo, Icon, EditModal, BroadcastsModal, DataTable, Schedule},
props: { props: {
listUrl: String, listUrl: String,
newArtUrl: String,
scheduleUrl: String, scheduleUrl: String,
filesUrl: String,
stationTimeZone: String, stationTimeZone: String,
connectionInfo: Object connectionInfo: Object
}, },
data() { data() {
return { return {
fields: [ fields: [
{key: 'art', label: this.$gettext('Art'), sortable: false, class: 'shrink pr-0'},
{key: 'display_name', label: this.$gettext('Display Name'), sortable: true}, {key: 'display_name', label: this.$gettext('Display Name'), sortable: true},
{key: 'streamer_username', isRowHeader: true, label: this.$gettext('Username'), sortable: true}, {key: 'streamer_username', isRowHeader: true, label: this.$gettext('Username'), sortable: true},
{key: 'comments', label: this.$gettext('Notes'), sortable: false}, {key: 'comments', label: this.$gettext('Notes'), sortable: false},
@ -94,30 +99,30 @@ export default {
}; };
}, },
computed: { computed: {
langAccountListTab () { langAccountListTab() {
return this.$gettext('Account List'); return this.$gettext('Account List');
}, },
langScheduleViewTab () { langScheduleViewTab() {
return this.$gettext('Schedule View'); return this.$gettext('Schedule View');
} }
}, },
methods: { methods: {
relist () { relist() {
this.$refs.datatable.refresh(); this.$refs.datatable.refresh();
}, },
doCreate () { doCreate() {
this.$refs.editModal.create(); this.$refs.editModal.create();
}, },
doCalendarClick (event) { doCalendarClick(event) {
this.doEdit(event.extendedProps.edit_url); this.doEdit(event.extendedProps.edit_url);
}, },
doEdit (url) { doEdit(url) {
this.$refs.editModal.edit(url); this.$refs.editModal.edit(url);
}, },
doShowBroadcasts (url) { doShowBroadcasts(url) {
this.$refs.broadcastsModal.open(url); this.$refs.broadcastsModal.open(url);
}, },
doDelete (url) { doDelete(url) {
this.$confirmDelete({ this.$confirmDelete({
title: this.$gettext('Delete Streamer?'), title: this.$gettext('Delete Streamer?'),
}).then((result) => { }).then((result) => {

View File

@ -6,6 +6,8 @@
<form-basic-info :form="$v.form"></form-basic-info> <form-basic-info :form="$v.form"></form-basic-info>
<form-schedule :form="$v.form" :schedule-items="form.schedule_items" <form-schedule :form="$v.form" :schedule-items="form.schedule_items"
:station-time-zone="stationTimeZone"></form-schedule> :station-time-zone="stationTimeZone"></form-schedule>
<form-artwork v-model="$v.form.artwork_file.$model" :artwork-src="record.links.art"
:new-art-url="newArtUrl" :edit-art-url="record.links.art"></form-artwork>
</b-tabs> </b-tabs>
</modal-form> </modal-form>
@ -14,14 +16,17 @@
import {required} from 'vuelidate/dist/validators.min.js'; import {required} from 'vuelidate/dist/validators.min.js';
import FormBasicInfo from './Form/BasicInfo'; import FormBasicInfo from './Form/BasicInfo';
import FormSchedule from './Form/Schedule'; import FormSchedule from './Form/Schedule';
import FormArtwork from './Form/Artwork';
import BaseEditModal from '~/components/Common/BaseEditModal'; import BaseEditModal from '~/components/Common/BaseEditModal';
import mergeExisting from "~/functions/mergeExisting";
export default { export default {
name: 'EditModal', name: 'EditModal',
mixins: [BaseEditModal], mixins: [BaseEditModal],
components: {FormBasicInfo, FormSchedule}, components: {FormBasicInfo, FormSchedule, FormArtwork},
props: { props: {
stationTimeZone: String stationTimeZone: String,
newArtUrl: String,
}, },
validations() { validations() {
let validations = { let validations = {
@ -32,10 +37,11 @@ export default {
'comments': {}, 'comments': {},
'is_active': {}, 'is_active': {},
'enforce_schedule': {}, 'enforce_schedule': {},
'artwork_file': {},
'schedule_items': { 'schedule_items': {
$each: { $each: {
'start_time': { required }, 'start_time': {required},
'end_time': { required }, 'end_time': {required},
'start_date': {}, 'start_date': {},
'end_date': {}, 'end_date': {},
'days': {} 'days': {}
@ -45,20 +51,28 @@ export default {
}; };
if (this.editUrl === null) { if (this.editUrl === null) {
validations.form.streamer_password = { required }; validations.form.streamer_password = {required};
} }
return validations; return validations;
}, },
data() {
return {
record: {
has_custom_art: false,
links: {}
},
}
},
computed: { computed: {
langTitle () { langTitle() {
return this.isEditMode return this.isEditMode
? this.$gettext('Edit Streamer') ? this.$gettext('Edit Streamer')
: this.$gettext('Add Streamer'); : this.$gettext('Add Streamer');
} }
}, },
methods: { methods: {
resetForm () { resetForm() {
this.form = { this.form = {
'streamer_username': null, 'streamer_username': null,
'streamer_password': null, 'streamer_password': null,
@ -66,8 +80,13 @@ export default {
'comments': null, 'comments': null,
'is_active': true, 'is_active': true,
'enforce_schedule': false, 'enforce_schedule': false,
'schedule_items': [] 'schedule_items': [],
'artwork_file': null
}; };
},
populateForm(d) {
this.record = d;
this.form = mergeExisting(this.form, d);
} }
} }
}; };

View File

@ -0,0 +1,85 @@
<template>
<b-tab :title="langTitle">
<b-form-group>
<b-row>
<b-col md="8">
<b-form-group label-for="edit_form_art">
<template #label>
<translate key="artwork_file">Select PNG/JPG artwork file</translate>
</template>
<template #description>
<translate key="artwork_file_desc">This image will be used as the default album art when this streamer is live.</translate>
</template>
<b-form-file id="edit_form_art" accept="image/jpeg, image/png"
@input="uploadNewArt"></b-form-file>
</b-form-group>
</b-col>
<b-col md="4" v-if="src && src !== ''">
<b-img :src="src" :alt="langTitle" rounded fluid></b-img>
<div class="buttons pt-3">
<b-button block variant="danger" @click="deleteArt">
<translate key="lang_btn_delete_art">Clear Artwork</translate>
</b-button>
</div>
</b-col>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
export default {
name: 'StreamersFormArtwork',
props: {
value: Object,
artworkSrc: String,
editArtUrl: String,
newArtUrl: String,
},
data() {
return {
localSrc: null,
};
},
computed: {
langTitle() {
return this.$gettext('Artwork');
},
src() {
return this.localSrc ?? this.artworkSrc;
}
},
methods: {
uploadNewArt(file) {
if (!(file instanceof File)) {
return;
}
let fileReader = new FileReader();
fileReader.addEventListener('load', () => {
this.localSrc = fileReader.result;
}, false);
fileReader.readAsDataURL(file);
let url = (this.editArtUrl) ? this.editArtUrl : this.newArtUrl;
let formData = new FormData();
formData.append('art', file);
this.axios.post(url, formData).then((resp) => {
this.$emit('input', resp.data);
});
},
deleteArt() {
if (this.editArtUrl) {
this.axios.delete(this.editArtUrl).then((resp) => {
this.localSrc = '';
});
} else {
this.localSrc = '';
}
}
}
};
</script>

View File

@ -2,6 +2,7 @@ import initBase from '~/base.js';
import '~/vendor/bootstrapVue.js'; import '~/vendor/bootstrapVue.js';
import '~/vendor/sweetalert.js'; import '~/vendor/sweetalert.js';
import '~/vendor/fancybox.js';
import '~/store.js'; import '~/store.js';
import '~/vendor/luxon.js'; import '~/vendor/luxon.js';

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Streamers\Art;
use App\Entity\Api\Status;
use App\Entity\Repository\StationStreamerRepository;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class DeleteArtAction
{
public function __construct(
private readonly StationStreamerRepository $streamerRepo
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id,
string $streamer_id
): ResponseInterface {
$station = $request->getStation();
$streamer = $this->streamerRepo->requireForStation($streamer_id, $station);
$this->streamerRepo->removeArtwork($streamer);
$this->streamerRepo->getEntityManager()
->flush();
return $response->withJson(Status::deleted());
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Streamers\Art;
use App\Entity;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class GetArtAction
{
public function __construct(
private readonly Entity\Repository\StationRepository $stationRepo,
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id,
string $streamer_id
): ResponseInterface {
// If a timestamp delimiter is added, strip it automatically.
$streamer_id = explode('|', $streamer_id, 2)[0];
$station = $request->getStation();
$artworkPath = Entity\StationStreamer::getArtworkPath($streamer_id);
$fsConfig = (new StationFilesystems($station))->getConfigFilesystem();
if ($fsConfig->fileExists($artworkPath)) {
return $response->withCacheLifetime(Response::CACHE_ONE_YEAR)
->streamFilesystemFile($fsConfig, $artworkPath, null, 'inline');
}
return $response->withRedirect(
(string)$this->stationRepo->getDefaultAlbumArtUrl($station),
302
);
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Streamers\Art;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\Flow;
use Psr\Http\Message\ResponseInterface;
final class PostArtAction
{
public function __construct(
private readonly Entity\Repository\StationStreamerRepository $streamerRepo,
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id,
?string $streamer_id = null
): ResponseInterface {
$station = $request->getStation();
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
if ($flowResponse instanceof ResponseInterface) {
return $flowResponse;
}
if (null !== $streamer_id) {
$streamer = $this->streamerRepo->requireForStation($streamer_id, $station);
$this->streamerRepo->writeArtwork(
$streamer,
$flowResponse->readAndDeleteUploadedFile()
);
$this->streamerRepo->getEntityManager()
->flush();
return $response->withJson(Entity\Api\Status::updated());
}
return $response->withJson($flowResponse);
}
}

View File

@ -5,14 +5,19 @@ declare(strict_types=1);
namespace App\Controller\Api\Stations; namespace App\Controller\Api\Stations;
use App\Controller\Api\Traits\CanSortResults; use App\Controller\Api\Traits\CanSortResults;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Entity; use App\Entity;
use App\Exception\StationUnsupportedException; use App\Exception\StationUnsupportedException;
use App\Http\Response; use App\Http\Response;
use App\Http\ServerRequest; use App\Http\ServerRequest;
use App\OpenApi; use App\OpenApi;
use App\Radio\AutoDJ\Scheduler;
use App\Service\Flow\UploadedFile;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/** @extends AbstractScheduledEntityController<Entity\StationStreamer> */ /** @extends AbstractScheduledEntityController<Entity\StationStreamer> */
#[ #[
@ -146,6 +151,17 @@ final class StreamersController extends AbstractScheduledEntityController
protected string $entityClass = Entity\StationStreamer::class; protected string $entityClass = Entity\StationStreamer::class;
protected string $resourceRouteName = 'api:stations:streamer'; protected string $resourceRouteName = 'api:stations:streamer';
public function __construct(
Entity\Repository\StationScheduleRepository $scheduleRepo,
Scheduler $scheduler,
ReloadableEntityManagerInterface $em,
Serializer $serializer,
ValidatorInterface $validator,
private readonly Entity\Repository\StationStreamerRepository $streamerRepo,
) {
parent::__construct($scheduleRepo, $scheduler, $em, $serializer, $validator);
}
public function listAction( public function listAction(
ServerRequest $request, ServerRequest $request,
Response $response, Response $response,
@ -178,6 +194,35 @@ final class StreamersController extends AbstractScheduledEntityController
return $this->listPaginatedFromQuery($request, $response, $qb->getQuery()); return $this->listPaginatedFromQuery($request, $response, $qb->getQuery());
} }
public function createAction(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
$station = $request->getStation();
$parsedBody = (array)$request->getParsedBody();
/** @var Entity\StationStreamer $record */
$record = $this->editRecord(
$parsedBody,
new Entity\StationStreamer($station)
);
if (!empty($parsedBody['artwork_file'])) {
$artwork = UploadedFile::fromArray($parsedBody['artwork_file'], $station->getRadioTempDir());
$this->streamerRepo->writeArtwork(
$record,
$artwork->readAndDeleteUploadedFile()
);
$this->em->persist($record);
$this->em->flush();
}
return $response->withJson($this->viewRecord($record, $request));
}
public function scheduleAction( public function scheduleAction(
ServerRequest $request, ServerRequest $request,
Response $response, Response $response,
@ -234,12 +279,26 @@ final class StreamersController extends AbstractScheduledEntityController
{ {
$return = parent::viewRecord($record, $request); $return = parent::viewRecord($record, $request);
$router = $request->getRouter();
$isInternal = ('true' === $request->getParam('internal', 'false')); $isInternal = ('true' === $request->getParam('internal', 'false'));
$return['links']['broadcasts'] = (string)$request->getRouter()->fromHere(
$return['has_custom_art'] = (0 !== $record->getArtUpdatedAt());
$return['art'] = (string)$router->fromHere(
route_name: 'api:stations:streamer:art',
route_params: ['streamer_id' => $record->getIdRequired() . '|' . $record->getArtUpdatedAt()],
absolute: !$isInternal
);
$return['links']['broadcasts'] = (string)$router->fromHere(
route_name: 'api:stations:streamer:broadcasts', route_name: 'api:stations:streamer:broadcasts',
route_params: ['id' => $record->getId()], route_params: ['id' => $record->getId()],
absolute: !$isInternal absolute: !$isInternal
); );
$return['links']['art'] = (string)$router->fromHere(
route_name: 'api:stations:streamer:art-internal',
route_params: ['streamer_id' => $record->getId()],
absolute: !$isInternal
);
return $return; return $return;
} }
@ -258,4 +317,13 @@ final class StreamersController extends AbstractScheduledEntityController
return $station; return $station;
} }
protected function deleteRecord(object $record): void
{
if (!($record instanceof Entity\StationStreamer)) {
throw new \InvalidArgumentException(sprintf('Record must be an instance of %s.', $this->entityClass));
}
$this->streamerRepo->delete($record);
}
} }

View File

@ -66,6 +66,7 @@ final class StreamersAction
title: __('Streamer/DJ Accounts'), title: __('Streamer/DJ Accounts'),
props: [ props: [
'listUrl' => (string)$router->fromHere('api:stations:streamers'), 'listUrl' => (string)$router->fromHere('api:stations:streamers'),
'newArtUrl' => (string)$router->fromHere('api:stations:streamers:new-art'),
'scheduleUrl' => (string)$router->fromHere('api:stations:streamers:schedule'), 'scheduleUrl' => (string)$router->fromHere('api:stations:streamers:schedule'),
'stationTimeZone' => $station->getTimezone(), 'stationTimeZone' => $station->getTimezone(),
'connectionInfo' => [ 'connectionInfo' => [

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Doctrine; namespace App\Doctrine;
use App\Environment; use App\Environment;
use App\Exception\NotFoundException;
use Azura\Normalizer\DoctrineEntityNormalizer; use Azura\Normalizer\DoctrineEntityNormalizer;
use Closure; use Closure;
use Doctrine\Persistence\ObjectRepository; use Doctrine\Persistence\ObjectRepository;
@ -51,7 +52,6 @@ class Repository
} }
/** /**
* @param int|string $id
* @return TEntity|null * @return TEntity|null
*/ */
public function find(int|string $id): ?object public function find(int|string $id): ?object
@ -59,6 +59,18 @@ class Repository
return $this->em->find($this->entityClass, $id); return $this->em->find($this->entityClass, $id);
} }
/**
* @return TEntity
*/
public function requireRecord(int|string $id): object
{
$record = $this->find($id);
if (null === $record) {
throw new NotFoundException();
}
return $record;
}
/** /**
* Generate an array result of all records. * Generate an array result of all records.
* *

View File

@ -61,7 +61,10 @@ class SongApiGenerator
$response->custom_fields = $this->getCustomFields(); $response->custom_fields = $this->getCustomFields();
} }
$response->art = $this->getAlbumArtUrl($song, $station, $baseUri, $allowRemoteArt); $response->art = UriResolver::resolve(
$baseUri ?? $this->router->getBaseUrl(),
$this->getAlbumArtUrl($song, $station, $allowRemoteArt)
);
return $response; return $response;
} }
@ -69,38 +72,42 @@ class SongApiGenerator
protected function getAlbumArtUrl( protected function getAlbumArtUrl(
Entity\Interfaces\SongInterface $song, Entity\Interfaces\SongInterface $song,
?Entity\Station $station = null, ?Entity\Station $station = null,
?UriInterface $baseUri = null,
bool $allowRemoteArt = false bool $allowRemoteArt = false
): UriInterface { ): UriInterface {
if (null === $baseUri) {
$baseUri = $this->router->getBaseUrl();
}
if (null !== $station && $song instanceof Entity\StationMedia) { if (null !== $station && $song instanceof Entity\StationMedia) {
$mediaUpdatedTimestamp = $song->getArtUpdatedAt(); $mediaUpdatedTimestamp = $song->getArtUpdatedAt();
if (0 !== $mediaUpdatedTimestamp) { if (0 !== $mediaUpdatedTimestamp) {
$path = $this->router->named( return $this->router->named(
'api:stations:media:art', route_name: 'api:stations:media:art',
[ route_params: [
'station_id' => $station->getId(), 'station_id' => $station->getId(),
'media_id' => $song->getUniqueId() . '-' . $mediaUpdatedTimestamp, 'media_id' => $song->getUniqueId() . '-' . $mediaUpdatedTimestamp,
] ]
); );
return UriResolver::resolve($baseUri, $path);
} }
} }
$path = ($allowRemoteArt && $this->remoteAlbumArt->enableForApis()) if ($allowRemoteArt && $this->remoteAlbumArt->enableForApis()) {
? $this->remoteAlbumArt->getUrlForSong($song) $url = $this->remoteAlbumArt->getUrlForSong($song);
: null; if (null !== $url) {
return Utils::uriFor($url);
if (null === $path) { }
$path = $this->stationRepo->getDefaultAlbumArtUrl($station);
} }
return UriResolver::resolve($baseUri, Utils::uriFor($path)); if (null !== $station) {
$currentStreamer = $station->getCurrentStreamer();
if (null !== $currentStreamer && 0 !== $currentStreamer->getArtUpdatedAt()) {
return $this->router->named(
route_name: 'api:stations:streamer:art',
route_params: [
'station_id' => $station->getIdRequired(),
'streamer_id' => $currentStreamer->getIdRequired() . '|' . $currentStreamer->getArtUpdatedAt(),
],
);
}
}
return $this->stationRepo->getDefaultAlbumArtUrl($station);
} }
/** /**

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220530010809 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add artwork file to StationStreamer';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE station_streamers ADD art_updated_at INT NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE station_streamers DROP art_updated_at');
}
}

View File

@ -7,15 +7,15 @@ namespace App\Entity\Repository;
use App\Doctrine\Repository; use App\Doctrine\Repository;
use App\Entity\Interfaces\StationAwareInterface; use App\Entity\Interfaces\StationAwareInterface;
use App\Entity\Station; use App\Entity\Station;
use App\Exception\NotFoundException;
/** /**
* @template TEntity as object * @template TEntity as object
* @extends Repository<TEntity>
*/ */
abstract class AbstractStationBasedRepository extends Repository abstract class AbstractStationBasedRepository extends Repository
{ {
/** /**
* @param int|string $id
* @param Station $station
* @return TEntity|null * @return TEntity|null
*/ */
public function findForStation(int|string $id, Station $station): ?object public function findForStation(int|string $id, Station $station): ?object
@ -28,4 +28,16 @@ abstract class AbstractStationBasedRepository extends Repository
return null; return null;
} }
/**
* @return TEntity
*/
public function requireForStation(int|string $id, Station $station): object
{
$record = $this->findForStation($id, $station);
if (null === $record) {
throw new NotFoundException();
}
return $record;
}
} }

View File

@ -5,17 +5,18 @@ declare(strict_types=1);
namespace App\Entity\Repository; namespace App\Entity\Repository;
use App\Doctrine\ReloadableEntityManagerInterface; use App\Doctrine\ReloadableEntityManagerInterface;
use App\Doctrine\Repository;
use App\Entity; use App\Entity;
use App\Environment; use App\Environment;
use App\Flysystem\StationFilesystems;
use App\Media\AlbumArt;
use App\Radio\AutoDJ\Scheduler; use App\Radio\AutoDJ\Scheduler;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\Serializer;
/** /**
* @extends Repository<Entity\StationStreamer> * @extends AbstractStationBasedRepository<Entity\StationStreamer>
*/ */
class StationStreamerRepository extends Repository class StationStreamerRepository extends AbstractStationBasedRepository
{ {
protected Scheduler $scheduler; protected Scheduler $scheduler;
@ -101,17 +102,58 @@ class StationStreamerRepository extends Repository
return true; return true;
} }
public function getStreamer(Entity\Station $station, string $username = ''): ?Entity\StationStreamer public function getStreamer(
{ Entity\Station $station,
string $username = '',
bool $activeOnly = true
): ?Entity\StationStreamer {
$criteria = [
'station' => $station,
'streamer_username' => $username,
];
if ($activeOnly) {
$criteria['is_active'] = 1;
}
/** @var Entity\StationStreamer|null $streamer */ /** @var Entity\StationStreamer|null $streamer */
$streamer = $this->repository->findOneBy( $streamer = $this->repository->findOneBy($criteria);
[
'station' => $station,
'streamer_username' => $username,
'is_active' => 1,
]
);
return $streamer; return $streamer;
} }
public function writeArtwork(
Entity\StationStreamer $streamer,
string $rawArtworkString
): void {
$artworkPath = Entity\StationStreamer::getArtworkPath($streamer->getIdRequired());
$artworkString = AlbumArt::resize($rawArtworkString);
$fsConfig = (new StationFilesystems($streamer->getStation()))->getConfigFilesystem();
$fsConfig->write($artworkPath, $artworkString);
$streamer->setArtUpdatedAt(time());
$this->em->persist($streamer);
}
public function removeArtwork(
Entity\StationStreamer $streamer
): void {
$artworkPath = Entity\StationStreamer::getArtworkPath($streamer->getIdRequired());
$fsConfig = (new StationFilesystems($streamer->getStation()))->getConfigFilesystem();
$fsConfig->delete($artworkPath);
$streamer->setArtUpdatedAt(0);
$this->em->persist($streamer);
}
public function delete(
Entity\StationStreamer $streamer
): void {
$this->removeArtwork($streamer);
$this->em->remove($streamer);
$this->em->flush();
}
} }

View File

@ -4,10 +4,6 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use App\Entity\Interfaces\IdentifiableEntityInterface;
use App\Entity\Interfaces\PathAwareInterface;
use App\Entity\Interfaces\ProcessableMediaInterface;
use App\Entity\Interfaces\SongInterface;
use App\Media\Metadata; use App\Media\Metadata;
use App\Media\MetadataInterface; use App\Media\MetadataInterface;
use App\OpenApi; use App\OpenApi;
@ -28,7 +24,11 @@ use Symfony\Component\Serializer\Annotation as Serializer;
ORM\Index(columns: ['title', 'artist', 'album'], name: 'search_idx'), ORM\Index(columns: ['title', 'artist', 'album'], name: 'search_idx'),
ORM\UniqueConstraint(name: 'path_unique_idx', columns: ['path', 'storage_location_id']) ORM\UniqueConstraint(name: 'path_unique_idx', columns: ['path', 'storage_location_id'])
] ]
class StationMedia implements SongInterface, ProcessableMediaInterface, PathAwareInterface, IdentifiableEntityInterface class StationMedia implements
Interfaces\SongInterface,
Interfaces\ProcessableMediaInterface,
Interfaces\PathAwareInterface,
Interfaces\IdentifiableEntityInterface
{ {
use Traits\HasAutoIncrementId; use Traits\HasAutoIncrementId;
use Traits\HasSongFields; use Traits\HasSongFields;

View File

@ -75,7 +75,7 @@ class StationQueue implements
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
protected ?int $duration = null; protected ?int $duration = null;
public function __construct(Station $station, SongInterface $song) public function __construct(Station $station, Interfaces\SongInterface $song)
{ {
$this->setSong($song); $this->setSong($song);
$this->station = $station; $this->station = $station;

View File

@ -32,7 +32,8 @@ use const PASSWORD_ARGON2ID;
class StationStreamer implements class StationStreamer implements
Stringable, Stringable,
Interfaces\StationCloneAwareInterface, Interfaces\StationCloneAwareInterface,
Interfaces\IdentifiableEntityInterface Interfaces\IdentifiableEntityInterface,
Interfaces\StationAwareInterface
{ {
use Traits\HasAutoIncrementId; use Traits\HasAutoIncrementId;
use Traits\TruncateStrings; use Traits\TruncateStrings;
@ -92,6 +93,12 @@ class StationStreamer implements
] ]
protected ?int $reactivate_at = null; protected ?int $reactivate_at = null;
#[
ORM\Column,
Attributes\AuditIgnore
]
protected int $art_updated_at = 0;
#[ #[
OA\Property(type: "array", items: new OA\Items()), OA\Property(type: "array", items: new OA\Items()),
ORM\OneToMany(mappedBy: 'streamer', targetEntity: StationSchedule::class), ORM\OneToMany(mappedBy: 'streamer', targetEntity: StationSchedule::class),
@ -208,6 +215,18 @@ class StationStreamer implements
$this->reactivate_at = time() + $seconds; $this->reactivate_at = time() + $seconds;
} }
public function getArtUpdatedAt(): int
{
return $this->art_updated_at;
}
public function setArtUpdatedAt(int $art_updated_at): self
{
$this->art_updated_at = $art_updated_at;
return $this;
}
/** /**
* @return Collection<StationSchedule> * @return Collection<StationSchedule>
*/ */
@ -225,4 +244,9 @@ class StationStreamer implements
{ {
$this->reactivate_at = null; $this->reactivate_at = null;
} }
public static function getArtworkPath(int|string $streamer_id): string
{
return 'streamer_' . $streamer_id . '.jpg';
}
} }

View File

@ -66,7 +66,10 @@ class UniqueEntityValidator extends ConstraintValidator
return; return;
} }
$class = $this->em->getClassMetadata((string)get_class($value)); /** @var class-string $className */
$className = get_class($value);
$class = $this->em->getClassMetadata($className);
$criteria = []; $criteria = [];
$hasNullValue = false; $hasNullValue = false;