Merge commit 'fa73cd82dcbe56db5e233169a41493f658a7fe1b'

This commit is contained in:
Buster Neece 2024-03-03 17:34:54 -06:00
commit 5726a5c90f
No known key found for this signature in database
43 changed files with 786 additions and 121 deletions

View File

@ -114,6 +114,7 @@ return static function (CallableEventDispatcherInterface $dispatcher) {
$e->addTasks([
App\Sync\Task\CheckFolderPlaylistsTask::class,
App\Sync\Task\CheckMediaTask::class,
App\Sync\Task\CheckPodcastPlaylistsTask::class,
App\Sync\Task\CheckRequestsTask::class,
App\Sync\Task\CheckUpdatesTask::class,
App\Sync\Task\CleanupHistoryTask::class,

View File

@ -125,6 +125,9 @@ return static function (RouteCollectorProxy $group) {
$group->post('/podcasts/art', Controller\Api\Stations\Podcasts\Art\PostArtAction::class)
->setName('api:stations:podcasts:new-art');
$group->get('/podcasts/playlists', Controller\Api\Stations\Podcasts\PlaylistsAction::class)
->setName('api:stations:podcasts:playlists');
$group->group(
'/podcast/{podcast_id}',
function (RouteCollectorProxy $group) {

View File

@ -1,9 +1,6 @@
<template>
<section
class="card"
role="region"
>
<div class="card-header text-bg-primary">
<card-page>
<template #header>
<div class="row align-items-center">
<div class="col-md-7">
<div class="d-flex align-items-center">
@ -27,9 +24,18 @@
/>
</div>
</div>
</div>
<div class="card-body buttons">
</template>
<template
v-if="!podcastIsManual"
#info
>
<p class="card-text">
{{
$gettext('This podcast is automatically synchronized with a playlist. Episodes cannot be manually added or removed via this panel.')
}}
</p>
</template>
<template #actions>
<router-link
class="btn btn-secondary"
:to="{name: 'stations:podcasts:index'}"
@ -39,10 +45,11 @@
</router-link>
<add-button
v-if="podcastIsManual"
:text="$gettext('Add Episode')"
@click="doCreate"
/>
</div>
</template>
<data-table
id="station_podcast_episodes"
@ -74,12 +81,18 @@
</span>
</div>
</template>
<template #cell(podcast_media)="{item}">
<template #cell(media)="{item}">
<template v-if="item.media">
<span>{{ item.media.original_name }}</span>
<br>
<small>{{ item.media.length_text }}</small>
</template>
<template v-else-if="item.playlist_media">
<span>{{ item.playlist_media.text }}</span>
</template>
<template v-else>
&nbsp;
</template>
</template>
<template #cell(is_published)="{item}">
<span v-if="item.is_published">
@ -106,6 +119,7 @@
{{ $gettext('Edit') }}
</button>
<button
v-if="podcastIsManual"
type="button"
class="btn btn-danger"
@click="doDelete(item.links.self)"
@ -115,14 +129,12 @@
</div>
</template>
</data-table>
</section>
</card-page>
<edit-modal
ref="$editEpisodeModal"
:podcast="podcast"
:create-url="podcast.links.episodes"
:new-art-url="podcast.links.episode_new_art"
:new-media-url="podcast.links.episode_new_media"
:podcast-id="podcast.id"
@relist="relist"
/>
</template>
@ -134,7 +146,7 @@ import Icon from '~/components/Common/Icon.vue';
import AlbumArt from '~/components/Common/AlbumArt.vue';
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
import {useTranslate} from "~/vendor/gettext";
import {ref} from "vue";
import {computed, ref} from "vue";
import AddButton from "~/components/Common/AddButton.vue";
import {IconChevronLeft} from "~/components/Common/icons";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
@ -143,6 +155,7 @@ import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
import {ApiPodcast} from "~/entities/ApiInterfaces.ts";
import useHasEditModal from "~/functions/useHasEditModal.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
import CardPage from "~/components/Common/CardPage.vue";
const props = defineProps<{
podcast: ApiPodcast
@ -168,7 +181,7 @@ const fields: DataTableField[] = [
sortable: false
},
{
key: 'podcast_media',
key: 'media',
label: $gettext('File Name'),
sortable: false
},
@ -200,6 +213,10 @@ const fields: DataTableField[] = [
}
];
const podcastIsManual = computed(() => {
return props.podcast.source == 'manual';
});
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
const $datatable = ref<DataTableTemplateRef>(null);

View File

@ -46,11 +46,17 @@
target="_blank"
>{{ $gettext('RSS Feed') }}</a>
</div>
<div
v-else
class="badges"
>
<span class="badge text-bg-info">
<div class="badges">
<span
v-if="item.source === 'playlist'"
class="badge text-bg-info"
>
{{ $gettext('Playlist-Based') }}
</span>
<span
v-if="!item.is_published"
class="badge text-bg-info"
>
{{ $gettext('Unpublished') }}
</span>
</div>

View File

@ -14,6 +14,7 @@
/>
<episode-form-media
v-if="podcastIsManual"
v-model="form.media_file"
:record-has-media="record.has_media"
:new-media-url="newMediaUrl"
@ -44,20 +45,18 @@ import Tabs from "~/components/Common/Tabs.vue";
const props = defineProps({
...baseEditModalProps,
podcastId: {
type: String,
required: true
},
newArtUrl: {
type: String,
required: true
},
newMediaUrl: {
type: String,
podcast: {
type: Object,
required: true
}
});
const newArtUrl = computed(() => props.podcast.links.episode_new_art);
const newMediaUrl = computed(() => props.podcast.links.episode_new_media);
const podcastIsManual = computed(() => {
return props.podcast.source == 'manual';
});
const emit = defineEmits(['relist']);
const $modal = ref<ModalFormTemplateRef>(null);

View File

@ -15,6 +15,10 @@
:language-options="languageOptions"
/>
<podcast-form-source
:form="form"
/>
<podcast-common-artwork
v-model="form.artwork_file"
:artwork-src="record.links.art"
@ -26,6 +30,7 @@
<script setup lang="ts">
import PodcastFormBasicInfo from './PodcastForm/BasicInfo.vue';
import PodcastFormSource from './PodcastForm/Source.vue';
import PodcastCommonArtwork from './Common/Artwork.vue';
import mergeExisting from "~/functions/mergeExisting";
import {baseEditModalProps, ModalFormTemplateRef, useBaseEditModal} from "~/functions/useBaseEditModal";

View File

@ -0,0 +1,128 @@
<template>
<tab
:label="$gettext('Source')"
:item-header-class="tabClass"
>
<div class="row g-3">
<form-group-multi-check
id="edit_form_source"
class="col-md-12"
:field="v$.source"
:options="sourceOptions"
stacked
radio
:label="$gettext('Source')"
/>
</div>
<section
v-show="form.source === 'playlist'"
class="card mb-3"
role="region"
>
<div class="card-header text-bg-primary">
<h2 class="card-title">
{{ $gettext('Playlist-Based Podcast') }}
</h2>
</div>
<div class="card-body">
<p>
{{
$gettext('Playlist-based podcasts will automatically sync with the contents of a playlist, creating new podcast episodes for any media added to the playlist.')
}}
</p>
<loading :loading="playlistsLoading">
<div class="row g-3 mb-3">
<form-group-select
id="form_edit_playlist_id"
class="col-md-12"
:field="v$.playlist_id"
:options="playlistOptions"
:label="$gettext('Select Playlist')"
/>
<form-group-checkbox
id="form_edit_playlist_auto_publish"
class="col-md-12"
:field="v$.playlist_auto_publish"
:label="$gettext('Automatically Publish New Episodes')"
:description="$gettext('Whether new episodes should be marked as published or held for review as unpublished.')"
/>
</div>
</loading>
</div>
</section>
</tab>
</template>
<script setup lang="ts">
import FormGroupSelect from "~/components/Form/FormGroupSelect.vue";
import {useVModel} from "@vueuse/core";
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
import {required} from "@vuelidate/validators";
import Tab from "~/components/Common/Tab.vue";
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
import {useTranslate} from "~/vendor/gettext.ts";
import {onMounted, ref, shallowRef} from "vue";
import {useAxios} from "~/vendor/axios.ts";
import objectToFormOptions from "~/functions/objectToFormOptions.ts";
import {getStationApiUrl} from "~/router.ts";
import Loading from "~/components/Common/Loading.vue";
const props = defineProps({
form: {
type: Object,
required: true
}
});
const emit = defineEmits(['update:form']);
const form = useVModel(props, 'form', emit);
const {v$, tabClass} = useVuelidateOnFormTab(
{
source: {required},
playlist_id: {},
playlist_auto_publish: {}
},
form,
{
source: 'manual',
playlist_id: null,
playlist_auto_publish: true,
}
);
const {$gettext} = useTranslate();
const sourceOptions = [
{
value: 'manual',
text: $gettext('Manually Add Episodes'),
description: $gettext('Create podcast episodes independent of your station\'s media collection.')
},
{
value: 'playlist',
text: $gettext('Synchronize with Playlist'),
description: $gettext('Automatically create new podcast episodes when media is added to a specified playlist.')
}
];
const playlistsLoading = ref(true);
const playlistOptions = shallowRef([]);
const {axios} = useAxios();
const playlistsApiUrl = getStationApiUrl('/podcasts/playlists');
const loadPlaylists = () => {
axios.get(playlistsApiUrl.value).then((resp) => {
playlistOptions.value = objectToFormOptions(resp.data);
}).finally(() => {
playlistsLoading.value = false;
});
};
onMounted(loadPlaylists);
</script>

View File

@ -604,6 +604,9 @@ export interface ApiNowPlayingStationRemote {
export type ApiPodcast = HasLinks & {
id?: string;
storage_location_id?: number;
source?: string;
playlist_id?: number | null;
playlist_auto_publish?: boolean;
title?: string;
link?: string | null;
description?: string;
@ -637,7 +640,9 @@ export type ApiPodcastEpisode = HasLinks & {
is_published?: boolean;
publish_at?: number | null;
has_media?: boolean;
media?: ApiPodcastMedia;
playlist_media_id?: string | null;
playlist_media?: ApiSong | null;
media?: ApiPodcastMedia | null;
has_custom_art?: boolean;
art?: string | null;
art_updated_at?: number;

View File

@ -8,6 +8,7 @@ use App\Container\EntityManagerAwareTrait;
use App\Controller\Api\Traits\CanSearchResults;
use App\Controller\SingleActionInterface;
use App\Entity\ApiGenerator\PodcastEpisodeApiGenerator;
use App\Entity\Enums\PodcastSources;
use App\Entity\PodcastEpisode;
use App\Http\Response;
use App\Http\ServerRequest;
@ -33,11 +34,16 @@ final class ListEpisodesAction implements SingleActionInterface
->from(PodcastEpisode::class, 'e')
->join('e.podcast', 'p')
->leftJoin('e.media', 'pm')
->leftJoin('e.playlist_media', 'sm')
->where('e.podcast = :podcast')
->setParameter('podcast', $podcast)
->andWhere('e.publish_at IS NULL OR e.publish_at <= :publishTime')
->setParameter('publishTime', time())
->andWhere('pm.id IS NOT NULL')
->andWhere(
'(p.source = :sourceManual AND pm.id IS NOT NULL) OR (p.source = :sourcePlaylist AND sm.id IS NOT NULL)'
)
->setParameter('sourceManual', PodcastSources::Manual->value)
->setParameter('sourcePlaylist', PodcastSources::Playlist->value)
->orderBy('e.publish_at', 'DESC');
$queryBuilder = $this->searchQueryBuilder(

View File

@ -7,6 +7,7 @@ namespace App\Controller\Api\Stations\Podcasts\Episodes\Media;
use App\Controller\SingleActionInterface;
use App\Entity\Api\Error;
use App\Entity\Api\Status;
use App\Entity\Enums\PodcastSources;
use App\Entity\PodcastEpisode;
use App\Entity\PodcastMedia;
use App\Entity\Repository\PodcastEpisodeRepository;
@ -14,6 +15,7 @@ use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
use App\Utilities\Types;
use InvalidArgumentException;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
@ -59,12 +61,14 @@ final class DeleteMediaAction implements SingleActionInterface
Response $response,
array $params
): ResponseInterface {
$episodeId = Types::string($params['episode_id'] ?? null);
$podcast = $request->getPodcast();
$episode = $this->episodeRepo->fetchEpisodeForPodcast(
$request->getPodcast(),
$episodeId
);
if ($podcast->getSource() !== PodcastSources::Manual) {
throw new InvalidArgumentException('Media cannot be manually set on this podcast.');
}
$episodeId = Types::string($params['episode_id'] ?? null);
$episode = $this->episodeRepo->fetchEpisodeForPodcast($podcast, $episodeId);
if (!($episode instanceof PodcastEpisode)) {
return $response->withStatus(404)

View File

@ -6,9 +6,11 @@ namespace App\Controller\Api\Stations\Podcasts\Episodes\Media;
use App\Controller\SingleActionInterface;
use App\Entity\Api\Error;
use App\Entity\Enums\PodcastSources;
use App\Entity\PodcastEpisode;
use App\Entity\PodcastMedia;
use App\Entity\Repository\PodcastEpisodeRepository;
use App\Entity\StationMedia;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
@ -68,26 +70,46 @@ final class GetMediaAction implements SingleActionInterface
$episodeId = Types::string($params['episode_id'] ?? null);
$station = $request->getStation();
$podcast = $request->getPodcast();
$episode = $this->episodeRepo->fetchEpisodeForPodcast(
$request->getPodcast(),
$podcast,
$episodeId
);
if ($episode instanceof PodcastEpisode) {
$podcastMedia = $episode->getMedia();
switch ($podcast->getSource()) {
case PodcastSources::Playlist:
$playlistMedia = $episode->getPlaylistMedia();
if ($podcastMedia instanceof PodcastMedia) {
$fsPodcasts = $this->stationFilesystems->getPodcastsFilesystem($station);
if ($playlistMedia instanceof StationMedia) {
$fsMedia = $this->stationFilesystems->getMediaFilesystem($station);
$path = $podcastMedia->getPath();
set_time_limit(600);
return $response->streamFilesystemFile(
$fsMedia,
$playlistMedia->getPath()
);
}
break;
if ($fsPodcasts->fileExists($path)) {
return $response->streamFilesystemFile(
$fsPodcasts,
$path,
$podcastMedia->getOriginalName()
);
}
case PodcastSources::Manual:
$podcastMedia = $episode->getMedia();
if ($podcastMedia instanceof PodcastMedia) {
$fsPodcasts = $this->stationFilesystems->getPodcastsFilesystem($station);
$path = $podcastMedia->getPath();
if ($fsPodcasts->fileExists($path)) {
return $response->streamFilesystemFile(
$fsPodcasts,
$path,
$podcastMedia->getOriginalName()
);
}
}
break;
}
}

View File

@ -7,6 +7,7 @@ namespace App\Controller\Api\Stations\Podcasts\Episodes\Media;
use App\Controller\SingleActionInterface;
use App\Entity\Api\Error;
use App\Entity\Api\Status;
use App\Entity\Enums\PodcastSources;
use App\Entity\Repository\PodcastEpisodeRepository;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
@ -14,6 +15,7 @@ use App\Http\ServerRequest;
use App\OpenApi;
use App\Service\Flow;
use App\Utilities\Types;
use InvalidArgumentException;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
@ -60,20 +62,22 @@ final class PostMediaAction implements SingleActionInterface
Response $response,
array $params
): ResponseInterface {
$episodeId = Types::stringOrNull($params['episode_id'] ?? null, true);
$podcast = $request->getPodcast();
$station = $request->getStation();
if ($podcast->getSource() !== PodcastSources::Manual) {
throw new InvalidArgumentException('Media cannot be manually set on this podcast.');
}
$episodeId = Types::stringOrNull($params['episode_id'] ?? null, true);
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
if ($flowResponse instanceof ResponseInterface) {
return $flowResponse;
}
if (null !== $episodeId) {
$episode = $this->episodeRepo->fetchEpisodeForPodcast(
$request->getPodcast(),
$episodeId
);
$episode = $this->episodeRepo->fetchEpisodeForPodcast($podcast, $episodeId);
if (null === $episode) {
return $response->withStatus(404)

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts;
use App\Container\EntityManagerAwareTrait;
use App\Controller\SingleActionInterface;
use App\Entity\Enums\PlaylistSources;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class PlaylistsAction implements SingleActionInterface
{
use EntityManagerAwareTrait;
public function __invoke(
ServerRequest $request,
Response $response,
array $params
): ResponseInterface {
$playlistsRaw = $this->em->createQuery(
<<<'DQL'
SELECT sp.id, sp.name
FROM App\Entity\StationPlaylist sp
WHERE sp.station = :station
AND sp.source = :sourceSongs
DQL
)->setParameter('station', $request->getStation())
->setParameter('sourceSongs', PlaylistSources::Songs->value)
->getArrayResult();
return $response->withJson(
array_column($playlistsRaw, 'name', 'id')
);
}
}

View File

@ -302,6 +302,12 @@ final class PodcastsController extends AbstractApiCrudController
unset($data['categories']);
}
if (isset($data['playlist_id'])) {
$data['playlist'] = $data['playlist_id'];
unset($data['playlist_id']);
}
$record = parent::fromArray($data, $record, $context);
if (null !== $newCategories) {

View File

@ -15,7 +15,7 @@ use RuntimeException;
#[
ORM\Entity(readOnly: true),
ORM\Table(name: 'analytics'),
ORM\Index(columns: ['type', 'moment'], name: 'search_idx'),
ORM\Index(name: 'search_idx', columns: ['type', 'moment']),
ORM\UniqueConstraint(name: 'stats_unique_idx', columns: ['station_id', 'type', 'moment'])
]
class Analytics implements IdentifiableEntityInterface

View File

@ -21,6 +21,15 @@ final class Podcast
#[OA\Property]
public int $storage_location_id;
#[OA\Property]
public string $source;
#[OA\Property]
public ?int $playlist_id = null;
#[OA\Property]
public bool $playlist_auto_publish = false;
#[OA\Property]
public string $title;

View File

@ -43,7 +43,13 @@ final class PodcastEpisode
public bool $has_media = false;
#[OA\Property]
public PodcastMedia $media;
public ?string $playlist_media_id = null;
#[OA\Property]
public ?Song $playlist_media = null;
#[OA\Property]
public ?PodcastMedia $media = null;
#[OA\Property]
public bool $has_custom_art = false;

View File

@ -38,6 +38,10 @@ final class PodcastApiGenerator
$return->id = $record->getIdRequired();
$return->storage_location_id = $record->getStorageLocation()->getIdRequired();
$return->source = $record->getSource()->value;
$return->playlist_id = $record->getPlaylist()?->getIdRequired();
$return->playlist_auto_publish = $record->playlistAutoPublish();
$return->title = $record->getTitle();
$return->link = $record->getLink();

View File

@ -6,13 +6,20 @@ namespace App\Entity\ApiGenerator;
use App\Entity\Api\PodcastEpisode as ApiPodcastEpisode;
use App\Entity\Api\PodcastMedia as ApiPodcastMedia;
use App\Entity\Enums\PodcastSources;
use App\Entity\PodcastEpisode;
use App\Entity\PodcastMedia;
use App\Entity\StationMedia;
use App\Http\ServerRequest;
use App\Utilities\Strings;
final class PodcastEpisodeApiGenerator
{
public function __construct(
private readonly SongApiGenerator $songApiGen
) {
}
public function __invoke(
PodcastEpisode $record,
ServerRequest $request
@ -34,21 +41,45 @@ final class PodcastEpisodeApiGenerator
$return->created_at = $record->getCreatedAt();
$return->publish_at = $record->getPublishAt();
$mediaRow = $record->getMedia();
$return->has_media = ($mediaRow instanceof PodcastMedia);
if ($mediaRow instanceof PodcastMedia) {
$media = new ApiPodcastMedia();
$media->id = $mediaRow->getId();
$media->original_name = $mediaRow->getOriginalName();
$media->length = $mediaRow->getLength();
$media->length_text = $mediaRow->getLengthText();
$media->path = $mediaRow->getPath();
switch ($podcast->getSource()) {
case PodcastSources::Playlist:
$return->media = null;
$return->has_media = true;
$return->media = $media;
} else {
$return->has_media = false;
$return->media = new ApiPodcastMedia();
$playlistMediaRow = $record->getPlaylistMedia();
if ($playlistMediaRow instanceof StationMedia) {
$return->has_media = true;
$return->playlist_media = $this->songApiGen->__invoke($playlistMediaRow);
$return->playlist_media_id = $playlistMediaRow->getUniqueId();
} else {
$return->has_media = false;
$return->playlist_media = null;
$return->playlist_media_id = null;
}
break;
case PodcastSources::Manual:
$return->playlist_media = null;
$return->playlist_media_id = null;
$mediaRow = $record->getMedia();
$return->has_media = ($mediaRow instanceof PodcastMedia);
if ($mediaRow instanceof PodcastMedia) {
$media = new ApiPodcastMedia();
$media->id = $mediaRow->getId();
$media->original_name = $mediaRow->getOriginalName();
$media->length = $mediaRow->getLength();
$media->length_text = $mediaRow->getLengthText();
$media->path = $mediaRow->getPath();
$return->has_media = true;
$return->media = $media;
} else {
$return->has_media = false;
$return->media = null;
}
break;
}
$return->is_published = $record->isPublished();

View File

@ -11,7 +11,7 @@ use Doctrine\ORM\Mapping as ORM;
#[
ORM\Entity(readOnly: true),
ORM\Table(name: 'audit_log'),
ORM\Index(columns: ['class', 'user', 'identifier'], name: 'idx_search')
ORM\Index(name: 'idx_search', columns: ['class', 'user', 'identifier'])
]
class AuditLog implements IdentifiableEntityInterface
{

View File

@ -6,6 +6,8 @@ namespace App\Entity;
use App\Entity\Interfaces\IdentifiableEntityInterface;
use App\Utilities\File;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use OpenApi\Attributes as OA;
use Stringable;
@ -45,6 +47,15 @@ class CustomField implements Stringable, IdentifiableEntityInterface
]
protected ?string $auto_assign = null;
/** @var Collection<int, StationMediaCustomField> */
#[ORM\OneToMany(targetEntity: StationMediaCustomField::class, mappedBy: 'field')]
protected Collection $media_fields;
public function __construct()
{
$this->media_fields = new ArrayCollection();
}
public function getName(): string
{
return $this->name;

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Entity\Enums;
enum PodcastSources: string
{
case Manual = 'manual';
case Playlist = 'playlist';
}

View File

@ -10,10 +10,10 @@ use NowPlaying\Result\Client;
#[
ORM\Entity,
ORM\Table(name: 'listener'),
ORM\Index(columns: ['timestamp_end', 'timestamp_start'], name: 'idx_timestamps'),
ORM\Index(columns: ['location_country'], name: 'idx_statistics_country'),
ORM\Index(columns: ['device_os_family'], name: 'idx_statistics_os'),
ORM\Index(columns: ['device_browser_family'], name: 'idx_statistics_browser')
ORM\Index(name: 'idx_timestamps', columns: ['timestamp_end', 'timestamp_start']),
ORM\Index(name: 'idx_statistics_country', columns: ['location_country']),
ORM\Index(name: 'idx_statistics_os', columns: ['device_os_family']),
ORM\Index(name: 'idx_statistics_browser', columns: ['device_browser_family'])
]
class Listener implements
Interfaces\IdentifiableEntityInterface,

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240221151753 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add ability for podcasts to sync from playlists.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE podcast ADD playlist_id INT DEFAULT NULL, ADD source VARCHAR(50) NOT NULL, ADD playlist_auto_publish TINYINT(1) NOT NULL');
$this->addSql('ALTER TABLE podcast ADD CONSTRAINT FK_D7E805BD6BBD148 FOREIGN KEY (playlist_id) REFERENCES station_playlists (id) ON DELETE CASCADE');
$this->addSql('CREATE INDEX IDX_D7E805BD6BBD148 ON podcast (playlist_id)');
$this->addSql('ALTER TABLE podcast_episode ADD playlist_media_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE podcast_episode ADD CONSTRAINT FK_77EB2BD017421B18 FOREIGN KEY (playlist_media_id) REFERENCES station_media (id) ON DELETE CASCADE');
$this->addSql('CREATE UNIQUE INDEX UNIQ_77EB2BD017421B18 ON podcast_episode (playlist_media_id)');
$this->addSql(
<<<'SQL'
UPDATE podcast SET source='manual'
SQL
);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE podcast DROP FOREIGN KEY FK_D7E805BD6BBD148');
$this->addSql('DROP INDEX IDX_D7E805BD6BBD148 ON podcast');
$this->addSql('ALTER TABLE podcast DROP playlist_id, DROP source, DROP playlist_auto_publish');
$this->addSql('ALTER TABLE podcast_episode DROP FOREIGN KEY FK_77EB2BD017421B18');
$this->addSql('DROP INDEX UNIQ_77EB2BD017421B18 ON podcast_episode');
$this->addSql('ALTER TABLE podcast_episode DROP playlist_media_id');
}
}

View File

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Entity;
use App\Entity\Enums\PodcastSources;
use App\Normalizer\Attributes\DeepNormalize;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@ -25,6 +27,20 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
#[ORM\JoinColumn(name: 'storage_location_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
protected StorageLocation $storage_location;
#[ORM\Column(nullable: false, insertable: false, updatable: false)]
protected int $storage_location_id;
#[DeepNormalize(true)]
#[ORM\ManyToOne(inversedBy: 'podcasts')]
#[ORM\JoinColumn(name: 'playlist_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
protected ?StationPlaylist $playlist = null;
#[ORM\Column(nullable: true, insertable: false, updatable: false)]
protected ?int $playlist_id = null;
#[ORM\Column(type: 'string', length: 50, enumType: PodcastSources::class)]
protected PodcastSources $source = PodcastSources::Manual;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
protected string $title;
@ -51,12 +67,15 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
#[Attributes\AuditIgnore]
protected int $art_updated_at = 0;
#[ORM\Column]
protected bool $playlist_auto_publish = true;
/** @var Collection<int, PodcastCategory> */
#[ORM\OneToMany(mappedBy: 'podcast', targetEntity: PodcastCategory::class)]
#[ORM\OneToMany(targetEntity: PodcastCategory::class, mappedBy: 'podcast')]
protected Collection $categories;
/** @var Collection<int, PodcastEpisode> */
#[ORM\OneToMany(mappedBy: 'podcast', targetEntity: PodcastEpisode::class, fetch: 'EXTRA_LAZY')]
#[ORM\OneToMany(targetEntity: PodcastEpisode::class, mappedBy: 'podcast', fetch: 'EXTRA_LAZY')]
protected Collection $episodes;
public function __construct(StorageLocation $storageLocation)
@ -72,6 +91,26 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
return $this->storage_location;
}
public function getPlaylist(): ?StationPlaylist
{
return $this->playlist;
}
public function setPlaylist(?StationPlaylist $playlist): void
{
$this->playlist = $playlist;
}
public function getSource(): PodcastSources
{
return $this->source;
}
public function setSource(PodcastSources $source): void
{
$this->source = $source;
}
public function getTitle(): string
{
return $this->title;
@ -156,6 +195,16 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
return $this;
}
public function playlistAutoPublish(): bool
{
return $this->playlist_auto_publish;
}
public function setPlaylistAutoPublish(bool $playlist_auto_publish): void
{
$this->playlist_auto_publish = $playlist_auto_publish;
}
/**
* @return Collection<int, PodcastCategory>
*/

View File

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Entity;
use App\Entity\Enums\PodcastSources;
use App\Entity\Interfaces\IdentifiableEntityInterface;
use App\Entity\Traits;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
@ -25,6 +25,13 @@ class PodcastEpisode implements IdentifiableEntityInterface
#[ORM\JoinColumn(name: 'podcast_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
protected Podcast $podcast;
#[ORM\ManyToOne(inversedBy: 'podcast_episodes')]
#[ORM\JoinColumn(name: 'playlist_media_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
protected ?StationMedia $playlist_media = null;
#[ORM\Column(nullable: true, insertable: false, updatable: false)]
protected ?int $playlist_media_id = null;
#[ORM\OneToOne(mappedBy: 'episode')]
protected ?PodcastMedia $media = null;
@ -73,6 +80,16 @@ class PodcastEpisode implements IdentifiableEntityInterface
return $this->media;
}
public function getPlaylistMedia(): ?StationMedia
{
return $this->playlist_media;
}
public function setPlaylistMedia(?StationMedia $playlist_media): void
{
$this->playlist_media = $playlist_media;
}
public function getTitle(): string
{
return $this->title;
@ -168,10 +185,9 @@ class PodcastEpisode implements IdentifiableEntityInterface
return false;
}
if ($this->getMedia() === null) {
return false;
}
return true;
return match ($this->getPodcast()->getSource()) {
PodcastSources::Manual => ($this->getMedia() !== null),
PodcastSources::Playlist => ($this->getPlaylistMedia() !== null)
};
}
}

View File

@ -53,7 +53,7 @@ class Relay implements IdentifiableEntityInterface
protected int $updated_at;
/** @var Collection<int, StationRemote> */
#[ORM\OneToMany(mappedBy: 'relay', targetEntity: StationRemote::class)]
#[ORM\OneToMany(targetEntity: StationRemote::class, mappedBy: 'relay')]
protected Collection $remotes;
public function __construct(string $baseUrl)

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Entity\Repository;
use App\Doctrine\Repository;
use App\Entity\Enums\PodcastSources;
use App\Entity\Podcast;
use App\Entity\PodcastEpisode;
use App\Entity\PodcastMedia;
@ -17,6 +18,7 @@ use App\Media\MetadataManager;
use InvalidArgumentException;
use League\Flysystem\UnableToDeleteFile;
use League\Flysystem\UnableToRetrieveMetadata;
use LogicException;
/**
* @extends Repository<PodcastEpisode>
@ -124,6 +126,11 @@ final class PodcastEpisodeRepository extends Repository
?ExtendedFilesystemInterface $fs = null
): void {
$podcast = $episode->getPodcast();
if ($podcast->getSource() !== PodcastSources::Manual) {
throw new LogicException('Cannot upload media to this podcast type.');
}
$storageLocation = $podcast->getStorageLocation();
$fs ??= $this->storageLocationRepo->getAdapter($storageLocation)

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Entity\Repository;
use App\Doctrine\Repository;
use App\Entity\Enums\PodcastSources;
use App\Entity\Podcast;
use App\Entity\Station;
use App\Exception\StorageLocationFullException;
@ -47,11 +48,15 @@ final class PodcastRepository extends Repository
SELECT DISTINCT p.id
FROM App\Entity\PodcastEpisode pe
JOIN pe.podcast p
JOIN pe.media pm
WHERE pm.id IS NOT NULL
AND (pe.publish_at IS NULL OR pe.publish_at <= :time)
LEFT JOIN pe.media pm
LEFT JOIN pe.playlist_media sm
WHERE
((p.source = :sourceManual AND pm.id IS NOT NULL) OR (p.source = :sourcePlaylist AND sm.id IS NOT NULL))
AND (pe.publish_at IS NULL OR pe.publish_at <= :time)
DQL
)->setParameter('time', time())
->setParameter('sourceManual', PodcastSources::Manual->value)
->setParameter('sourcePlaylist', PodcastSources::Playlist->value)
->enableResultCache(60, 'podcast_ids_' . $station->getIdRequired())
->getSingleColumnResult();
}

View File

@ -38,7 +38,7 @@ class Role implements JsonSerializable, Stringable, IdentifiableEntityInterface
/** @var Collection<int, RolePermission> */
#[
OA\Property(type: "array", items: new OA\Items()),
ORM\OneToMany(mappedBy: 'role', targetEntity: RolePermission::class)
ORM\OneToMany(targetEntity: RolePermission::class, mappedBy: 'role')
]
protected Collection $permissions;

View File

@ -10,9 +10,9 @@ use Doctrine\ORM\Mapping as ORM;
#[
ORM\Entity,
ORM\Table(name: 'song_history'),
ORM\Index(columns: ['is_visible'], name: 'idx_is_visible'),
ORM\Index(columns: ['timestamp_start'], name: 'idx_timestamp_start'),
ORM\Index(columns: ['timestamp_end'], name: 'idx_timestamp_end')
ORM\Index(name: 'idx_is_visible', columns: ['is_visible']),
ORM\Index(name: 'idx_timestamp_start', columns: ['timestamp_start']),
ORM\Index(name: 'idx_timestamp_end', columns: ['timestamp_end'])
]
class SongHistory implements
Interfaces\SongInterface,

View File

@ -33,7 +33,7 @@ use Symfony\Component\Validator\Constraints as Assert;
OA\Schema(schema: "Station", type: "object"),
ORM\Entity,
ORM\Table(name: 'station'),
ORM\Index(columns: ['short_name'], name: 'idx_short_name'),
ORM\Index(name: 'idx_short_name', columns: ['short_name']),
ORM\HasLifecycleCallbacks,
Attributes\Auditable,
AppAssert\StationPortChecker,
@ -285,7 +285,7 @@ class Station implements Stringable, IdentifiableEntityInterface
/** @var Collection<int, SongHistory> */
#[
ORM\OneToMany(mappedBy: 'station', targetEntity: SongHistory::class),
ORM\OneToMany(targetEntity: SongHistory::class, mappedBy: 'station'),
ORM\OrderBy(['timestamp_start' => 'desc'])
]
protected Collection $history;
@ -333,7 +333,7 @@ class Station implements Stringable, IdentifiableEntityInterface
protected ?StorageLocation $podcasts_storage_location = null;
/** @var Collection<int, StationStreamer> */
#[ORM\OneToMany(mappedBy: 'station', targetEntity: StationStreamer::class)]
#[ORM\OneToMany(targetEntity: StationStreamer::class, mappedBy: 'station')]
protected Collection $streamers;
#[
@ -353,41 +353,49 @@ class Station implements Stringable, IdentifiableEntityInterface
protected ?string $fallback_path = null;
/** @var Collection<int, RolePermission> */
#[ORM\OneToMany(mappedBy: 'station', targetEntity: RolePermission::class)]
#[ORM\OneToMany(targetEntity: RolePermission::class, mappedBy: 'station')]
protected Collection $permissions;
/** @var Collection<int, StationPlaylist> */
#[
ORM\OneToMany(mappedBy: 'station', targetEntity: StationPlaylist::class),
ORM\OneToMany(targetEntity: StationPlaylist::class, mappedBy: 'station'),
ORM\OrderBy(['type' => 'ASC', 'weight' => 'DESC'])
]
protected Collection $playlists;
/** @var Collection<int, StationMount> */
#[ORM\OneToMany(mappedBy: 'station', targetEntity: StationMount::class)]
#[ORM\OneToMany(targetEntity: StationMount::class, mappedBy: 'station')]
protected Collection $mounts;
/** @var Collection<int, StationRemote> */
#[ORM\OneToMany(mappedBy: 'station', targetEntity: StationRemote::class)]
#[ORM\OneToMany(targetEntity: StationRemote::class, mappedBy: 'station')]
protected Collection $remotes;
/** @var Collection<int, StationHlsStream> */
#[ORM\OneToMany(mappedBy: 'station', targetEntity: StationHlsStream::class)]
#[ORM\OneToMany(targetEntity: StationHlsStream::class, mappedBy: 'station')]
protected Collection $hls_streams;
/** @var Collection<int, StationWebhook> */
#[ORM\OneToMany(
mappedBy: 'station',
targetEntity: StationWebhook::class,
mappedBy: 'station',
cascade: ['persist'],
fetch: 'EXTRA_LAZY'
)]
protected Collection $webhooks;
/** @var Collection<int, StationStreamerBroadcast> */
#[ORM\OneToMany(targetEntity: StationStreamerBroadcast::class, mappedBy: 'station')]
protected Collection $streamer_broadcasts;
/** @var Collection<int, SftpUser> */
#[ORM\OneToMany(mappedBy: 'station', targetEntity: SftpUser::class)]
#[ORM\OneToMany(targetEntity: SftpUser::class, mappedBy: 'station')]
protected Collection $sftp_users;
/** @var Collection<int, StationRequest> */
#[ORM\OneToMany(targetEntity: StationRequest::class, mappedBy: 'station')]
protected Collection $requests;
#[
ORM\ManyToOne,
ORM\JoinColumn(name: 'current_song_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL'),
@ -408,7 +416,9 @@ class Station implements Stringable, IdentifiableEntityInterface
$this->hls_streams = new ArrayCollection();
$this->webhooks = new ArrayCollection();
$this->streamers = new ArrayCollection();
$this->streamer_broadcasts = new ArrayCollection();
$this->sftp_users = new ArrayCollection();
$this->requests = new ArrayCollection();
}
public function getName(): ?string

View File

@ -23,7 +23,7 @@ use Symfony\Component\Serializer\Annotation as Serializer;
OA\Schema(type: "object"),
ORM\Entity,
ORM\Table(name: 'station_media'),
ORM\Index(columns: ['title', 'artist', 'album'], name: 'search_idx'),
ORM\Index(name: 'search_idx', columns: ['title', 'artist', 'album']),
ORM\UniqueConstraint(name: 'path_unique_idx', columns: ['path', 'storage_location_id'])
]
class StationMedia implements
@ -193,22 +193,27 @@ class StationMedia implements
/** @var Collection<int, StationPlaylistMedia> */
#[
OA\Property(type: "array", items: new OA\Items()),
ORM\OneToMany(mappedBy: 'media', targetEntity: StationPlaylistMedia::class),
ORM\OneToMany(targetEntity: StationPlaylistMedia::class, mappedBy: 'media'),
DeepNormalize(true),
Serializer\MaxDepth(1)
]
protected Collection $playlists;
/** @var Collection<int, StationMediaCustomField> */
#[ORM\OneToMany(mappedBy: 'media', targetEntity: StationMediaCustomField::class)]
#[ORM\OneToMany(targetEntity: StationMediaCustomField::class, mappedBy: 'media')]
protected Collection $custom_fields;
/** @var Collection<int, PodcastEpisode> */
#[ORM\OneToMany(targetEntity: PodcastEpisode::class, mappedBy: 'playlist_media')]
protected Collection $podcast_episodes;
public function __construct(StorageLocation $storageLocation, string $path)
{
$this->storage_location = $storageLocation;
$this->playlists = new ArrayCollection();
$this->custom_fields = new ArrayCollection();
$this->podcast_episodes = new ArrayCollection();
$this->setPath($path);
$this->generateUniqueId();
@ -445,6 +450,14 @@ class StationMedia implements
$this->custom_fields = $customFields;
}
/**
* @return Collection<int, PodcastEpisode>
*/
public function getPodcastEpisodes(): Collection
{
return $this->podcast_episodes;
}
public static function needsReprocessing(int $fileModifiedTime = 0, int $dbModifiedTime = 0): bool
{
return $fileModifiedTime > $dbModifiedTime;

View File

@ -16,7 +16,7 @@ class StationMediaCustomField implements IdentifiableEntityInterface
use Traits\HasAutoIncrementId;
use Traits\TruncateStrings;
#[ORM\ManyToOne(inversedBy: 'metadata')]
#[ORM\ManyToOne(inversedBy: 'custom_fields')]
#[ORM\JoinColumn(name: 'media_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
protected StationMedia $media;

View File

@ -175,26 +175,35 @@ class StationPlaylist implements
/** @var Collection<int, StationPlaylistMedia> */
#[
ORM\OneToMany(mappedBy: 'playlist', targetEntity: StationPlaylistMedia::class, fetch: 'EXTRA_LAZY'),
ORM\OneToMany(targetEntity: StationPlaylistMedia::class, mappedBy: 'playlist', fetch: 'EXTRA_LAZY'),
ORM\OrderBy(['weight' => 'ASC'])
]
protected Collection $media_items;
/** @var Collection<int, StationPlaylistFolder> */
#[
ORM\OneToMany(mappedBy: 'playlist', targetEntity: StationPlaylistFolder::class, fetch: 'EXTRA_LAZY')
ORM\OneToMany(targetEntity: StationPlaylistFolder::class, mappedBy: 'playlist', fetch: 'EXTRA_LAZY')
]
protected Collection $folders;
/** @var Collection<int, StationSchedule> */
#[
OA\Property(type: "array", items: new OA\Items()),
ORM\OneToMany(mappedBy: 'playlist', targetEntity: StationSchedule::class, fetch: 'EXTRA_LAZY'),
ORM\OneToMany(targetEntity: StationSchedule::class, mappedBy: 'playlist', fetch: 'EXTRA_LAZY'),
DeepNormalize(true),
Serializer\MaxDepth(1)
]
protected Collection $schedule_items;
/** @var Collection<int, Podcast> */
#[
OA\Property(type: "array", items: new OA\Items()),
ORM\OneToMany(targetEntity: Podcast::class, mappedBy: 'playlist', fetch: 'EXTRA_LAZY'),
DeepNormalize(true),
Serializer\MaxDepth(1)
]
protected Collection $podcasts;
public function __construct(Station $station)
{
$this->station = $station;
@ -207,6 +216,7 @@ class StationPlaylist implements
$this->media_items = new ArrayCollection();
$this->folders = new ArrayCollection();
$this->schedule_items = new ArrayCollection();
$this->podcasts = new ArrayCollection();
}
public function getStation(): Station
@ -414,6 +424,14 @@ class StationPlaylist implements
return $this->schedule_items;
}
/**
* @return Collection<int, Podcast>
*/
public function getPodcasts(): Collection
{
return $this->podcasts;
}
/**
* Indicates whether a playlist is enabled and has content which can be scheduled by an AutoDJ scheduler.
*

View File

@ -9,10 +9,10 @@ use Doctrine\ORM\Mapping as ORM;
#[
ORM\Entity,
ORM\Table(name: 'station_queue'),
ORM\Index(columns: ['is_played'], name: 'idx_is_played'),
ORM\Index(columns: ['timestamp_played'], name: 'idx_timestamp_played'),
ORM\Index(columns: ['sent_to_autodj'], name: 'idx_sent_to_autodj'),
ORM\Index(columns: ['timestamp_cued'], name: 'idx_timestamp_cued')
ORM\Index(name: 'idx_is_played', columns: ['is_played']),
ORM\Index(name: 'idx_timestamp_played', columns: ['timestamp_played']),
ORM\Index(name: 'idx_sent_to_autodj', columns: ['sent_to_autodj']),
ORM\Index(name: 'idx_timestamp_cued', columns: ['timestamp_cued'])
]
class StationQueue implements
Interfaces\SongInterface,

View File

@ -19,7 +19,7 @@ class StationRequest implements
{
use Traits\HasAutoIncrementId;
#[ORM\ManyToOne(inversedBy: 'media')]
#[ORM\ManyToOne(inversedBy: 'requests')]
#[ORM\JoinColumn(name: 'station_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
protected Station $station;

View File

@ -101,16 +101,25 @@ class StationStreamer implements
/** @var Collection<int, StationSchedule> */
#[
OA\Property(type: "array", items: new OA\Items()),
ORM\OneToMany(mappedBy: 'streamer', targetEntity: StationSchedule::class),
ORM\OneToMany(targetEntity: StationSchedule::class, mappedBy: 'streamer'),
DeepNormalize(true),
Serializer\MaxDepth(1)
]
protected Collection $schedule_items;
/** @var Collection<int, StationStreamerBroadcast> */
#[
ORM\OneToMany(targetEntity: StationStreamerBroadcast::class, mappedBy: 'streamer'),
DeepNormalize(true)
]
protected Collection $broadcasts;
public function __construct(Station $station)
{
$this->station = $station;
$this->schedule_items = new ArrayCollection();
$this->broadcasts = new ArrayCollection();
}
public function getStation(): Station

View File

@ -98,9 +98,13 @@ class StorageLocation implements Stringable, IdentifiableEntityInterface
protected string|int|null $storageUsed = null;
/** @var Collection<int, StationMedia> */
#[ORM\OneToMany(mappedBy: 'storage_location', targetEntity: StationMedia::class)]
#[ORM\OneToMany(targetEntity: StationMedia::class, mappedBy: 'storage_location')]
protected Collection $media;
/** @var Collection<int, UnprocessableMedia> */
#[ORM\OneToMany(targetEntity: UnprocessableMedia::class, mappedBy: 'storage_location')]
protected Collection $unprocessable_media;
public function __construct(
StorageLocationTypes $type,
StorageLocationAdapters $adapter
@ -109,6 +113,7 @@ class StorageLocation implements Stringable, IdentifiableEntityInterface
$this->adapter = $adapter;
$this->media = new ArrayCollection();
$this->unprocessable_media = new ArrayCollection();
}
public function getType(): StorageLocationTypes

View File

@ -20,7 +20,7 @@ class UnprocessableMedia implements ProcessableMediaInterface, PathAwareInterfac
public const REPROCESS_THRESHOLD_MINIMUM = 604800; // One week
#[ORM\ManyToOne(inversedBy: 'media')]
#[ORM\ManyToOne(inversedBy: 'unprocessable_media')]
#[ORM\JoinColumn(name: 'storage_location_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
protected StorageLocation $storage_location;

View File

@ -119,7 +119,7 @@ class User implements Stringable, IdentifiableEntityInterface
/** @var Collection<int, ApiKey> */
#[
ORM\OneToMany(mappedBy: 'user', targetEntity: ApiKey::class),
ORM\OneToMany(targetEntity: ApiKey::class, mappedBy: 'user'),
Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL]),
DeepNormalize(true)
]
@ -127,12 +127,20 @@ class User implements Stringable, IdentifiableEntityInterface
/** @var Collection<int, UserPasskey> */
#[
ORM\OneToMany(mappedBy: 'user', targetEntity: UserPasskey::class),
ORM\OneToMany(targetEntity: UserPasskey::class, mappedBy: 'user'),
Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL]),
DeepNormalize(true)
]
protected Collection $passkeys;
/** @var Collection<int, UserLoginToken> */
#[
ORM\OneToMany(targetEntity: UserLoginToken::class, mappedBy: 'user'),
Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL]),
DeepNormalize(true)
]
protected Collection $login_tokens;
public function __construct()
{
$this->created_at = time();
@ -140,6 +148,7 @@ class User implements Stringable, IdentifiableEntityInterface
$this->roles = new ArrayCollection();
$this->api_keys = new ArrayCollection();
$this->login_tokens = new ArrayCollection();
}
#[ORM\PreUpdate]

View File

@ -15,7 +15,7 @@ class UserLoginToken
{
use Traits\HasSplitTokenFields;
#[ORM\ManyToOne(fetch: 'EAGER', inversedBy: 'api_keys')]
#[ORM\ManyToOne(fetch: 'EAGER', inversedBy: 'login_tokens')]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
protected User $user;

View File

@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Sync\Task;
use App\Entity\Enums\PodcastSources;
use App\Entity\Podcast;
use App\Entity\PodcastEpisode;
use App\Entity\Repository\PodcastEpisodeRepository;
use App\Entity\Station;
use App\Entity\StationMedia;
use App\Flysystem\StationFilesystems;
final class CheckPodcastPlaylistsTask extends AbstractTask
{
public function __construct(
private readonly StationFilesystems $stationFilesystems,
private readonly PodcastEpisodeRepository $podcastEpisodeRepo
) {
}
public static function getSchedulePattern(): string
{
return '*/10 * * * *';
}
public function run(bool $force = false): void
{
foreach ($this->iterateStations() as $station) {
$this->syncPodcastPlaylists($station);
}
}
public function syncPodcastPlaylists(Station $station): void
{
$this->logger->info(
'Processing playlist-based podcasts for station...',
[
'station' => $station->getName(),
]
);
$fsMedia = $this->stationFilesystems->getMediaFilesystem($station);
$fsPodcasts = $this->stationFilesystems->getPodcastsFilesystem($station);
$podcasts = $this->em->createQuery(
<<<'DQL'
SELECT p, sp
FROM App\Entity\Podcast p
JOIN p.playlist sp
WHERE p.source = :source
DQL
)->setParameter('source', PodcastSources::Playlist->value)
->execute();
$mediaInPlaylistQuery = $this->em->createQuery(
<<<'DQL'
SELECT spm.media_id
FROM App\Entity\StationPlaylistMedia spm
WHERE spm.playlist = :playlist
DQL
);
$mediaInPodcastQuery = $this->em->createQuery(
<<<'DQL'
SELECT pe.id, pe.playlist_media_id
FROM App\Entity\PodcastEpisode pe
WHERE pe.podcast = :podcast
DQL
);
$stats = [
'added' => 0,
'removed' => 0,
'unchanged' => 0,
];
/** @var Podcast $podcast */
foreach ($podcasts as $podcast) {
$playlist = $podcast->getPlaylist();
$mediaInPlaylist = array_column(
$mediaInPlaylistQuery->setParameter('playlist', $playlist)->getArrayResult(),
'media_id',
'media_id'
);
$mediaInPodcast = array_column(
$mediaInPodcastQuery->setParameter('podcast', $podcast)->getArrayResult(),
'id',
'playlist_media_id'
);
$mediaToAdd = [];
foreach ($mediaInPlaylist as $mediaId) {
if (isset($mediaInPodcast[$mediaId])) {
$stats['unchanged']++;
unset($mediaInPodcast[$mediaId]);
} else {
$mediaToAdd[] = $mediaId;
}
}
foreach ($mediaToAdd as $mediaId) {
$media = $this->em->find(StationMedia::class, $mediaId);
if ($media instanceof StationMedia) {
// Create new podcast episode.
$podcastEpisode = new PodcastEpisode($podcast);
$podcastEpisode->setPlaylistMedia($media);
$podcastEpisode->setExplicit(false);
$podcastEpisode->setTitle($media->getTitle() ?? 'Untitled Episode');
$podcastEpisode->setDescription(
implode("\n", array_filter([
$media->getArtist(),
$media->getAlbum(),
$media->getLyrics(),
]))
);
if (!$podcast->playlistAutoPublish()) {
// Set a date in the future to unpublish the episode.
$podcastEpisode->setPublishAt(
strtotime('+10 years')
);
} else {
$podcastEpisode->setPublishAt($media->getMtime());
}
$this->em->persist($podcastEpisode);
$this->em->flush();
$artPath = StationMedia::getArtPath($media->getUniqueId());
if ($fsMedia->fileExists($artPath)) {
$art = $fsMedia->read($artPath);
$this->podcastEpisodeRepo->writeEpisodeArt($podcastEpisode, $art);
}
$stats['added']++;
}
}
// Remove remaining media that doesn't match.
foreach ($mediaInPodcast as $episodeId) {
$episode = $this->em->find(PodcastEpisode::class, $episodeId);
if ($episode instanceof PodcastEpisode) {
$this->podcastEpisodeRepo->delete($episode, $fsPodcasts);
}
$stats['removed']++;
}
}
$this->logger->debug(
'Playlist-based podcasts for station processed.',
[
'station' => $station->getName(),
'stats' => $stats,
]
);
}
}