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
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
- 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));
// 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 = [
[
'file',

View File

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

View File

@ -6,6 +6,8 @@
<form-basic-info :form="$v.form"></form-basic-info>
<form-schedule :form="$v.form" :schedule-items="form.schedule_items"
: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>
</modal-form>
@ -14,14 +16,17 @@
import {required} from 'vuelidate/dist/validators.min.js';
import FormBasicInfo from './Form/BasicInfo';
import FormSchedule from './Form/Schedule';
import FormArtwork from './Form/Artwork';
import BaseEditModal from '~/components/Common/BaseEditModal';
import mergeExisting from "~/functions/mergeExisting";
export default {
name: 'EditModal',
mixins: [BaseEditModal],
components: {FormBasicInfo, FormSchedule},
components: {FormBasicInfo, FormSchedule, FormArtwork},
props: {
stationTimeZone: String
stationTimeZone: String,
newArtUrl: String,
},
validations() {
let validations = {
@ -32,10 +37,11 @@ export default {
'comments': {},
'is_active': {},
'enforce_schedule': {},
'artwork_file': {},
'schedule_items': {
$each: {
'start_time': { required },
'end_time': { required },
'start_time': {required},
'end_time': {required},
'start_date': {},
'end_date': {},
'days': {}
@ -45,20 +51,28 @@ export default {
};
if (this.editUrl === null) {
validations.form.streamer_password = { required };
validations.form.streamer_password = {required};
}
return validations;
},
data() {
return {
record: {
has_custom_art: false,
links: {}
},
}
},
computed: {
langTitle () {
langTitle() {
return this.isEditMode
? this.$gettext('Edit Streamer')
: this.$gettext('Add Streamer');
}
},
methods: {
resetForm () {
resetForm() {
this.form = {
'streamer_username': null,
'streamer_password': null,
@ -66,8 +80,13 @@ export default {
'comments': null,
'is_active': true,
'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/sweetalert.js';
import '~/vendor/fancybox.js';
import '~/store.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;
use App\Controller\Api\Traits\CanSortResults;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Entity;
use App\Exception\StationUnsupportedException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
use App\Radio\AutoDJ\Scheduler;
use App\Service\Flow\UploadedFile;
use Carbon\CarbonInterface;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/** @extends AbstractScheduledEntityController<Entity\StationStreamer> */
#[
@ -146,6 +151,17 @@ final class StreamersController extends AbstractScheduledEntityController
protected string $entityClass = Entity\StationStreamer::class;
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(
ServerRequest $request,
Response $response,
@ -178,6 +194,35 @@ final class StreamersController extends AbstractScheduledEntityController
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(
ServerRequest $request,
Response $response,
@ -234,12 +279,26 @@ final class StreamersController extends AbstractScheduledEntityController
{
$return = parent::viewRecord($record, $request);
$router = $request->getRouter();
$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_params: ['id' => $record->getId()],
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;
}
@ -258,4 +317,13 @@ final class StreamersController extends AbstractScheduledEntityController
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'),
props: [
'listUrl' => (string)$router->fromHere('api:stations:streamers'),
'newArtUrl' => (string)$router->fromHere('api:stations:streamers:new-art'),
'scheduleUrl' => (string)$router->fromHere('api:stations:streamers:schedule'),
'stationTimeZone' => $station->getTimezone(),
'connectionInfo' => [

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Doctrine;
use App\Environment;
use App\Exception\NotFoundException;
use Azura\Normalizer\DoctrineEntityNormalizer;
use Closure;
use Doctrine\Persistence\ObjectRepository;
@ -51,7 +52,6 @@ class Repository
}
/**
* @param int|string $id
* @return TEntity|null
*/
public function find(int|string $id): ?object
@ -59,6 +59,18 @@ class Repository
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.
*

View File

@ -61,7 +61,10 @@ class SongApiGenerator
$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;
}
@ -69,38 +72,42 @@ class SongApiGenerator
protected function getAlbumArtUrl(
Entity\Interfaces\SongInterface $song,
?Entity\Station $station = null,
?UriInterface $baseUri = null,
bool $allowRemoteArt = false
): UriInterface {
if (null === $baseUri) {
$baseUri = $this->router->getBaseUrl();
}
if (null !== $station && $song instanceof Entity\StationMedia) {
$mediaUpdatedTimestamp = $song->getArtUpdatedAt();
if (0 !== $mediaUpdatedTimestamp) {
$path = $this->router->named(
'api:stations:media:art',
[
return $this->router->named(
route_name: 'api:stations:media:art',
route_params: [
'station_id' => $station->getId(),
'media_id' => $song->getUniqueId() . '-' . $mediaUpdatedTimestamp,
]
);
return UriResolver::resolve($baseUri, $path);
}
}
$path = ($allowRemoteArt && $this->remoteAlbumArt->enableForApis())
? $this->remoteAlbumArt->getUrlForSong($song)
: null;
if (null === $path) {
$path = $this->stationRepo->getDefaultAlbumArtUrl($station);
if ($allowRemoteArt && $this->remoteAlbumArt->enableForApis()) {
$url = $this->remoteAlbumArt->getUrlForSong($song);
if (null !== $url) {
return Utils::uriFor($url);
}
}
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\Entity\Interfaces\StationAwareInterface;
use App\Entity\Station;
use App\Exception\NotFoundException;
/**
* @template TEntity as object
* @extends Repository<TEntity>
*/
abstract class AbstractStationBasedRepository extends Repository
{
/**
* @param int|string $id
* @param Station $station
* @return TEntity|null
*/
public function findForStation(int|string $id, Station $station): ?object
@ -28,4 +28,16 @@ abstract class AbstractStationBasedRepository extends Repository
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;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Doctrine\Repository;
use App\Entity;
use App\Environment;
use App\Flysystem\StationFilesystems;
use App\Media\AlbumArt;
use App\Radio\AutoDJ\Scheduler;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\Serializer;
/**
* @extends Repository<Entity\StationStreamer>
* @extends AbstractStationBasedRepository<Entity\StationStreamer>
*/
class StationStreamerRepository extends Repository
class StationStreamerRepository extends AbstractStationBasedRepository
{
protected Scheduler $scheduler;
@ -101,17 +102,58 @@ class StationStreamerRepository extends Repository
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 */
$streamer = $this->repository->findOneBy(
[
'station' => $station,
'streamer_username' => $username,
'is_active' => 1,
]
);
$streamer = $this->repository->findOneBy($criteria);
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;
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\MetadataInterface;
use App\OpenApi;
@ -28,7 +24,11 @@ use Symfony\Component\Serializer\Annotation as Serializer;
ORM\Index(columns: ['title', 'artist', 'album'], name: 'search_idx'),
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\HasSongFields;

View File

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

View File

@ -32,7 +32,8 @@ use const PASSWORD_ARGON2ID;
class StationStreamer implements
Stringable,
Interfaces\StationCloneAwareInterface,
Interfaces\IdentifiableEntityInterface
Interfaces\IdentifiableEntityInterface,
Interfaces\StationAwareInterface
{
use Traits\HasAutoIncrementId;
use Traits\TruncateStrings;
@ -92,6 +93,12 @@ class StationStreamer implements
]
protected ?int $reactivate_at = null;
#[
ORM\Column,
Attributes\AuditIgnore
]
protected int $art_updated_at = 0;
#[
OA\Property(type: "array", items: new OA\Items()),
ORM\OneToMany(mappedBy: 'streamer', targetEntity: StationSchedule::class),
@ -208,6 +215,18 @@ class StationStreamer implements
$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>
*/
@ -225,4 +244,9 @@ class StationStreamer implements
{
$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;
}
$class = $this->em->getClassMetadata((string)get_class($value));
/** @var class-string $className */
$className = get_class($value);
$class = $this->em->getClassMetadata($className);
$criteria = [];
$hasNullValue = false;