Clean up Podcast API endpoints, add "is_published" to podcast and episodes, make Podcast pages in the UI properly routed with VueRouter so browser history works.

This commit is contained in:
Buster Neece 2024-01-26 19:33:32 -06:00
parent a614af165f
commit a4d45b6260
No known key found for this signature in database
13 changed files with 314 additions and 300 deletions

View File

@ -23,6 +23,7 @@ return static function (RouteCollectorProxy $app) {
'stations:logs' => '/logs',
'stations:playlists:index' => '/playlists',
'stations:podcasts:index' => '/podcasts',
'stations:podcast:episodes' => '/podcast/{podcast_id}',
'stations:mounts:index' => '/mounts',
'stations:profile:index' => '/profile',
'stations:profile:edit' => '/profile/edit',

View File

@ -30,16 +30,14 @@
</div>
<div class="card-body buttons">
<button
type="button"
<router-link
class="btn btn-secondary"
@click="doClearPodcast()"
:to="{name: 'stations:podcasts:index'}"
>
<icon :icon="IconChevronLeft" />
<span>
{{ $gettext('All Podcasts') }}
</span>
</button>
{{ $gettext('All Podcasts') }}
</router-link>
<add-button
:text="$gettext('Add Episode')"
@click="doCreate"
@ -53,45 +51,55 @@
:fields="fields"
:api-url="podcast.links.episodes"
>
<template #cell(art)="row">
<album-art :src="row.item.art" />
<template #cell(art)="{item}">
<album-art :src="item.art" />
</template>
<template #cell(title)="row">
<template #cell(title)="{item}">
<h5 class="m-0">
{{ row.item.title }}
{{ item.title }}
</h5>
<a
:href="row.item.links.public"
target="_blank"
>{{ $gettext('Public Page') }}</a>
<div v-if="item.is_published">
<a
:href="item.links.public"
target="_blank"
>{{ $gettext('Public Page') }}</a>
</div>
<div
v-else
class="badges"
>
<span class="badge text-bg-info">
{{ $gettext('Unpublished') }}
</span>
</div>
</template>
<template #cell(podcast_media)="row">
<template v-if="row.item.media">
<span>{{ row.item.media.original_name }}</span>
<template #cell(podcast_media)="{item}">
<template v-if="item.media">
<span>{{ item.media.original_name }}</span>
<br>
<small>{{ row.item.media.length_text }}</small>
<small>{{ item.media.length_text }}</small>
</template>
</template>
<template #cell(explicit)="row">
<template #cell(explicit)="{item}">
<span
v-if="row.item.explicit"
v-if="item.explicit"
class="badge text-bg-danger"
>{{ $gettext('Explicit') }}</span>
<span v-else>&nbsp;</span>
</template>
<template #cell(actions)="row">
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(row.item.links.self)"
@click="doEdit(item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(row.item.links.self)"
@click="doDelete(item.links.self)"
>
{{ $gettext('Delete') }}
</button>
@ -111,32 +119,25 @@
</template>
<script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
import EditModal from './EpisodeEditModal.vue';
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import EditModal from './Podcasts/EpisodeEditModal.vue';
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 {useSweetAlert} from "~/vendor/sweetalert";
import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios";
import AddButton from "~/components/Common/AddButton.vue";
import {IconChevronLeft} from "~/components/Common/icons";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import {getStationApiUrl} from "~/router.ts";
import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
import {ApiPodcast} from "~/entities/ApiInterfaces.ts";
const props = defineProps({
quotaUrl: {
type: String,
required: true
},
podcast: {
type: Object,
required: true
}
});
const props = defineProps<{
podcast: ApiPodcast
}>();
const emit = defineEmits(['clear-podcast']);
const quotaUrl = getStationApiUrl('/quota/station_podcasts');
const {$gettext} = useTranslate();
@ -166,24 +167,8 @@ const doEdit = (url) => {
$editEpisodeModal.value?.edit(url);
};
const doClearPodcast = () => {
emit('clear-podcast');
};
const {confirmDelete} = useSweetAlert();
const {notifySuccess} = useNotify();
const {axios} = useAxios();
const doDelete = (url) => {
confirmDelete({
title: $gettext('Delete Episode?'),
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
notifySuccess(resp.data.message);
relist();
});
}
});
};
const {doDelete} = useConfirmAndDelete(
$gettext('Delete Episode?'),
() => relist()
);
</script>

View File

@ -1,39 +1,158 @@
<template>
<episodes-view
v-if="activePodcast"
:podcast="activePodcast"
:quota-url="quotaUrl"
@clear-podcast="onClearPodcast"
/>
<list-view
v-else
v-bind="pickProps(props, listViewProps)"
:quota-url="quotaUrl"
@select-podcast="onSelectPodcast"
<card-page>
<template #header>
<div class="row align-items-center">
<div class="col-md-7">
<h2 class="card-title">
{{ $gettext('Podcasts') }}
</h2>
</div>
<div class="col-md-5 text-end">
<stations-common-quota
ref="$quota"
:quota-url="quotaUrl"
/>
</div>
</div>
</template>
<template #actions>
<add-button
:text="$gettext('Add Podcast')"
@click="doCreate"
/>
</template>
<data-table
id="station_podcasts"
ref="$datatable"
paginated
:fields="fields"
:api-url="listUrl"
>
<template #cell(art)="{item}">
<album-art :src="item.art" />
</template>
<template #cell(title)="{item}">
<h5 class="m-0">
{{ item.title }}
</h5>
<div v-if="item.is_published">
<a
:href="item.links.public_episodes"
target="_blank"
>{{ $gettext('Public Page') }}</a> &bull;
<a
:href="item.links.public_feed"
target="_blank"
>{{ $gettext('RSS Feed') }}</a>
</div>
<div
v-else
class="badges"
>
<span class="badge text-bg-info">
{{ $gettext('Unpublished') }}
</span>
</div>
</template>
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(item.links.self)"
>
{{ $gettext('Delete') }}
</button>
<router-link
class="btn btn-secondary"
:to="{name: 'stations:podcast:episodes', params: {podcast_id: item.id}}"
>
{{ $gettext('Episodes') }}
</router-link>
</div>
</template>
</data-table>
</card-page>
<edit-modal
ref="$editPodcastModal"
:create-url="listUrl"
:new-art-url="newArtUrl"
:language-options="languageOptions"
:categories-options="categoriesOptions"
@relist="relist"
/>
</template>
<script setup lang="ts">
import EpisodesView from './Podcasts/EpisodesView.vue';
import ListView from './Podcasts/ListView.vue';
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import EditModal from './Podcasts/PodcastEditModal.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 listViewProps from "./Podcasts/listViewProps";
import {pickProps} from "~/functions/pickProps";
import {getStationApiUrl} from "~/router";
import AddButton from "~/components/Common/AddButton.vue";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import CardPage from "~/components/Common/CardPage.vue";
import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
const props = defineProps({
...listViewProps
languageOptions: {
type: Object,
required: true
},
categoriesOptions: {
type: Object,
required: true
},
});
const quotaUrl = getStationApiUrl('/quota/station_podcasts');
const listUrl = getStationApiUrl('/podcasts');
const newArtUrl = getStationApiUrl('/podcasts/art');
const activePodcast = ref(null);
const {$gettext} = useTranslate();
const onSelectPodcast = (podcast) => {
activePodcast.value = podcast;
const fields: DataTableField[] = [
{key: 'art', label: $gettext('Art'), sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Podcast'), sortable: false},
{
key: 'episodes',
label: $gettext('# Episodes'),
sortable: false,
},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
const $datatable = ref<DataTableTemplateRef>(null);
const relist = () => {
$quota.value?.update();
$datatable.value?.refresh();
};
const onClearPodcast = () => {
activePodcast.value = null;
}
const $editPodcastModal = ref<InstanceType<typeof EditModal> | null>(null);
const doCreate = () => {
$editPodcastModal.value?.create();
};
const doEdit = (url) => {
$editPodcastModal.value?.edit(url);
};
const {doDelete} = useConfirmAndDelete(
$gettext('Delete Podcast?'),
() => relist()
);
</script>

View File

@ -1,172 +0,0 @@
<template>
<section
class="card"
role="region"
>
<div class="card-header text-bg-primary">
<div class="row align-items-center">
<div class="col-md-7">
<h2 class="card-title">
{{ $gettext('Podcasts') }}
</h2>
</div>
<div class="col-md-5 text-end">
<stations-common-quota
ref="$quota"
:quota-url="quotaUrl"
/>
</div>
</div>
</div>
<div class="card-body buttons">
<add-button
:text="$gettext('Add Podcast')"
@click="doCreate"
/>
</div>
<data-table
id="station_podcasts"
ref="$datatable"
paginated
:fields="fields"
:api-url="listUrl"
>
<template #cell(art)="{item}">
<album-art :src="item.art" />
</template>
<template #cell(title)="{item}">
<h5 class="m-0">
{{ item.title }}
</h5>
<a
:href="item.links.public_episodes"
target="_blank"
>{{ $gettext('Public Page') }}</a> &bull;
<a
:href="item.links.public_feed"
target="_blank"
>{{ $gettext('RSS Feed') }}</a>
</template>
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(item.links.self)"
>
{{ $gettext('Delete') }}
</button>
<button
type="button"
class="btn btn-secondary"
@click="doSelectPodcast(item)"
>
{{ $gettext('Episodes') }}
</button>
</div>
</template>
</data-table>
</section>
<edit-modal
ref="$editPodcastModal"
:create-url="listUrl"
:new-art-url="newArtUrl"
:language-options="languageOptions"
:categories-options="categoriesOptions"
@relist="relist"
/>
</template>
<script setup lang="ts">
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import EditModal from './PodcastEditModal.vue';
import AlbumArt from '~/components/Common/AlbumArt.vue';
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
import listViewProps from "./listViewProps";
import {useTranslate} from "~/vendor/gettext";
import {ref} from "vue";
import {useSweetAlert} from "~/vendor/sweetalert";
import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios";
import {getStationApiUrl} from "~/router";
import AddButton from "~/components/Common/AddButton.vue";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
const props = defineProps({
...listViewProps,
quotaUrl: {
type: String,
required: true
}
});
const listUrl = getStationApiUrl('/podcasts');
const newArtUrl = getStationApiUrl('/podcasts/art');
const emit = defineEmits(['select-podcast']);
const {$gettext} = useTranslate();
const fields: DataTableField[] = [
{key: 'art', label: $gettext('Art'), sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Podcast'), sortable: false},
{
key: 'episodes',
label: $gettext('# Episodes'),
sortable: false,
formatter: (val) => {
return val?.length ?? 0;
}
},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
const $datatable = ref<DataTableTemplateRef>(null);
const relist = () => {
$quota.value?.update();
$datatable.value?.refresh();
};
const $editPodcastModal = ref<InstanceType<typeof EditModal> | null>(null);
const doCreate = () => {
$editPodcastModal.value?.create();
};
const doEdit = (url) => {
$editPodcastModal.value?.edit(url);
};
const doSelectPodcast = (podcast) => {
emit('select-podcast', podcast);
};
const {confirmDelete} = useSweetAlert();
const {notifySuccess} = useNotify();
const {axios} = useAxios();
const doDelete = (url) => {
confirmDelete({
title: $gettext('Delete Podcast?'),
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
notifySuccess(resp.data.message);
relist();
});
}
});
};
</script>

View File

@ -1,10 +0,0 @@
export default {
languageOptions: {
type: Object,
required: true
},
categoriesOptions: {
type: Object,
required: true
}
}

View File

@ -1,6 +1,7 @@
import {getStationApiUrl} from "~/router.ts";
import populateComponentRemotely from "~/functions/populateComponentRemotely.ts";
import {RouteRecordRaw} from "vue-router";
import {useAxios} from "~/vendor/axios.ts";
export default function useStationsRoutes(): RouteRecordRaw[] {
return [
@ -62,6 +63,22 @@ export default function useStationsRoutes(): RouteRecordRaw[] {
name: 'stations:podcasts:index',
...populateComponentRemotely(getStationApiUrl('/vue/podcasts'))
},
{
path: '/podcast/:podcast_id',
component: () => import('~/components/Stations/PodcastEpisodes.vue'),
name: 'stations:podcast:episodes',
beforeEnter: async (to, _, next) => {
const apiUrl = getStationApiUrl(`/podcast/${to.params.podcast_id}`);
const {axios} = useAxios();
to.meta.state = {
podcast: await axios.get(apiUrl.value).then(r => r.data)
};
next();
},
props: (to) => {
return to.meta.state;
}
},
{
path: '/profile',
name: 'stations:profile:index',

View File

@ -597,26 +597,39 @@ export interface ApiNowPlayingStationRemote {
}
export type ApiPodcast = HasLinks & {
id?: string | null;
storage_location_id?: number | null;
title?: string | null;
id?: string;
storage_location_id?: number;
title?: string;
link?: string | null;
description?: string | null;
language?: string | null;
author?: string | null;
email?: string | null;
description?: string;
description_short?: string;
language?: string;
language_name?: string;
author?: string;
email?: string;
has_custom_art?: boolean;
art?: string | null;
art?: string;
art_updated_at?: number;
categories?: string[];
episodes?: string[];
is_published?: boolean;
episodes?: number;
categories?: ApiPodcastCategory[];
};
export interface ApiPodcastCategory {
category?: string;
text?: string;
title?: string;
subtitle?: string | null;
}
export type ApiPodcastEpisode = HasLinks & {
id?: string | null;
title?: string | null;
description?: string | null;
id?: string;
title?: string;
description?: string;
description_short?: string;
explicit?: boolean;
created_at?: number;
is_published?: boolean;
publish_at?: number | null;
has_media?: boolean;
media?: ApiPodcastMedia;
@ -649,32 +662,32 @@ export interface ApiSong {
* The song artist.
* @example "Chet Porter"
*/
artist?: string;
artist?: string | null;
/**
* The song title.
* @example "Aluko River"
*/
title?: string;
title?: string | null;
/**
* The song album.
* @example "Moving Castle"
*/
album?: string;
album?: string | null;
/**
* The song genre.
* @example "Rock"
*/
genre?: string;
genre?: string | null;
/**
* The International Standard Recording Code (ISRC) of the file.
* @example "US28E1600021"
*/
isrc?: string;
isrc?: string | null;
/**
* Lyrics to the song.
* @example ""
*/
lyrics?: string;
lyrics?: string | null;
/**
* URL to the album artwork (if available).
* @example "https://picsum.photos/1200/1200"

View File

@ -54,6 +54,12 @@ final class Podcast
#[OA\Property]
public int $art_updated_at = 0;
#[OA\Property]
public bool $is_published = true;
#[OA\Property]
public int $episodes = 0;
/**
* @var PodcastCategory[]
*/

View File

@ -33,6 +33,9 @@ final class PodcastEpisode
#[OA\Property]
public int $created_at;
#[OA\Property]
public bool $is_published = true;
#[OA\Property]
public ?int $publish_at = null;

View File

@ -7,6 +7,8 @@ namespace App\Entity\ApiGenerator;
use App\Entity\Api\Podcast as ApiPodcast;
use App\Entity\Api\PodcastCategory as ApiPodcastCategory;
use App\Entity\Podcast;
use App\Entity\Repository\PodcastRepository;
use App\Entity\Station;
use App\Http\ServerRequest;
use App\Utilities\Strings;
use Symfony\Component\Intl\Exception\MissingResourceException;
@ -14,6 +16,16 @@ use Symfony\Component\Intl\Languages;
final class PodcastApiGenerator
{
/**
* @var array<string, array<string>>
*/
private array $publishedPodcasts = [];
public function __construct(
private readonly PodcastRepository $podcastRepo
) {
}
public function __invoke(
Podcast $record,
ServerRequest $request
@ -60,9 +72,13 @@ final class PodcastApiGenerator
}
$return->categories = $categories;
$return->is_published = $this->isPublished($record, $station);
$return->art_updated_at = $record->getArtUpdatedAt();
$return->has_custom_art = (0 !== $record->getArtUpdatedAt());
$return->episodes = $record->getEpisodes()->count();
$baseRouteParams = [
'station_id' => $station->getIdRequired(),
'podcast_id' => $record->getIdRequired(),
@ -104,4 +120,21 @@ final class PodcastApiGenerator
return $return;
}
private function isPublished(
Podcast $podcast,
Station $station
): bool {
if (!isset($this->publishedPodcasts[$station->getShortName()])) {
$this->publishedPodcasts[$station->getShortName()] = $this->podcastRepo->getPodcastIdsWithPublishedEpisodes(
$station
);
}
return in_array(
$podcast->getIdRequired(),
$this->publishedPodcasts[$station->getShortName()] ?? [],
true
);
}
}

View File

@ -51,6 +51,8 @@ final class PodcastEpisodeApiGenerator
$return->media = new ApiPodcastMedia();
}
$return->is_published = $record->isPublished();
$return->art_updated_at = $record->getArtUpdatedAt();
$return->has_custom_art = (0 !== $return->art_updated_at);

View File

@ -56,7 +56,7 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
protected Collection $categories;
/** @var Collection<int, PodcastEpisode> */
#[ORM\OneToMany(mappedBy: 'podcast', targetEntity: PodcastEpisode::class)]
#[ORM\OneToMany(mappedBy: 'podcast', targetEntity: PodcastEpisode::class, fetch: 'EXTRA_LAZY')]
protected Collection $episodes;
public function __construct(StorageLocation $storageLocation)

View File

@ -3938,44 +3938,52 @@ components:
properties:
id:
type: string
nullable: true
storage_location_id:
type: integer
nullable: true
title:
type: string
nullable: true
link:
type: string
nullable: true
description:
type: string
nullable: true
description_short:
type: string
language:
type: string
nullable: true
language_name:
type: string
author:
type: string
nullable: true
email:
type: string
nullable: true
has_custom_art:
type: boolean
art:
type: string
nullable: true
art_updated_at:
type: integer
is_published:
type: boolean
episodes:
type: integer
categories:
type: array
items:
type: string
episodes:
type: array
items:
type: string
$ref: '#/components/schemas/Api_PodcastCategory'
type: object
Api_PodcastCategory:
properties:
category:
type: string
text:
type: string
title:
type: string
subtitle:
type: string
nullable: true
type: object
Api_PodcastEpisode:
type: object
allOf:
@ -3985,15 +3993,18 @@ components:
properties:
id:
type: string
nullable: true
title:
type: string
nullable: true
description:
type: string
nullable: true
description_short:
type: string
explicit:
type: boolean
created_at:
type: integer
is_published:
type: boolean
publish_at:
type: integer
nullable: true
@ -4041,26 +4052,32 @@ components:
description: 'The song artist.'
type: string
example: 'Chet Porter'
nullable: true
title:
description: 'The song title.'
type: string
example: 'Aluko River'
nullable: true
album:
description: 'The song album.'
type: string
example: 'Moving Castle'
nullable: true
genre:
description: 'The song genre.'
type: string
example: Rock
nullable: true
isrc:
description: 'The International Standard Recording Code (ISRC) of the file.'
type: string
example: US28E1600021
nullable: true
lyrics:
description: 'Lyrics to the song.'
type: string
example: ''
nullable: true
art:
description: 'URL to the album artwork (if available).'
example: 'https://picsum.photos/1200/1200'