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()); $episodes = $this->episodeActionReader->parseRssXml((string) $feed->getBody());
usort($episodes, fn (EpisodeActionExtraData $a, EpisodeActionExtraData $b) => $b->getFetchedAtUnix() <=> $a->getFetchedAtUnix()); 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()); return new JSONResponse($episodes, $feed->getStatusCode());
} }

View File

@ -7,81 +7,99 @@ namespace OCA\RePod\Core\EpisodeAction;
use OCA\GPodderSync\Core\EpisodeAction\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-import-type EpisodeActionType from EpisodeAction
* *
* @psalm-type EpisodeActionExtraDataType = array{ * @psalm-type EpisodeActionExtraDataType = array{
* episodeUrl: ?string, * podcast: string,
* podcastName: ?string, * url: ?string,
* episodeName: ?string, * name: string,
* episodeLink: ?string, * link: ?string,
* episodeImage: ?string, * image: ?string,
* episodeDescription: ?string, * description: ?string,
* fetchedAtUnix: int, * fetchedAtUnix: int,
* episodeGuid: string, * guid: string,
* episodePubDate: ?\DateTime, * type: ?string,
* episodeFilesize: ?int, * size: ?int,
* episodeDuration: ?int, * pubDate: ?\DateTime,
* episodeAction: ?EpisodeActionType * duration: ?int,
* action: ?EpisodeActionType
* } * }
*/ */
class EpisodeActionExtraData implements \JsonSerializable class EpisodeActionExtraData implements \JsonSerializable
{ {
public function __construct( public function __construct(
private ?string $episodeUrl, private string $podcast,
private ?string $podcastName, private ?string $url,
private ?string $episodeName, private string $name,
private ?string $episodeLink, private ?string $link,
private ?string $episodeImage, private ?string $image,
private ?string $episodeDescription, private ?string $description,
private int $fetchedAtUnix, private int $fetchedAtUnix,
private string $episodeGuid, private string $guid,
private ?\DateTime $episodePubDate, private ?string $type,
private ?int $episodeFilesize, private ?int $size,
private ?int $episodeDuration, private ?\DateTime $pubDate,
private ?EpisodeAction $episodeAction private ?int $duration,
) { private ?EpisodeAction $action
$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;
}
public function __toString(): string { public function __toString(): string {
return $this->episodeUrl ?? '/no episodeUrl/'; return $this->url ?? '/no episodeUrl/';
} }
public function getEpisodeGuid(): string { public function getPodcast(): string {
return $this->episodeGuid; return $this->podcast;
} }
public function getEpisodePubDate(): ?\DateTime { public function getUrl(): ?string {
return $this->episodePubDate; return $this->url;
} }
public function getEpisodeFilesize(): ?int { public function getName(): string {
return $this->episodeFilesize; return $this->name;
} }
public function getEpisodeDuration(): ?int { public function getLink(): ?string {
return $this->episodeDuration; return $this->link;
} }
public function getEpisodeAction(): ?EpisodeAction { public function getImage(): ?string {
return $this->episodeAction; return $this->image;
} }
public function getEpisodeUrl(): ?string { public function getDescription(): ?string {
return $this->episodeUrl; 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 { public function toArray(): array {
return return
[ [
'podcastName' => $this->podcastName, 'podcast' => $this->podcast,
'episodeUrl' => $this->episodeUrl, 'url' => $this->url,
'episodeName' => $this->episodeName, 'name' => $this->name,
'episodeLink' => $this->episodeLink, 'link' => $this->link,
'episodeImage' => $this->episodeImage, 'image' => $this->image,
'episodeDescription' => $this->episodeDescription, 'description' => $this->description,
'fetchedAtUnix' => $this->fetchedAtUnix, 'fetchedAtUnix' => $this->fetchedAtUnix,
'episodeGuid' => $this->episodeGuid, 'guid' => $this->guid,
'episodePubDate' => $this->episodePubDate, 'type' => $this->type,
'episodeFilesize' => $this->episodeFilesize, 'size' => $this->size,
'episodeDuration' => $this->episodeDuration, 'pubDate' => $this->pubDate,
'episodeAction' => $this->episodeAction ? $this->episodeAction->toArray() : null, 'duration' => $this->duration,
'action' => $this->action ? $this->action->toArray() : null,
]; ];
} }
@ -111,24 +130,4 @@ class EpisodeActionExtraData implements \JsonSerializable
public function jsonSerialize(): mixed { public function jsonSerialize(): mixed {
return $this->toArray(); 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[] * @return EpisodeActionExtraData[]
* @throws \Exception if the XML data could not be parsed * @throws \Exception if the XML data could not be parsed
@ -29,101 +30,104 @@ class EpisodeActionReader
$episodes = []; $episodes = [];
$xml = new \SimpleXMLElement($xmlString); $xml = new \SimpleXMLElement($xmlString);
$channel = $xml->channel; $channel = $xml->channel;
$podcast = (string) $channel->title;
// Find episode by url and add data for it // Find episode by url and add data for it
/** @var \SimpleXMLElement $item */ /** @var \SimpleXMLElement $item */
foreach ($channel->item as $item) { foreach ($channel->item as $item) {
$episodeUrl = (string) $item->enclosure['url']; $url = (string) $item->enclosure['url'];
$type = (string) $item->enclosure['type'];
// Get episode guid $size = (int) $item->enclosure['length'];
$episodeGuid = (string) $item->guid; $guid = (string) $item->guid;
$rawDuration = $this->stringOrNull($item->duration);
// Get episode filesize
$episodeFilesize = (int) $item->enclosure['length'];
// Get episode action // Get episode action
$episodeAction = $this->episodeActionRepository->findByGuid($episodeGuid, $this->userService->getUserUID()); $action = $this->episodeActionRepository->findByGuid($guid, $this->userService->getUserUID());
if ($episodeAction) { if ($action) {
$episodeUrl = $episodeAction->getEpisode(); $url = $action->getEpisode();
} else { } else {
$episodeAction = $this->episodeActionRepository->findByEpisodeUrl($episodeUrl, $this->userService->getUserUID()); $action = $this->episodeActionRepository->findByEpisodeUrl($url, $this->userService->getUserUID());
} }
// Get episode name // Get episode name
$episodeName = $this->stringOrNull($item->title); $name = (string) $item->title;
// Get episode link // Get episode link
$episodeLink = $this->stringOrNull($item->link); $link = $this->stringOrNull($item->link);
// Get episode image // 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'); $itemChildren = $item->children('http://www.itunes.com/dtds/podcast-1.0.dtd');
if ($episodeChildren) { if ($itemChildren) {
$episodeImageAttributes = (array) $episodeChildren->image->attributes(); $imageAttributes = (array) $itemChildren->image->attributes();
$episodeImage = $this->stringOrNull(array_key_exists('href', $episodeImageAttributes) ? (string) $episodeImageAttributes['href'] : ''); $image = $this->stringOrNull(array_key_exists('href', $imageAttributes) ? (string) $imageAttributes['href'] : '');
$iTunesChildren = $item->children('itunes', true); $iTunesItemChildren = $item->children('itunes', true);
$iTunesChannelChildren = $channel->children('itunes', true);
// Get episode duration // Get episode duration
if ($iTunesChildren) { if ($iTunesItemChildren) {
$rawDuration = $this->stringOrNull((string) $iTunesChildren->duration); $rawDuration = $this->stringOrNull($rawDuration ?? $iTunesItemChildren->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 ($iTunesChildren && !$episodeImage) { if ($iTunesItemChildren && !$image) {
$episodeImage = $this->stringOrNull((string) $iTunesChildren->image['href']); $image = $this->stringOrNull($iTunesItemChildren->image['href']);
} }
if ($iTunesChildren && !$episodeImage) { if ($iTunesChannelChildren && !$image) {
$episodeImage = $this->stringOrNull((string) $iTunesChildren->image['href']); $image = $this->stringOrNull($iTunesChannelChildren->image['href']);
} }
if (!$episodeImage) { if (!$image) {
$channelChildren = $channel->children('http://www.itunes.com/dtds/podcast-1.0.dtd'); $channelChildren = $channel->children('http://www.itunes.com/dtds/podcast-1.0.dtd');
if ($channelChildren) { if ($channelChildren) {
$episodeImageAttributes = (array) $channelChildren->image->attributes(); $imageAttributes = (array) $channelChildren->image->attributes();
$episodeImage = $this->stringOrNull(array_key_exists('href', $episodeImageAttributes) ? (string) $episodeImageAttributes['href'] : ''); $image = $this->stringOrNull(array_key_exists('href', $imageAttributes) ? (string) $imageAttributes['href'] : '');
} }
} }
if (!$episodeImage) { if (!$image) {
preg_match('/<itunes:image\s+href="([^"]+)"/', $xmlString, $matches); preg_match('/<itunes:image\s+href="([^"]+)"/', $xmlString, $matches);
$episodeImage = $this->stringOrNull($matches[1]); $image = $this->stringOrNull($matches[1]);
} }
} }
// Get episode description // Get episode description
$episodeDescription = $this->stringOrNull($item->description); $itemContent = $item->children('content', true);
if ($itemContent) {
$episodeContentChildren = $item->children('content', true); $description = $this->stringOrNull($itemContent->encoded);
if ($episodeContentChildren) { } else {
$episodeDescription = $this->stringOrNull($episodeContentChildren->encoded); $description = $this->stringOrNull($item->description);
} }
// Remove tags // Remove tags
$episodeDescription = strip_tags($episodeDescription ?? ''); $description = strip_tags($description ?? '');
// Get episode pubDate // Get episode pubDate
$rawPubDate = $this->stringOrNull($item->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( $episodes[] = new EpisodeActionExtraData(
$episodeUrl, $podcast,
$this->stringOrNull($channel->title), $url,
$episodeName, $name,
$episodeLink, $link,
$episodeImage, $image,
$episodeDescription, $description,
$fetchedAtUnix ?? (new \DateTime())->getTimestamp(), $fetchedAtUnix ?? (new \DateTime())->getTimestamp(),
$episodeGuid, $guid,
$episodePubDate, $type,
$episodeFilesize, $size,
$episodeDuration ?? null, $pubDate,
$episodeAction $duration,
$action
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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