refacto: simplify EpisodeActionExtraData API
All checks were successful
repod / xml (push) Successful in 32s
repod / php (push) Successful in 1m2s
repod / nodejs (push) Successful in 2m1s

This commit is contained in:
Michel Roux 2024-01-11 00:14:15 +01:00
parent f266186656
commit 785414f33f
8 changed files with 180 additions and 178 deletions

View File

@ -30,7 +30,7 @@ class EpisodesController extends Controller
$episodes = $this->episodeActionReader->parseRssXml((string) $feed->getBody());
usort($episodes, fn (EpisodeActionExtraData $a, EpisodeActionExtraData $b) => $b->getFetchedAtUnix() <=> $a->getFetchedAtUnix());
$episodes = array_intersect_key($episodes, array_unique(array_map(fn (EpisodeActionExtraData $episode) => $episode->getEpisodeGuid(), $episodes)));
$episodes = array_intersect_key($episodes, array_unique(array_map(fn (EpisodeActionExtraData $episode) => $episode->getGuid(), $episodes)));
return new JSONResponse($episodes, $feed->getStatusCode());
}

View File

@ -7,81 +7,99 @@ namespace OCA\RePod\Core\EpisodeAction;
use OCA\GPodderSync\Core\EpisodeAction\EpisodeAction;
/**
* https://github.com/pbek/nextcloud-nextpod/blob/main/lib/Core/EpisodeAction/EpisodeActionExtraData.php.
* Base: https://github.com/pbek/nextcloud-nextpod/blob/main/lib/Core/EpisodeAction/EpisodeActionExtraData.php.
* Specs: https://github.com/Podcast-Standards-Project/PSP-1-Podcast-RSS-Specification/blob/main/README.md#required-item-elements.
*
* @psalm-import-type EpisodeActionType from EpisodeAction
*
* @psalm-type EpisodeActionExtraDataType = array{
* episodeUrl: ?string,
* podcastName: ?string,
* episodeName: ?string,
* episodeLink: ?string,
* episodeImage: ?string,
* episodeDescription: ?string,
* podcast: string,
* url: ?string,
* name: string,
* link: ?string,
* image: ?string,
* description: ?string,
* fetchedAtUnix: int,
* episodeGuid: string,
* episodePubDate: ?\DateTime,
* episodeFilesize: ?int,
* episodeDuration: ?int,
* episodeAction: ?EpisodeActionType
* guid: string,
* type: ?string,
* size: ?int,
* pubDate: ?\DateTime,
* duration: ?int,
* action: ?EpisodeActionType
* }
*/
class EpisodeActionExtraData implements \JsonSerializable
{
public function __construct(
private ?string $episodeUrl,
private ?string $podcastName,
private ?string $episodeName,
private ?string $episodeLink,
private ?string $episodeImage,
private ?string $episodeDescription,
private string $podcast,
private ?string $url,
private string $name,
private ?string $link,
private ?string $image,
private ?string $description,
private int $fetchedAtUnix,
private string $episodeGuid,
private ?\DateTime $episodePubDate,
private ?int $episodeFilesize,
private ?int $episodeDuration,
private ?EpisodeAction $episodeAction
) {
$this->episodeUrl = $episodeUrl;
$this->podcastName = $podcastName;
$this->episodeName = $episodeName;
$this->episodeLink = $episodeLink;
$this->episodeImage = $episodeImage;
$this->episodeDescription = $episodeDescription;
$this->fetchedAtUnix = $fetchedAtUnix;
$this->episodeGuid = $episodeGuid;
$this->episodePubDate = $episodePubDate;
$this->episodeFilesize = $episodeFilesize;
$this->episodeDuration = $episodeDuration;
$this->episodeAction = $episodeAction;
}
private string $guid,
private ?string $type,
private ?int $size,
private ?\DateTime $pubDate,
private ?int $duration,
private ?EpisodeAction $action
) {}
public function __toString(): string {
return $this->episodeUrl ?? '/no episodeUrl/';
return $this->url ?? '/no episodeUrl/';
}
public function getEpisodeGuid(): string {
return $this->episodeGuid;
public function getPodcast(): string {
return $this->podcast;
}
public function getEpisodePubDate(): ?\DateTime {
return $this->episodePubDate;
public function getUrl(): ?string {
return $this->url;
}
public function getEpisodeFilesize(): ?int {
return $this->episodeFilesize;
public function getName(): string {
return $this->name;
}
public function getEpisodeDuration(): ?int {
return $this->episodeDuration;
public function getLink(): ?string {
return $this->link;
}
public function getEpisodeAction(): ?EpisodeAction {
return $this->episodeAction;
public function getImage(): ?string {
return $this->image;
}
public function getEpisodeUrl(): ?string {
return $this->episodeUrl;
public function getDescription(): ?string {
return $this->description;
}
public function getFetchedAtUnix(): int {
return $this->fetchedAtUnix;
}
public function getGuid(): string {
return $this->guid;
}
public function getType(): ?string {
return $this->type;
}
public function getSize(): ?int {
return $this->size;
}
public function getPubDate(): ?\DateTime {
return $this->pubDate;
}
public function getDuration(): ?int {
return $this->duration;
}
public function getAction(): ?EpisodeAction {
return $this->action;
}
/**
@ -90,18 +108,19 @@ class EpisodeActionExtraData implements \JsonSerializable
public function toArray(): array {
return
[
'podcastName' => $this->podcastName,
'episodeUrl' => $this->episodeUrl,
'episodeName' => $this->episodeName,
'episodeLink' => $this->episodeLink,
'episodeImage' => $this->episodeImage,
'episodeDescription' => $this->episodeDescription,
'podcast' => $this->podcast,
'url' => $this->url,
'name' => $this->name,
'link' => $this->link,
'image' => $this->image,
'description' => $this->description,
'fetchedAtUnix' => $this->fetchedAtUnix,
'episodeGuid' => $this->episodeGuid,
'episodePubDate' => $this->episodePubDate,
'episodeFilesize' => $this->episodeFilesize,
'episodeDuration' => $this->episodeDuration,
'episodeAction' => $this->episodeAction ? $this->episodeAction->toArray() : null,
'guid' => $this->guid,
'type' => $this->type,
'size' => $this->size,
'pubDate' => $this->pubDate,
'duration' => $this->duration,
'action' => $this->action ? $this->action->toArray() : null,
];
}
@ -111,24 +130,4 @@ class EpisodeActionExtraData implements \JsonSerializable
public function jsonSerialize(): mixed {
return $this->toArray();
}
public function getPodcastName(): ?string {
return $this->podcastName;
}
public function getEpisodeName(): ?string {
return $this->episodeName;
}
public function getEpisodeLink(): ?string {
return $this->episodeLink;
}
public function getFetchedAtUnix(): int {
return $this->fetchedAtUnix;
}
public function getEpisodeImage(): ?string {
return $this->episodeImage;
}
}

View File

@ -20,7 +20,8 @@ class EpisodeActionReader
}
/**
* https://github.com/pbek/nextcloud-nextpod/blob/main/lib/Core/EpisodeAction/EpisodeActionExtraData.php#L119.
* Base: https://github.com/pbek/nextcloud-nextpod/blob/main/lib/Core/EpisodeAction/EpisodeActionExtraData.php#L119.
* Specs : https://github.com/Podcast-Standards-Project/PSP-1-Podcast-RSS-Specification/blob/main/README.md.
*
* @return EpisodeActionExtraData[]
* @throws \Exception if the XML data could not be parsed
@ -29,101 +30,104 @@ class EpisodeActionReader
$episodes = [];
$xml = new \SimpleXMLElement($xmlString);
$channel = $xml->channel;
$podcast = (string) $channel->title;
// Find episode by url and add data for it
/** @var \SimpleXMLElement $item */
foreach ($channel->item as $item) {
$episodeUrl = (string) $item->enclosure['url'];
// Get episode guid
$episodeGuid = (string) $item->guid;
// Get episode filesize
$episodeFilesize = (int) $item->enclosure['length'];
$url = (string) $item->enclosure['url'];
$type = (string) $item->enclosure['type'];
$size = (int) $item->enclosure['length'];
$guid = (string) $item->guid;
$rawDuration = $this->stringOrNull($item->duration);
// Get episode action
$episodeAction = $this->episodeActionRepository->findByGuid($episodeGuid, $this->userService->getUserUID());
$action = $this->episodeActionRepository->findByGuid($guid, $this->userService->getUserUID());
if ($episodeAction) {
$episodeUrl = $episodeAction->getEpisode();
if ($action) {
$url = $action->getEpisode();
} else {
$episodeAction = $this->episodeActionRepository->findByEpisodeUrl($episodeUrl, $this->userService->getUserUID());
$action = $this->episodeActionRepository->findByEpisodeUrl($url, $this->userService->getUserUID());
}
// Get episode name
$episodeName = $this->stringOrNull($item->title);
$name = (string) $item->title;
// Get episode link
$episodeLink = $this->stringOrNull($item->link);
$link = $this->stringOrNull($item->link);
// Get episode image
$episodeImage = $this->stringOrNull($channel->image->url);
$image = $this->stringOrNull($channel->image->url);
$episodeChildren = $item->children('http://www.itunes.com/dtds/podcast-1.0.dtd');
if ($episodeChildren) {
$episodeImageAttributes = (array) $episodeChildren->image->attributes();
$episodeImage = $this->stringOrNull(array_key_exists('href', $episodeImageAttributes) ? (string) $episodeImageAttributes['href'] : '');
$iTunesChildren = $item->children('itunes', true);
$itemChildren = $item->children('http://www.itunes.com/dtds/podcast-1.0.dtd');
if ($itemChildren) {
$imageAttributes = (array) $itemChildren->image->attributes();
$image = $this->stringOrNull(array_key_exists('href', $imageAttributes) ? (string) $imageAttributes['href'] : '');
$iTunesItemChildren = $item->children('itunes', true);
$iTunesChannelChildren = $channel->children('itunes', true);
// Get episode duration
if ($iTunesChildren) {
$rawDuration = $this->stringOrNull((string) $iTunesChildren->duration);
$splitDuration = array_reverse(explode(':', $rawDuration ?? ''));
$episodeDuration = (int) $splitDuration[0];
$episodeDuration += !empty($splitDuration[1]) ? (int) $splitDuration[1] * 60 : 0;
$episodeDuration += !empty($splitDuration[2]) ? (int) $splitDuration[2] * 60 : 0;
if ($iTunesItemChildren) {
$rawDuration = $this->stringOrNull($rawDuration ?? $iTunesItemChildren->duration);
}
if ($iTunesChildren && !$episodeImage) {
$episodeImage = $this->stringOrNull((string) $iTunesChildren->image['href']);
if ($iTunesItemChildren && !$image) {
$image = $this->stringOrNull($iTunesItemChildren->image['href']);
}
if ($iTunesChildren && !$episodeImage) {
$episodeImage = $this->stringOrNull((string) $iTunesChildren->image['href']);
if ($iTunesChannelChildren && !$image) {
$image = $this->stringOrNull($iTunesChannelChildren->image['href']);
}
if (!$episodeImage) {
if (!$image) {
$channelChildren = $channel->children('http://www.itunes.com/dtds/podcast-1.0.dtd');
if ($channelChildren) {
$episodeImageAttributes = (array) $channelChildren->image->attributes();
$episodeImage = $this->stringOrNull(array_key_exists('href', $episodeImageAttributes) ? (string) $episodeImageAttributes['href'] : '');
$imageAttributes = (array) $channelChildren->image->attributes();
$image = $this->stringOrNull(array_key_exists('href', $imageAttributes) ? (string) $imageAttributes['href'] : '');
}
}
if (!$episodeImage) {
if (!$image) {
preg_match('/<itunes:image\s+href="([^"]+)"/', $xmlString, $matches);
$episodeImage = $this->stringOrNull($matches[1]);
$image = $this->stringOrNull($matches[1]);
}
}
// Get episode description
$episodeDescription = $this->stringOrNull($item->description);
$episodeContentChildren = $item->children('content', true);
if ($episodeContentChildren) {
$episodeDescription = $this->stringOrNull($episodeContentChildren->encoded);
$itemContent = $item->children('content', true);
if ($itemContent) {
$description = $this->stringOrNull($itemContent->encoded);
} else {
$description = $this->stringOrNull($item->description);
}
// Remove tags
$episodeDescription = strip_tags($episodeDescription ?? '');
$description = strip_tags($description ?? '');
// Get episode pubDate
$rawPubDate = $this->stringOrNull($item->pubDate);
$episodePubDate = $rawPubDate ? new \DateTime($rawPubDate) : null;
$pubDate = $rawPubDate ? new \DateTime($rawPubDate) : null;
// Get episode duration
$splitDuration = array_reverse(explode(':', $rawDuration ?? ''));
$duration = (int) $splitDuration[0];
$duration += !empty($splitDuration[1]) ? (int) $splitDuration[1] * 60 : 0;
$duration += !empty($splitDuration[2]) ? (int) $splitDuration[2] * 60 : 0;
$episodes[] = new EpisodeActionExtraData(
$episodeUrl,
$this->stringOrNull($channel->title),
$episodeName,
$episodeLink,
$episodeImage,
$episodeDescription,
$podcast,
$url,
$name,
$link,
$image,
$description,
$fetchedAtUnix ?? (new \DateTime())->getTimestamp(),
$episodeGuid,
$episodePubDate,
$episodeFilesize,
$episodeDuration ?? null,
$episodeAction
$guid,
$type,
$size,
$pubDate,
$duration,
$action
);
}

View File

@ -3,21 +3,21 @@
<Loading v-if="loading" />
<AdaptativeList v-if="!loading">
<NcListItem v-for="episode in episodes"
:key="episode.episodeGuid"
:key="episode.guid"
:active="isCurrentEpisode(episode)"
:class="episode.episodeAction && episode.episodeAction.position >= episode.episodeAction.total ? 'ended': ''"
:details="formatDistanceToNow(new Date(episode.episodePubDate.date))"
:class="episode.action && episode.action.position >= episode.action.total ? 'ended': ''"
:details="formatDistanceToNow(new Date(episode.pubDate.date))"
:force-display-actions="true"
:name="episode.episodeName"
:title="episode.episodeDescription"
:name="episode.name"
:title="episode.description"
@click="modalEpisode = episode">
<template #icon>
<NcAvatar :display-name="episode.episodeName"
<NcAvatar :display-name="episode.name"
:is-no-user="true"
:url="episode.episodeImage" />
:url="episode.image" />
</template>
<template #subname>
{{ formatTimer(new Date(episode.episodeDuration*1000)) }}
{{ formatTimer(new Date(episode.duration*1000)) }}
</template>
<template #actions>
<NcActionButton v-if="!isCurrentEpisode(episode)" @click="load(episode)">
@ -37,12 +37,12 @@
</AdaptativeList>
<NcModal v-if="modalEpisode"
@close="modalEpisode = null">
<Modal :episode-description="modalEpisode.episodeDescription"
:episode-image="modalEpisode.episodeImage"
:episode-link="modalEpisode.episodeLink"
:episode-name="modalEpisode.episodeName"
:episode-url="modalEpisode.episodeUrl"
:podcast-name="modalEpisode.podcastName" />
<Modal :description="modalEpisode.description"
:image="modalEpisode.image"
:link="modalEpisode.link"
:name="modalEpisode.name"
:podcast="modalEpisode.podcast"
:url="modalEpisode.url" />
</NcModal>
</div>
</template>
@ -92,7 +92,7 @@ export default {
try {
this.loading = true
const episodes = await axios.get(generateUrl('/apps/repod/episodes/list?url={url}', { url: this.url }))
this.episodes = [...episodes.data].sort((a, b) => a.episodePubDate.date < b.episodePubDate.date)
this.episodes = episodes.data
} catch (e) {
console.error(e)
showError(t('repod', 'Could not fetch episodes'))
@ -104,7 +104,7 @@ export default {
formatTimer,
formatDistanceToNow,
isCurrentEpisode(episode) {
return this.currentEpisode && this.currentEpisode.episodeUrl === episode.episodeUrl
return this.currentEpisode && this.currentEpisode.url === episode.url
},
load(episode) {
this.$store.dispatch('player/load', episode)

View File

@ -1,20 +1,20 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="content">
<NcAvatar :display-name="episodeName"
<NcAvatar :display-name="name"
:is-no-user="true"
size="256"
:url="episodeImage" />
<h2>{{ episodeName }}</h2>
<div v-html="description" />
:url="image" />
<h2>{{ name }}</h2>
<p v-html="strippedDescription" />
<div class="buttons">
<NcButton v-if="episodeLink" :href="episodeLink" target="_blank">
<NcButton v-if="link" :href="link" target="_blank">
<template #icon>
<OpenInNew :size="20" />
</template>
{{ podcastName }}
</NcButton>
<NcButton v-if="episodeUrl" :href="episodeUrl" target="_blank">
<NcButton v-if="url" :href="url" target="_blank">
<template #icon>
<Download :size="20" />
</template>
@ -38,35 +38,35 @@ export default {
OpenInNew,
},
props: {
episodeName: {
name: {
type: String,
required: true,
},
episodeImage: {
image: {
type: String,
required: true,
},
episodeDescription: {
description: {
type: String,
default: '',
},
url: {
type: String,
required: true,
},
episodeUrl: {
type: String,
required: true,
},
episodeLink: {
link: {
type: String,
default: null,
},
podcastName: {
podcast: {
type: String,
required: true,
},
},
computed: {
description() {
strippedDescription() {
const pre = document.createElement('pre')
pre.innerHTML = this.episodeDescription
pre.innerHTML = this.description
const strippedDescription = pre.textContent || pre.innerText || ''
return strippedDescription.replace(/\n/g, '<br>')
},

View File

@ -3,7 +3,7 @@
<Loading v-if="!player.loaded" />
<ProgressBar v-if="player.loaded" />
<div v-if="player.loaded" class="player">
<img :src="player.episode.episodeImage">
<img :src="player.episode.image">
<Infos class="infos" />
<Controls class="controls" />
<Timer class="timer" />

View File

@ -1,10 +1,10 @@
<template>
<div>
<a :href="player.episode.episodeLink" target="_blank">
<strong>{{ player.episode.episodeName }}</strong>
<a :href="player.episode.link" target="_blank">
<strong>{{ player.episode.name }}</strong>
</a>
<router-link :to="toUrl(player.podcastUrl)">
<i>{{ player.episode.podcastName }}</i>
<i>{{ player.episode.podcast }}</i>
</router-link>
</div>
</template>

View File

@ -44,7 +44,7 @@ export const player = {
if (episode) {
state.podcastUrl = atob(router.currentRoute.params.url)
audio.src = episode.episodeUrl
audio.src = episode.url
audio.load()
audio.play()
@ -71,7 +71,7 @@ export const player = {
load: async (context, episode) => {
context.commit('episode', episode)
try {
const action = await axios.get(generateUrl('/apps/repod/episodes/action?url={url}', { url: episode.episodeUrl }))
const action = await axios.get(generateUrl('/apps/repod/episodes/action?url={url}', { url: episode.url }))
context.commit('action', action.data)
} catch {}
},
@ -87,19 +87,18 @@ export const player = {
stop: (context) => {
context.dispatch('pause')
context.commit('episode', null)
context.commit('action', null)
},
time: async (context) => axios.post(generateUrl('/apps/gpoddersync/episode_action/create'), [{
podcast: context.state.podcastUrl,
episode: context.state.episode.episodeUrl,
guid: context.state.episode.episodeGuid,
episode: context.state.episode.url,
guid: context.state.episode.guid,
action: 'play',
timestamp: format(new Date(), 'yyyy-MM-dd\'T\'HH:mm:ss'),
started: Math.round(context.state.action ? context.state.action.started : 0),
position: Math.round(audio.currentTime),
total: Math.round(audio.duration),
}]),
volume: (context, volume) => {
volume: (_, volume) => {
audio.volume = volume
},
},