diff --git a/CHANGELOG.md b/CHANGELOG.md index 516c1a9f1..38b29abd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/config/routes/api_station.php b/config/routes/api_station.php index b6a40f187..444bb2496 100644 --- a/config/routes/api_station.php +++ b/config/routes/api_station.php @@ -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', diff --git a/frontend/vue/components/Stations/Streamers.vue b/frontend/vue/components/Stations/Streamers.vue index a6512f1a4..03a5531a2 100644 --- a/frontend/vue/components/Stations/Streamers.vue +++ b/frontend/vue/components/Stations/Streamers.vue @@ -24,6 +24,9 @@ + @@ -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) => { diff --git a/frontend/vue/components/Stations/Streamers/EditModal.vue b/frontend/vue/components/Stations/Streamers/EditModal.vue index 1f3ed7207..8ad1e241b 100644 --- a/frontend/vue/components/Stations/Streamers/EditModal.vue +++ b/frontend/vue/components/Stations/Streamers/EditModal.vue @@ -6,6 +6,8 @@ + @@ -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); } } }; diff --git a/frontend/vue/components/Stations/Streamers/Form/Artwork.vue b/frontend/vue/components/Stations/Streamers/Form/Artwork.vue new file mode 100644 index 000000000..1a1d1e59b --- /dev/null +++ b/frontend/vue/components/Stations/Streamers/Form/Artwork.vue @@ -0,0 +1,85 @@ + + + diff --git a/frontend/vue/pages/Stations/Streamers.js b/frontend/vue/pages/Stations/Streamers.js index 4ee536594..a38fe17b1 100644 --- a/frontend/vue/pages/Stations/Streamers.js +++ b/frontend/vue/pages/Stations/Streamers.js @@ -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'; diff --git a/src/Controller/Api/Stations/Streamers/Art/DeleteArtAction.php b/src/Controller/Api/Stations/Streamers/Art/DeleteArtAction.php new file mode 100644 index 000000000..880d94f95 --- /dev/null +++ b/src/Controller/Api/Stations/Streamers/Art/DeleteArtAction.php @@ -0,0 +1,36 @@ +getStation(); + + $streamer = $this->streamerRepo->requireForStation($streamer_id, $station); + + $this->streamerRepo->removeArtwork($streamer); + $this->streamerRepo->getEntityManager() + ->flush(); + + return $response->withJson(Status::deleted()); + } +} diff --git a/src/Controller/Api/Stations/Streamers/Art/GetArtAction.php b/src/Controller/Api/Stations/Streamers/Art/GetArtAction.php new file mode 100644 index 000000000..27c613ab4 --- /dev/null +++ b/src/Controller/Api/Stations/Streamers/Art/GetArtAction.php @@ -0,0 +1,44 @@ +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 + ); + } +} diff --git a/src/Controller/Api/Stations/Streamers/Art/PostArtAction.php b/src/Controller/Api/Stations/Streamers/Art/PostArtAction.php new file mode 100644 index 000000000..359aa3491 --- /dev/null +++ b/src/Controller/Api/Stations/Streamers/Art/PostArtAction.php @@ -0,0 +1,49 @@ +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); + } +} diff --git a/src/Controller/Api/Stations/StreamersController.php b/src/Controller/Api/Stations/StreamersController.php index dc9f9922c..7f3c7fdfa 100644 --- a/src/Controller/Api/Stations/StreamersController.php +++ b/src/Controller/Api/Stations/StreamersController.php @@ -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 */ #[ @@ -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); + } } diff --git a/src/Controller/Stations/StreamersAction.php b/src/Controller/Stations/StreamersAction.php index 2627dc597..3d8962954 100644 --- a/src/Controller/Stations/StreamersAction.php +++ b/src/Controller/Stations/StreamersAction.php @@ -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' => [ diff --git a/src/Doctrine/Repository.php b/src/Doctrine/Repository.php index a452843b1..1482b1c14 100644 --- a/src/Doctrine/Repository.php +++ b/src/Doctrine/Repository.php @@ -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. * diff --git a/src/Entity/ApiGenerator/SongApiGenerator.php b/src/Entity/ApiGenerator/SongApiGenerator.php index bea4573c2..b21692ca7 100644 --- a/src/Entity/ApiGenerator/SongApiGenerator.php +++ b/src/Entity/ApiGenerator/SongApiGenerator.php @@ -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); } /** diff --git a/src/Entity/Migration/Version20220530010809.php b/src/Entity/Migration/Version20220530010809.php new file mode 100644 index 000000000..31aea5c8f --- /dev/null +++ b/src/Entity/Migration/Version20220530010809.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/src/Entity/Repository/AbstractStationBasedRepository.php b/src/Entity/Repository/AbstractStationBasedRepository.php index 2a90fe001..1064c79df 100644 --- a/src/Entity/Repository/AbstractStationBasedRepository.php +++ b/src/Entity/Repository/AbstractStationBasedRepository.php @@ -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 */ 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; + } } diff --git a/src/Entity/Repository/StationStreamerRepository.php b/src/Entity/Repository/StationStreamerRepository.php index 64d18c0bb..f1023242f 100644 --- a/src/Entity/Repository/StationStreamerRepository.php +++ b/src/Entity/Repository/StationStreamerRepository.php @@ -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 + * @extends AbstractStationBasedRepository */ -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(); + } } diff --git a/src/Entity/StationMedia.php b/src/Entity/StationMedia.php index b5c63594c..9575038ab 100644 --- a/src/Entity/StationMedia.php +++ b/src/Entity/StationMedia.php @@ -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; diff --git a/src/Entity/StationQueue.php b/src/Entity/StationQueue.php index 9f7002c60..384a3da1f 100644 --- a/src/Entity/StationQueue.php +++ b/src/Entity/StationQueue.php @@ -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; diff --git a/src/Entity/StationStreamer.php b/src/Entity/StationStreamer.php index b01055f08..b22e12cc8 100644 --- a/src/Entity/StationStreamer.php +++ b/src/Entity/StationStreamer.php @@ -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 */ @@ -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'; + } } diff --git a/src/Validator/Constraints/UniqueEntityValidator.php b/src/Validator/Constraints/UniqueEntityValidator.php index 6f79e495d..43058421c 100644 --- a/src/Validator/Constraints/UniqueEntityValidator.php +++ b/src/Validator/Constraints/UniqueEntityValidator.php @@ -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;