Add support for per-streamer custom artwork.
This commit is contained in:
parent
fe87dc2fae
commit
6278bbd53d
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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' => [
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue